Coverage for src/workstack/status/renderers/simple.py: 72%

127 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-19 09:31 -0400

1"""Simple text-based status renderer.""" 

2 

3import click 

4 

5from workstack.status.models.status_data import StatusData 

6 

7 

8class SimpleRenderer: 

9 """Renders status information as simple formatted text.""" 

10 

11 def render(self, status: StatusData) -> None: 

12 """Render status data to console. 

13 

14 Args: 

15 status: Status data to render 

16 """ 

17 self._render_header(status) 

18 self._render_plan(status) 

19 self._render_stack(status) 

20 self._render_pr_status(status) 

21 self._render_git_status(status) 

22 self._render_related_worktrees(status) 

23 

24 def _render_file_list(self, files: list[str], *, max_files: int = 3) -> None: 

25 """Render a list of files with truncation. 

26 

27 Args: 

28 files: List of file paths 

29 max_files: Maximum number of files to display 

30 """ 

31 for file in files[:max_files]: 

32 click.echo(f" {file}") 

33 

34 if len(files) > max_files: 

35 remaining = len(files) - max_files 

36 click.echo( 

37 click.style( 

38 f" ... and {remaining} more", 

39 fg="white", 

40 dim=True, 

41 ) 

42 ) 

43 

44 def _render_header(self, status: StatusData) -> None: 

45 """Render worktree header section. 

46 

47 Args: 

48 status: Status data 

49 """ 

50 wt = status.worktree_info 

51 

52 # Title 

53 name_color = "green" if wt.is_root else "cyan" 

54 click.echo(click.style(f"Worktree: {wt.name}", fg=name_color, bold=True)) 

55 

56 # Location 

57 click.echo(click.style(f"Location: {wt.path}", fg="white", dim=True)) 

58 

59 # Branch 

60 if wt.branch: 

61 click.echo(click.style(f"Branch: {wt.branch}", fg="yellow")) 

62 else: 

63 click.echo(click.style("Branch: (detached HEAD)", fg="red", dim=True)) 

64 

65 click.echo() 

66 

67 def _render_plan(self, status: StatusData) -> None: 

68 """Render plan file section if available. 

69 

70 Args: 

71 status: Status data 

72 """ 

73 if status.plan is None or not status.plan.exists: 

74 return 

75 

76 click.echo(click.style("Plan:", fg="bright_magenta", bold=True)) 

77 

78 if status.plan.first_lines: 

79 for line in status.plan.first_lines: 

80 click.echo(f" {line}") 

81 

82 click.echo( 

83 click.style( 

84 f" ({status.plan.line_count} lines in .PLAN.md)", 

85 fg="white", 

86 dim=True, 

87 ) 

88 ) 

89 click.echo() 

90 

91 def _render_stack(self, status: StatusData) -> None: 

92 """Render Graphite stack section if available. 

93 

94 Args: 

95 status: Status data 

96 """ 

97 if status.stack_position is None: 

98 return 

99 

100 stack = status.stack_position 

101 

102 click.echo(click.style("Stack Position:", fg="blue", bold=True)) 

103 

104 # Show position in stack 

105 if stack.is_trunk: 

106 click.echo(" This is a trunk branch") 

107 else: 

108 if stack.parent_branch: 

109 parent = click.style(stack.parent_branch, fg="yellow") 

110 click.echo(f" Parent: {parent}") 

111 

112 if stack.children_branches: 

113 children = ", ".join(click.style(c, fg="yellow") for c in stack.children_branches) 

114 click.echo(f" Children: {children}") 

115 

116 # Show stack visualization 

117 if len(stack.stack) > 1: 

118 click.echo() 

119 click.echo(click.style(" Stack:", fg="white", dim=True)) 

120 for branch in reversed(stack.stack): 

121 is_current = branch == stack.current_branch 

122 

123 if is_current: 

124 marker = click.style("◉", fg="bright_green") 

125 branch_text = click.style(branch, fg="bright_green", bold=True) 

126 else: 

127 marker = click.style("◯", fg="bright_black") 

128 branch_text = branch 

129 

130 click.echo(f" {marker} {branch_text}") 

131 

132 click.echo() 

133 

134 def _render_pr_status(self, status: StatusData) -> None: 

135 """Render PR status section if available. 

136 

137 Args: 

138 status: Status data 

139 """ 

