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

1"""The Settings Profile tab: pick a profile, see your changes, and save, update or discard them.""" 

2 

3from __future__ import annotations 

4 

5from collections.abc import Callable 

6from dataclasses import dataclass 

7from enum import StrEnum 

8from typing import TYPE_CHECKING 

9 

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 

16 

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) 

48 

49if TYPE_CHECKING: 

50 from lilbee.cli.tui.app import LilbeeApp 

51 

52 

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 } 

61 

62 

63PROFILE_SELECT_ID = "profile-select" 

64_NOT_LOADED = "" 

65_OWNED_FOLDERS = frozenset({ProfileFolder.PROJECT, ProfileFolder.GLOBAL}) 

66 

67 

68class ProfileAction(StrEnum): 

69 """An action pill on the Profile tab.""" 

70 

71 NEW = "new" 

72 UPDATE = "update" 

73 SAVE_AS = "save_as" 

74 DISCARD = "discard" 

75 REAPPLY = "reapply" 

76 MANAGE = "manage" 

77 

78 

79_ROW_ACTIONS = ( 

80 ProfileAction.UPDATE, 

81 ProfileAction.SAVE_AS, 

82 ProfileAction.DISCARD, 

83 ProfileAction.REAPPLY, 

84) 

85 

86 

87@dataclass(frozen=True) 

88class ProfileSnapshot: 

89 """The active profile and your changes, every profile, and whether a project folder exists.""" 

90 

91 active: ActiveProfile 

92 catalog: ProfileCatalog 

93 has_project: bool 

94 

95 

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 ) 

104 

105 

106class ProfileTab(Vertical): 

107 """The Profile tab body; ``show`` fills it from a snapshot the screen loads.""" 

108 

109 app: LilbeeApp # type: ignore[assignment] 

110 

111 class Stale(Message): 

112 """The profile state changed or a dialog closed; the screen reloads the snapshot.""" 

113 

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 } 

125 

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 ) 

169 

170 def on_mount(self) -> None: 

171 self._stale() 

172 

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) 

179 

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 

189 

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) 

214 

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) 

228 

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] 

244 

245 def focus_select(self) -> None: 

246 """Put focus on the profile dropdown.""" 

247 self.query_one(f"#{PROFILE_SELECT_ID}", Select).focus() 

248 

249 def _stale(self) -> None: 

250 self.post_message(self.Stale()) 

251 

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) 

257 

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) 

263 

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() 

270 

271 run_profile_op(self, lambda: profiles.update(ProfileStore()), _done) 

272 

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)) 

277 

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) 

287 

288 def _on_save_request(self, request: SaveRequest | None) -> None: 

289 if request is None: 

290 return 

291 

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() 

298 

299 run_profile_op(self, lambda: profiles.save_as(request.name, request.folder), _done) 

300 

301 def _discard(self, snapshot: ProfileSnapshot) -> None: 

302 name = snapshot.active.name 

303 

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() 

310 

311 run_profile_op(self, profiles.discard, _done) 

312 

313 def _reapply(self, snapshot: ProfileSnapshot) -> None: 

314 start_profile_switch(self.app, self, snapshot.active.name, self._stale) 

315 

316 def _manage(self, snapshot: ProfileSnapshot) -> None: 

317 self.app.push_screen(ProfileLibrary(on_change=self._stale)) 

318 

319 

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]})" 

324 

325 

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))