Coverage for src/secchi/ui/widgets/sidebar.py: 22%

219 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-04 22:15 +0000

1"""Sidebar — favorites + all packages, keyboard-navigable.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6from typing import ClassVar 

7 

8from textual.app import ComposeResult 

9from textual.binding import Binding 

10from textual.containers import Vertical, VerticalScroll 

11from textual.message import Message 

12from textual.widgets import Static 

13 

14from secchi.models import PackageRef, Project 

15from secchi.spotlight import FALLBACK_SPOTLIGHT, Spotlight, spotlight_disabled 

16from secchi.trending import FALLBACK_TRENDING, TrendingRepo 

17from secchi.ui import palette 

18 

19logger = logging.getLogger(__name__) 

20 

21 

22class SidebarItem(Static): 

23 """A clickable/selectable package source showing name + registry + version.""" 

24 

25 def __init__(self, ref: PackageRef) -> None: 

26 self.ref = ref 

27 self._version = "" 

28 super().__init__(self._content()) 

29 self.can_focus = False 

30 

31 def set_version(self, version: str) -> None: 

32 self._version = version 

33 self.update(self._content()) 

34 

35 def _content(self) -> str: 

36 name = self.ref.name 

37 display = name if len(name) <= 13 else name[:12] + "…" 

38 version = self._version or "…" 

39 registry = self.ref.registry.display_name 

40 return f"{display:<13} [dim]{registry:<8} {version:>6}[/]" 

41 

42 def on_click(self) -> None: 

43 self.post_message(Sidebar.PackageSelected(self.ref)) 

44 

45 

46class ProjectItem(Static): 

47 """Clickable workspace project heading.""" 

48 

49 def __init__(self, project: Project) -> None: 

50 self.project = project 

51 super().__init__() 

52 self.can_focus = False 

53 

54 def on_click(self) -> None: 

55 self.post_message(Sidebar.ProjectSelected(self.project)) 

56 

57 

58class Sidebar(Vertical): 

59 """Left sidebar: PACKAGES (favorites + all).""" 

60 

61 can_focus = True 

62 

63 BINDINGS: ClassVar[list[Binding]] = [ 

64 Binding("up", "cursor_up", "Up", show=False), 

65 Binding("down", "cursor_down", "Down", show=False), 

66 Binding("enter", "select_cursor", "Select", show=False), 

67 ] 

68 

69 class PackageSelected(Message): 

70 """Emitted when a package is chosen (click or Enter). Bubbles to the app.""" 

71 

72 def __init__(self, ref: PackageRef) -> None: 

73 super().__init__() 

74 self.ref = ref 

75 

76 class ProjectSelected(Message): 

77 """Emitted when a workspace project heading is chosen.""" 

78 

79 def __init__(self, project: Project) -> None: 

80 super().__init__() 

81 self.project = project 

82 

83 def __init__(self) -> None: 

84 super().__init__() 

85 self._items: dict[str, SidebarItem] = {} 

86 self._order: list[str] = [] 

87 self._cursor: int = -1 

88 self._favorites_only: bool = False 

89 self._spotlight: Spotlight | None = ( 

90 None if spotlight_disabled() else FALLBACK_SPOTLIGHT 

91 ) 

92 self._last_spotlight_markup: str = "" 

93 self._trending: TrendingRepo | None = FALLBACK_TRENDING 

94 self._last_trending_markup: str = "" 

95 

96 def compose(self) -> ComposeResult: 

97 yield VerticalScroll(id="sidebar-list") 

98 if self._spotlight is not None: 

99 self._last_spotlight_markup = self._spotlight_markup() 

100 promo = Static(self._last_spotlight_markup, classes="sidebar-promo") 

101 promo.border_title = "SPOTLIGHT" 

102 yield promo 

103 if self._trending is not None: 

104 self._last_trending_markup = self._trending_markup() 

105 trending = Static(self._last_trending_markup, classes="sidebar-trending") 

106 trending.border_title = "NEW & TRENDING THIS WEEK" 

107 yield trending 

108 

109 def on_mount(self) -> None: 

110 self._build() 

111 

112 # ── construction ── 

113 

114 def _build(self) -> None: 

115 listing = self.query_one("#sidebar-list", VerticalScroll) 

116 listing.remove_children() 

117 self._items.clear() 

118 self._order.clear() 

119 

120 app = self.app 

121 if not hasattr(app, "project"): 

122 return 

123 project: Project = app.project 

124 workspace = getattr(app, "workspace_projects", []) 

125 

126 if workspace: 

127 listing.mount(Static("PROJECTS", classes="sidebar-title")) 

128 projects = workspace 

129 if self._favorites_only: 

130 projects = [project for project in projects if project.favorite] 

131 for project in projects: 

132 star = f"[{palette.YELLOW}]★[/] " if project.favorite else "" 

133 description = ( 

134 f" [dim]— {project.description[:24]}[/]" 

135 if project.description 

136 else "" 

137 ) 

138 title = project.title or project.name 

139 project_item = ProjectItem(project) 

140 project_item.update(f"{star}[b]{title}[/]{description}") 

141 project_item.add_class("sidebar-project") 

142 listing.mount(project_item) 

143 if project.repository_url: 

144 listing.mount( 

145 Static( 

146 f" [dim]{project.repository_url[:38]}[/]", 

147 classes="sidebar-project-repository", 

148 ) 

149 ) 

150 for ref in project.packages: 

151 self._add_item(ref, key_suffix=f"project:{project.name}") 

152 self._refresh_versions() 

153 self._highlight() 

154 return 

155 

156 packages = getattr(app, "visible_packages", project.packages) 

157 

158 listing.mount(Static("PACKAGES", classes="sidebar-title")) 

159 

160 favorites = [r for r in packages if r.favorite] 

161 all_pkgs = packages 

162 

163 if favorites: 

164 listing.mount( 

165 Static( 

166 f"[{palette.YELLOW}]★ Favorites[/] [dim]({len(favorites)})[/]", 

167 classes="sidebar-section", 

168 ) 

169 ) 

170 for ref in favorites: 

171 self._add_item(ref, key_suffix="fav") 

172 

173 listing.mount( 

174 Static( 

175 f"All Packages [dim]({len(all_pkgs)})[/]", 

176 classes="sidebar-section sidebar-section--all", 

177 ) 

178 ) 

179 shown = favorites if self._favorites_only else all_pkgs 

180 for ref in shown: 

181 self._add_item(ref, key_suffix="all") 

182 

183 self._refresh_versions() 

184 self._highlight() 

185 

186 def _add_item(self, ref: PackageRef, key_suffix: str) -> None: 

187 item = SidebarItem(ref) 

188 order_key = f"{key_suffix}:{ref.registry.value}:{ref.name}" 

189 self._items[order_key] = item 

190 self._order.append(order_key) 

191 self.query_one("#sidebar-list", VerticalScroll).mount(item) 

192 

193 # ── version population ── 

194 

195 def _refresh_versions(self) -> None: 

196 app = self.app 

197 data = getattr(app, "package_data", {}) 

198 for _order_key, item in self._items.items(): 

199 info = data.get(self._pkg_key(item.ref)) 

200 if info and info.latest_version: 

201 item.set_version(info.latest_version) 

202 

203 def refresh_versions(self) -> None: 

204 self._refresh_versions() 

205 

206 def set_spotlight(self, spotlight: Spotlight | None) -> None: 

207 self._spotlight = None if spotlight_disabled() else spotlight 

208 new_markup = self._spotlight_markup() 

209 try: 

210 promo = self.query_one(".sidebar-promo", Static) 

211 except Exception: 

212 # Dynamic sidebar cards may be updated before they are mounted. 

213 logger.debug("Unable to find spotlight card", exc_info=True) 

214 if self._spotlight is not None and self.is_mounted: 

215 self._last_spotlight_markup = new_markup 

216 promo = Static(new_markup, classes="sidebar-promo") 

217 promo.border_title = "SPOTLIGHT" 

218 self.mount(promo) 

219 return 

220 if self._spotlight is None: 

221 promo.remove() 

222 self._last_spotlight_markup = "" 

223 elif new_markup != self._last_spotlight_markup: 

224 self._last_spotlight_markup = new_markup 

225 promo.update(new_markup) 

226 

227 def set_trending(self, trending: TrendingRepo | None) -> None: 

228 self._trending = trending 

229 new_markup = self._trending_markup() 

230 try: 

231 card = self.query_one(".sidebar-trending", Static) 

232 except Exception: 

233 # Dynamic sidebar cards may be updated before they are mounted. 

234 logger.debug("Unable to find trending card", exc_info=True) 

235 if self._trending is not None and self.is_mounted: 

236 self._last_trending_markup = new_markup 

237 card = Static(new_markup, classes="sidebar-trending") 

238 card.border_title = "NEW & TRENDING THIS WEEK" 

239 self.mount(card) 

240 return 

241 if self._trending is None: 

242 card.remove() 

243 self._last_trending_markup = "" 

244 elif new_markup != self._last_trending_markup: 

245 self._last_trending_markup = new_markup 

246 card.update(new_markup) 

247 

248 # ── selection / highlight ── 

249 

250 def _pkg_key(self, ref: PackageRef) -> str: 

251 project = f"{ref.project_name}:" if ref.project_name else "" 

252 return f"{project}{ref.registry.value}:{ref.name}" 

253 

254 def select_package(self, ref: PackageRef) -> None: 

255 target = self._pkg_key(ref) 

256 for i, order_key in enumerate(self._order): 

257 if self._pkg_key(self._items[order_key].ref) == target: 

258 self._cursor = i 

259 break 

260 self._highlight() 

261 

262 def deselect_all(self) -> None: 

263 self._cursor = -1 

264 self._highlight() 

265 

266 def _highlight(self) -> None: 

267 for i, order_key in enumerate(self._order): 

268 item = self._items[order_key] 

269 item.set_class(i == self._cursor, "sidebar-item--selected") 

270 

271 # ── actions ── 

272 

273 def action_cursor_down(self) -> None: 

274 if not self._order: 

275 return 

276 self._cursor = min(self._cursor + 1, len(self._order) - 1) 

277 self._highlight() 

278 self._scroll_to_cursor() 

279 

280 def action_cursor_up(self) -> None: 

281 if not self._order: 

282 return 

283 self._cursor = max(self._cursor - 1, 0) 

284 self._highlight() 

285 self._scroll_to_cursor() 

286 

287 def action_select_cursor(self) -> None: 

288 if 0 <= self._cursor < len(self._order): 

289 ref = self._items[self._order[self._cursor]].ref 

290 self.post_message(self.PackageSelected(ref)) 

291 

292 def _scroll_to_cursor(self) -> None: 

293 if 0 <= self._cursor < len(self._order): 

294 item = self._items[self._order[self._cursor]] 

295 self.query_one("#sidebar-list", VerticalScroll).scroll_to_widget( 

296 item, animate=False 

297 ) 

298 

299 def toggle_favorites_filter(self) -> None: 

300 self._favorites_only = not self._favorites_only 

301 self._build() 

302 

303 def _spotlight_markup(self) -> str: 

304 if self._spotlight is None: 

305 return "" 

306 stars = ( 

307 f"[{palette.YELLOW}]★ {self._spotlight.stars}[/] " 

308 if self._spotlight.stars is not None 

309 else "" 

310 ) 

311 return ( 

312 f"[b white]{self._spotlight.title}[/]\n" 

313 f"[#94A3B8]{self._spotlight.description}[/]\n" 

314 f"{stars}[dim]{self._spotlight.project_stage}[/]\n" 

315 f"[#22D3EE]{self._spotlight.url}[/]" 

316 ) 

317 

318 def _trending_markup(self) -> str: 

319 if self._trending is None: 

320 return "" 

321 lang = ( 

322 f" [{palette.PURPLE}]{self._trending.language}[/]" 

323 if self._trending.language 

324 else "" 

325 ) 

326 return ( 

327 f"[b white]{self._trending.title}[/]{lang}\n" 

328 f"[#94A3B8]{self._trending.description}[/]\n" 

329 f"[#22D3EE]{self._trending.url}[/] " 

330 f"[{palette.YELLOW}]★ {self._trending.stars}[/]" 

331 )