detached d6476fb

feat(ui): implement tab workspace switching with coordinator callbacks

- Track currentContent in MainWindow to properly swap workspace views
- Fix SetContent() to remove old content before adding new workspace
- Add onTabSwitched callback to TabCoordinator for workspace view switching
- Implement Switch() method in TabCoordinator for direct tab switching
- Implement SwitchByIndex() for Alt+1-9 switching
- Implement SwitchToLastActive() for Alt+Tab style switching
- Update Create() to set new tab as active and switch workspace view
- Update Close() to switch workspace view after tab removal
- Update SwitchNext/SwitchPrev to invoke onTabSwitched callback
- Move border overlay attachment to MainWindow for all-tab visibility
- Add switchWorkspaceView() method in App to orchestrate view swapping
- Wire TabBar.SetOnSwitch to TabCoordinator for click-based switching
commit
d6476fbcd572f95dc253ffdaaa1b6ccf992bfeb4
author
brice · · 2025-12-18 06:44 UTC
parent
3d9e595
signature
unsigned
3 files · +182 -12
M internal/ui/app.go +52 -4
@@ -183,6 +183,15 @@ func (a *App) onActivate(ctx context.Context) {
183183 // Initialize border manager for mode indicators
184184 a.borderMgr = focus.NewBorderManager(a.widgetFactory)
185185
186+ // Attach border overlay to main window (visible for all tabs)
187+ if a.borderMgr != nil && a.mainWindow != nil {
188+ if borderWidget := a.borderMgr.Widget(); borderWidget != nil {
189+ if gtkWidget := borderWidget.GtkWidget(); gtkWidget != nil {
190+ a.mainWindow.AddOverlay(gtkWidget)
191+ }
192+ }
193+ }
194+
186195 // Initialize coordinators
187196 a.initCoordinators(ctx)
188197
@@ -286,8 +295,18 @@ func (a *App) initCoordinators(ctx context.Context) {
286295 a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
287296 a.createWorkspaceView(ctx, tab)
288297 })
298+ a.tabCoord.SetOnTabSwitched(func(ctx context.Context, tab *entity.Tab) {
299+ a.switchWorkspaceView(ctx, tab.ID)
300+ })
289301 a.tabCoord.SetOnQuit(a.Quit)
290302
303+ // Wire tab bar click handling to coordinator
304+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
305+ a.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) {
306+ a.tabCoord.Switch(ctx, tabID)
307+ })
308+ }
309+
291310 // 3. Workspace Coordinator
292311 a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
293312 PanesUC: a.panesUC,
@@ -411,10 +430,8 @@ func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
411430 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
412431 }
413432
414- // Attach border manager overlay for mode indicators
415- if a.borderMgr != nil {
416- wsView.SetModeBorderOverlay(a.borderMgr.Widget())
417- }
433+ // Note: Border overlay is attached to MainWindow, not per-workspace
434+ // This ensures it's visible regardless of which tab is active
418435
419436 // Set omnibox config for this workspace view
420437 wsView.SetOmniboxConfig(a.omniboxCfg)
@@ -454,6 +471,37 @@ func (a *App) activeWorkspaceView() *component.WorkspaceView {
454471 return a.workspaceViews[activeTab.ID]
455472 }
456473
474+// switchWorkspaceView swaps the displayed workspace view for a tab.
475+func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
476+ log := logging.FromContext(ctx)
477+
478+ wsView, exists := a.workspaceViews[tabID]
479+ if !exists {
480+ log.Warn().Str("tab_id", string(tabID)).Msg("no workspace view for tab")
481+ return
482+ }
483+
484+ // Get the workspace view's root widget
485+ widget := wsView.Widget()
486+ if widget == nil {
487+ log.Warn().Str("tab_id", string(tabID)).Msg("workspace view has no widget")
488+ return
489+ }
490+
491+ gtkWidget := widget.GtkWidget()
492+ if gtkWidget == nil {
493+ log.Warn().Str("tab_id", string(tabID)).Msg("workspace view widget has no GTK widget")
494+ return
495+ }
496+
497+ // Swap content (MainWindow.SetContent now properly removes old content)
498+ if a.mainWindow != nil {
499+ a.mainWindow.SetContent(gtkWidget)
500+ }
501+
502+ log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")
503+}
504+
457505 // ToggleOmnibox implements OmniboxProvider.
458506 // Toggles the omnibox visibility in the active workspace view.
459507 func (a *App) ToggleOmnibox(ctx context.Context) {
@@ -183,6 +183,15 @@ func (a *App) onActivate(ctx context.Context) {
183 // Initialize border manager for mode indicators183 // Initialize border manager for mode indicators
184 a.borderMgr = focus.NewBorderManager(a.widgetFactory)184 a.borderMgr = focus.NewBorderManager(a.widgetFactory)
185 185
186+ // Attach border overlay to main window (visible for all tabs)
187+ if a.borderMgr != nil && a.mainWindow != nil {
188+ if borderWidget := a.borderMgr.Widget(); borderWidget != nil {
189+ if gtkWidget := borderWidget.GtkWidget(); gtkWidget != nil {
190+ a.mainWindow.AddOverlay(gtkWidget)
191+ }
192+ }
193+ }
194+
186 // Initialize coordinators195 // Initialize coordinators
187 a.initCoordinators(ctx)196 a.initCoordinators(ctx)
188 197
@@ -286,8 +295,18 @@ func (a *App) initCoordinators(ctx context.Context) {
286 a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {295 a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
287 a.createWorkspaceView(ctx, tab)296 a.createWorkspaceView(ctx, tab)
288 })297 })
298+ a.tabCoord.SetOnTabSwitched(func(ctx context.Context, tab *entity.Tab) {
299+ a.switchWorkspaceView(ctx, tab.ID)
300+ })
289 a.tabCoord.SetOnQuit(a.Quit)301 a.tabCoord.SetOnQuit(a.Quit)
290 302
303+ // Wire tab bar click handling to coordinator
304+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
305+ a.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) {
306+ a.tabCoord.Switch(ctx, tabID)
307+ })
308+ }
309+
291 // 3. Workspace Coordinator310 // 3. Workspace Coordinator
292 a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{311 a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
293 PanesUC: a.panesUC,312 PanesUC: a.panesUC,
@@ -411,10 +430,8 @@ func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
411 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)430 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
412 }431 }
413 432
414- // Attach border manager overlay for mode indicators433+ // Note: Border overlay is attached to MainWindow, not per-workspace
415- if a.borderMgr != nil {434+ // This ensures it's visible regardless of which tab is active
416- wsView.SetModeBorderOverlay(a.borderMgr.Widget())
417- }
418 435
419 // Set omnibox config for this workspace view436 // Set omnibox config for this workspace view
420 wsView.SetOmniboxConfig(a.omniboxCfg)437 wsView.SetOmniboxConfig(a.omniboxCfg)
@@ -454,6 +471,37 @@ func (a *App) activeWorkspaceView() *component.WorkspaceView {
454 return a.workspaceViews[activeTab.ID]471 return a.workspaceViews[activeTab.ID]
455 }472 }
456 473
474+// switchWorkspaceView swaps the displayed workspace view for a tab.
475+func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
476+ log := logging.FromContext(ctx)
477+
478+ wsView, exists := a.workspaceViews[tabID]
479+ if !exists {
480+ log.Warn().Str("tab_id", string(tabID)).Msg("no workspace view for tab")
481+ return
482+ }
483+
484+ // Get the workspace view's root widget
485+ widget := wsView.Widget()
486+ if widget == nil {
487+ log.Warn().Str("tab_id", string(tabID)).Msg("workspace view has no widget")
488+ return
489+ }
490+
491+ gtkWidget := widget.GtkWidget()
492+ if gtkWidget == nil {
493+ log.Warn().Str("tab_id", string(tabID)).Msg("workspace view widget has no GTK widget")
494+ return
495+ }
496+
497+ // Swap content (MainWindow.SetContent now properly removes old content)
498+ if a.mainWindow != nil {
499+ a.mainWindow.SetContent(gtkWidget)
500+ }
501+
502+ log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")
503+}
504+
457 // ToggleOmnibox implements OmniboxProvider.505 // ToggleOmnibox implements OmniboxProvider.
458 // Toggles the omnibox visibility in the active workspace view.506 // Toggles the omnibox visibility in the active workspace view.
459 func (a *App) ToggleOmnibox(ctx context.Context) {507 func (a *App) ToggleOmnibox(ctx context.Context) {
M internal/ui/coordinator/tab.go +120 -3
@@ -19,8 +19,9 @@ type TabCoordinator struct {
1919 config *config.Config
2020
2121 // Callbacks to avoid circular dependencies
22- onTabCreated func(ctx context.Context, tab *entity.Tab)
23- onQuit func()
22+ onTabCreated func(ctx context.Context, tab *entity.Tab)
23+ onTabSwitched func(ctx context.Context, tab *entity.Tab)
24+ onQuit func()
2425 }
2526
2627 // TabCoordinatorConfig holds configuration for TabCoordinator.
@@ -49,6 +50,12 @@ func (c *TabCoordinator) SetOnTabCreated(fn func(ctx context.Context, tab *entit
4950 c.onTabCreated = fn
5051 }
5152
53+// SetOnTabSwitched sets the callback for when a tab switch occurs.
54+// This is used to swap workspace views in the content area.
55+func (c *TabCoordinator) SetOnTabSwitched(fn func(ctx context.Context, tab *entity.Tab)) {
56+ c.onTabSwitched = fn
57+}
58+
5259 // SetOnQuit sets the callback for when the last tab is closed.
5360 func (c *TabCoordinator) SetOnQuit(fn func()) {
5461 c.onQuit = fn
@@ -68,6 +75,9 @@ func (c *TabCoordinator) Create(ctx context.Context, initialURL string) (*entity
6875 return nil, err
6976 }
7077
78+ // Set new tab as active (updates domain state and tracks previous)
79+ c.tabs.SetActive(output.Tab.ID)
80+
7181 // Update tab bar
7282 if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
7383 c.mainWindow.TabBar().AddTab(output.Tab)
@@ -82,6 +92,11 @@ func (c *TabCoordinator) Create(ctx context.Context, initialURL string) (*entity
8292 c.onTabCreated(ctx, output.Tab)
8393 }
8494
95+ // Switch to the new tab's workspace view
96+ if c.onTabSwitched != nil {
97+ c.onTabSwitched(ctx, output.Tab)
98+ }
99+
85100 log.Debug().Str("tab_id", string(output.Tab.ID)).Msg("tab created")
86101 return output.Tab, nil
87102 }
@@ -119,10 +134,52 @@ func (c *TabCoordinator) Close(ctx context.Context) error {
119134 c.onQuit()
120135 }
121136
137+ // Switch workspace view to new active tab (if not last)
138+ if !wasLast && c.onTabSwitched != nil {
139+ if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
140+ c.onTabSwitched(ctx, tab)
141+ }
142+ }
143+
122144 log.Debug().Str("tab_id", string(activeID)).Bool("was_last", wasLast).Msg("tab closed")
123145 return nil
124146 }
125147
148+// Switch switches to a specific tab by ID.
149+func (c *TabCoordinator) Switch(ctx context.Context, tabID entity.TabID) error {
150+ log := logging.FromContext(ctx)
151+
152+ // Skip if already active
153+ if tabID == c.tabs.ActiveTabID {
154+ return nil
155+ }
156+
157+ log.Debug().
158+ Str("from", string(c.tabs.ActiveTabID)).
159+ Str("to", string(tabID)).
160+ Msg("switching tab")
161+
162+ // Update domain state
163+ if err := c.tabsUC.Switch(ctx, c.tabs, tabID); err != nil {
164+ log.Error().Err(err).Str("tab_id", string(tabID)).Msg("failed to switch tab")
165+ return err
166+ }
167+
168+ // Update tab bar UI
169+ if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
170+ c.mainWindow.TabBar().SetActive(tabID)
171+ }
172+
173+ // Invoke callback for workspace view switching
174+ if c.onTabSwitched != nil {
175+ if tab := c.tabs.Find(tabID); tab != nil {
176+ c.onTabSwitched(ctx, tab)
177+ }
178+ }
179+
180+ return nil
181+}
182+
126183 // SwitchNext switches to the next tab.
127184 func (c *TabCoordinator) SwitchNext(ctx context.Context) error {
128185 log := logging.FromContext(ctx)
@@ -136,7 +193,12 @@ func (c *TabCoordinator) SwitchNext(ctx context.Context) error {
136193 c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
137194 }
138195
139- // TODO: Add onTabSwitched callback for workspace view switching
196+ // Invoke callback for workspace view switching
197+ if c.onTabSwitched != nil {
198+ if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
199+ c.onTabSwitched(ctx, tab)
200+ }
201+ }
140202
141203 log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to next tab")
142204 return nil
@@ -155,10 +217,65 @@ func (c *TabCoordinator) SwitchPrev(ctx context.Context) error {
155217 c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
156218 }
157219
220+ // Invoke callback for workspace view switching
221+ if c.onTabSwitched != nil {
222+ if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
223+ c.onTabSwitched(ctx, tab)
224+ }
225+ }
226+
158227 log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to previous tab")
159228 return nil
160229 }
161230
231+// SwitchByIndex switches to a tab by 0-based index.
232+func (c *TabCoordinator) SwitchByIndex(ctx context.Context, index int) error {
233+ log := logging.FromContext(ctx)
234+
235+ if err := c.tabsUC.SwitchByIndex(ctx, c.tabs, index); err != nil {
236+ log.Error().Err(err).Int("index", index).Msg("failed to switch to tab by index")
237+ return err
238+ }
239+
240+ if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
241+ c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
242+ }
243+
244+ // Invoke callback for workspace view switching
245+ if c.onTabSwitched != nil {
246+ if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
247+ c.onTabSwitched(ctx, tab)
248+ }
249+ }
250+
251+ log.Debug().Int("index", index).Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to tab by index")
252+ return nil
253+}
254+
255+// SwitchToLastActive switches to the previously active tab (Alt+Tab style).
256+func (c *TabCoordinator) SwitchToLastActive(ctx context.Context) error {
257+ log := logging.FromContext(ctx)
258+
259+ if err := c.tabsUC.SwitchToLastActive(ctx, c.tabs); err != nil {
260+ log.Error().Err(err).Msg("failed to switch to last active tab")
261+ return err
262+ }
263+
264+ if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
265+ c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
266+ }
267+
268+ // Invoke callback for workspace view switching
269+ if c.onTabSwitched != nil {
270+ if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
271+ c.onTabSwitched(ctx, tab)
272+ }
273+ }
274+
275+ log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to last active tab")
276+ return nil
277+}
278+
162279 // UpdateBarVisibility shows or hides the tab bar based on tab count.
163280 func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context) {
164281 log := logging.FromContext(ctx)
@@ -19,8 +19,9 @@ type TabCoordinator struct {
19 config *config.Config19 config *config.Config
20 20
21 // Callbacks to avoid circular dependencies21 // Callbacks to avoid circular dependencies
22- onTabCreated func(ctx context.Context, tab *entity.Tab)22+ onTabCreated func(ctx context.Context, tab *entity.Tab)
23- onQuit func()23+ onTabSwitched func(ctx context.Context, tab *entity.Tab)
24+ onQuit func()
24 }25 }
25 26
26 // TabCoordinatorConfig holds configuration for TabCoordinator.27 // TabCoordinatorConfig holds configuration for TabCoordinator.
@@ -49,6 +50,12 @@ func (c *TabCoordinator) SetOnTabCreated(fn func(ctx context.Context, tab *entit
49 c.onTabCreated = fn50 c.onTabCreated = fn
50 }51 }
51 52
53+// SetOnTabSwitched sets the callback for when a tab switch occurs.
54+// This is used to swap workspace views in the content area.
55+func (c *TabCoordinator) SetOnTabSwitched(fn func(ctx context.Context, tab *entity.Tab)) {
56+ c.onTabSwitched = fn
57+}
58+
52 // SetOnQuit sets the callback for when the last tab is closed.59 // SetOnQuit sets the callback for when the last tab is closed.
53 func (c *TabCoordinator) SetOnQuit(fn func()) {60 func (c *TabCoordinator) SetOnQuit(fn func()) {
54 c.onQuit = fn61 c.onQuit = fn
@@ -68,6 +75,9 @@ func (c *TabCoordinator) Create(ctx context.Context, initialURL string) (*entity
68 return nil, err75 return nil, err
69 }76 }
70 77
78+ // Set new tab as active (updates domain state and tracks previous)
79+ c.tabs.SetActive(output.Tab.ID)
80+
71 // Update tab bar81 // Update tab bar
72 if c.mainWindow != nil && c.mainWindow.TabBar() != nil {82 if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
73 c.mainWindow.TabBar().AddTab(output.Tab)83 c.mainWindow.TabBar().AddTab(output.Tab)
@@ -82,6 +92,11 @@ func (c *TabCoordinator) Create(ctx context.Context, initialURL string) (*entity
82 c.onTabCreated(ctx, output.Tab)92 c.onTabCreated(ctx, output.Tab)
83 }93 }
84 94
95+ // Switch to the new tab's workspace view
96+ if c.onTabSwitched != nil {
97+ c.onTabSwitched(ctx, output.Tab)
98+ }
99+
85 log.Debug().Str("tab_id", string(output.Tab.ID)).Msg("tab created")100 log.Debug().Str("tab_id", string(output.Tab.ID)).Msg("tab created")
86 return output.Tab, nil101 return output.Tab, nil
87 }102 }
@@ -119,10 +134,52 @@ func (c *TabCoordinator) Close(ctx context.Context) error {
119 c.onQuit()134 c.onQuit()
120 }135 }
121 136
137+ // Switch workspace view to new active tab (if not last)
138+ if !wasLast && c.onTabSwitched != nil {
139+ if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
140+ c.onTabSwitched(ctx, tab)
141+ }
142+ }
143+
122 log.Debug().Str("tab_id", string(activeID)).Bool("was_last", wasLast).Msg("tab closed")144 log.Debug().Str("tab_id", string(activeID)).Bool("was_last", wasLast).Msg("tab closed")
123 return nil145 return nil
124 }146 }
125 147
148+// Switch switches to a specific tab by ID.
149+func (c *TabCoordinator) Switch(ctx context.Context, tabID entity.TabID) error {
150+ log := logging.FromContext(ctx)
151+
152+ // Skip if already active
153+ if tabID == c.tabs.ActiveTabID {
154+ return nil
155+ }
156+
157+ log.Debug().
158+ Str("from", string(c.tabs.ActiveTabID)).
159+ Str("to", string(tabID)).
160+ Msg("switching tab")
161+
162+ // Update domain state
163+ if err := c.tabsUC.Switch(ctx, c.tabs, tabID); err != nil {
164+ log.Error().Err(err).Str("tab_id", string(tabID)).Msg("failed to switch tab")
165+ return err
166+ }
167+
168+ // Update tab bar UI
169+ if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
170+ c.mainWindow.TabBar().SetActive(tabID)
171+ }
172+
173+ // Invoke callback for workspace view switching
174+ if c.onTabSwitched != nil {
175+ if tab := c.tabs.Find(tabID); tab != nil {
176+ c.onTabSwitched(ctx, tab)
177+ }
178+ }
179+
180+ return nil
181+}
182+
126 // SwitchNext switches to the next tab.183 // SwitchNext switches to the next tab.
127 func (c *TabCoordinator) SwitchNext(ctx context.Context) error {184 func (c *TabCoordinator) SwitchNext(ctx context.Context) error {
128 log := logging.FromContext(ctx)185 log := logging.FromContext(ctx)
@@ -136,7 +193,12 @@ func (c *TabCoordinator) SwitchNext(ctx context.Context) error {
136 c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)193 c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
137 }194 }
138 195
139- // TODO: Add onTabSwitched callback for workspace view switching196+ // Invoke callback for workspace view switching
197+ if c.onTabSwitched != nil {
198+ if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
199+ c.onTabSwitched(ctx, tab)
200+ }
201+ }
140 202
141 log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to next tab")203 log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to next tab")
142 return nil204 return nil
@@ -155,10 +217,65 @@ func (c *TabCoordinator) SwitchPrev(ctx context.Context) error {
155 c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)217 c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
156 }218 }
157 219
220+ // Invoke callback for workspace view switching
221+ if c.onTabSwitched != nil {
222+ if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
223+ c.onTabSwitched(ctx, tab)
224+ }
225+ }
226+
158 log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to previous tab")227 log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to previous tab")
159 return nil228 return nil
160 }229 }
161 230
231+// SwitchByIndex switches to a tab by 0-based index.
232+func (c *TabCoordinator) SwitchByIndex(ctx context.Context, index int) error {
233+ log := logging.FromContext(ctx)
234+
235+ if err := c.tabsUC.SwitchByIndex(ctx, c.tabs, index); err != nil {
236+ log.Error().Err(err).Int("index", index).Msg("failed to switch to tab by index")
237+ return err
238+ }
239+
240+ if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
241+ c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
242+ }
243+
244+ // Invoke callback for workspace view switching
245+ if c.onTabSwitched != nil {
246+ if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
247+ c.onTabSwitched(ctx, tab)
248+ }
249+ }
250+
251+ log.Debug().Int("index", index).Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to tab by index")
252+ return nil
253+}
254+
255+// SwitchToLastActive switches to the previously active tab (Alt+Tab style).
256+func (c *TabCoordinator) SwitchToLastActive(ctx context.Context) error {
257+ log := logging.FromContext(ctx)
258+
259+ if err := c.tabsUC.SwitchToLastActive(ctx, c.tabs); err != nil {
260+ log.Error().Err(err).Msg("failed to switch to last active tab")
261+ return err
262+ }
263+
264+ if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
265+ c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
266+ }
267+
268+ // Invoke callback for workspace view switching
269+ if c.onTabSwitched != nil {
270+ if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
271+ c.onTabSwitched(ctx, tab)
272+ }
273+ }
274+
275+ log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to last active tab")
276+ return nil
277+}
278+
162 // UpdateBarVisibility shows or hides the tab bar based on tab count.279 // UpdateBarVisibility shows or hides the tab bar based on tab count.
163 func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context) {280 func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context) {
164 log := logging.FromContext(ctx)281 log := logging.FromContext(ctx)
M internal/ui/window/main_window.go +10 -5
@@ -24,6 +24,7 @@ type MainWindow struct {
2424 tabBar *component.TabBar
2525 contentOverlay *gtk.Overlay // Overlay for content + omnibox
2626 contentArea *gtk.Box // Container for workspace content
27+ currentContent *gtk.Widget // Track current content for removal on tab switch
2728
2829 cfg *config.Config
2930 logger zerolog.Logger
@@ -148,15 +149,19 @@ func (mw *MainWindow) ContentArea() *gtk.Box {
148149 }
149150
150151 // SetContent sets the content of the content area.
151-// This removes any existing children first.
152+// This removes any existing content first to properly swap workspace views on tab switch.
152153 func (mw *MainWindow) SetContent(widget *gtk.Widget) {
153- // Remove existing children
154- // Note: GTK4 Box doesn't have a simple "clear" method,
155- // so we'd need to track children. For now, just append.
156- // TODO: Track children for proper content swapping
154+ // Remove existing content first
155+ if mw.currentContent != nil {
156+ mw.contentArea.Remove(mw.currentContent)
157+ mw.currentContent = nil
158+ }
159+
160+ // Add new content
157161 if widget != nil {
158162 widget.SetVisible(true)
159163 mw.contentArea.Append(widget)
164+ mw.currentContent = widget
160165 }
161166 }
162167
@@ -24,6 +24,7 @@ type MainWindow struct {
24 tabBar *component.TabBar24 tabBar *component.TabBar
25 contentOverlay *gtk.Overlay // Overlay for content + omnibox25 contentOverlay *gtk.Overlay // Overlay for content + omnibox
26 contentArea *gtk.Box // Container for workspace content26 contentArea *gtk.Box // Container for workspace content
27+ currentContent *gtk.Widget // Track current content for removal on tab switch
27 28
28 cfg *config.Config29 cfg *config.Config
29 logger zerolog.Logger30 logger zerolog.Logger
@@ -148,15 +149,19 @@ func (mw *MainWindow) ContentArea() *gtk.Box {
148 }149 }
149 150
150 // SetContent sets the content of the content area.151 // SetContent sets the content of the content area.
151-// This removes any existing children first.152+// This removes any existing content first to properly swap workspace views on tab switch.
152 func (mw *MainWindow) SetContent(widget *gtk.Widget) {153 func (mw *MainWindow) SetContent(widget *gtk.Widget) {
153- // Remove existing children154+ // Remove existing content first
154- // Note: GTK4 Box doesn't have a simple "clear" method,155+ if mw.currentContent != nil {
155- // so we'd need to track children. For now, just append.156+ mw.contentArea.Remove(mw.currentContent)
156- // TODO: Track children for proper content swapping157+ mw.currentContent = nil
158+ }
159+
160+ // Add new content
157 if widget != nil {161 if widget != nil {
158 widget.SetVisible(true)162 widget.SetVisible(true)
159 mw.contentArea.Append(widget)163 mw.contentArea.Append(widget)
164+ mw.currentContent = widget
160 }165 }
161 }166 }
162 167

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close