Coverage for src/lilbee/cli/tui/screens/profile_library.py: 100%

280 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +0000

1"""The profile library: every profile with its folder, and the file operations on each.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6from collections.abc import Callable 

7from dataclasses import dataclass, replace 

8from enum import StrEnum 

9from pathlib import Path 

10from typing import TYPE_CHECKING, ClassVar, TypeVar 

11 

12from rich.cells import cell_len 

13from textual import events, on, work 

14from textual.app import ComposeResult, CSSPathType 

15from textual.binding import Binding, BindingType 

16from textual.containers import Horizontal, Vertical, VerticalScroll 

17from textual.content import Content 

18from textual.screen import ModalScreen 

19from textual.style import Style 

20from textual.widgets import DataTable, OptionList, Static 

21from textual.widgets.option_list import Option 

22 

23from lilbee.app import profiles 

24from lilbee.app.profiles import ProfileDiff 

25from lilbee.cli.tui import messages as msg 

26from lilbee.cli.tui.screens.profile_dialogs import ( 

27 PathRequest, 

28 ProfilePathDialog, 

29 SaveProfileDialog, 

30 SaveRequest, 

31 add_cost_column, 

32 credit_text, 

33 diff_cells, 

34 free_copy_name, 

35 has_project_folder, 

36 run_profile_op, 

37 save_folders, 

38 shorten_middle, 

39 start_profile_switch, 

40) 

41from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog 

42from lilbee.core.profile_files import ProfileCatalog, ProfileEntry, ProfileFolder, ProfileStore 

43 

44if TYPE_CHECKING: 

45 from lilbee.cli.tui.app import LilbeeApp 

46 

47R = TypeVar("R") 

48 

49WIDE_COLUMNS = 100 

50NARROW_CLASS = "-narrow" 

51_ROW_GAP = " " 

52_ROW_PADDING = 2 # an option's left and right padding inside the list 

53_MIN_NAME = 8 

54# The 60-cell confirm dialog, less its border, its padding and the full stop after the path. 

55_DELETE_PATH_WIDTH = 53 

56_APPLY_KEY = "enter" 

57_CLOSE_KEY = "esc" 

58 

59 

60class LibraryAction(StrEnum): 

61 """A file operation the library runs on a key.""" 

62 

63 DUPLICATE = "duplicate" 

64 RENAME = "rename" 

65 DELETE = "delete" 

66 EXPORT = "export" 

67 IMPORT = "import" 

68 

69 

70@dataclass(frozen=True) 

71class _LibraryKey: 

72 key: str 

73 action: LibraryAction 

74 label: str 

75 short_label: str 

76 

77 

78_KEYS = ( 

79 _LibraryKey( 

80 "d", LibraryAction.DUPLICATE, msg.PROFILE_KEY_DUPLICATE, msg.PROFILE_KEY_DUPLICATE_SHORT 

81 ), 

82 _LibraryKey("r", LibraryAction.RENAME, msg.PROFILE_KEY_RENAME, msg.PROFILE_KEY_RENAME_SHORT), 

83 _LibraryKey("x", LibraryAction.DELETE, msg.PROFILE_KEY_DELETE, msg.PROFILE_KEY_DELETE_SHORT), 

84 _LibraryKey("e", LibraryAction.EXPORT, msg.PROFILE_KEY_EXPORT, msg.PROFILE_KEY_EXPORT_SHORT), 

85 _LibraryKey("i", LibraryAction.IMPORT, msg.PROFILE_KEY_IMPORT, msg.PROFILE_KEY_IMPORT_SHORT), 

86) 

87 

88 

89@dataclass(frozen=True) 

90class LibraryState: 

91 """Every profile file found, and whether there is a project folder to write to.""" 

92 

93 catalog: ProfileCatalog 

94 has_project: bool 

95 active_path: Path | None = None 

96 

97 

98def load_library() -> LibraryState: 

99 """Scan the profile folders; runs off the event loop.""" 

100 store = ProfileStore() 

101 applied = profiles.active(store).entry 

102 return LibraryState( 

103 profiles.list_profiles(store), 

104 has_project_folder(), 

105 applied.path if applied is not None else None, 

106 ) 

107 

108 

109def _tag_text(entry: ProfileEntry, *, active: bool = False, with_problem: bool = True) -> str: 

110 tags = [msg.PROFILE_FOLDER_TAG[entry.folder]] 

111 if active: 

112 tags.append(msg.PROFILE_LIBRARY_ACTIVE) 

113 if entry.shadowed_by is not None: 

114 tags.append(msg.PROFILE_LIBRARY_SHADOWED) 

115 if with_problem and entry.error is not None: 

116 tags.append(msg.PROFILE_LIBRARY_BROKEN.format(reason=entry.error)) 

117 return ", ".join(tags) 

118 

119 

120def entry_label( 

121 entry: ProfileEntry, name_width: int = 0, row_width: int = 0, *, active: bool = False 

122) -> Content: 

123 """A list row: the name padded to *name_width*, then its folder and any problem. 

