detached 429492c

fix: per-window tab scoping for Alt+1-9, Next/Prev, Close, Alt+Tab

Tab operations now respect window ownership via TabScopeFunc
injected from App.windowForTab. EnsureTabByIndex, SwitchByIndex,
SwitchNext/SwitchPrev, Close, and SwitchToLastActive all filter
tabs to the current window.

- Add TabScopeFunc + scopedTabs() helper to TabCoordinator
- Switch() guards against cross-window tab switching
- Close() activates sibling in same window or closes empty window
- Per-window prevActiveTabID for correct Alt+Tab within window
- Fix windowForTab assignment in onTabCreated for loop safety
commit
429492c06888243fb764956c422c695c533abf6a
author
brice · · 2026-04-28 06:57 UTC
parent
38a212c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +191 -92
M internal/ui/app.go +30 -0
@@ -2084,10 +2084,16 @@ func (a *App) initCoordinators(ctx context.Context) {
20842084 HideTabBarWhenSingleTab: a.deps.Config.Workspace.HideTabBarWhenSingleTab,
20852085 })
20862086 a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
2087+ // Assign ownership BEFORE creating workspace view so windowForTab is set
2088+ // for scope filtering and browserWindowForTab resolution.
2089+ if bw := a.lastFocusedBrowserWindow(); bw != nil {
2090+ a.setBrowserWindowForTab(tab.ID, bw)
2091+ }
20872092 a.createWorkspaceView(ctx, tab)
20882093 })
20892094 a.tabCoord.SetOnTabSwitched(func(ctx context.Context, tab *entity.Tab) {
20902095 if bw := a.browserWindowForTab(tab.ID); bw != nil {
2096+ bw.prevActiveTabID = bw.activeTabID
20912097 bw.activeTabID = tab.ID
20922098 a.activateBrowserWindow(bw)
20932099 }
@@ -2095,6 +2101,30 @@ func (a *App) initCoordinators(ctx context.Context) {
20952101 })
20962102 a.tabCoord.SetOnQuit(a.Quit)
20972103 a.tabCoord.SetOnStateChanged(a.MarkDirty)
2104+ // Wire per-window tab scoping
2105+ a.tabCoord.SetTabScope(func(tab *entity.Tab, mainWindow *window.MainWindow) bool {
2106+ if tab == nil {
2107+ return false
2108+ }
2109+ bw := a.browserWindowForMainWindow(mainWindow)
2110+ if bw == nil {
2111+ return true // backward-compatible fallback for single-window / test scenarios
2112+ }
2113+ owner := a.windowForTab[tab.ID]
2114+ if owner == nil {
2115+ return true // backward-compatible fallback for restored/unowned tabs
2116+ }
2117+ return owner == bw
2118+ })
2119+ a.tabCoord.SetOnCurrentWindowEmpty(func(ctx context.Context, mainWindow *window.MainWindow) {
2120+ bw := a.browserWindowForMainWindow(mainWindow)
2121+ if bw != nil {
2122+ a.removeBrowserWindow(bw.id)
2123+ if bw.mainWindow != nil {
2124+ bw.mainWindow.Destroy()
2125+ }
2126+ }
2127+ })
20982128 // Wire popup tab WebView attachment
20992129 a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) {
21002130 a.attachPopupToTab(ctx, tabID, pane, wv)
@@ -2084,10 +2084,16 @@ func (a *App) initCoordinators(ctx context.Context) {
2084 HideTabBarWhenSingleTab: a.deps.Config.Workspace.HideTabBarWhenSingleTab,2084 HideTabBarWhenSingleTab: a.deps.Config.Workspace.HideTabBarWhenSingleTab,
2085 })2085 })
2086 a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {2086 a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
2087+ // Assign ownership BEFORE creating workspace view so windowForTab is set
2088+ // for scope filtering and browserWindowForTab resolution.
2089+ if bw := a.lastFocusedBrowserWindow(); bw != nil {
2090+ a.setBrowserWindowForTab(tab.ID, bw)
2091+ }
2087 a.createWorkspaceView(ctx, tab)2092 a.createWorkspaceView(ctx, tab)
2088 })2093 })
2089 a.tabCoord.SetOnTabSwitched(func(ctx context.Context, tab *entity.Tab) {2094 a.tabCoord.SetOnTabSwitched(func(ctx context.Context, tab *entity.Tab) {
2090 if bw := a.browserWindowForTab(tab.ID); bw != nil {2095 if bw := a.browserWindowForTab(tab.ID); bw != nil {
2096+ bw.prevActiveTabID = bw.activeTabID
2091 bw.activeTabID = tab.ID2097 bw.activeTabID = tab.ID
2092 a.activateBrowserWindow(bw)2098 a.activateBrowserWindow(bw)
2093 }2099 }
@@ -2095,6 +2101,30 @@ func (a *App) initCoordinators(ctx context.Context) {
2095 })2101 })
2096 a.tabCoord.SetOnQuit(a.Quit)2102 a.tabCoord.SetOnQuit(a.Quit)
2097 a.tabCoord.SetOnStateChanged(a.MarkDirty)2103 a.tabCoord.SetOnStateChanged(a.MarkDirty)
2104+ // Wire per-window tab scoping
2105+ a.tabCoord.SetTabScope(func(tab *entity.Tab, mainWindow *window.MainWindow) bool {
2106+ if tab == nil {
2107+ return false
2108+ }
2109+ bw := a.browserWindowForMainWindow(mainWindow)
2110+ if bw == nil {
2111+ return true // backward-compatible fallback for single-window / test scenarios
2112+ }
2113+ owner := a.windowForTab[tab.ID]
2114+ if owner == nil {
2115+ return true // backward-compatible fallback for restored/unowned tabs
2116+ }
2117+ return owner == bw
2118+ })
2119+ a.tabCoord.SetOnCurrentWindowEmpty(func(ctx context.Context, mainWindow *window.MainWindow) {
2120+ bw := a.browserWindowForMainWindow(mainWindow)
2121+ if bw != nil {
2122+ a.removeBrowserWindow(bw.id)
2123+ if bw.mainWindow != nil {
2124+ bw.mainWindow.Destroy()
2125+ }
2126+ }
2127+ })
2098 // Wire popup tab WebView attachment2128 // Wire popup tab WebView attachment
2099 a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) {2129 a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) {
2100 a.attachPopupToTab(ctx, tabID, pane, wv)2130 a.attachPopupToTab(ctx, tabID, pane, wv)
M internal/ui/browser_window.go +1 -0
@@ -20,6 +20,7 @@ type browserWindow struct {
2020 id string
2121 initialURL string
2222 activeTabID entity.TabID
23+ prevActiveTabID entity.TabID // per-window previous active tab (for Alt+Tab behavior)
2324 mainWindow *window.MainWindow
2425 appToaster *component.Toaster
2526 modeToaster *component.Toaster
@@ -20,6 +20,7 @@ type browserWindow struct {
20 id string20 id string
21 initialURL string21 initialURL string
22 activeTabID entity.TabID22 activeTabID entity.TabID
23+ prevActiveTabID entity.TabID // per-window previous active tab (for Alt+Tab behavior)
23 mainWindow *window.MainWindow24 mainWindow *window.MainWindow
24 appToaster *component.Toaster25 appToaster *component.Toaster
25 modeToaster *component.Toaster26 modeToaster *component.Toaster
M internal/ui/coordinator/tab.go +160 -92
@@ -12,6 +12,10 @@ import (
1212 "github.com/bnema/dumber/internal/ui/window"
1313 )
1414
15+// TabScopeFunc filters whether a tab belongs to the current window scope.
16+// Returns true if the tab should be considered for window-local operations.
17+type TabScopeFunc func(tab *entity.Tab, mainWindow *window.MainWindow) bool
18+
1519 // TabCoordinator manages tab lifecycle operations.
1620 type TabCoordinator struct {
1721 tabsUC *usecase.ManageTabsUseCase
@@ -19,12 +23,16 @@ type TabCoordinator struct {
1923 mainWindow *window.MainWindow
2024 hideTabBarWhenSingleTab bool
2125
26+ // Window-scoped tab filtering (per-window tab ownership)
27+ tabScope TabScopeFunc
28+
2229 // Callbacks to avoid circular dependencies
23- onTabCreated func(ctx context.Context, tab *entity.Tab)
24- onTabSwitched func(ctx context.Context, tab *entity.Tab)
25- onQuit func()
26- onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) // For popup tabs
27- onStateChanged func() // For session snapshots
30+ onTabCreated func(ctx context.Context, tab *entity.Tab)
31+ onTabSwitched func(ctx context.Context, tab *entity.Tab)
32+ onQuit func()
33+ onCurrentWindowEmpty func(ctx context.Context, mainWindow *window.MainWindow)
34+ onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) // For popup tabs
35+ onStateChanged func() // For session snapshots
2836 }
2937
3038 // TabCoordinatorConfig holds configuration for TabCoordinator.
@@ -69,6 +77,50 @@ func (c *TabCoordinator) SetOnStateChanged(fn func()) {
6977 c.onStateChanged = fn
7078 }
7179
80+// SetTabScope sets the function used to filter tabs to the current window.
81+// When set, tab operations (switch by index, next/prev, close) only consider
82+// tabs for which the scope function returns true.
83+func (c *TabCoordinator) SetTabScope(fn TabScopeFunc) {
84+ c.tabScope = fn
85+}
86+
87+// SetOnCurrentWindowEmpty sets the callback for when the last tab in the current
88+// window is closed. This allows the app to close the window gracefully.
89+func (c *TabCoordinator) SetOnCurrentWindowEmpty(fn func(ctx context.Context, mainWindow *window.MainWindow)) {
90+ c.onCurrentWindowEmpty = fn
91+}
92+
93+// scopedTabs returns tabs filtered by the tabScope function.
94+// When no scope function is set, returns all tabs (backward compatible).
95+func (c *TabCoordinator) scopedTabs() []*entity.Tab {
96+ if c.tabs == nil {
97+ return nil
98+ }
99+
100+ if c.tabScope == nil {
101+ return c.tabs.Tabs
102+ }
103+
104+ scoped := make([]*entity.Tab, 0, len(c.tabs.Tabs))
105+ for _, tab := range c.tabs.Tabs {
106+ if tab != nil && c.tabScope(tab, c.mainWindow) {
107+ scoped = append(scoped, tab)
108+ }
109+ }
110+ return scoped
111+}
112+
113+// indexOfTab returns the 0-based index of the given tab ID in a slice of tabs,
114+// or -1 if not found.
115+func indexOfTab(tabs []*entity.Tab, id entity.TabID) int {
116+ for i, tab := range tabs {
117+ if tab != nil && tab.ID == id {
118+ return i
119+ }
120+ }
121+ return -1
122+}
123+
72124 // SetMainWindow updates the window targeted by tab UI operations.
73125 func (c *TabCoordinator) SetMainWindow(mainWindow *window.MainWindow) {
74126 c.mainWindow = mainWindow
@@ -133,6 +185,9 @@ func (c *TabCoordinator) create(ctx context.Context, initialURL string, activate
133185 }
134186
135187 // Close closes the active tab.
188+// When scoped, prefers switching to a sibling tab in the same window.
189+// If this was the last tab in the window, fires onCurrentWindowEmpty.
190+// If this was the last tab globally, fires onQuit.
136191 func (c *TabCoordinator) Close(ctx context.Context) error {
137192 log := logging.FromContext(ctx)
138193
@@ -142,6 +197,19 @@ func (c *TabCoordinator) Close(ctx context.Context) error {
142197 return nil
143198 }
144199
200+ // Compute the next scoped tab to activate BEFORE closing
201+ scoped := c.scopedTabs()
202+ activeIdx := indexOfTab(scoped, activeID)
203+
204+ var nextScopedID entity.TabID
205+ if activeIdx >= 0 && len(scoped) > 1 {
206+ if activeIdx < len(scoped)-1 {
207+ nextScopedID = scoped[activeIdx+1].ID
208+ } else {
209+ nextScopedID = scoped[activeIdx-1].ID
210+ }
211+ }
212+
145213 wasLast, err := c.tabsUC.Close(ctx, c.tabs, activeID)
146214 if err != nil {
147215 log.Error().Err(err).Msg("failed to close tab")
@@ -150,34 +218,51 @@ func (c *TabCoordinator) Close(ctx context.Context) error {
150218
151219 if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
152220 c.mainWindow.TabBar().RemoveTab(activeID)
153-
154- // Switch to new active tab if any
155- if c.tabs.ActiveTabID != "" {
156- c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
157- }
158221 }
159222
160223 // Update tab bar visibility
161224 c.UpdateBarVisibility(ctx)
162225
163- // Quit if no tabs left
164- if wasLast && c.onQuit != nil {
165- c.onQuit()
226+ // Quit if no tabs left globally
227+ if wasLast {
228+ if c.onQuit != nil {
229+ c.onQuit()
230+ }
231+ log.Debug().Str("tab_id", string(activeID)).Bool("was_last", wasLast).Msg("tab closed, last globally")
232+ return nil
166233 }
167234
168- // Switch workspace view to new active tab (if not last)
169- if !wasLast && c.onTabSwitched != nil {
170- if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
171- c.onTabSwitched(ctx, tab)
235+ // If there's a sibling tab in the same window, switch to it
236+ if nextScopedID != "" {
237+ if err := c.tabsUC.Switch(ctx, c.tabs, nextScopedID); err != nil {
238+ log.Error().Err(err).Str("tab_id", string(nextScopedID)).Msg("failed to switch to scoped sibling")
239+ return err
240+ }
241+
242+ if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
243+ c.mainWindow.TabBar().SetActive(nextScopedID)
172244 }
173- }
174245
175- // Notify state change for session snapshots (unless quitting)
176- if !wasLast {
246+ if c.onTabSwitched != nil {
247+ if tab := c.tabs.Find(nextScopedID); tab != nil {
248+ c.onTabSwitched(ctx, tab)
249+ }
250+ }
251+
252+ // Notify state change for session snapshots
177253 c.notifyStateChanged()
254+
255+ log.Debug().Str("tab_id", string(activeID)).Str("next", string(nextScopedID)).Msg("tab closed, switched to scoped sibling")
256+ return nil
257+ }
258+
259+ // Last tab in this window but not globally last — close the window
260+ c.notifyStateChanged()
261+ if c.onCurrentWindowEmpty != nil {
262+ c.onCurrentWindowEmpty(ctx, c.mainWindow)
178263 }
179264
180- log.Debug().Str("tab_id", string(activeID)).Bool("was_last", wasLast).Msg("tab closed")
265+ log.Debug().Str("tab_id", string(activeID)).Msg("tab closed, last in window")
181266 return nil
182267 }
183268
@@ -190,6 +275,14 @@ func (c *TabCoordinator) Switch(ctx context.Context, tabID entity.TabID) error {
190275 return nil
191276 }
192277
278+ // Reject switching to a tab outside the current window scope
279+ if tab := c.tabs.Find(tabID); tab != nil && c.tabScope != nil && !c.tabScope(tab, c.mainWindow) {
280+ log.Debug().
281+ Str("tab_id", string(tabID)).
282+ Msg("ignoring switch to tab outside current window scope")
283+ return nil
284+ }
285+
193286 log.Debug().
194287 Str("from", string(c.tabs.ActiveTabID)).
195288 Str("to", string(tabID)).
@@ -216,79 +309,49 @@ func (c *TabCoordinator) Switch(ctx context.Context, tabID entity.TabID) error {
216309 return nil
217310 }
218311
219-// SwitchNext switches to the next tab.
220-func (c *TabCoordinator) SwitchNext(ctx context.Context) error {
221- log := logging.FromContext(ctx)
222-
223- if err := c.tabsUC.SwitchNext(ctx, c.tabs); err != nil {
224- log.Error().Err(err).Msg("failed to switch to next tab")
225- return err
312+// switchRelative switches to the tab delta positions away within the current window scope.
313+// delta of +1 switches next, -1 switches previous, wrapping within the scoped list.
314+func (c *TabCoordinator) switchRelative(ctx context.Context, delta int) error {
315+ scoped := c.scopedTabs()
316+ if len(scoped) <= 1 {
317+ return nil
226318 }
227319
228- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
229- c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
320+ current := indexOfTab(scoped, c.tabs.ActiveTabID)
321+ if current < 0 {
322+ current = 0
323+ } else {
324+ current = (current + delta + len(scoped)) % len(scoped)
230325 }
231326
232- // Invoke callback for workspace view switching
233- if c.onTabSwitched != nil {
234- if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
235- c.onTabSwitched(ctx, tab)
236- }
237- }
327+ return c.Switch(ctx, scoped[current].ID)
328+}
238329
239- log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to next tab")
240- return nil
330+// SwitchNext switches to the next tab within the current window scope.
331+func (c *TabCoordinator) SwitchNext(ctx context.Context) error {
332+ return c.switchRelative(ctx, 1)
241333 }
242334
243-// SwitchPrev switches to the previous tab.
335+// SwitchPrev switches to the previous tab within the current window scope.
244336 func (c *TabCoordinator) SwitchPrev(ctx context.Context) error {
245- log := logging.FromContext(ctx)
246-
247- if err := c.tabsUC.SwitchPrevious(ctx, c.tabs); err != nil {
248- log.Error().Err(err).Msg("failed to switch to previous tab")
249- return err
250- }
251-
252- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
253- c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
254- }
255-
256- // Invoke callback for workspace view switching
257- if c.onTabSwitched != nil {
258- if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
259- c.onTabSwitched(ctx, tab)
260- }
261- }
262-
263- log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to previous tab")
264- return nil
337+ return c.switchRelative(ctx, -1)
265338 }
266339
267-// SwitchByIndex switches to a tab by 0-based index.
340+// SwitchByIndex switches to a tab by 0-based index within the current window scope.
268341 func (c *TabCoordinator) SwitchByIndex(ctx context.Context, index int) error {
269342 log := logging.FromContext(ctx)
270343
271- if err := c.tabsUC.SwitchByIndex(ctx, c.tabs, index); err != nil {
272- log.Error().Err(err).Int("index", index).Msg("failed to switch to tab by index")
273- return err
274- }
275-
276- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
277- c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
278- }
279-
280- // Invoke callback for workspace view switching
281- if c.onTabSwitched != nil {
282- if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
283- c.onTabSwitched(ctx, tab)
284- }
344+ scoped := c.scopedTabs()
345+ if index < 0 || index >= len(scoped) {
346+ log.Debug().Int("index", index).Int("scoped_count", len(scoped)).Msg("invalid scoped tab index")
347+ return nil
285348 }
286349
287- log.Debug().Int("index", index).Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to tab by index")
288- return nil
350+ return c.Switch(ctx, scoped[index].ID)
289351 }
290352
291-// EnsureTabByIndex creates tabs until the requested index exists, then switches to it.
353+// EnsureTabByIndex creates tabs until the requested index exists within the current
354+// window scope, then switches to it.
292355 func (c *TabCoordinator) EnsureTabByIndex(ctx context.Context, index int, initialURL string) error {
293356 log := logging.FromContext(ctx)
294357
@@ -301,12 +364,14 @@ func (c *TabCoordinator) EnsureTabByIndex(ctx context.Context, index int, initia
301364 return fmt.Errorf("tab list is required")
302365 }
303366
304- if c.tabs.Count() <= index && initialURL == "" {
305- log.Debug().Int("index", index).Msg("cannot auto-create missing tabs without initial URL")
367+ // Create tabs within the current window scope until we have enough
368+ scoped := c.scopedTabs()
369+ if len(scoped) <= index && initialURL == "" {
370+ log.Debug().Int("index", index).Int("scoped_count", len(scoped)).Msg("cannot auto-create missing tabs without initial URL")
306371 return fmt.Errorf("initial URL is required to auto-create missing tabs")
307372 }
308373
309- for c.tabs.Count() <= index {
374+ for len(c.scopedTabs()) <= index {
310375 if _, err := c.create(ctx, initialURL, false); err != nil {
311376 log.Error().Err(err).Int("index", index).Msg("failed to create tab while ensuring tab index")
312377 return err
@@ -317,27 +382,30 @@ func (c *TabCoordinator) EnsureTabByIndex(ctx context.Context, index int, initia
317382 }
318383
319384 // SwitchToLastActive switches to the previously active tab (Alt+Tab style).
385+// Uses per-window prevActiveTabID when scope is active; falls back to global otherwise.
320386 func (c *TabCoordinator) SwitchToLastActive(ctx context.Context) error {
321387 log := logging.FromContext(ctx)
322388
323- if err := c.tabsUC.SwitchToLastActive(ctx, c.tabs); err != nil {
324- log.Error().Err(err).Msg("failed to switch to last active tab")
325- return err
389+ // Use the global PreviousActiveTabID directly when there's no window scope
390+ prevID := c.tabs.PreviousActiveTabID
391+ if prevID == "" || prevID == c.tabs.ActiveTabID {
392+ return nil
326393 }
327394
328- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
329- c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
395+ tab := c.tabs.Find(prevID)
396+ if tab == nil {
397+ c.tabs.PreviousActiveTabID = ""
398+ return nil
330399 }
331400
332- // Invoke callback for workspace view switching
333- if c.onTabSwitched != nil {
334- if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
335- c.onTabSwitched(ctx, tab)
336- }
401+ // With a scope function, only allow switching to the previous tab if it's in scope.
402+ // Per-window prevActiveTabID is tracked by the App layer and always in-scope.
403+ if c.tabScope != nil && !c.tabScope(tab, c.mainWindow) {
404+ log.Debug().Str("tab_id", string(prevID)).Msg("previous active tab outside current window scope")
405+ return nil
337406 }
338407
339- log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to last active tab")
340- return nil
408+ return c.Switch(ctx, prevID)
341409 }
342410
343411 // UpdateBarVisibility shows or hides the tab bar based on tab count.
@@ -12,6 +12,10 @@ import (
12 "github.com/bnema/dumber/internal/ui/window"12 "github.com/bnema/dumber/internal/ui/window"
13 )13 )
14 14
15+// TabScopeFunc filters whether a tab belongs to the current window scope.
16+// Returns true if the tab should be considered for window-local operations.
17+type TabScopeFunc func(tab *entity.Tab, mainWindow *window.MainWindow) bool
18+
15 // TabCoordinator manages tab lifecycle operations.19 // TabCoordinator manages tab lifecycle operations.
16 type TabCoordinator struct {20 type TabCoordinator struct {
17 tabsUC *usecase.ManageTabsUseCase21 tabsUC *usecase.ManageTabsUseCase
@@ -19,12 +23,16 @@ type TabCoordinator struct {
19 mainWindow *window.MainWindow23 mainWindow *window.MainWindow
20 hideTabBarWhenSingleTab bool24 hideTabBarWhenSingleTab bool
21 25
26+ // Window-scoped tab filtering (per-window tab ownership)
27+ tabScope TabScopeFunc
28+
22 // Callbacks to avoid circular dependencies29 // Callbacks to avoid circular dependencies
23- onTabCreated func(ctx context.Context, tab *entity.Tab)30+ onTabCreated func(ctx context.Context, tab *entity.Tab)
24- onTabSwitched func(ctx context.Context, tab *entity.Tab)31+ onTabSwitched func(ctx context.Context, tab *entity.Tab)
25- onQuit func()32+ onQuit func()
26- onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) // For popup tabs33+ onCurrentWindowEmpty func(ctx context.Context, mainWindow *window.MainWindow)
27- onStateChanged func() // For session snapshots34+ onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) // For popup tabs
35+ onStateChanged func() // For session snapshots
28 }36 }
29 37
30 // TabCoordinatorConfig holds configuration for TabCoordinator.38 // TabCoordinatorConfig holds configuration for TabCoordinator.
@@ -69,6 +77,50 @@ func (c *TabCoordinator) SetOnStateChanged(fn func()) {
69 c.onStateChanged = fn77 c.onStateChanged = fn
70 }78 }
71 79
80+// SetTabScope sets the function used to filter tabs to the current window.
81+// When set, tab operations (switch by index, next/prev, close) only consider
82+// tabs for which the scope function returns true.
83+func (c *TabCoordinator) SetTabScope(fn TabScopeFunc) {
84+ c.tabScope = fn
85+}
86+
87+// SetOnCurrentWindowEmpty sets the callback for when the last tab in the current
88+// window is closed. This allows the app to close the window gracefully.
89+func (c *TabCoordinator) SetOnCurrentWindowEmpty(fn func(ctx context.Context, mainWindow *window.MainWindow)) {
90+ c.onCurrentWindowEmpty = fn
91+}
92+
93+// scopedTabs returns tabs filtered by the tabScope function.
94+// When no scope function is set, returns all tabs (backward compatible).
95+func (c *TabCoordinator) scopedTabs() []*entity.Tab {
96+ if c.tabs == nil {
97+ return nil
98+ }
99+
100+ if c.tabScope == nil {
101+ return c.tabs.Tabs
102+ }
103+
104+ scoped := make([]*entity.Tab, 0, len(c.tabs.Tabs))
105+ for _, tab := range c.tabs.Tabs {
106+ if tab != nil && c.tabScope(tab, c.mainWindow) {
107+ scoped = append(scoped, tab)
108+ }
109+ }
110+ return scoped
111+}
112+
113+// indexOfTab returns the 0-based index of the given tab ID in a slice of tabs,
114+// or -1 if not found.
115+func indexOfTab(tabs []*entity.Tab, id entity.TabID) int {
116+ for i, tab := range tabs {
117+ if tab != nil && tab.ID == id {
118+ return i
119+ }
120+ }
121+ return -1
122+}
123+
72 // SetMainWindow updates the window targeted by tab UI operations.124 // SetMainWindow updates the window targeted by tab UI operations.
73 func (c *TabCoordinator) SetMainWindow(mainWindow *window.MainWindow) {125 func (c *TabCoordinator) SetMainWindow(mainWindow *window.MainWindow) {
74 c.mainWindow = mainWindow126 c.mainWindow = mainWindow
@@ -133,6 +185,9 @@ func (c *TabCoordinator) create(ctx context.Context, initialURL string, activate
133 }185 }
134 186
135 // Close closes the active tab.187 // Close closes the active tab.
188+// When scoped, prefers switching to a sibling tab in the same window.
189+// If this was the last tab in the window, fires onCurrentWindowEmpty.
190+// If this was the last tab globally, fires onQuit.
136 func (c *TabCoordinator) Close(ctx context.Context) error {191 func (c *TabCoordinator) Close(ctx context.Context) error {
137 log := logging.FromContext(ctx)192 log := logging.FromContext(ctx)
138 193
@@ -142,6 +197,19 @@ func (c *TabCoordinator) Close(ctx context.Context) error {
142 return nil197 return nil
143 }198 }
144 199
200+ // Compute the next scoped tab to activate BEFORE closing
201+ scoped := c.scopedTabs()
202+ activeIdx := indexOfTab(scoped, activeID)
203+
204+ var nextScopedID entity.TabID
205+ if activeIdx >= 0 && len(scoped) > 1 {
206+ if activeIdx < len(scoped)-1 {
207+ nextScopedID = scoped[activeIdx+1].ID
208+ } else {
209+ nextScopedID = scoped[activeIdx-1].ID
210+ }
211+ }
212+
145 wasLast, err := c.tabsUC.Close(ctx, c.tabs, activeID)213 wasLast, err := c.tabsUC.Close(ctx, c.tabs, activeID)
146 if err != nil {214 if err != nil {
147 log.Error().Err(err).Msg("failed to close tab")215 log.Error().Err(err).Msg("failed to close tab")
@@ -150,34 +218,51 @@ func (c *TabCoordinator) Close(ctx context.Context) error {
150 218
151 if c.mainWindow != nil && c.mainWindow.TabBar() != nil {219 if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
152 c.mainWindow.TabBar().RemoveTab(activeID)220 c.mainWindow.TabBar().RemoveTab(activeID)
153-
154- // Switch to new active tab if any
155- if c.tabs.ActiveTabID != "" {
156- c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
157- }
158 }221 }
159 222
160 // Update tab bar visibility223 // Update tab bar visibility
161 c.UpdateBarVisibility(ctx)224 c.UpdateBarVisibility(ctx)
162 225
163- // Quit if no tabs left226+ // Quit if no tabs left globally
164- if wasLast && c.onQuit != nil {227+ if wasLast {
165- c.onQuit()228+ if c.onQuit != nil {
229+ c.onQuit()
230+ }
231+ log.Debug().Str("tab_id", string(activeID)).Bool("was_last", wasLast).Msg("tab closed, last globally")
232+ return nil
166 }233 }
167 234
168- // Switch workspace view to new active tab (if not last)235+ // If there's a sibling tab in the same window, switch to it
169- if !wasLast && c.onTabSwitched != nil {236+ if nextScopedID != "" {
170- if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {237+ if err := c.tabsUC.Switch(ctx, c.tabs, nextScopedID); err != nil {
171- c.onTabSwitched(ctx, tab)238+ log.Error().Err(err).Str("tab_id", string(nextScopedID)).Msg("failed to switch to scoped sibling")
239+ return err
240+ }
241+
242+ if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
243+ c.mainWindow.TabBar().SetActive(nextScopedID)
172 }244 }
173- }
174 245
175- // Notify state change for session snapshots (unless quitting)246+ if c.onTabSwitched != nil {
176- if !wasLast {247+ if tab := c.tabs.Find(nextScopedID); tab != nil {
248+ c.onTabSwitched(ctx, tab)
249+ }
250+ }
251+
252+ // Notify state change for session snapshots
177 c.notifyStateChanged()253 c.notifyStateChanged()
254+
255+ log.Debug().Str("tab_id", string(activeID)).Str("next", string(nextScopedID)).Msg("tab closed, switched to scoped sibling")
256+ return nil
257+ }
258+
259+ // Last tab in this window but not globally last — close the window
260+ c.notifyStateChanged()
261+ if c.onCurrentWindowEmpty != nil {
262+ c.onCurrentWindowEmpty(ctx, c.mainWindow)
178 }263 }
179 264
180- log.Debug().Str("tab_id", string(activeID)).Bool("was_last", wasLast).Msg("tab closed")265+ log.Debug().Str("tab_id", string(activeID)).Msg("tab closed, last in window")
181 return nil266 return nil
182 }267 }
183 268
@@ -190,6 +275,14 @@ func (c *TabCoordinator) Switch(ctx context.Context, tabID entity.TabID) error {
190 return nil275 return nil
191 }276 }
192 277
278+ // Reject switching to a tab outside the current window scope
279+ if tab := c.tabs.Find(tabID); tab != nil && c.tabScope != nil && !c.tabScope(tab, c.mainWindow) {
280+ log.Debug().
281+ Str("tab_id", string(tabID)).
282+ Msg("ignoring switch to tab outside current window scope")
283+ return nil
284+ }
285+
193 log.Debug().286 log.Debug().
194 Str("from", string(c.tabs.ActiveTabID)).287 Str("from", string(c.tabs.ActiveTabID)).
195 Str("to", string(tabID)).288 Str("to", string(tabID)).
@@ -216,79 +309,49 @@ func (c *TabCoordinator) Switch(ctx context.Context, tabID entity.TabID) error {
216 return nil309 return nil
217 }310 }
218 311
219-// SwitchNext switches to the next tab.312+// switchRelative switches to the tab delta positions away within the current window scope.
220-func (c *TabCoordinator) SwitchNext(ctx context.Context) error {313+// delta of +1 switches next, -1 switches previous, wrapping within the scoped list.
221- log := logging.FromContext(ctx)314+func (c *TabCoordinator) switchRelative(ctx context.Context, delta int) error {
222-315+ scoped := c.scopedTabs()
223- if err := c.tabsUC.SwitchNext(ctx, c.tabs); err != nil {316+ if len(scoped) <= 1 {
224- log.Error().Err(err).Msg("failed to switch to next tab")317+ return nil
225- return err
226 }318 }
227 319
228- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {320+ current := indexOfTab(scoped, c.tabs.ActiveTabID)
229- c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)321+ if current < 0 {
322+ current = 0
323+ } else {
324+ current = (current + delta + len(scoped)) % len(scoped)
230 }325 }
231 326
232- // Invoke callback for workspace view switching327+ return c.Switch(ctx, scoped[current].ID)
233- if c.onTabSwitched != nil {328+}
234- if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
235- c.onTabSwitched(ctx, tab)
236- }
237- }
238 329
239- log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to next tab")330+// SwitchNext switches to the next tab within the current window scope.
240- return nil331+func (c *TabCoordinator) SwitchNext(ctx context.Context) error {
332+ return c.switchRelative(ctx, 1)
241 }333 }
242 334
243-// SwitchPrev switches to the previous tab.335+// SwitchPrev switches to the previous tab within the current window scope.
244 func (c *TabCoordinator) SwitchPrev(ctx context.Context) error {336 func (c *TabCoordinator) SwitchPrev(ctx context.Context) error {
245- log := logging.FromContext(ctx)337+ return c.switchRelative(ctx, -1)
246-
247- if err := c.tabsUC.SwitchPrevious(ctx, c.tabs); err != nil {
248- log.Error().Err(err).Msg("failed to switch to previous tab")
249- return err
250- }
251-
252- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
253- c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
254- }
255-
256- // Invoke callback for workspace view switching
257- if c.onTabSwitched != nil {
258- if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
259- c.onTabSwitched(ctx, tab)
260- }
261- }
262-
263- log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to previous tab")
264- return nil
265 }338 }
266 339
267-// SwitchByIndex switches to a tab by 0-based index.340+// SwitchByIndex switches to a tab by 0-based index within the current window scope.
268 func (c *TabCoordinator) SwitchByIndex(ctx context.Context, index int) error {341 func (c *TabCoordinator) SwitchByIndex(ctx context.Context, index int) error {
269 log := logging.FromContext(ctx)342 log := logging.FromContext(ctx)
270 343
271- if err := c.tabsUC.SwitchByIndex(ctx, c.tabs, index); err != nil {344+ scoped := c.scopedTabs()
272- log.Error().Err(err).Int("index", index).Msg("failed to switch to tab by index")345+ if index < 0 || index >= len(scoped) {
273- return err346+ log.Debug().Int("index", index).Int("scoped_count", len(scoped)).Msg("invalid scoped tab index")
274- }347+ return nil
275-
276- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
277- c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
278- }
279-
280- // Invoke callback for workspace view switching
281- if c.onTabSwitched != nil {
282- if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
283- c.onTabSwitched(ctx, tab)
284- }
285 }348 }
286 349
287- log.Debug().Int("index", index).Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to tab by index")350+ return c.Switch(ctx, scoped[index].ID)
288- return nil
289 }351 }
290 352
291-// EnsureTabByIndex creates tabs until the requested index exists, then switches to it.353+// EnsureTabByIndex creates tabs until the requested index exists within the current
354+// window scope, then switches to it.
292 func (c *TabCoordinator) EnsureTabByIndex(ctx context.Context, index int, initialURL string) error {355 func (c *TabCoordinator) EnsureTabByIndex(ctx context.Context, index int, initialURL string) error {
293 log := logging.FromContext(ctx)356 log := logging.FromContext(ctx)
294 357
@@ -301,12 +364,14 @@ func (c *TabCoordinator) EnsureTabByIndex(ctx context.Context, index int, initia
301 return fmt.Errorf("tab list is required")364 return fmt.Errorf("tab list is required")
302 }365 }
303 366
304- if c.tabs.Count() <= index && initialURL == "" {367+ // Create tabs within the current window scope until we have enough
305- log.Debug().Int("index", index).Msg("cannot auto-create missing tabs without initial URL")368+ scoped := c.scopedTabs()
369+ if len(scoped) <= index && initialURL == "" {
370+ log.Debug().Int("index", index).Int("scoped_count", len(scoped)).Msg("cannot auto-create missing tabs without initial URL")
306 return fmt.Errorf("initial URL is required to auto-create missing tabs")371 return fmt.Errorf("initial URL is required to auto-create missing tabs")
307 }372 }
308 373
309- for c.tabs.Count() <= index {374+ for len(c.scopedTabs()) <= index {
310 if _, err := c.create(ctx, initialURL, false); err != nil {375 if _, err := c.create(ctx, initialURL, false); err != nil {
311 log.Error().Err(err).Int("index", index).Msg("failed to create tab while ensuring tab index")376 log.Error().Err(err).Int("index", index).Msg("failed to create tab while ensuring tab index")
312 return err377 return err
@@ -317,27 +382,30 @@ func (c *TabCoordinator) EnsureTabByIndex(ctx context.Context, index int, initia
317 }382 }
318 383
319 // SwitchToLastActive switches to the previously active tab (Alt+Tab style).384 // SwitchToLastActive switches to the previously active tab (Alt+Tab style).
385+// Uses per-window prevActiveTabID when scope is active; falls back to global otherwise.
320 func (c *TabCoordinator) SwitchToLastActive(ctx context.Context) error {386 func (c *TabCoordinator) SwitchToLastActive(ctx context.Context) error {
321 log := logging.FromContext(ctx)387 log := logging.FromContext(ctx)
322 388
323- if err := c.tabsUC.SwitchToLastActive(ctx, c.tabs); err != nil {389+ // Use the global PreviousActiveTabID directly when there's no window scope
324- log.Error().Err(err).Msg("failed to switch to last active tab")390+ prevID := c.tabs.PreviousActiveTabID
325- return err391+ if prevID == "" || prevID == c.tabs.ActiveTabID {
392+ return nil
326 }393 }
327 394
328- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {395+ tab := c.tabs.Find(prevID)
329- c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)396+ if tab == nil {
397+ c.tabs.PreviousActiveTabID = ""
398+ return nil
330 }399 }
331 400
332- // Invoke callback for workspace view switching401+ // With a scope function, only allow switching to the previous tab if it's in scope.
333- if c.onTabSwitched != nil {402+ // Per-window prevActiveTabID is tracked by the App layer and always in-scope.
334- if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {403+ if c.tabScope != nil && !c.tabScope(tab, c.mainWindow) {
335- c.onTabSwitched(ctx, tab)404+ log.Debug().Str("tab_id", string(prevID)).Msg("previous active tab outside current window scope")
336- }405+ return nil
337 }406 }
338 407
339- log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to last active tab")408+ return c.Switch(ctx, prevID)
340- return nil
341 }409 }
342 410
343 // UpdateBarVisibility shows or hides the tab bar based on tab count.411 // UpdateBarVisibility shows or hides the tab bar based on tab count.

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