Skip to content

Commit 01f6e7a

Browse files
shuvebclaude
andcommitted
Refactor TUI from screen-pushing to panel-swapping navigation model
Replace pushed Screens (ProjectListScreen, ModuleListScreen, FeatureListScreen) with Widget-based panels that mount inside #main-content, keeping chrome (K9sHeader, CommandBar, StatusBar) always visible. Move navigation bindings and logic into MFBTApp, integrate breadcrumb into K9sHeader, fix token manager to preserve existing auth fields on refresh, and update tests accordingly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1967cc9 commit 01f6e7a

9 files changed

Lines changed: 459 additions & 320 deletions

File tree

src/mfbt/token_manager.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -95,17 +95,18 @@ def __init__(
9595
self.base_url = base_url
9696

9797
def save_tokens(self, token_response: TokenResponse) -> None:
98-
"""Save tokens to auth.json, adding issued_at timestamp."""
99-
from mfbt.config import save_auth
100-
101-
auth_data: dict[str, Any] = {
102-
"access_token": token_response.access_token,
103-
"refresh_token": token_response.refresh_token,
104-
"token_type": token_response.token_type,
105-
"expires_at": token_response.expires_at,
106-
"issued_at": token_response.issued_at
107-
or datetime.now(timezone.utc).isoformat(),
108-
}
98+
"""Save tokens to auth.json, preserving existing fields like client_id."""
99+
from mfbt.config import load_auth, save_auth
100+
101+
# Load existing auth to preserve client_id and other fields
102+
auth_data = load_auth(self.project_root)
103+
auth_data["access_token"] = token_response.access_token
104+
auth_data["refresh_token"] = token_response.refresh_token
105+
auth_data["token_type"] = token_response.token_type
106+
auth_data["expires_at"] = token_response.expires_at
107+
auth_data["issued_at"] = (
108+
token_response.issued_at or datetime.now(timezone.utc).isoformat()
109+
)
109110
save_auth(self.project_root, auth_data)
110111
logger.info("Tokens saved via TokenManager")
111112

@@ -409,7 +410,11 @@ def _run(self) -> None:
409410
except TokenRefreshNetworkError:
410411
# Transient — log and keep monitoring
411412
logger.warning("Token refresh transient failure, will retry next cycle")
413+
if self._on_refreshed is not None:
414+
self._on_refreshed()
412415
except Exception:
413416
logger.exception("Unexpected error in token monitor")
417+
if self._on_refreshed is not None:
418+
self._on_refreshed()
414419

415420
self._stop_event.wait(timeout=self._check_interval)

src/mfbt/tui/app.py

Lines changed: 235 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
"""Main Textual application for the mfbt TUI."""
1+
"""Main Textual application for the mfbt TUI.
2+
3+
Uses a panel-swapping model: the chrome (K9sHeader, CommandBar,
4+
StatusBar) stays mounted at all times. Content panels (project list, module
5+
list, feature list) are swapped inside #main-content. Modal overlays (feature
6+
detail, info modal, help) are pushed as ModalScreen on top.
7+
"""
28

39
from __future__ import annotations
410

@@ -11,7 +17,6 @@
1117

1218
from mfbt.tui.data_provider import TUIDataProvider
1319
from mfbt.tui.navigation import NavigationItem, NavigationLevel, NavigationState
14-
from mfbt.tui.widgets.breadcrumb_bar import BreadcrumbBar
1520
from mfbt.tui.widgets.command_bar import CommandBar
1621
from mfbt.tui.widgets.k9s_header import K9sHeader
1722
from mfbt.tui.widgets.status_bar import StatusBar
@@ -59,6 +64,10 @@ class MFBTApp(App):
5964
BINDINGS = [
6065
Binding("q", "quit", "Quit", priority=True),
6166
Binding("question_mark", "show_help", "Help", show=False),
67+
Binding("escape", "go_back", "Back", show=False),
68+
Binding("backspace", "go_back", "Back", show=False),
69+
Binding("r", "refresh", "Refresh", show=False),
70+
Binding("d", "describe", "Describe", show=False),
6271
]
6372

6473
def __init__(
@@ -79,15 +88,12 @@ def __init__(
7988

8089
def compose(self) -> ComposeResult:
8190
yield K9sHeader(id="k9s-header")
82-
yield BreadcrumbBar(id="breadcrumb-bar")
8391
yield Container(id="main-content")
8492
yield CommandBar(id="command-bar")
8593
yield StatusBar(id="status-bar")
8694

8795
def on_mount(self) -> None:
88-
from mfbt.tui.screens.project_list import ProjectListScreen
89-
90-
self.push_screen(ProjectListScreen(self.data_provider))
96+
self._show_project_list()
9197
self._update_nav_display()
9298

9399
# Wire up transport change callback
@@ -104,6 +110,227 @@ def _on_transport(mode: TransportMode) -> None:
104110
# Start background token monitor
105111
self._start_token_monitor()
106112

113+
# ---- Content panel swapping ----------------------------------------
114+
115+
def _clear_main_content(self) -> None:
116+
"""Remove all children from #main-content."""
117+
container = self.query_one("#main-content", Container)
118+
container.remove_children()
119+
120+
def _show_project_list(self) -> None:
121+
"""Mount the project list panel."""
122+
from mfbt.tui.screens.project_list import ProjectListPanel
123+
124+
self._clear_main_content()
125+
container = self.query_one("#main-content", Container)
126+
container.mount(ProjectListPanel(self.data_provider))
127+
128+
def _show_module_list(
129+
self,
130+
project_id: str,
131+
project_name: str,
132+
project_key: str,
133+
project_metadata: dict[str, Any],
134+
) -> None:
135+
"""Mount the module list panel for a project."""
136+
from mfbt.tui.screens.module_list import ModuleListPanel
137+
138+
self._clear_main_content()
139+
container = self.query_one("#main-content", Container)
140+
container.mount(
141+
ModuleListPanel(
142+
data_provider=self.data_provider,
143+
project_id=project_id,
144+
project_name=project_name,
145+
project_key=project_key,
146+
project_metadata=project_metadata,
147+
)
148+
)
149+
150+
def _show_feature_list(
151+
self,
152+
project_id: str,
153+
project_name: str,
154+
project_key: str,
155+
project_metadata: dict[str, Any],
156+
module_id: str,
157+
module_name: str,
158+
module_key: str,
159+
module_metadata: dict[str, Any],
160+
) -> None:
161+
"""Mount the feature list panel for a module."""
162+
from mfbt.tui.screens.feature_list import FeatureListPanel
163+
164+
self._clear_main_content()
165+
container = self.query_one("#main-content", Container)
166+
container.mount(
167+
FeatureListPanel(
168+
data_provider=self.data_provider,
169+
project_id=project_id,
170+
project_name=project_name,
171+
module_id=module_id,
172+
module_name=module_name,
173+
module_key=module_key,
174+
module_metadata=module_metadata,
175+
)
176+
)
177+
178+
# ---- Message handlers from content panels --------------------------
179+
180+
def on_project_list_panel_project_selected(
181+
self, event: Any
182+
) -> None:
183+
"""Handle project selection: navigate to modules."""
184+
data = event.data
185+
project_id = data.get("id", "")
186+
project_name = data.get("name", "Unknown")
187+
project_key = data.get("key", data.get("short_url_id", ""))
188+
tech_stack = data.get("tech_stack", "")
189+
if isinstance(tech_stack, list):
190+
tech_stack = ", ".join(tech_stack[:3])
191+
192+
project_metadata = {
193+
"tech_stack": tech_stack,
194+
"status": data.get("status", ""),
195+
"description": data.get("description", ""),
196+
}
197+
198+
# Push nav state
199+
self.nav_state.drill_down(
200+
NavigationItem(
201+
id=project_id,
202+
name=project_name,
203+
level=NavigationLevel.PROJECTS,
204+
key=project_key,
205+
metadata=project_metadata,
206+
)
207+
)
208+
209+
self._show_module_list(project_id, project_name, project_key, project_metadata)
210+
self._update_nav_display()
211+
212+
def on_module_list_panel_module_selected(
213+
self, event: Any
214+
) -> None:
215+
"""Handle module selection: navigate to features."""
216+
data = event.data
217+
module_id = data.get("id", "")
218+
module_name = data.get("title", data.get("name", "Unknown"))
219+
module_key = data.get("module_key", data.get("key", ""))
220+
feature_count = data.get("feature_count", 0)
221+
222+
module_metadata = {
223+
"feature_count": feature_count or 0,
224+
}
225+
226+
# The project info is carried in the event
227+
project_id = event.project_id
228+
project_name = event.project_name
229+
project_key = event.project_key
230+
project_metadata = event.project_metadata
231+
232+
# Push nav state
233+
self.nav_state.drill_down(
234+
NavigationItem(
235+
id=module_id,
236+
name=module_name,
237+
level=NavigationLevel.MODULES,
238+
key=module_key,
239+
metadata=module_metadata,
240+
)
241+
)
242+
243+
self._show_feature_list(
244+
project_id=project_id,
245+
project_name=project_name,
246+
project_key=project_key,
247+
project_metadata=project_metadata,
248+
module_id=module_id,
249+
module_name=module_name,
250+
module_key=module_key,
251+
module_metadata=module_metadata,
252+
)
253+
self._update_nav_display()
254+
255+
def on_feature_list_panel_feature_selected(
256+
self, event: Any
257+
) -> None:
258+
"""Handle feature selection: open detail modal."""
259+
from mfbt.tui.screens.feature_detail import FeatureDetailScreen
260+
261+
feature_id = event.data.get("id", "")
262+
self.push_screen(
263+
FeatureDetailScreen(
264+
data_provider=self.data_provider,
265+
feature_id=feature_id,
266+
feature_data=event.data,
267+
)
268+
)
269+
270+
# ---- Describe handlers from content panels -------------------------
271+
272+
def _handle_describe(self, data: dict[str, Any] | None, resource_type: str) -> None:
273+
"""Open info modal for the given resource."""
274+
if data:
275+
from mfbt.tui.screens.info_modal import InfoModal
276+
277+
self.push_screen(InfoModal(data=data, resource_type=resource_type))
278+
279+
# ---- Navigation actions -------------------------------------------
280+
281+
def action_go_back(self) -> None:
282+
"""Navigate back one level."""
283+
level = self.nav_state.level
284+
if level == NavigationLevel.PROJECTS:
285+
return # Already at root, do nothing
286+
elif level == NavigationLevel.MODULES:
287+
self.nav_state.go_up()
288+
self._show_project_list()
289+
elif level == NavigationLevel.FEATURES:
290+
self.nav_state.go_up()
291+
# Restore module list
292+
if self.nav_state.stack:
293+
proj = self.nav_state.stack[0]
294+
self._show_module_list(
295+
project_id=proj.id,
296+
project_name=proj.name,
297+
project_key=proj.key,
298+
project_metadata=proj.metadata,
299+
)
300+
self._update_nav_display()
301+
302+
def action_refresh(self) -> None:
303+
"""Refresh the current content panel."""
304+
from mfbt.tui.screens.feature_list import FeatureListPanel
305+
from mfbt.tui.screens.module_list import ModuleListPanel
306+
from mfbt.tui.screens.project_list import ProjectListPanel
307+
308+
container = self.query_one("#main-content", Container)
309+
for child in container.children:
310+
if isinstance(child, (ProjectListPanel, ModuleListPanel, FeatureListPanel)):
311+
child.refresh_data()
312+
break
313+
314+
def action_describe(self) -> None:
315+
"""Show info modal for the selected item."""
316+
from mfbt.tui.screens.feature_list import FeatureListPanel
317+
from mfbt.tui.screens.module_list import ModuleListPanel
318+
from mfbt.tui.screens.project_list import ProjectListPanel
319+
320+
container = self.query_one("#main-content", Container)
321+
for child in container.children:
322+
if isinstance(child, ProjectListPanel):
323+
self._handle_describe(child.get_selected_data(), "project")
324+
return
325+
if isinstance(child, ModuleListPanel):
326+
self._handle_describe(child.get_selected_data(), "module")
327+
return
328+
if isinstance(child, FeatureListPanel):
329+
self._handle_describe(child.get_selected_data(), "feature")
330+
return
331+
332+
# ---- Token monitor -------------------------------------------------
333+
107334
def _start_token_monitor(self) -> None:
108335
"""Start the background token refresh monitor."""
109336
from mfbt.token_manager import BackgroundTokenMonitor
@@ -150,8 +377,7 @@ def _update_transport(self, mode: TransportMode) -> None:
150377
except Exception:
151378
pass
152379

153-
def on_screen_resume(self) -> None:
154-
self._update_nav_display()
380+
# ---- Display updates -----------------------------------------------
155381

156382
def on_resize(self) -> None:
157383
if self.size.width < 80:
@@ -171,13 +397,6 @@ def action_show_help(self) -> None:
171397

172398
def _update_nav_display(self) -> None:
173399
"""Update breadcrumb, header, command bar, and status bar from nav state."""
174-
# Breadcrumb bar
175-
try:
176-
breadcrumb = self.query_one("#breadcrumb-bar", BreadcrumbBar)
177-
breadcrumb.path_rich = self.nav_state.breadcrumb_path_rich
178-
except Exception:
179-
pass
180-
181400
# Command bar hints
182401
try:
183402
command_bar = self.query_one("#command-bar", CommandBar)
@@ -191,6 +410,7 @@ def _update_nav_display(self) -> None:
191410
# K9s header context
192411
try:
193412
header = self.query_one("#k9s-header", K9sHeader)
413+
header.breadcrumb = self.nav_state.breadcrumb_path_rich
194414
level = self.nav_state.level
195415
header.context_level = level.value
196416

@@ -227,43 +447,3 @@ def _update_nav_display(self) -> None:
227447
status_bar.resource_type = "Features"
228448
except Exception:
229449
pass
230-
231-
def push_screen( # type: ignore[override]
232-
self, screen: Any, *args: Any, **kwargs: Any
233-
) -> Any:
234-
"""Override to track navigation state on screen push."""
235-
# Track navigation for non-modal screens
236-
from mfbt.tui.screens.feature_list import FeatureListScreen
237-
from mfbt.tui.screens.module_list import ModuleListScreen
238-
239-
if isinstance(screen, ModuleListScreen):
240-
self.nav_state.drill_down(
241-
NavigationItem(
242-
id=screen.project_id,
243-
name=screen.project_name,
244-
level=NavigationLevel.PROJECTS,
245-
key=getattr(screen, "project_key", ""),
246-
metadata=getattr(screen, "project_metadata", {}),
247-
)
248-
)
249-
elif isinstance(screen, FeatureListScreen):
250-
self.nav_state.drill_down(
251-
NavigationItem(
252-
id=screen.module_id,
253-
name=screen.module_name,
254-
level=NavigationLevel.MODULES,
255-
key=getattr(screen, "module_key", ""),
256-
metadata=getattr(screen, "module_metadata", {}),
257-
)
258-
)
259-
260-
result = super().push_screen(screen, *args, **kwargs)
261-
self._update_nav_display()
262-
return result
263-
264-
def pop_screen(self) -> Any:
265-
"""Override to track navigation state on screen pop."""
266-
self.nav_state.go_up()
267-
result = super().pop_screen()
268-
self._update_nav_display()
269-
return result

0 commit comments

Comments
 (0)