124 

125 With a *row_width*, the row is cut with an ellipsis to fit, so it never wraps. 

126 A name that is too long loses its middle, so its tail still shows. 

127 """ 

128 name = shorten_middle(entry.name, name_width) if row_width > 0 else entry.name 

129 padding = " " * (name_width - cell_len(name)) 

130 tags = Content.styled(_tag_text(entry, active=active), "$text-muted") 

131 row = Content.assemble(name, padding, _ROW_GAP, tags) 

132 return row.truncate(row_width, ellipsis=True) if row_width > 0 else row 

133 

134 

135def _hints(*, narrow: bool = False) -> Content: 

136 pairs = [ 

137 (_APPLY_KEY, msg.PROFILE_KEY_APPLY, "screen.apply"), 

138 *( 

139 (k.key, k.short_label if narrow else k.label, f"screen.run('{k.action}')") 

140 for k in _KEYS 

141 ), 

142 (_CLOSE_KEY, msg.PROFILE_KEY_CLOSE, "screen.close"), 

143 ] 

144 return Content(" ").join( 

145 Content.assemble(Content.styled(key, "$accent bold"), " ", label).stylize( 

146 Style.from_meta({"@click": action}) 

147 ) 

148 for key, label, action in pairs 

149 ) 

150 

151 

152def _without(catalog: ProfileCatalog, path: Path) -> ProfileCatalog: 

153 return replace(catalog, entries=tuple(e for e in catalog.entries if e.path != path)) 

154 

155 

156class ProfileLibrary(ModalScreen[None]): 

157 """Lists every profile and applies, duplicates, renames, deletes, exports or imports one.""" 

158 

159 CSS_PATH: ClassVar[CSSPathType] = ["dialog.tcss", "profile_library.tcss"] 

160 BINDINGS: ClassVar[list[BindingType]] = [ 

161 Binding("escape", "close", msg.PROFILE_KEY_CLOSE, show=False), 

162 Binding("enter", "apply", msg.PROFILE_KEY_APPLY, show=False, priority=True), 

163 *(Binding(k.key, f"run('{k.action.value}')", k.label, show=False) for k in _KEYS), 

164 ] 

165 

166 app: LilbeeApp # type: ignore[assignment] 

167 

168 def __init__(self, *, on_change: Callable[[], None]) -> None: 

169 super().__init__() 

170 self._on_change = on_change 

171 self._state: LibraryState | None = None 

172 self._select_path: Path | None = None 

173 self._entries: list[ProfileEntry] = [] 

174 self._credit: str | None = None 

175 self._handlers: dict[LibraryAction, Callable[[LibraryState], None]] = { 

176 LibraryAction.DUPLICATE: self._duplicate, 

177 LibraryAction.RENAME: self._rename, 

178 LibraryAction.DELETE: self._delete, 

179 LibraryAction.EXPORT: self._export, 

180 LibraryAction.IMPORT: self._import, 

181 } 

182 

183 def compose(self) -> ComposeResult: 

184 with Vertical(id="library-body", classes="dialog-body"): 

185 yield Static(msg.PROFILE_LIBRARY_TITLE, id="library-title", classes="dialog-title") 

186 with Horizontal(id="library-main"): 

187 yield OptionList(id="library-list") 

188 with VerticalScroll(id="library-detail"): 

189 yield Static("", id="library-name", markup=False) 

190 for widget_id in ("library-folder", "library-description", "library-credit"): 

191 yield Static("", id=widget_id, classes="library-muted", markup=False) 

192 yield Static("", id="library-problem", markup=False) 

193 yield Static("", id="library-changes-title", classes="profile-section-title") 

194 table: DataTable[str | Content] = DataTable( 

195 id="library-changes", cursor_type="none" 

196 ) 

197 table.add_columns( 

198 msg.PROFILE_COL_SETTING, msg.PROFILE_COL_NOW, msg.PROFILE_COL_AFTER 

199 ) 

200 add_cost_column(table) 

201 yield table 

202 yield Static("", id="library-narrow-credit", classes="library-muted", markup=False) 

203 yield Static(_hints(), id="library-hints") 

204 

205 def on_mount(self) -> None: 

206 self._fit(self.app.size.width) 

207 self.query_one("#library-list", OptionList).focus() 

208 self.reload() 

209 

210 def on_resize(self, event: events.Resize) -> None: 

211 self._fit(event.size.width) 

212 self.call_after_refresh(self._paint_rows) 

213 

214 def _fit(self, width: int) -> None: 

215 """Below WIDE_COLUMNS the library shows the list only, plus the selection's credit.""" 

