Coverage for src/lilbee/cli/tui/screens/profile_tab.py: 100%
181 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 Settings Profile tab: pick a profile, see your changes, and save, update or discard them."""
3from __future__ import annotations
5from collections.abc import Callable
6from dataclasses import dataclass
7from enum import StrEnum
8from typing import TYPE_CHECKING
10from textual import on
11from textual.app import ComposeResult
12from textual.containers import Horizontal, Vertical
13from textual.content import Content
14from textual.message import Message
15from textual.widgets import DataTable, Select, Static
17from lilbee.app import profiles
18from lilbee.app.profiles import (
19 ActiveProfile,
20 ChangeRow,
21 DiscardResult,
22 ProfileStatus,
23)
24from lilbee.cli.tui import messages as msg
25from lilbee.cli.tui.pill import pill
26from lilbee.cli.tui.screens.profile_dialogs import (
27 SaveProfileDialog,
28 SaveRequest,
29 add_cost_column,
30 credit_text,
31 effect_cell,
32 free_copy_name,
33 has_project_folder,
34 run_profile_op,
35 save_folders,
36 start_profile_switch,
37 value_text,
38)
39from lilbee.cli.tui.screens.profile_library import ProfileLibrary
40from lilbee.cli.tui.screens.settings_widgets import user_pill
41from lilbee.cli.tui.widgets.confirm_dialog import ConfirmPill
42from lilbee.core.profile_files import (
43 DEFAULT_PROFILE_NAME,
44 ProfileCatalog,
45 ProfileFolder,
46 ProfileStore,
47)
49if TYPE_CHECKING:
50 from lilbee.cli.tui.app import LilbeeApp
53def _action_labels(name: str) -> dict[ProfileAction, str]:
54 """The text of each action pill while *name* is the active profile."""
55 return {
56 ProfileAction.UPDATE: msg.PROFILE_ACTION_UPDATE.format(name=name),
57 ProfileAction.SAVE_AS: msg.PROFILE_ACTION_SAVE_AS,
58 ProfileAction.DISCARD: msg.PROFILE_ACTION_DISCARD,
59 ProfileAction.REAPPLY: msg.PROFILE_ACTION_REAPPLY.format(name=name),
60 }
63PROFILE_SELECT_ID = "profile-select"
64_NOT_LOADED = ""
65_OWNED_FOLDERS = frozenset({ProfileFolder.PROJECT, ProfileFolder.GLOBAL})
68class ProfileAction(StrEnum):
69 """An action pill on the Profile tab."""
71 NEW = "new"
72 UPDATE = "update"
73 SAVE_AS = "save_as"
74 DISCARD = "discard"
75 REAPPLY = "reapply"
76 MANAGE = "manage"
79_ROW_ACTIONS = (
80 ProfileAction.UPDATE,
81 ProfileAction.SAVE_AS,
82 ProfileAction.DISCARD,
83 ProfileAction.REAPPLY,
84)
87@dataclass(frozen=True)
88class ProfileSnapshot:
89 """The active profile and your changes, every profile, and whether a project folder exists."""
91 active: ActiveProfile
92 catalog: ProfileCatalog
93 has_project: bool
96def load_snapshot() -> ProfileSnapshot:
97 """Scan the profile folders and read config.toml; runs off the event loop."""
98 store = ProfileStore()
99 return ProfileSnapshot(
100 active=profiles.active(store),
101 catalog=profiles.list_profiles(store),
102 has_project=has_project_folder(),
103 )
106class ProfileTab(Vertical):
107 """The Profile tab body; ``show`` fills it from a snapshot the screen loads."""
109 app: LilbeeApp # type: ignore[assignment]
111 class Stale(Message):
112 """The profile state changed or a dialog closed; the screen reloads the snapshot."""
114 def __init__(self) -> None:
115 super().__init__(id="profile-tab")
116 self._snapshot: ProfileSnapshot | None = None
117 self._actions: dict[ProfileAction, Callable[[ProfileSnapshot], None]] = {
118 ProfileAction.NEW: self._new,
119 ProfileAction.UPDATE: self._update,
120 ProfileAction.SAVE_AS: self._save_as,
121 ProfileAction.DISCARD: self._discard,
122 ProfileAction.REAPPLY: self._reapply,
123 ProfileAction.MANAGE: self._manage,
124 }
126 def compose(self) -> ComposeResult:
127 yield Static(
128 Content.assemble(msg.PROFILE_TAB_TITLE, " ", pill("select", "$warning", "$text")),
129 id="profile-title",
130 classes="setting-title",
131 )
132 yield Static(msg.PROFILE_TAB_HELP, classes="setting-help")
133 with Horizontal(classes="setting-editor-row"):
134 yield Select[str](
135 [(_NOT_LOADED, _NOT_LOADED)],
136 id=PROFILE_SELECT_ID,
137 classes="setting-editor",
138 allow_blank=False,
139 )
140 yield ConfirmPill("+", pill_id="profile-new", answer=ProfileAction.NEW)
141 yield Static("", id="profile-description", markup=False)
142 yield Static("", id="profile-credit", markup=False)
143 yield Static("", id="profile-folder", markup=False)
144 yield Static("", id="profile-status-note", markup=False)
145 yield Static(
146 msg.PROFILE_CHANGES_TITLE,
147 id="profile-changes-title",
148 classes="setting-title profile-section",
149 )
150 yield Static("", id="profile-changes-help", classes="setting-help", markup=False)
151 table: DataTable[str | Content] = DataTable(id="profile-changes", cursor_type="none")
152 table.add_columns(msg.PROFILE_COL_SETTING, msg.PROFILE_COL_YOURS, msg.PROFILE_COL_PROFILE)
153 add_cost_column(table)
154 yield table
155 with Horizontal(id="profile-actions"):
156 for action in _ROW_ACTIONS:
157 yield ConfirmPill(
158 _action_labels(DEFAULT_PROFILE_NAME)[action],
159 pill_id=f"profile-{action.value}",
160 answer=action,
161 primary=action is ProfileAction.UPDATE,
162 )
163 yield Static(msg.PROFILE_ALL_TITLE, classes="setting-title profile-section")
164 yield Static(msg.PROFILE_ALL_HELP, classes="setting-help")
165 with Horizontal(id="profile-manage-row"):
166 yield ConfirmPill(
167 msg.PROFILE_MANAGE_LABEL, pill_id="profile-manage", answer=ProfileAction.MANAGE
168 )
170 def on_mount(self) -> None:
171 self._stale()
173 def show(self, snapshot: ProfileSnapshot) -> None:
174 """Fill every part of the tab from *snapshot*."""
175 self._snapshot = snapshot
176 self._show_header(snapshot)
177 self._show_changes(snapshot)
178 self._show_actions(snapshot)
180 def show_failure(self, reason: str) -> None:
181 """Tell the user why the profiles did not load."""
182 self._snapshot = None
183 self.query_one(f"#{PROFILE_SELECT_ID}", Select).disabled = True
184 for action in _ROW_ACTIONS:
185 self.query_one(f"#profile-{action.value}", ConfirmPill).display = False
186 line = self.query_one("#profile-description", Static)
187 line.update(msg.PROFILE_SCAN_FAILED.format(reason=reason))
188 line.display = True
190 def _show_header(self, snapshot: ProfileSnapshot) -> None:
191 active = snapshot.active
192 names = snapshot.catalog.usable_names()
193 if active.name not in names:
194 names = [active.name, *names]
195 select = self.query_one(f"#{PROFILE_SELECT_ID}", Select)
196 select.disabled = False
197 with select.prevent(Select.Changed):
198 select.set_options((_option_label(snapshot.catalog, name), name) for name in names)
199 select.value = active.name
200 profile = active.entry.file if active.entry is not None else None
201 description = profile.description if profile is not None else None
202 credit = credit_text(profile) if profile is not None else ""
203 folder = msg.PROFILE_FOLDER_TEXT[active.entry.folder] if active.entry is not None else ""
204 note = profiles.status_note(active.status)
205 for widget_id, text in (
206 ("profile-description", description),
207 ("profile-credit", credit),
208 ("profile-folder", folder),
209 ("profile-status-note", note),
210 ):
211 line = self.query_one(f"#{widget_id}", Static)
212 line.update(text or "")
213 line.display = bool(text)
215 def _show_changes(self, snapshot: ProfileSnapshot) -> None:
216 name = snapshot.active.name
217 changes = snapshot.active.changes
218 title = msg.PROFILE_CHANGES_TITLE
219 self.query_one("#profile-changes-title", Static).update(
220 Content.assemble(title, " ", user_pill()) if changes else title
221 )
222 text = msg.PROFILE_CHANGES_HELP if changes else msg.PROFILE_CHANGES_NONE
223 self.query_one("#profile-changes-help", Static).update(text.format(name=name))
224 table = self.query_one("#profile-changes", DataTable)
225 table.display = bool(changes)
226 table.clear()
227 table.add_rows(_change_cells(row) for row in changes)
229 def _show_actions(self, snapshot: ProfileSnapshot) -> None:
230 active = snapshot.active
231 owned = active.entry is not None and active.entry.folder in _OWNED_FOLDERS
232 current = active.status is ProfileStatus.CURRENT
233 shown = {
234 ProfileAction.UPDATE: owned and current and bool(active.changes),
235 ProfileAction.SAVE_AS: True,
236 ProfileAction.DISCARD: bool(active.changes),
237 ProfileAction.REAPPLY: active.status is ProfileStatus.CHANGED,
238 }
239 labels = _action_labels(active.name)
240 for action in _ROW_ACTIONS:
241 pill = self.query_one(f"#profile-{action.value}", ConfirmPill)
242 pill.update(labels[action])
243 pill.display = shown[action]
245 def focus_select(self) -> None:
246 """Put focus on the profile dropdown."""
247 self.query_one(f"#{PROFILE_SELECT_ID}", Select).focus()
249 def _stale(self) -> None:
250 self.post_message(self.Stale())
252 @on(Select.Changed, f"#{PROFILE_SELECT_ID}")
253 def _on_pick(self, event: Select.Changed) -> None:
254 event.stop()
255 if event.value != _NOT_LOADED:
256 start_profile_switch(self.app, self, str(event.value), self._stale)
258 @on(ConfirmPill.Picked)
259 def _on_action(self, event: ConfirmPill.Picked) -> None:
260 event.stop()
261 if self._snapshot is not None:
262 self._actions[ProfileAction(str(event.answer))](self._snapshot)
264 def _update(self, snapshot: ProfileSnapshot) -> None:
265 def _done(result: profiles.SaveResult) -> None:
266 self.app.publish_settings(result.absorbed)
267 self.notify(msg.PROFILE_UPDATED.format(name=result.location.name))
268 self.app.notify_warnings(result.warnings)
269 self._stale()
271 run_profile_op(self, lambda: profiles.update(ProfileStore()), _done)
273 def _new(self, snapshot: ProfileSnapshot) -> None:
274 folder = save_folders(snapshot.has_project)[0]
275 taken = snapshot.catalog
276 self._save_as(snapshot, initial=free_copy_name(snapshot.active.name, folder, taken))
278 def _save_as(self, snapshot: ProfileSnapshot, initial: str = "") -> None:
279 dialog = SaveProfileDialog(
280 title=msg.PROFILE_SAVE_TITLE,
281 explain=msg.profile_save_explain(snapshot.active.name, len(snapshot.active.changes)),
282 catalog=snapshot.catalog,
283 folders=save_folders(snapshot.has_project),
284 initial=initial,
285 )
286 self.app.push_screen(dialog, self._on_save_request)
288 def _on_save_request(self, request: SaveRequest | None) -> None:
289 if request is None:
290 return
292 def _done(result: profiles.SaveResult) -> None:
293 self.app.publish_settings(result.absorbed)
294 location = result.location
295 self.notify(msg.PROFILE_SAVED.format(name=location.name, path=location.path))
296 self.app.notify_warnings(result.warnings)
297 self._stale()
299 run_profile_op(self, lambda: profiles.save_as(request.name, request.folder), _done)
301 def _discard(self, snapshot: ProfileSnapshot) -> None:
302 name = snapshot.active.name
304 # Leaving Settings asks about a rebuild, once, for every edit and discard of the visit.
305 def _done(result: DiscardResult) -> None:
306 self.app.publish_settings(result.dropped)
307 self.notify(msg.PROFILE_DISCARDED.format(name=name))
308 self.app.notify_warnings(result.warnings)
309 self._stale()
311 run_profile_op(self, profiles.discard, _done)
313 def _reapply(self, snapshot: ProfileSnapshot) -> None:
314 start_profile_switch(self.app, self, snapshot.active.name, self._stale)
316 def _manage(self, snapshot: ProfileSnapshot) -> None:
317 self.app.push_screen(ProfileLibrary(on_change=self._stale))
320def _option_label(catalog: ProfileCatalog, name: str) -> str:
321 """The dropdown text for *name*: the name, then the folder it is saved in."""
322 entry = catalog.find(name)
323 return name if entry is None else f"{name} ({msg.PROFILE_FOLDER_TAG[entry.folder]})"
326def _change_cells(row: ChangeRow) -> tuple[str | Content, ...]:
327 return (row.key, value_text(row.yours), value_text(row.profile_value), effect_cell(row.effect))