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
« 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."""
3from __future__ import annotations
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
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
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
44if TYPE_CHECKING:
45 from lilbee.cli.tui.app import LilbeeApp
47R = TypeVar("R")
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"
60class LibraryAction(StrEnum):
61 """A file operation the library runs on a key."""
63 DUPLICATE = "duplicate"
64 RENAME = "rename"
65 DELETE = "delete"
66 EXPORT = "export"
67 IMPORT = "import"
70@dataclass(frozen=True)
71class _LibraryKey:
72 key: str
73 action: LibraryAction
74 label: str
75 short_label: str
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)
89@dataclass(frozen=True)
90class LibraryState:
91 """Every profile file found, and whether there is a project folder to write to."""
93 catalog: ProfileCatalog
94 has_project: bool
95 active_path: Path | None = None
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 )
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)
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.
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
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 )
152def _without(catalog: ProfileCatalog, path: Path) -> ProfileCatalog:
153 return replace(catalog, entries=tuple(e for e in catalog.entries if e.path != path))
156class ProfileLibrary(ModalScreen[None]):
157 """Lists every profile and applies, duplicates, renames, deletes, exports or imports one."""
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 ]
166 app: LilbeeApp # type: ignore[assignment]
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 }
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")
205 def on_mount(self) -> None:
206 self._fit(self.app.size.width)
207 self.query_one("#library-list", OptionList).focus()
208 self.reload()
210 def on_resize(self, event: events.Resize) -> None:
211 self._fit(event.size.width)
212 self.call_after_refresh(self._paint_rows)
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()
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.
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))
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
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)
264 def _is_active(self, entry: ProfileEntry) -> bool:
265 return self._state is not None and entry.path == self._state.active_path
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]
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)
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)
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)
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)
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)
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
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
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()
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)
357 def action_close(self) -> None:
358 self.dismiss(None)
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()
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)
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 )
384 def _write(request: SaveRequest) -> profiles.ProfileLocation:
385 return profiles.duplicate(ProfileStore(), entry.name, request.name, request.folder)
387 def _done(location: profiles.ProfileLocation) -> None:
388 self.notify(msg.PROFILE_SAVED.format(name=location.name, path=location.path))
389 self._changed()
391 self._ask(dialog, _write, _done)
393 def _rename(self, state: LibraryState) -> None:
394 entry = self._target()
395 if entry is None:
396 return
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)
408 def _write(request: SaveRequest) -> profiles.ProfileLocation:
409 return profiles.rename(ProfileStore(), entry.name, request.name)
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)
415 run_profile_op(self, lambda: profiles.owned(ProfileStore(), entry.name), _ask_name)
417 def _delete(self, state: LibraryState) -> None:
418 entry = self._target()
419 if entry is None:
420 return
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)
432 def _on_answer(confirmed: bool | None) -> None:
433 if confirmed:
434 run_profile_op(self, lambda: profiles.delete(ProfileStore(), entry.name), _done)
436 def _done(location: profiles.ProfileLocation) -> None:
437 self.notify(msg.PROFILE_DELETED.format(path=location.path))
438 self._changed()
440 run_profile_op(self, lambda: profiles.owned(ProfileStore(), entry.name), _confirm)
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 )
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()
458 self.app.push_screen(dialog, _done)
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)
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 )
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()
478 self.app.push_screen(dialog, _done)
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*."""
488 def _on_answer(request: R | None) -> None:
489 if request is not None:
490 run_profile_op(self, lambda: write(request), done)
492 self.app.push_screen(dialog, _on_answer)