detached 4613d9c

Merge pull request #239 from bnema/fix/per-window-tab-scoping

fix: per-window tab scoping for Alt+1-9, Next/Prev, Close, Alt+Tab
commit
4613d9c6bd2bdf2060604219be64fa6a3260f564
author
bnema · · 2026-04-28 20:06 UTC
committer
GitHub · · 2026-04-28 20:06 UTC
merge of
38a212c + 1e06425 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
3 files · +348 -121
M internal/ui/app.go +149 -20
@@ -63,6 +63,12 @@ func EnsureAdwaitaInitialized() {
6363 })
6464 }
6565
66+// defaultTabName returns a window-scoped default tab name.
67+// index is the 1-based tab position within the window.
68+func defaultTabName(index int) string {
69+ return fmt.Sprintf("Tab %d", index)
70+}
71+
6672 // App wraps the GTK Application and manages the browser lifecycle.
6773 type App struct {
6874 deps *Dependencies
@@ -505,6 +511,35 @@ func (a *App) browserWindowForTab(tabID entity.TabID) *browserWindow {
505511 return a.browserWindowForMainWindow(a.mainWindow)
506512 }
507513
514+func (a *App) browserWindowHasLiveTab(bw *browserWindow, tabID entity.TabID) bool {
515+ if a == nil || a.tabs == nil || bw == nil || tabID == "" {
516+ return false
517+ }
518+ if a.tabs.Find(tabID) == nil {
519+ return false
520+ }
521+ if a.windowForTab[tabID] != bw {
522+ return false
523+ }
524+ return true
525+}
526+
527+func (a *App) tabCountForBrowserWindow(bw *browserWindow) int {
528+ if a.tabs == nil || bw == nil {
529+ return 0
530+ }
531+ count := 0
532+ for _, tab := range a.tabs.Tabs {
533+ if tab == nil {
534+ continue
535+ }
536+ if owner := a.windowForTab[tab.ID]; owner == bw {
537+ count++
538+ }
539+ }
540+ return count
541+}
542+
508543 func (a *App) browserWindowForPane(paneID entity.PaneID) *browserWindow {
509544 if paneID == "" || a.tabs == nil {
510545 return nil
@@ -1861,6 +1896,8 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er
18611896 // Replace tabs in-place to preserve references held by TabCoordinator
18621897 a.tabs.ReplaceFrom(restoredTabs)
18631898
1899+ a.assignRestoredTabsToCurrentWindow()
1900+
18641901 // Create UI for each restored tab and add to tab bar
18651902 // Don't attach to content area yet - only the active tab should be attached
18661903 tabBar := a.mainWindow.TabBar()
@@ -1892,6 +1929,34 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er
18921929 return nil
18931930 }
18941931
1932+func (a *App) assignRestoredTabsToCurrentWindow() {
1933+ if a == nil || a.tabs == nil {
1934+ return
1935+ }
1936+
1937+ restoreWindow := a.browserWindowForMainWindow(a.mainWindow)
1938+ if restoreWindow == nil {
1939+ restoreWindow = a.lastFocusedBrowserWindow()
1940+ }
1941+ if restoreWindow == nil {
1942+ return
1943+ }
1944+
1945+ for _, tab := range a.tabs.Tabs {
1946+ if tab != nil {
1947+ a.setBrowserWindowForTab(tab.ID, restoreWindow)
1948+ }
1949+ }
1950+
1951+ restoreWindow.activeTabID = a.tabs.ActiveTabID
1952+ previousID := a.tabs.PreviousActiveTabID
1953+ if previousID != "" && previousID != a.tabs.ActiveTabID && a.tabs.Find(previousID) != nil {
1954+ restoreWindow.prevActiveTabID = previousID
1955+ return
1956+ }
1957+ restoreWindow.prevActiveTabID = ""
1958+}
1959+
18951960 func (a *App) finalizeActivation(ctx context.Context) {
18961961 log := logging.FromContext(ctx)
18971962
@@ -2057,26 +2122,8 @@ func (a *App) initContentCoordinator(
20572122 })
20582123 }
20592124
2060-// initCoordinators initializes all coordinators and wires their callbacks.
2061-func (a *App) initCoordinators(ctx context.Context) {
2062- log := logging.FromContext(ctx)
2063- log.Debug().Msg("initializing coordinators")
2064-
2065- // Helper to get active workspace and view
2066- getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) {
2067- return a.activeWorkspace(), a.activeWorkspaceView()
2068- }
2069-
2070- // Create FaviconAdapter with service and engine FaviconDatabase.
2071- // Skip if FaviconService is nil (e.g. in tests or when favicon support is disabled).
2072- if a.deps.FaviconService != nil {
2073- a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, a.engine.FaviconDatabase(), a.deps.FaviconAdapterConfig)
2074- }
2075-
2076- // 1. Content Coordinator (no dependencies on other coordinators)
2077- a.initContentCoordinator(ctx, getActiveWS)
2078-
2079- // 2. Tab Coordinator
2125+// initTabCoordinator creates the TabCoordinator and wires all its callbacks.
2126+func (a *App) initTabCoordinator(ctx context.Context) {
20802127 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
20812128 TabsUC: a.tabsUC,
20822129 Tabs: a.tabs,
@@ -2084,10 +2131,32 @@ func (a *App) initCoordinators(ctx context.Context) {
20842131 HideTabBarWhenSingleTab: a.deps.Config.Workspace.HideTabBarWhenSingleTab,
20852132 })
20862133 a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
2134+ // Assign ownership BEFORE creating workspace view so windowForTab is set
2135+ // for scope filtering and browserWindowForTab resolution.
2136+ bw := a.browserWindowForTab(tab.ID)
2137+ if bw != nil {
2138+ a.setBrowserWindowForTab(tab.ID, bw)
2139+ }
2140+ // Set a window-scoped default title so "Tab N" doesn't use global position.
2141+ // Count only live tabs owned by this window via the helper.
2142+ // Only assign a default name if the creator did not supply one.
2143+ if strings.TrimSpace(tab.Name) == "" {
2144+ count := a.tabCountForBrowserWindow(bw)
2145+ if count <= 0 {
2146+ count = 1
2147+ }
2148+ tab.Name = defaultTabName(count)
2149+ }
20872150 a.createWorkspaceView(ctx, tab)
20882151 })
20892152 a.tabCoord.SetOnTabSwitched(func(ctx context.Context, tab *entity.Tab) {
20902153 if bw := a.browserWindowForTab(tab.ID); bw != nil {
2154+ oldActiveID := bw.activeTabID
2155+ if oldActiveID != "" && oldActiveID != tab.ID && a.tabs.Find(oldActiveID) != nil {
2156+ bw.prevActiveTabID = oldActiveID
2157+ } else if bw.prevActiveTabID == tab.ID || a.tabs.Find(bw.prevActiveTabID) == nil {
2158+ bw.prevActiveTabID = ""
2159+ }
20912160 bw.activeTabID = tab.ID
20922161 a.activateBrowserWindow(bw)
20932162 }
@@ -2095,6 +2164,43 @@ func (a *App) initCoordinators(ctx context.Context) {
20952164 })
20962165 a.tabCoord.SetOnQuit(a.Quit)
20972166 a.tabCoord.SetOnStateChanged(a.MarkDirty)
2167+ // Wire per-window tab scoping
2168+ a.tabCoord.SetTabScope(func(tab *entity.Tab, mainWindow *window.MainWindow) bool {
2169+ if tab == nil {
2170+ return false
2171+ }
2172+ bw := a.browserWindowForMainWindow(mainWindow)
2173+ if bw == nil {
2174+ return true // backward-compatible fallback for single-window / test scenarios
2175+ }
2176+ owner := a.windowForTab[tab.ID]
2177+ if owner == nil {
2178+ return false // unowned tabs are out of scope when a real window exists
2179+ }
2180+ return owner == bw
2181+ })
2182+ // Wire per-window previous active tab ID provider for scoped Alt+Tab switching
2183+ a.tabCoord.SetPreviousActiveTabIDProvider(func(mainWindow *window.MainWindow) entity.TabID {
2184+ bw := a.browserWindowForMainWindow(mainWindow)
2185+ if bw == nil {
2186+ return ""
2187+ }
2188+ prevID := bw.prevActiveTabID
2189+ if !a.browserWindowHasLiveTab(bw, prevID) {
2190+ bw.prevActiveTabID = ""
2191+ return ""
2192+ }
2193+ return prevID
2194+ })
2195+ a.tabCoord.SetOnCurrentWindowEmpty(func(ctx context.Context, mainWindow *window.MainWindow) {
2196+ bw := a.browserWindowForMainWindow(mainWindow)
2197+ if bw != nil {
2198+ a.removeBrowserWindow(bw.id)
2199+ if bw.mainWindow != nil {
2200+ bw.mainWindow.Destroy()
2201+ }
2202+ }
2203+ })
20982204 // Wire popup tab WebView attachment
20992205 a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) {
21002206 a.attachPopupToTab(ctx, tabID, pane, wv)
@@ -2103,6 +2209,29 @@ func (a *App) initCoordinators(ctx context.Context) {
21032209 for _, bw := range a.browserWindows {
21042210 a.wireBrowserWindowTabBar(ctx, bw)
21052211 }
2212+}
2213+
2214+// initCoordinators initializes all coordinators and wires their callbacks.
2215+func (a *App) initCoordinators(ctx context.Context) {
2216+ log := logging.FromContext(ctx)
2217+ log.Debug().Msg("initializing coordinators")
2218+
2219+ // Helper to get active workspace and view
2220+ getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) {
2221+ return a.activeWorkspace(), a.activeWorkspaceView()
2222+ }
2223+
2224+ // Create FaviconAdapter with service and engine FaviconDatabase.
2225+ // Skip if FaviconService is nil (e.g. in tests or when favicon support is disabled).
2226+ if a.deps.FaviconService != nil {
2227+ a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, a.engine.FaviconDatabase(), a.deps.FaviconAdapterConfig)
2228+ }
2229+
2230+ // 1. Content Coordinator (no dependencies on other coordinators)
2231+ a.initContentCoordinator(ctx, getActiveWS)
2232+
2233+ // 2. Tab Coordinator
2234+ a.initTabCoordinator(ctx)
21062235
21072236 // Set fullscreen callback to hide/show tab bar (after tabCoord is initialized)
21082237 a.contentCoord.SetOnFullscreenChanged(func(paneID entity.PaneID, entering bool) {
@@ -63,6 +63,12 @@ func EnsureAdwaitaInitialized() {
63 })63 })
64 }64 }
65 65
66+// defaultTabName returns a window-scoped default tab name.
67+// index is the 1-based tab position within the window.
68+func defaultTabName(index int) string {
69+ return fmt.Sprintf("Tab %d", index)
70+}
71+
66 // App wraps the GTK Application and manages the browser lifecycle.72 // App wraps the GTK Application and manages the browser lifecycle.
67 type App struct {73 type App struct {
68 deps *Dependencies74 deps *Dependencies
@@ -505,6 +511,35 @@ func (a *App) browserWindowForTab(tabID entity.TabID) *browserWindow {
505 return a.browserWindowForMainWindow(a.mainWindow)511 return a.browserWindowForMainWindow(a.mainWindow)
506 }512 }
507 513
514+func (a *App) browserWindowHasLiveTab(bw *browserWindow, tabID entity.TabID) bool {
515+ if a == nil || a.tabs == nil || bw == nil || tabID == "" {
516+ return false
517+ }
518+ if a.tabs.Find(tabID) == nil {
519+ return false
520+ }
521+ if a.windowForTab[tabID] != bw {
522+ return false
523+ }
524+ return true
525+}
526+
527+func (a *App) tabCountForBrowserWindow(bw *browserWindow) int {
528+ if a.tabs == nil || bw == nil {
529+ return 0
530+ }
531+ count := 0
532+ for _, tab := range a.tabs.Tabs {
533+ if tab == nil {
534+ continue
535+ }
536+ if owner := a.windowForTab[tab.ID]; owner == bw {
537+ count++
538+ }
539+ }
540+ return count
541+}
542+
508 func (a *App) browserWindowForPane(paneID entity.PaneID) *browserWindow {543 func (a *App) browserWindowForPane(paneID entity.PaneID) *browserWindow {
509 if paneID == "" || a.tabs == nil {544 if paneID == "" || a.tabs == nil {
510 return nil545 return nil
@@ -1861,6 +1896,8 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er
1861 // Replace tabs in-place to preserve references held by TabCoordinator1896 // Replace tabs in-place to preserve references held by TabCoordinator
1862 a.tabs.ReplaceFrom(restoredTabs)1897 a.tabs.ReplaceFrom(restoredTabs)
1863 1898
1899+ a.assignRestoredTabsToCurrentWindow()
1900+
1864 // Create UI for each restored tab and add to tab bar1901 // Create UI for each restored tab and add to tab bar
1865 // Don't attach to content area yet - only the active tab should be attached1902 // Don't attach to content area yet - only the active tab should be attached
1866 tabBar := a.mainWindow.TabBar()1903 tabBar := a.mainWindow.TabBar()
@@ -1892,6 +1929,34 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er
1892 return nil1929 return nil
1893 }1930 }
1894 1931
1932+func (a *App) assignRestoredTabsToCurrentWindow() {
1933+ if a == nil || a.tabs == nil {
1934+ return
1935+ }
1936+
1937+ restoreWindow := a.browserWindowForMainWindow(a.mainWindow)
1938+ if restoreWindow == nil {
1939+ restoreWindow = a.lastFocusedBrowserWindow()
1940+ }
1941+ if restoreWindow == nil {
1942+ return
1943+ }
1944+
1945+ for _, tab := range a.tabs.Tabs {
1946+ if tab != nil {
1947+ a.setBrowserWindowForTab(tab.ID, restoreWindow)
1948+ }
1949+ }
1950+
1951+ restoreWindow.activeTabID = a.tabs.ActiveTabID
1952+ previousID := a.tabs.PreviousActiveTabID
1953+ if previousID != "" && previousID != a.tabs.ActiveTabID && a.tabs.Find(previousID) != nil {
1954+ restoreWindow.prevActiveTabID = previousID
1955+ return
1956+ }
1957+ restoreWindow.prevActiveTabID = ""
1958+}
1959+
1895 func (a *App) finalizeActivation(ctx context.Context) {1960 func (a *App) finalizeActivation(ctx context.Context) {
1896 log := logging.FromContext(ctx)1961 log := logging.FromContext(ctx)
1897 1962
@@ -2057,26 +2122,8 @@ func (a *App) initContentCoordinator(
2057 })2122 })
2058 }2123 }
2059 2124
2060-// initCoordinators initializes all coordinators and wires their callbacks.2125+// initTabCoordinator creates the TabCoordinator and wires all its callbacks.
2061-func (a *App) initCoordinators(ctx context.Context) {2126+func (a *App) initTabCoordinator(ctx context.Context) {
2062- log := logging.FromContext(ctx)
2063- log.Debug().Msg("initializing coordinators")
2064-
2065- // Helper to get active workspace and view
2066- getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) {
2067- return a.activeWorkspace(), a.activeWorkspaceView()
2068- }
2069-
2070- // Create FaviconAdapter with service and engine FaviconDatabase.
2071- // Skip if FaviconService is nil (e.g. in tests or when favicon support is disabled).
2072- if a.deps.FaviconService != nil {
2073- a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, a.engine.FaviconDatabase(), a.deps.FaviconAdapterConfig)
2074- }
2075-
2076- // 1. Content Coordinator (no dependencies on other coordinators)
2077- a.initContentCoordinator(ctx, getActiveWS)
2078-
2079- // 2. Tab Coordinator
2080 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{2127 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
2081 TabsUC: a.tabsUC,2128 TabsUC: a.tabsUC,
2082 Tabs: a.tabs,2129 Tabs: a.tabs,
@@ -2084,10 +2131,32 @@ func (a *App) initCoordinators(ctx context.Context) {
2084 HideTabBarWhenSingleTab: a.deps.Config.Workspace.HideTabBarWhenSingleTab,2131 HideTabBarWhenSingleTab: a.deps.Config.Workspace.HideTabBarWhenSingleTab,
2085 })2132 })
2086 a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {2133 a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
2134+ // Assign ownership BEFORE creating workspace view so windowForTab is set
2135+ // for scope filtering and browserWindowForTab resolution.
2136+ bw := a.browserWindowForTab(tab.ID)
2137+ if bw != nil {
2138+ a.setBrowserWindowForTab(tab.ID, bw)
2139+ }
2140+ // Set a window-scoped default title so "Tab N" doesn't use global position.
2141+ // Count only live tabs owned by this window via the helper.
2142+ // Only assign a default name if the creator did not supply one.
2143+ if strings.TrimSpace(tab.Name) == "" {
2144+ count := a.tabCountForBrowserWindow(bw)
2145+ if count <= 0 {
2146+ count = 1
2147+ }
2148+ tab.Name = defaultTabName(count)
2149+ }
2087 a.createWorkspaceView(ctx, tab)2150 a.createWorkspaceView(ctx, tab)
2088 })2151 })
2089 a.tabCoord.SetOnTabSwitched(func(ctx context.Context, tab *entity.Tab) {2152 a.tabCoord.SetOnTabSwitched(func(ctx context.Context, tab *entity.Tab) {
2090 if bw := a.browserWindowForTab(tab.ID); bw != nil {2153 if bw := a.browserWindowForTab(tab.ID); bw != nil {
2154+ oldActiveID := bw.activeTabID
2155+ if oldActiveID != "" && oldActiveID != tab.ID && a.tabs.Find(oldActiveID) != nil {
2156+ bw.prevActiveTabID = oldActiveID
2157+ } else if bw.prevActiveTabID == tab.ID || a.tabs.Find(bw.prevActiveTabID) == nil {
2158+ bw.prevActiveTabID = ""
2159+ }
2091 bw.activeTabID = tab.ID2160 bw.activeTabID = tab.ID
2092 a.activateBrowserWindow(bw)2161 a.activateBrowserWindow(bw)
2093 }2162 }
@@ -2095,6 +2164,43 @@ func (a *App) initCoordinators(ctx context.Context) {
2095 })2164 })
2096 a.tabCoord.SetOnQuit(a.Quit)2165 a.tabCoord.SetOnQuit(a.Quit)
2097 a.tabCoord.SetOnStateChanged(a.MarkDirty)2166 a.tabCoord.SetOnStateChanged(a.MarkDirty)
2167+ // Wire per-window tab scoping
2168+ a.tabCoord.SetTabScope(func(tab *entity.Tab, mainWindow *window.MainWindow) bool {
2169+ if tab == nil {
2170+ return false
2171+ }
2172+ bw := a.browserWindowForMainWindow(mainWindow)
2173+ if bw == nil {
2174+ return true // backward-compatible fallback for single-window / test scenarios
2175+ }
2176+ owner := a.windowForTab[tab.ID]
2177+ if owner == nil {
2178+ return false // unowned tabs are out of scope when a real window exists
2179+ }
2180+ return owner == bw
2181+ })
2182+ // Wire per-window previous active tab ID provider for scoped Alt+Tab switching
2183+ a.tabCoord.SetPreviousActiveTabIDProvider(func(mainWindow *window.MainWindow) entity.TabID {
2184+ bw := a.browserWindowForMainWindow(mainWindow)
2185+ if bw == nil {
2186+ return ""
2187+ }
2188+ prevID := bw.prevActiveTabID
2189+ if !a.browserWindowHasLiveTab(bw, prevID) {
2190+ bw.prevActiveTabID = ""
2191+ return ""
2192+ }
2193+ return prevID
2194+ })
2195+ a.tabCoord.SetOnCurrentWindowEmpty(func(ctx context.Context, mainWindow *window.MainWindow) {
2196+ bw := a.browserWindowForMainWindow(mainWindow)
2197+ if bw != nil {
2198+ a.removeBrowserWindow(bw.id)
2199+ if bw.mainWindow != nil {
2200+ bw.mainWindow.Destroy()
2201+ }
2202+ }
2203+ })
2098 // Wire popup tab WebView attachment2204 // Wire popup tab WebView attachment
2099 a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) {2205 a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) {
2100 a.attachPopupToTab(ctx, tabID, pane, wv)2206 a.attachPopupToTab(ctx, tabID, pane, wv)
@@ -2103,6 +2209,29 @@ func (a *App) initCoordinators(ctx context.Context) {
2103 for _, bw := range a.browserWindows {2209 for _, bw := range a.browserWindows {
2104 a.wireBrowserWindowTabBar(ctx, bw)2210 a.wireBrowserWindowTabBar(ctx, bw)
2105 }2211 }
2212+}
2213+
2214+// initCoordinators initializes all coordinators and wires their callbacks.
2215+func (a *App) initCoordinators(ctx context.Context) {
2216+ log := logging.FromContext(ctx)
2217+ log.Debug().Msg("initializing coordinators")
2218+
2219+ // Helper to get active workspace and view
2220+ getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) {
2221+ return a.activeWorkspace(), a.activeWorkspaceView()
2222+ }
2223+
2224+ // Create FaviconAdapter with service and engine FaviconDatabase.
2225+ // Skip if FaviconService is nil (e.g. in tests or when favicon support is disabled).
2226+ if a.deps.FaviconService != nil {
2227+ a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, a.engine.FaviconDatabase(), a.deps.FaviconAdapterConfig)
2228+ }
2229+
2230+ // 1. Content Coordinator (no dependencies on other coordinators)
2231+ a.initContentCoordinator(ctx, getActiveWS)
2232+
2233+ // 2. Tab Coordinator
2234+ a.initTabCoordinator(ctx)
2106 2235
2107 // Set fullscreen callback to hide/show tab bar (after tabCoord is initialized)2236 // Set fullscreen callback to hide/show tab bar (after tabCoord is initialized)
2108 a.contentCoord.SetOnFullscreenChanged(func(paneID entity.PaneID, entering bool) {2237 a.contentCoord.SetOnFullscreenChanged(func(paneID entity.PaneID, entering bool) {
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 +198 -101
@@ -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,20 @@ 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
36+
37+ // previousActiveTabID provides the per-window previous active tab ID for Alt+Tab style switching.
38+ // When set and tabScope is active, SwitchToLastActive prefers this over the global PreviousActiveTabID.
39+ previousActiveTabID func(mainWindow *window.MainWindow) entity.TabID
2840 }
2941
3042 // TabCoordinatorConfig holds configuration for TabCoordinator.
@@ -69,6 +81,57 @@ func (c *TabCoordinator) SetOnStateChanged(fn func()) {
6981 c.onStateChanged = fn
7082 }
7183
84+// SetPreviousActiveTabIDProvider sets the callback for retrieving the per-window
85+// previous active tab ID. When set and tabScope is active, SwitchToLastActive
86+// prefers this provider over the global PreviousActiveTabID.
87+func (c *TabCoordinator) SetPreviousActiveTabIDProvider(fn func(mainWindow *window.MainWindow) entity.TabID) {
88+ c.previousActiveTabID = fn
89+}
90+
91+// SetTabScope sets the function used to filter tabs to the current window.
92+// When set, tab operations (switch by index, next/prev, close) only consider
93+// tabs for which the scope function returns true.
94+func (c *TabCoordinator) SetTabScope(fn TabScopeFunc) {
95+ c.tabScope = fn
96+}
97+
98+// SetOnCurrentWindowEmpty sets the callback for when the last tab in the current
99+// window is closed. This allows the app to close the window gracefully.
100+func (c *TabCoordinator) SetOnCurrentWindowEmpty(fn func(ctx context.Context, mainWindow *window.MainWindow)) {
101+ c.onCurrentWindowEmpty = fn
102+}
103+
104+// scopedTabs returns tabs filtered by the tabScope function.
105+// When no scope function is set, returns all tabs (backward compatible).
106+func (c *TabCoordinator) scopedTabs() []*entity.Tab {
107+ if c.tabs == nil {
108+ return nil
109+ }
110+
111+ if c.tabScope == nil {
112+ return c.tabs.Tabs
113+ }
114+
115+ scoped := make([]*entity.Tab, 0, len(c.tabs.Tabs))
116+ for _, tab := range c.tabs.Tabs {
117+ if tab != nil && c.tabScope(tab, c.mainWindow) {
118+ scoped = append(scoped, tab)
119+ }
120+ }
121+ return scoped
122+}
123+
124+// indexOfTab returns the 0-based index of the given tab ID in a slice of tabs,
125+// or -1 if not found.
126+func indexOfTab(tabs []*entity.Tab, id entity.TabID) int {
127+ for i, tab := range tabs {
128+ if tab != nil && tab.ID == id {
129+ return i
130+ }
131+ }
132+ return -1
133+}
134+
72135 // SetMainWindow updates the window targeted by tab UI operations.
73136 func (c *TabCoordinator) SetMainWindow(mainWindow *window.MainWindow) {
74137 c.mainWindow = mainWindow
@@ -104,6 +167,13 @@ func (c *TabCoordinator) create(ctx context.Context, initialURL string, activate
104167 c.tabs.SetActive(output.Tab.ID)
105168 }
106169
170+ // Notify app before adding the tab to the visible tab bar.
171+ // The app assigns window ownership and the window-scoped default name here,
172+ // and TabBar.AddTab reads tab.Title() immediately when creating the button.
173+ if c.onTabCreated != nil {
174+ c.onTabCreated(ctx, output.Tab)
175+ }
176+
107177 // Update tab bar
108178 if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
109179 c.mainWindow.TabBar().AddTab(output.Tab)
@@ -115,11 +185,6 @@ func (c *TabCoordinator) create(ctx context.Context, initialURL string, activate
115185 // Update tab bar visibility
116186 c.UpdateBarVisibility(ctx)
117187
118- // Notify app to create workspace view
119- if c.onTabCreated != nil {
120- c.onTabCreated(ctx, output.Tab)
121- }
122-
123188 // Switch to the new tab's workspace view when activation is requested.
124189 if activate && c.onTabSwitched != nil {
125190 c.onTabSwitched(ctx, output.Tab)
@@ -133,6 +198,9 @@ func (c *TabCoordinator) create(ctx context.Context, initialURL string, activate
133198 }
134199
135200 // Close closes the active tab.
201+// When scoped, prefers switching to a sibling tab in the same window.
202+// If this was the last tab in the window, fires onCurrentWindowEmpty.
203+// If this was the last tab globally, fires onQuit.
136204 func (c *TabCoordinator) Close(ctx context.Context) error {
137205 log := logging.FromContext(ctx)
138206
@@ -142,6 +210,22 @@ func (c *TabCoordinator) Close(ctx context.Context) error {
142210 return nil
143211 }
144212
213+ // Compute the next scoped tab to activate BEFORE closing
214+ scoped := c.scopedTabs()
215+ activeIdx := indexOfTab(scoped, activeID)
216+ if activeIdx < 0 && len(scoped) > 0 {
217+ log.Debug().Str("active_tab_id", string(activeID)).Int("scoped_count", len(scoped)).Msg("active tab not in scoped list")
218+ }
219+
220+ var nextScopedID entity.TabID
221+ if activeIdx >= 0 && len(scoped) > 1 {
222+ if activeIdx < len(scoped)-1 {
223+ nextScopedID = scoped[activeIdx+1].ID
224+ } else {
225+ nextScopedID = scoped[activeIdx-1].ID
226+ }
227+ }
228+
145229 wasLast, err := c.tabsUC.Close(ctx, c.tabs, activeID)
146230 if err != nil {
147231 log.Error().Err(err).Msg("failed to close tab")
@@ -150,34 +234,51 @@ func (c *TabCoordinator) Close(ctx context.Context) error {
150234
151235 if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
152236 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- }
158237 }
159238
160239 // Update tab bar visibility
161240 c.UpdateBarVisibility(ctx)
162241
163- // Quit if no tabs left
164- if wasLast && c.onQuit != nil {
165- c.onQuit()
242+ // Quit if no tabs left globally
243+ if wasLast {
244+ if c.onQuit != nil {
245+ c.onQuit()
246+ }
247+ log.Debug().Str("tab_id", string(activeID)).Bool("was_last", wasLast).Msg("tab closed, last globally")
248+ return nil
166249 }
167250
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)
251+ // If there's a sibling tab in the same window, switch to it
252+ if nextScopedID != "" {
253+ if err := c.tabsUC.Switch(ctx, c.tabs, nextScopedID); err != nil {
254+ log.Error().Err(err).Str("tab_id", string(nextScopedID)).Msg("failed to switch to scoped sibling")
255+ return err
256+ }
257+
258+ if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
259+ c.mainWindow.TabBar().SetActive(nextScopedID)
260+ }
261+
262+ if c.onTabSwitched != nil {
263+ if tab := c.tabs.Find(nextScopedID); tab != nil {
264+ c.onTabSwitched(ctx, tab)
265+ }
172266 }
173- }
174267
175- // Notify state change for session snapshots (unless quitting)
176- if !wasLast {
268+ // Notify state change for session snapshots
177269 c.notifyStateChanged()
270+
271+ log.Debug().Str("tab_id", string(activeID)).Str("next", string(nextScopedID)).Msg("tab closed, switched to scoped sibling")
272+ return nil
273+ }
274+
275+ // Last tab in this window but not globally last — close the window
276+ c.notifyStateChanged()
277+ if c.onCurrentWindowEmpty != nil {
278+ c.onCurrentWindowEmpty(ctx, c.mainWindow)
178279 }
179280
180- log.Debug().Str("tab_id", string(activeID)).Bool("was_last", wasLast).Msg("tab closed")
281+ log.Debug().Str("tab_id", string(activeID)).Msg("tab closed, last in window")
181282 return nil
182283 }
183284
@@ -190,6 +291,14 @@ func (c *TabCoordinator) Switch(ctx context.Context, tabID entity.TabID) error {
190291 return nil
191292 }
192293
294+ // Reject switching to a tab outside the current window scope
295+ if tab := c.tabs.Find(tabID); tab != nil && c.tabScope != nil && !c.tabScope(tab, c.mainWindow) {
296+ log.Debug().
297+ Str("tab_id", string(tabID)).
298+ Msg("ignoring switch to tab outside current window scope")
299+ return nil
300+ }
301+
193302 log.Debug().
194303 Str("from", string(c.tabs.ActiveTabID)).
195304 Str("to", string(tabID)).
@@ -216,79 +325,49 @@ func (c *TabCoordinator) Switch(ctx context.Context, tabID entity.TabID) error {
216325 return nil
217326 }
218327
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
328+// switchRelative switches to the tab delta positions away within the current window scope.
329+// delta of +1 switches next, -1 switches previous, wrapping within the scoped list.
330+func (c *TabCoordinator) switchRelative(ctx context.Context, delta int) error {
331+ scoped := c.scopedTabs()
332+ if len(scoped) <= 1 {
333+ return nil
226334 }
227335
228- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
229- c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
336+ current := indexOfTab(scoped, c.tabs.ActiveTabID)
337+ if current < 0 {
338+ current = 0
339+ } else {
340+ current = (current + delta + len(scoped)) % len(scoped)
230341 }
231342
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- }
343+ return c.Switch(ctx, scoped[current].ID)
344+}
238345
239- log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to next tab")
240- return nil
346+// SwitchNext switches to the next tab within the current window scope.
347+func (c *TabCoordinator) SwitchNext(ctx context.Context) error {
348+ return c.switchRelative(ctx, 1)
241349 }
242350
243-// SwitchPrev switches to the previous tab.
351+// SwitchPrev switches to the previous tab within the current window scope.
244352 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
353+ return c.switchRelative(ctx, -1)
265354 }
266355
267-// SwitchByIndex switches to a tab by 0-based index.
356+// SwitchByIndex switches to a tab by 0-based index within the current window scope.
268357 func (c *TabCoordinator) SwitchByIndex(ctx context.Context, index int) error {
269358 log := logging.FromContext(ctx)
270359
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- }
360+ scoped := c.scopedTabs()
361+ if index < 0 || index >= len(scoped) {
362+ log.Debug().Int("index", index).Int("scoped_count", len(scoped)).Msg("invalid scoped tab index")
363+ return nil
285364 }
286365
287- log.Debug().Int("index", index).Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to tab by index")
288- return nil
366+ return c.Switch(ctx, scoped[index].ID)
289367 }
290368
291-// EnsureTabByIndex creates tabs until the requested index exists, then switches to it.
369+// EnsureTabByIndex creates tabs until the requested index exists within the current
370+// window scope, then switches to it.
292371 func (c *TabCoordinator) EnsureTabByIndex(ctx context.Context, index int, initialURL string) error {
293372 log := logging.FromContext(ctx)
294373
@@ -301,12 +380,14 @@ func (c *TabCoordinator) EnsureTabByIndex(ctx context.Context, index int, initia
301380 return fmt.Errorf("tab list is required")
302381 }
303382
304- if c.tabs.Count() <= index && initialURL == "" {
305- log.Debug().Int("index", index).Msg("cannot auto-create missing tabs without initial URL")
383+ // Create tabs within the current window scope until we have enough
384+ scoped := c.scopedTabs()
385+ if len(scoped) <= index && initialURL == "" {
386+ log.Debug().Int("index", index).Int("scoped_count", len(scoped)).Msg("cannot auto-create missing tabs without initial URL")
306387 return fmt.Errorf("initial URL is required to auto-create missing tabs")
307388 }
308389
309- for c.tabs.Count() <= index {
390+ for len(c.scopedTabs()) <= index {
310391 if _, err := c.create(ctx, initialURL, false); err != nil {
311392 log.Error().Err(err).Int("index", index).Msg("failed to create tab while ensuring tab index")
312393 return err
@@ -317,27 +398,41 @@ func (c *TabCoordinator) EnsureTabByIndex(ctx context.Context, index int, initia
317398 }
318399
319400 // SwitchToLastActive switches to the previously active tab (Alt+Tab style).
401+// When window-scoped (tabScope is set and previousActiveTabID provider is available),
402+// it uses the per-window previous active tab ID. Falls back to the global
403+// PreviousActiveTabID otherwise.
320404 func (c *TabCoordinator) SwitchToLastActive(ctx context.Context) error {
321405 log := logging.FromContext(ctx)
322406
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
407+ var prevID entity.TabID
408+ if c.tabScope != nil && c.previousActiveTabID != nil {
409+ // Use per-window previous active tab ID when scoped
410+ prevID = c.previousActiveTabID(c.mainWindow)
411+ } else {
412+ // Fall back to global PreviousActiveTabID when not scoped or no provider
413+ prevID = c.tabs.PreviousActiveTabID
326414 }
327415
328- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
329- c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
416+ if prevID == "" || prevID == c.tabs.ActiveTabID {
417+ return nil
330418 }
331419
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)
420+ tab := c.tabs.Find(prevID)
421+ if tab == nil {
422+ // Only clear the global previous active tab ID if that's what we used
423+ if c.tabScope == nil || c.previousActiveTabID == nil {
424+ c.tabs.PreviousActiveTabID = ""
336425 }
426+ return nil
337427 }
338428
339- log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to last active tab")
340- return nil
429+ // With a scope function, only allow switching to the previous tab if it's in scope.
430+ if c.tabScope != nil && !c.tabScope(tab, c.mainWindow) {
431+ log.Debug().Str("tab_id", string(prevID)).Msg("previous active tab outside current window scope")
432+ return nil
433+ }
434+
435+ return c.Switch(ctx, prevID)
341436 }
342437
343438 // UpdateBarVisibility shows or hides the tab bar based on tab count.
@@ -401,6 +496,13 @@ func (c *TabCoordinator) CreateWithPane(
401496 // Set new tab as active
402497 c.tabs.SetActive(output.Tab.ID)
403498
499+ // Notify app before adding tab to the visible tab bar.
500+ // The app assigns window ownership and the scoped default name here,
501+ // and TabBar.AddTab reads tab.Title() immediately when creating the button.
502+ if c.onTabCreated != nil {
503+ c.onTabCreated(ctx, output.Tab)
504+ }
505+
404506 // Update tab bar
405507 if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
406508 c.mainWindow.TabBar().AddTab(output.Tab)
@@ -410,11 +512,6 @@ func (c *TabCoordinator) CreateWithPane(
410512 // Update tab bar visibility
411513 c.UpdateBarVisibility(ctx)
412514
413- // Notify app to create workspace view
414- if c.onTabCreated != nil {
415- c.onTabCreated(ctx, output.Tab)
416- }
417-
418515 // Attach the popup WebView to the new tab's workspace
419516 if c.onAttachPopupToTab != nil {
420517 c.onAttachPopupToTab(ctx, output.Tab.ID, pane, wv)
@@ -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,20 @@ 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
36+
37+ // previousActiveTabID provides the per-window previous active tab ID for Alt+Tab style switching.
38+ // When set and tabScope is active, SwitchToLastActive prefers this over the global PreviousActiveTabID.
39+ previousActiveTabID func(mainWindow *window.MainWindow) entity.TabID
28 }40 }
29 41
30 // TabCoordinatorConfig holds configuration for TabCoordinator.42 // TabCoordinatorConfig holds configuration for TabCoordinator.
@@ -69,6 +81,57 @@ func (c *TabCoordinator) SetOnStateChanged(fn func()) {
69 c.onStateChanged = fn81 c.onStateChanged = fn
70 }82 }
71 83
84+// SetPreviousActiveTabIDProvider sets the callback for retrieving the per-window
85+// previous active tab ID. When set and tabScope is active, SwitchToLastActive
86+// prefers this provider over the global PreviousActiveTabID.
87+func (c *TabCoordinator) SetPreviousActiveTabIDProvider(fn func(mainWindow *window.MainWindow) entity.TabID) {
88+ c.previousActiveTabID = fn
89+}
90+
91+// SetTabScope sets the function used to filter tabs to the current window.
92+// When set, tab operations (switch by index, next/prev, close) only consider
93+// tabs for which the scope function returns true.
94+func (c *TabCoordinator) SetTabScope(fn TabScopeFunc) {
95+ c.tabScope = fn
96+}
97+
98+// SetOnCurrentWindowEmpty sets the callback for when the last tab in the current
99+// window is closed. This allows the app to close the window gracefully.
100+func (c *TabCoordinator) SetOnCurrentWindowEmpty(fn func(ctx context.Context, mainWindow *window.MainWindow)) {
101+ c.onCurrentWindowEmpty = fn
102+}
103+
104+// scopedTabs returns tabs filtered by the tabScope function.
105+// When no scope function is set, returns all tabs (backward compatible).
106+func (c *TabCoordinator) scopedTabs() []*entity.Tab {
107+ if c.tabs == nil {
108+ return nil
109+ }
110+
111+ if c.tabScope == nil {
112+ return c.tabs.Tabs
113+ }
114+
115+ scoped := make([]*entity.Tab, 0, len(c.tabs.Tabs))
116+ for _, tab := range c.tabs.Tabs {
117+ if tab != nil && c.tabScope(tab, c.mainWindow) {
118+ scoped = append(scoped, tab)
119+ }
120+ }
121+ return scoped
122+}
123+
124+// indexOfTab returns the 0-based index of the given tab ID in a slice of tabs,
125+// or -1 if not found.
126+func indexOfTab(tabs []*entity.Tab, id entity.TabID) int {
127+ for i, tab := range tabs {
128+ if tab != nil && tab.ID == id {
129+ return i
130+ }
131+ }
132+ return -1
133+}
134+
72 // SetMainWindow updates the window targeted by tab UI operations.135 // SetMainWindow updates the window targeted by tab UI operations.
73 func (c *TabCoordinator) SetMainWindow(mainWindow *window.MainWindow) {136 func (c *TabCoordinator) SetMainWindow(mainWindow *window.MainWindow) {
74 c.mainWindow = mainWindow137 c.mainWindow = mainWindow
@@ -104,6 +167,13 @@ func (c *TabCoordinator) create(ctx context.Context, initialURL string, activate
104 c.tabs.SetActive(output.Tab.ID)167 c.tabs.SetActive(output.Tab.ID)
105 }168 }
106 169
170+ // Notify app before adding the tab to the visible tab bar.
171+ // The app assigns window ownership and the window-scoped default name here,
172+ // and TabBar.AddTab reads tab.Title() immediately when creating the button.
173+ if c.onTabCreated != nil {
174+ c.onTabCreated(ctx, output.Tab)
175+ }
176+
107 // Update tab bar177 // Update tab bar
108 if c.mainWindow != nil && c.mainWindow.TabBar() != nil {178 if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
109 c.mainWindow.TabBar().AddTab(output.Tab)179 c.mainWindow.TabBar().AddTab(output.Tab)
@@ -115,11 +185,6 @@ func (c *TabCoordinator) create(ctx context.Context, initialURL string, activate
115 // Update tab bar visibility185 // Update tab bar visibility
116 c.UpdateBarVisibility(ctx)186 c.UpdateBarVisibility(ctx)
117 187
118- // Notify app to create workspace view
119- if c.onTabCreated != nil {
120- c.onTabCreated(ctx, output.Tab)
121- }
122-
123 // Switch to the new tab's workspace view when activation is requested.188 // Switch to the new tab's workspace view when activation is requested.
124 if activate && c.onTabSwitched != nil {189 if activate && c.onTabSwitched != nil {
125 c.onTabSwitched(ctx, output.Tab)190 c.onTabSwitched(ctx, output.Tab)
@@ -133,6 +198,9 @@ func (c *TabCoordinator) create(ctx context.Context, initialURL string, activate
133 }198 }
134 199
135 // Close closes the active tab.200 // Close closes the active tab.
201+// When scoped, prefers switching to a sibling tab in the same window.
202+// If this was the last tab in the window, fires onCurrentWindowEmpty.
203+// If this was the last tab globally, fires onQuit.
136 func (c *TabCoordinator) Close(ctx context.Context) error {204 func (c *TabCoordinator) Close(ctx context.Context) error {
137 log := logging.FromContext(ctx)205 log := logging.FromContext(ctx)
138 206
@@ -142,6 +210,22 @@ func (c *TabCoordinator) Close(ctx context.Context) error {
142 return nil210 return nil
143 }211 }
144 212
213+ // Compute the next scoped tab to activate BEFORE closing
214+ scoped := c.scopedTabs()
215+ activeIdx := indexOfTab(scoped, activeID)
216+ if activeIdx < 0 && len(scoped) > 0 {
217+ log.Debug().Str("active_tab_id", string(activeID)).Int("scoped_count", len(scoped)).Msg("active tab not in scoped list")
218+ }
219+
220+ var nextScopedID entity.TabID
221+ if activeIdx >= 0 && len(scoped) > 1 {
222+ if activeIdx < len(scoped)-1 {
223+ nextScopedID = scoped[activeIdx+1].ID
224+ } else {
225+ nextScopedID = scoped[activeIdx-1].ID
226+ }
227+ }
228+
145 wasLast, err := c.tabsUC.Close(ctx, c.tabs, activeID)229 wasLast, err := c.tabsUC.Close(ctx, c.tabs, activeID)
146 if err != nil {230 if err != nil {
147 log.Error().Err(err).Msg("failed to close tab")231 log.Error().Err(err).Msg("failed to close tab")
@@ -150,34 +234,51 @@ func (c *TabCoordinator) Close(ctx context.Context) error {
150 234
151 if c.mainWindow != nil && c.mainWindow.TabBar() != nil {235 if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
152 c.mainWindow.TabBar().RemoveTab(activeID)236 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 }237 }
159 238
160 // Update tab bar visibility239 // Update tab bar visibility
161 c.UpdateBarVisibility(ctx)240 c.UpdateBarVisibility(ctx)
162 241
163- // Quit if no tabs left242+ // Quit if no tabs left globally
164- if wasLast && c.onQuit != nil {243+ if wasLast {
165- c.onQuit()244+ if c.onQuit != nil {
245+ c.onQuit()
246+ }
247+ log.Debug().Str("tab_id", string(activeID)).Bool("was_last", wasLast).Msg("tab closed, last globally")
248+ return nil
166 }249 }
167 250
168- // Switch workspace view to new active tab (if not last)251+ // If there's a sibling tab in the same window, switch to it
169- if !wasLast && c.onTabSwitched != nil {252+ if nextScopedID != "" {
170- if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {253+ if err := c.tabsUC.Switch(ctx, c.tabs, nextScopedID); err != nil {
171- c.onTabSwitched(ctx, tab)254+ log.Error().Err(err).Str("tab_id", string(nextScopedID)).Msg("failed to switch to scoped sibling")
255+ return err
256+ }
257+
258+ if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
259+ c.mainWindow.TabBar().SetActive(nextScopedID)
260+ }
261+
262+ if c.onTabSwitched != nil {
263+ if tab := c.tabs.Find(nextScopedID); tab != nil {
264+ c.onTabSwitched(ctx, tab)
265+ }
172 }266 }
173- }
174 267
175- // Notify state change for session snapshots (unless quitting)268+ // Notify state change for session snapshots
176- if !wasLast {
177 c.notifyStateChanged()269 c.notifyStateChanged()
270+
271+ log.Debug().Str("tab_id", string(activeID)).Str("next", string(nextScopedID)).Msg("tab closed, switched to scoped sibling")
272+ return nil
273+ }
274+
275+ // Last tab in this window but not globally last — close the window
276+ c.notifyStateChanged()
277+ if c.onCurrentWindowEmpty != nil {
278+ c.onCurrentWindowEmpty(ctx, c.mainWindow)
178 }279 }
179 280
180- log.Debug().Str("tab_id", string(activeID)).Bool("was_last", wasLast).Msg("tab closed")281+ log.Debug().Str("tab_id", string(activeID)).Msg("tab closed, last in window")
181 return nil282 return nil
182 }283 }
183 284
@@ -190,6 +291,14 @@ func (c *TabCoordinator) Switch(ctx context.Context, tabID entity.TabID) error {
190 return nil291 return nil
191 }292 }
192 293
294+ // Reject switching to a tab outside the current window scope
295+ if tab := c.tabs.Find(tabID); tab != nil && c.tabScope != nil && !c.tabScope(tab, c.mainWindow) {
296+ log.Debug().
297+ Str("tab_id", string(tabID)).
298+ Msg("ignoring switch to tab outside current window scope")
299+ return nil
300+ }
301+
193 log.Debug().302 log.Debug().
194 Str("from", string(c.tabs.ActiveTabID)).303 Str("from", string(c.tabs.ActiveTabID)).
195 Str("to", string(tabID)).304 Str("to", string(tabID)).
@@ -216,79 +325,49 @@ func (c *TabCoordinator) Switch(ctx context.Context, tabID entity.TabID) error {
216 return nil325 return nil
217 }326 }
218 327
219-// SwitchNext switches to the next tab.328+// switchRelative switches to the tab delta positions away within the current window scope.
220-func (c *TabCoordinator) SwitchNext(ctx context.Context) error {329+// delta of +1 switches next, -1 switches previous, wrapping within the scoped list.
221- log := logging.FromContext(ctx)330+func (c *TabCoordinator) switchRelative(ctx context.Context, delta int) error {
222-331+ scoped := c.scopedTabs()
223- if err := c.tabsUC.SwitchNext(ctx, c.tabs); err != nil {332+ if len(scoped) <= 1 {
224- log.Error().Err(err).Msg("failed to switch to next tab")333+ return nil
225- return err
226 }334 }
227 335
228- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {336+ current := indexOfTab(scoped, c.tabs.ActiveTabID)
229- c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)337+ if current < 0 {
338+ current = 0
339+ } else {
340+ current = (current + delta + len(scoped)) % len(scoped)
230 }341 }
231 342
232- // Invoke callback for workspace view switching343+ return c.Switch(ctx, scoped[current].ID)
233- if c.onTabSwitched != nil {344+}
234- if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {
235- c.onTabSwitched(ctx, tab)
236- }
237- }
238 345
239- log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to next tab")346+// SwitchNext switches to the next tab within the current window scope.
240- return nil347+func (c *TabCoordinator) SwitchNext(ctx context.Context) error {
348+ return c.switchRelative(ctx, 1)
241 }349 }
242 350
243-// SwitchPrev switches to the previous tab.351+// SwitchPrev switches to the previous tab within the current window scope.
244 func (c *TabCoordinator) SwitchPrev(ctx context.Context) error {352 func (c *TabCoordinator) SwitchPrev(ctx context.Context) error {
245- log := logging.FromContext(ctx)353+ 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 }354 }
266 355
267-// SwitchByIndex switches to a tab by 0-based index.356+// 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 {357 func (c *TabCoordinator) SwitchByIndex(ctx context.Context, index int) error {
269 log := logging.FromContext(ctx)358 log := logging.FromContext(ctx)
270 359
271- if err := c.tabsUC.SwitchByIndex(ctx, c.tabs, index); err != nil {360+ scoped := c.scopedTabs()
272- log.Error().Err(err).Int("index", index).Msg("failed to switch to tab by index")361+ if index < 0 || index >= len(scoped) {
273- return err362+ log.Debug().Int("index", index).Int("scoped_count", len(scoped)).Msg("invalid scoped tab index")
274- }363+ 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 }364 }
286 365
287- log.Debug().Int("index", index).Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to tab by index")366+ return c.Switch(ctx, scoped[index].ID)
288- return nil
289 }367 }
290 368
291-// EnsureTabByIndex creates tabs until the requested index exists, then switches to it.369+// EnsureTabByIndex creates tabs until the requested index exists within the current
370+// window scope, then switches to it.
292 func (c *TabCoordinator) EnsureTabByIndex(ctx context.Context, index int, initialURL string) error {371 func (c *TabCoordinator) EnsureTabByIndex(ctx context.Context, index int, initialURL string) error {
293 log := logging.FromContext(ctx)372 log := logging.FromContext(ctx)
294 373
@@ -301,12 +380,14 @@ func (c *TabCoordinator) EnsureTabByIndex(ctx context.Context, index int, initia
301 return fmt.Errorf("tab list is required")380 return fmt.Errorf("tab list is required")
302 }381 }
303 382
304- if c.tabs.Count() <= index && initialURL == "" {383+ // 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")384+ scoped := c.scopedTabs()
385+ if len(scoped) <= index && initialURL == "" {
386+ 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")387 return fmt.Errorf("initial URL is required to auto-create missing tabs")
307 }388 }
308 389
309- for c.tabs.Count() <= index {390+ for len(c.scopedTabs()) <= index {
310 if _, err := c.create(ctx, initialURL, false); err != nil {391 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")392 log.Error().Err(err).Int("index", index).Msg("failed to create tab while ensuring tab index")
312 return err393 return err
@@ -317,27 +398,41 @@ func (c *TabCoordinator) EnsureTabByIndex(ctx context.Context, index int, initia
317 }398 }
318 399
319 // SwitchToLastActive switches to the previously active tab (Alt+Tab style).400 // SwitchToLastActive switches to the previously active tab (Alt+Tab style).
401+// When window-scoped (tabScope is set and previousActiveTabID provider is available),
402+// it uses the per-window previous active tab ID. Falls back to the global
403+// PreviousActiveTabID otherwise.
320 func (c *TabCoordinator) SwitchToLastActive(ctx context.Context) error {404 func (c *TabCoordinator) SwitchToLastActive(ctx context.Context) error {
321 log := logging.FromContext(ctx)405 log := logging.FromContext(ctx)
322 406
323- if err := c.tabsUC.SwitchToLastActive(ctx, c.tabs); err != nil {407+ var prevID entity.TabID
324- log.Error().Err(err).Msg("failed to switch to last active tab")408+ if c.tabScope != nil && c.previousActiveTabID != nil {
325- return err409+ // Use per-window previous active tab ID when scoped
410+ prevID = c.previousActiveTabID(c.mainWindow)
411+ } else {
412+ // Fall back to global PreviousActiveTabID when not scoped or no provider
413+ prevID = c.tabs.PreviousActiveTabID
326 }414 }
327 415
328- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {416+ if prevID == "" || prevID == c.tabs.ActiveTabID {
329- c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)417+ return nil
330 }418 }
331 419
332- // Invoke callback for workspace view switching420+ tab := c.tabs.Find(prevID)
333- if c.onTabSwitched != nil {421+ if tab == nil {
334- if tab := c.tabs.Find(c.tabs.ActiveTabID); tab != nil {422+ // Only clear the global previous active tab ID if that's what we used
335- c.onTabSwitched(ctx, tab)423+ if c.tabScope == nil || c.previousActiveTabID == nil {
424+ c.tabs.PreviousActiveTabID = ""
336 }425 }
426+ return nil
337 }427 }
338 428
339- log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to last active tab")429+ // With a scope function, only allow switching to the previous tab if it's in scope.
340- return nil430+ if c.tabScope != nil && !c.tabScope(tab, c.mainWindow) {
431+ log.Debug().Str("tab_id", string(prevID)).Msg("previous active tab outside current window scope")
432+ return nil
433+ }
434+
435+ return c.Switch(ctx, prevID)
341 }436 }
342 437
343 // UpdateBarVisibility shows or hides the tab bar based on tab count.438 // UpdateBarVisibility shows or hides the tab bar based on tab count.
@@ -401,6 +496,13 @@ func (c *TabCoordinator) CreateWithPane(
401 // Set new tab as active496 // Set new tab as active
402 c.tabs.SetActive(output.Tab.ID)497 c.tabs.SetActive(output.Tab.ID)
403 498
499+ // Notify app before adding tab to the visible tab bar.
500+ // The app assigns window ownership and the scoped default name here,
501+ // and TabBar.AddTab reads tab.Title() immediately when creating the button.
502+ if c.onTabCreated != nil {
503+ c.onTabCreated(ctx, output.Tab)
504+ }
505+
404 // Update tab bar506 // Update tab bar
405 if c.mainWindow != nil && c.mainWindow.TabBar() != nil {507 if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
406 c.mainWindow.TabBar().AddTab(output.Tab)508 c.mainWindow.TabBar().AddTab(output.Tab)
@@ -410,11 +512,6 @@ func (c *TabCoordinator) CreateWithPane(
410 // Update tab bar visibility512 // Update tab bar visibility
411 c.UpdateBarVisibility(ctx)513 c.UpdateBarVisibility(ctx)
412 514
413- // Notify app to create workspace view
414- if c.onTabCreated != nil {
415- c.onTabCreated(ctx, output.Tab)
416- }
417-
418 // Attach the popup WebView to the new tab's workspace515 // Attach the popup WebView to the new tab's workspace
419 if c.onAttachPopupToTab != nil {516 if c.onAttachPopupToTab != nil {
420 c.onAttachPopupToTab(ctx, output.Tab.ID, pane, wv)517 c.onAttachPopupToTab(ctx, output.Tab.ID, pane, wv)

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