216 narrow = width < WIDE_COLUMNS 

217 self.set_class(narrow, NARROW_CLASS) 

218 self.query_one("#library-hints", Static).update(_hints(narrow=narrow)) 

219 self._update_narrow_credit() 

220 

221 @work(exclusive=True, group="profile-library-load", exit_on_error=False) 

222 async def reload(self) -> None: 

223 """Rescan the profile folders off the loop, then show the list. 

224 

225 Only the scan runs on a thread, so a newer rescan cancels this task at the await. 

226 """ 

227 self.show(await asyncio.to_thread(load_library)) 

228 

229 def show(self, state: LibraryState) -> None: 

230 """Fill the list from *state*, keeping the highlighted file when it is still there.""" 

231 selected = self._selected() 

232 wanted = self._select_path or (selected.path if selected is not None else state.active_path) 

233 self._select_path = None 

234 self._state = state 

235 self._entries = list(state.catalog.entries) 

236 listing = self.query_one("#library-list", OptionList) 

237 listing.set_options(Option("") for _ in self._entries) 

238 self._paint_rows() 

239 paths = [entry.path for entry in self._entries] 

240 if wanted in paths: 

241 listing.highlighted = paths.index(wanted) 

242 elif self._entries: 

243 listing.highlighted = 0 

244 

245 def _paint_rows(self) -> None: 

246 """Label each row to fit the list: the name is cut, and the folder tags stay aligned.""" 

247 listing = self.query_one("#library-list", OptionList) 

248 row_width = max(listing.scrollable_content_region.width - _ROW_PADDING, 0) 

249 name_width = max((cell_len(entry.name) for entry in self._entries), default=0) 

250 if row_width: 

251 # A long problem text is cut at the row end; it must not squeeze every name. 

252 tag_width = max( 

253 ( 

254 cell_len(_tag_text(entry, active=self._is_active(entry), with_problem=False)) 

255 for entry in self._entries 

256 ), 

257 default=0, 

258 ) 

259 name_width = max(min(name_width, row_width - len(_ROW_GAP) - tag_width), _MIN_NAME) 

260 for index, entry in enumerate(self._entries): 

261 label = entry_label(entry, name_width, row_width, active=self._is_active(entry)) 

262 listing.replace_option_prompt_at_index(index, label) 

263 

264 def _is_active(self, entry: ProfileEntry) -> bool: 

265 return self._state is not None and entry.path == self._state.active_path 

266 

267 def _selected(self) -> ProfileEntry | None: 

268 index = self.query_one("#library-list", OptionList).highlighted 

269 return None if index is None or index >= len(self._entries) else self._entries[index] 

270 

271 @on(OptionList.OptionHighlighted, "#library-list") 

272 def _on_highlight(self) -> None: 

273 entry = self._selected() 

274 if entry is not None: 

275 self._show_detail(entry) 

276 

277 def _show_detail(self, entry: ProfileEntry) -> None: 

278 profile = entry.file 

279 shadowed = entry.shadowed_by 

280 problem = entry.error 

281 if problem is None and shadowed is not None: 

282 problem = msg.PROFILE_LIBRARY_SHADOWED_NOTE.format(folder=shadowed.value) 

283 self._credit = credit_text(profile) if profile is not None else None 

284 for widget_id, text in ( 

285 ("library-name", entry.name), 

286 ("library-folder", msg.PROFILE_FOLDER_TEXT[entry.folder]), 

287 ("library-description", profile.description if profile is not None else None), 

288 ("library-credit", self._credit), 

289 ("library-problem", problem), 

290 ): 

291 line = self.query_one(f"#{widget_id}", Static) 