140 if status.pr_status is None: 

141 return 

142 

143 pr = status.pr_status 

144 

145 click.echo(click.style("Pull Request:", fg="blue", bold=True)) 

146 

147 # PR number and state 

148 pr_link = click.style(f"#{pr.number}", fg="cyan") 

149 state_color = ( 

150 "green" if pr.state == "OPEN" else "red" if pr.state == "CLOSED" else "magenta" 

151 ) 

152 state_text = click.style(pr.state, fg=state_color) 

153 click.echo(f" {pr_link} {state_text}") 

154 

155 # Draft status 

156 if pr.is_draft: 

157 click.echo(click.style(" Draft PR", fg="yellow")) 

158 

159 # Checks status 

160 if pr.checks_passing is not None: 

161 if pr.checks_passing: 

162 click.echo(click.style(" Checks: passing", fg="green")) 

163 else: 

164 click.echo(click.style(" Checks: failing", fg="red")) 

165 

166 # Ready to merge 

167 if pr.ready_to_merge: 

168 click.echo(click.style(" ✓ Ready to merge", fg="green", bold=True)) 

169 

170 # URL 

171 click.echo(click.style(f" {pr.url}", fg="white", dim=True)) 

172 

173 click.echo() 

174 

175 def _render_git_status(self, status: StatusData) -> None: 

176 """Render git status section. 

177 

178 Args: 

179 status: Status data 

180 """ 

181 if status.git_status is None: 

182 return 

183 

184 git = status.git_status 

185 

186 click.echo(click.style("Git Status:", fg="blue", bold=True)) 

187 

188 # Clean/dirty status 

189 if git.clean: 

190 click.echo(click.style(" Working tree clean", fg="green")) 

191 else: 

192 click.echo(click.style(" Working tree has changes:", fg="yellow")) 

193 

194 if git.staged_files: 

195 click.echo(click.style(" Staged:", fg="green")) 

196 self._render_file_list(git.staged_files, max_files=3) 

197 

198 if git.modified_files: 

199 click.echo(click.style(" Modified:", fg="yellow")) 

200 self._render_file_list(git.modified_files, max_files=3) 

201 

202 if git.untracked_files: 

203 click.echo(click.style(" Untracked:", fg="red")) 

204 self._render_file_list(git.untracked_files, max_files=3) 

205 

206 # Ahead/behind 

207 if git.ahead > 0 or git.behind > 0: 

208 parts = [] 

209 if git.ahead > 0: 

210 parts.append(click.style(f"{git.ahead} ahead", fg="green")) 

211 if git.behind > 0: 

212 parts.append(click.style(f"{git.behind} behind", fg="red")) 

213 

214 click.echo(f" Branch: {', '.join(parts)}") 

215 

216 # Recent commits 

217 if git.recent_commits: 

218 click.echo() 

219 click.echo(click.style(" Recent commits:", fg="white", dim=True)) 

220 for commit in git.recent_commits[:3]: 

221 sha = click.style(commit.sha, fg="yellow") 

222 message = commit.message[:60] 

223 if len(commit.message) > 60: 

224 message += "..." 

225 click.echo(f" {sha} {message}") 

226 

227 click.echo() 

228 

229 def _render_related_worktrees(self, status: StatusData) -> None: 

230 """Render related worktrees section. 

231 

232 Args: 

233 status: Status data 

234 """ 

235 if not status.related_worktrees: 

236 return 

237 

238 click.echo(click.style("Related Worktrees:", fg="blue", bold=True)) 

239 

240 for wt in status.related_worktrees[:5]: 

241 name_color = "green" if wt.is_root else "cyan" 

242 name_part = click.style(wt.name, fg=name_color) 

243 

244 if wt.branch: 

245 branch_part = click.style(f"[{wt.branch}]", fg="yellow", dim=True) 

246 click.echo(f" {name_part} {branch_part}") 

247 else: 

248 click.echo(f" {name_part}") 

249 

250 if len(status.related_worktrees) > 5: 

251 remaining = len(status.related_worktrees) - 5 

252 click.echo( 

253 click.style( 

254 f" ... and {remaining} more", 

255 fg="white", 

256 dim=True, 

257 ) 

258 ) 

259 

260 click.echo()