292 line.update(text or "") 

293 line.display = bool(text) 

294 self._update_narrow_credit() 

295 self._show_diff(entry.path, None) 

296 if profile is not None and self._reaches(entry): 

297 self._load_diff(entry) 

298 

299 def _update_narrow_credit(self) -> None: 

300 """Mirror the selection's credit outside the detail pane, for the list-only layout.""" 

301 widget = self.query_one("#library-narrow-credit", Static) 

302 widget.update(self._credit or "") 

303 widget.display = bool(self._credit) and self.has_class(NARROW_CLASS) 

304 

305 @work(exclusive=True, group="profile-library-diff", exit_on_error=False) 

306 async def _load_diff(self, entry: ProfileEntry) -> None: 

307 try: 

308 diff = await asyncio.to_thread(profiles.diff, ProfileStore(), entry.name) 

309 except (ValueError, OSError): 

310 return 

311 self._show_diff(entry.path, diff) 

312 

313 def _show_diff(self, path: Path, diff: ProfileDiff | None) -> None: 

314 """Show *diff* for the file at *path*; None, or a file no longer highlighted, hides it.""" 

315 selected = self._selected() 

316 current = diff if selected is not None and selected.path == path else None 

317 title = self.query_one("#library-changes-title", Static) 

318 table = self.query_one("#library-changes", DataTable) 

319 title.display = current is not None 

320 table.display = current is not None and bool(current.changes) 

321 if current is None: 

322 return 

323 changes = current.changes 

324 title.update(msg.PROFILE_LIBRARY_CHANGES if changes else msg.PROFILE_LIBRARY_NO_CHANGES) 

325 table.clear() 

326 table.add_rows(diff_cells(row) for row in changes) 

327 

328 def _reaches(self, entry: ProfileEntry) -> bool: 

329 """Whether the name of *entry* picks its own file; operations act by name.""" 

330 found = self._state.catalog.find(entry.name) if self._state is not None else None 

331 return found is not None and found.path == entry.path 

332 

333 def _target(self) -> ProfileEntry | None: 

334 """The highlighted entry, or None with a toast when its name picks another file.""" 

335 entry = self._selected() 

336 if entry is None or self._reaches(entry): 

337 return entry 

338 self.notify( 

339 msg.PROFILE_LIBRARY_UNREACHABLE.format( 

340 name=entry.name, folder=msg.PROFILE_FOLDER_TAG[entry.folder] 

341 ), 

342 severity="error", 

343 ) 

344 return None 

345 

346 def _changed(self, select: Path | None = None) -> None: 

347 """Rescan; the list then highlights the file at *select*, else the one it had.""" 

348 self._select_path = select 

349 self.reload() 

350 self._on_change() 

351 

352 def action_run(self, action: str) -> None: 

353 """Run the file operation a key stands for.""" 

354 if self._state is not None: 

355 self._handlers[LibraryAction(action)](self._state) 

356 

357 def action_close(self) -> None: 

358 self.dismiss(None) 

359 

360 @on(OptionList.OptionSelected, "#library-list") 

361 def _on_row_selected(self, event: OptionList.OptionSelected) -> None: 

362 """A click only highlights a row; Enter applies it.""" 

363 event.stop() 

364 

365 def action_apply(self) -> None: 

366 """Ask to apply the highlighted profile.""" 

367 entry = self._target() 

368 if entry is not None: 

369 start_profile_switch(self.app, self, entry.name, self._changed) 

370 

371 def _duplicate(self, state: LibraryState) -> None: 

372 entry = self._target() 

373 if entry is None: 

374 return 

375 folders = save_folders(state.has_project) 

376 dialog = SaveProfileDialog( 

377 title=msg.PROFILE_DUPLICATE_TITLE.format(name=entry.name), 

378 explain=msg.PROFILE_DUPLICATE_EXPLAIN.format(name=entry.name), 

379 catalog=state.catalog, 

380 folders=folders, 

381 initial=free_copy_name(entry.name, folders[0], state.catalog), 

382 ) 

383 

384 def _write(request: SaveRequest) -> profiles.ProfileLocation: 

385 return profiles.duplicate(ProfileStore(), entry.name, request.name, request.folder) 

386 

387 def _done(location: profiles.ProfileLocation) -> None: 

388 self.notify(msg.PROFILE_SAVED.format(name=location.name, path=location.path)) 

389 self._changed() 

390 

391 self._ask(dialog, _write, _done) 

392 

393 def _rename(self, state: LibraryState) -> None: 

394 entry = self._target() 

395 if entry is None: 

396 return 

397 

398 def _ask_name(owned: ProfileEntry) -> None: 

399 dialog = SaveProfileDialog( 

400 title=msg.PROFILE_RENAME_TITLE.format(name=owned.name), 

401 explain=msg.PROFILE_RENAME_EXPLAIN, 

402 catalog=_without(state.catalog, owned.path), 

403 folders=(owned.folder,), 

404 initial=owned.name, 

405 ) 

406 self._ask(dialog, _write, _done) 

407 

408 def _write(request: SaveRequest) -> profiles.ProfileLocation: 

409 return profiles.rename(ProfileStore(), entry.name, request.name) 

410 

411 def _done(location: profiles.ProfileLocation) -> None: 

412 self.notify(msg.PROFILE_RENAMED.format(old=entry.name, name=location.name)) 

413 self._changed(select=location.path) 

414 

415 run_profile_op(self, lambda: profiles.owned(ProfileStore(), entry.name), _ask_name) 

416 

417 def _delete(self, state: LibraryState) -> None: 

418 entry = self._target() 

419 if entry is None: 

420 return 

421 

422 def _confirm(owned: ProfileEntry) -> None: 

423 dialog = ConfirmDialog( 

424 msg.PROFILE_DELETE_TITLE.format(name=owned.name), 

425 msg.PROFILE_DELETE_MESSAGE.format( 

426 path=shorten_middle(str(owned.path), _DELETE_PATH_WIDTH) 

427 ), 

428 focus_no=True, 

429 ) 

430 self.app.push_screen(dialog, _on_answer) 

431 

432 def _on_answer(confirmed: bool | None) -> None: 

433 if confirmed: 

434 run_profile_op(self, lambda: profiles.delete(ProfileStore(), entry.name), _done) 

435 

436 def _done(location: profiles.ProfileLocation) -> None: 

437 self.notify(msg.PROFILE_DELETED.format(path=location.path)) 

438 self._changed() 

439 

440 run_profile_op(self, lambda: profiles.owned(ProfileStore(), entry.name), _confirm) 

441 

442 def _export(self, state: LibraryState) -> None: 

443 entry = self._target() 

444 if entry is None: 

445 return 

446 dialog = ProfilePathDialog( 

447 title=msg.PROFILE_EXPORT_TITLE.format(name=entry.name), 

448 explain=msg.PROFILE_EXPORT_EXPLAIN, 

449 action_label=msg.PROFILE_EXPORT_LABEL, 

450 write=lambda request: profiles.export(ProfileStore(), entry.name, request.path), 

451 ) 

452 

453 def _done(location: profiles.ProfileLocation | None) -> None: 

454 if location is not None: 

455 self.notify(msg.PROFILE_EXPORTED.format(name=location.name, path=location.path)) 

456 self._changed() 

457 

458 self.app.push_screen(dialog, _done) 

459 

460 def _import(self, state: LibraryState) -> None: 

461 def _write(request: PathRequest) -> profiles.ProfileLocation: 

462 folder = request.folder or ProfileFolder.GLOBAL 

463 return profiles.import_profile(ProfileStore(), request.path, folder) 

464 

465 dialog = ProfilePathDialog( 

466 title=msg.PROFILE_IMPORT_TITLE, 

467 explain=msg.PROFILE_IMPORT_EXPLAIN, 

468 action_label=msg.PROFILE_IMPORT_LABEL, 

469 write=_write, 

470 folders=save_folders(state.has_project), 

471 ) 

472 

473 def _done(location: profiles.ProfileLocation | None) -> None: 

474 if location is not None: 

475 self.notify(msg.PROFILE_IMPORTED.format(name=location.name, path=location.path)) 

476 self._changed() 

477 

478 self.app.push_screen(dialog, _done) 

479 

480 def _ask( 

481 self, 

482 dialog: ModalScreen[R | None], 

483 write: Callable[[R], profiles.ProfileLocation], 

484 done: Callable[[profiles.ProfileLocation], None], 

485 ) -> None: 

486 """Push *dialog*; on an answer, run *write* with it in a worker, then *done*.""" 

487 

488 def _on_answer(request: R | None) -> None: 

489 if request is not None: 

490 run_profile_op(self, lambda: write(request), done) 

491 

492 self.app.push_screen(dialog, _on_answer)