detached 7f05154

Merge pull request #306 from bnema/fix/webview-tab-release

fix(ui): release WebViews when closing tabs
commit
7f051542d0a76cafb51f28fdec3bb98da82ae86f
author
bnema · · 2026-06-17 20:40 UTC
committer
GitHub · · 2026-06-17 20:40 UTC
merge of
90363e4 + fece6d9 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
4 files · +301 -34
M internal/ui/app.go +3 -0
@@ -3222,6 +3222,9 @@ func (a *App) initTabCoordinator(ctx context.Context) {
32223222 }
32233223 a.switchWorkspaceView(ctx, tab.ID)
32243224 })
3225+ a.tabCoord.SetOnTabClosed(func(ctx context.Context, _ coordinator.TabTarget, tab *entity.Tab) {
3226+ a.releaseTabWorkspace(ctx, tab)
3227+ })
32253228 a.tabCoord.SetOnStateChanged(a.MarkDirty)
32263229 // Per-window tab ownership is now enforced through explicit TabTarget.
32273230 // The per-window TabList (bw.tabs) is the source of truth; no scope filtering needed.
@@ -3222,6 +3222,9 @@ func (a *App) initTabCoordinator(ctx context.Context) {
3222 }3222 }
3223 a.switchWorkspaceView(ctx, tab.ID)3223 a.switchWorkspaceView(ctx, tab.ID)
3224 })3224 })
3225+ a.tabCoord.SetOnTabClosed(func(ctx context.Context, _ coordinator.TabTarget, tab *entity.Tab) {
3226+ a.releaseTabWorkspace(ctx, tab)
3227+ })
3225 a.tabCoord.SetOnStateChanged(a.MarkDirty)3228 a.tabCoord.SetOnStateChanged(a.MarkDirty)
3226 // Per-window tab ownership is now enforced through explicit TabTarget.3229 // Per-window tab ownership is now enforced through explicit TabTarget.
3227 // The per-window TabList (bw.tabs) is the source of truth; no scope filtering needed.3230 // The per-window TabList (bw.tabs) is the source of truth; no scope filtering needed.
M internal/ui/browser_window.go +119 -34
@@ -296,62 +296,147 @@ func (a *App) registerBrowserWindow(bw *browserWindow) {
296296 a.browserWindowOrder = append(a.browserWindowOrder, bw.id)
297297 }
298298
299+func (a *App) releaseTabWorkspace(ctx context.Context, tab *entity.Tab) {
300+ if tab == nil {
301+ return
302+ }
303+ if ctx == nil {
304+ ctx = context.Background()
305+ }
306+
307+ a.releaseFloatingSessionsForTab(ctx, tab.ID)
308+ if a.contentCoord != nil && tab.Workspace != nil {
309+ for _, pane := range tab.Workspace.AllPanes() {
310+ if pane == nil {
311+ continue
312+ }
313+ a.contentCoord.ReleaseWebView(ctx, pane.ID)
314+ }
315+ }
316+ delete(a.workspaceViews, tab.ID)
317+ delete(a.windowForTab, tab.ID)
318+ if a.tabs != nil && a.tabs.Find(tab.ID) != nil {
319+ a.tabs.Remove(tab.ID)
320+ }
321+}
322+
299323 func (a *App) removeBrowserWindow(id string) {
300324 if id == "" || a.browserWindows == nil {
301325 return
302326 }
303327 removed := a.browserWindows[id]
328+ wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow
304329
305- // Remove from registration order
330+ a.removeBrowserWindowOrder(id)
331+ a.releaseBrowserWindowTabs(context.Background(), id, removed)
332+ if removed != nil {
333+ removed.teardownForDestroy()
334+ }
335+ if a.contentCoord != nil {
336+ a.contentCoord.ClearPopupNamedContextsForWindow(id)
337+ }
338+ delete(a.browserWindows, id)
339+
340+ fallback := a.deterministicBrowserWindowFallback()
341+ a.updateLastFocusedWindowAfterRemoval(id, fallback)
342+ if wasMainWindow {
343+ a.clearMainBrowserWindowAfterRemoval(fallback)
344+ }
345+}
346+
347+func (a *App) removeBrowserWindowOrder(id string) {
306348 for i, wid := range a.browserWindowOrder {
307349 if wid == id {
308350 a.browserWindowOrder = append(a.browserWindowOrder[:i], a.browserWindowOrder[i+1:]...)
309- break
351+ return
310352 }
311353 }
312- wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow
313- ownedTabIDs := make([]entity.TabID, 0)
314- for tabID, bw := range a.windowForTab {
315- if bw != nil && bw.id == id {
316- ownedTabIDs = append(ownedTabIDs, tabID)
354+}
355+
356+func (a *App) releaseBrowserWindowTabs(ctx context.Context, windowID string, bw *browserWindow) {
357+ ownedTabs := a.collectBrowserWindowTabs(windowID, bw)
358+ for _, tabID := range sortedTabIDs(ownedTabs) {
359+ a.releaseTabWorkspace(ctx, ownedTabs[tabID])
360+ }
361+ a.cleanupStaleTabMappingsForWindow(windowID)
362+}
363+
364+func (a *App) collectBrowserWindowTabs(windowID string, bw *browserWindow) map[entity.TabID]*entity.Tab {
365+ ownedTabs := make(map[entity.TabID]*entity.Tab)
366+ if bw != nil && bw.tabs != nil {
367+ for _, tab := range bw.tabs.Tabs {
368+ if tab != nil {
369+ ownedTabs[tab.ID] = tab
370+ }
371+ }
372+ }
373+ for tabID, owner := range a.windowForTab {
374+ if owner == nil || owner.id != windowID {
375+ continue
317376 }
377+ if tab := tabFromBrowserWindow(owner, tabID); tab != nil {
378+ ownedTabs[tabID] = tab
379+ continue
380+ }
381+ if a.tabs != nil {
382+ ownedTabs[tabID] = a.tabs.Find(tabID)
383+ }
384+ }
385+ return ownedTabs
386+}
387+
388+func tabFromBrowserWindow(bw *browserWindow, tabID entity.TabID) *entity.Tab {
389+ if bw == nil || bw.tabs == nil {
390+ return nil
391+ }
392+ return bw.tabs.Find(tabID)
393+}
394+
395+func sortedTabIDs(tabs map[entity.TabID]*entity.Tab) []entity.TabID {
396+ ids := make([]entity.TabID, 0, len(tabs))
397+ for tabID := range tabs {
398+ ids = append(ids, tabID)
318399 }
319- for _, tabID := range ownedTabIDs {
320- a.releaseFloatingSessionsForTab(context.Background(), tabID)
400+ sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
401+ return ids
402+}
403+
404+func (a *App) cleanupStaleTabMappingsForWindow(windowID string) {
405+ for tabID, bw := range a.windowForTab {
406+ if bw == nil || bw.id != windowID {
407+ continue
408+ }
321409 delete(a.workspaceViews, tabID)
322410 delete(a.windowForTab, tabID)
323411 if a.tabs != nil && a.tabs.Find(tabID) != nil {
324412 a.tabs.Remove(tabID)
325413 }
326414 }
327- if removed != nil {
328- removed.teardownForDestroy()
415+}
416+
417+func (a *App) updateLastFocusedWindowAfterRemoval(removedID string, fallback *browserWindow) {
418+ if a.lastFocusedWindowID != removedID {
419+ return
329420 }
330- if a.contentCoord != nil {
331- a.contentCoord.ClearPopupNamedContextsForWindow(id)
421+ if fallback != nil {
422+ a.lastFocusedWindowID = fallback.id
423+ return
332424 }
333- delete(a.browserWindows, id)
334- fallback := a.deterministicBrowserWindowFallback()
335- if a.lastFocusedWindowID == id {
336- if fallback != nil {
337- a.lastFocusedWindowID = fallback.id
338- } else {
339- a.lastFocusedWindowID = ""
340- }
425+ a.lastFocusedWindowID = ""
426+}
427+
428+func (a *App) clearMainBrowserWindowAfterRemoval(fallback *browserWindow) {
429+ a.clearResizeModeBorder()
430+ if fallback != nil {
431+ a.activateBrowserWindow(fallback)
432+ return
341433 }
342- if wasMainWindow {
343- a.clearResizeModeBorder()
344- if fallback != nil {
345- a.activateBrowserWindow(fallback)
346- return
347- }
348- a.lastFocusedWindowID = ""
349- a.mainWindow = nil
350- a.keyboardHandler = nil
351- a.globalShortcutHandler = nil
352- if a.tabCoord != nil {
353- a.tabCoord.SetCurrentTarget(coordinator.TabTarget{})
354- }
434+ a.lastFocusedWindowID = ""
435+ a.mainWindow = nil
436+ a.keyboardHandler = nil
437+ a.globalShortcutHandler = nil
438+ if a.tabCoord != nil {
439+ a.tabCoord.SetCurrentTarget(coordinator.TabTarget{})
355440 }
356441 }
357442
@@ -296,62 +296,147 @@ func (a *App) registerBrowserWindow(bw *browserWindow) {
296 a.browserWindowOrder = append(a.browserWindowOrder, bw.id)296 a.browserWindowOrder = append(a.browserWindowOrder, bw.id)
297 }297 }
298 298
299+func (a *App) releaseTabWorkspace(ctx context.Context, tab *entity.Tab) {
300+ if tab == nil {
301+ return
302+ }
303+ if ctx == nil {
304+ ctx = context.Background()
305+ }
306+
307+ a.releaseFloatingSessionsForTab(ctx, tab.ID)
308+ if a.contentCoord != nil && tab.Workspace != nil {
309+ for _, pane := range tab.Workspace.AllPanes() {
310+ if pane == nil {
311+ continue
312+ }
313+ a.contentCoord.ReleaseWebView(ctx, pane.ID)
314+ }
315+ }
316+ delete(a.workspaceViews, tab.ID)
317+ delete(a.windowForTab, tab.ID)
318+ if a.tabs != nil && a.tabs.Find(tab.ID) != nil {
319+ a.tabs.Remove(tab.ID)
320+ }
321+}
322+
299 func (a *App) removeBrowserWindow(id string) {323 func (a *App) removeBrowserWindow(id string) {
300 if id == "" || a.browserWindows == nil {324 if id == "" || a.browserWindows == nil {
301 return325 return
302 }326 }
303 removed := a.browserWindows[id]327 removed := a.browserWindows[id]
328+ wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow
304 329
305- // Remove from registration order330+ a.removeBrowserWindowOrder(id)
331+ a.releaseBrowserWindowTabs(context.Background(), id, removed)
332+ if removed != nil {
333+ removed.teardownForDestroy()
334+ }
335+ if a.contentCoord != nil {
336+ a.contentCoord.ClearPopupNamedContextsForWindow(id)
337+ }
338+ delete(a.browserWindows, id)
339+
340+ fallback := a.deterministicBrowserWindowFallback()
341+ a.updateLastFocusedWindowAfterRemoval(id, fallback)
342+ if wasMainWindow {
343+ a.clearMainBrowserWindowAfterRemoval(fallback)
344+ }
345+}
346+
347+func (a *App) removeBrowserWindowOrder(id string) {
306 for i, wid := range a.browserWindowOrder {348 for i, wid := range a.browserWindowOrder {
307 if wid == id {349 if wid == id {
308 a.browserWindowOrder = append(a.browserWindowOrder[:i], a.browserWindowOrder[i+1:]...)350 a.browserWindowOrder = append(a.browserWindowOrder[:i], a.browserWindowOrder[i+1:]...)
309- break351+ return
310 }352 }
311 }353 }
312- wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow354+}
313- ownedTabIDs := make([]entity.TabID, 0)355+
314- for tabID, bw := range a.windowForTab {356+func (a *App) releaseBrowserWindowTabs(ctx context.Context, windowID string, bw *browserWindow) {
315- if bw != nil && bw.id == id {357+ ownedTabs := a.collectBrowserWindowTabs(windowID, bw)
316- ownedTabIDs = append(ownedTabIDs, tabID)358+ for _, tabID := range sortedTabIDs(ownedTabs) {
359+ a.releaseTabWorkspace(ctx, ownedTabs[tabID])
360+ }
361+ a.cleanupStaleTabMappingsForWindow(windowID)
362+}
363+
364+func (a *App) collectBrowserWindowTabs(windowID string, bw *browserWindow) map[entity.TabID]*entity.Tab {
365+ ownedTabs := make(map[entity.TabID]*entity.Tab)
366+ if bw != nil && bw.tabs != nil {
367+ for _, tab := range bw.tabs.Tabs {
368+ if tab != nil {
369+ ownedTabs[tab.ID] = tab
370+ }
371+ }
372+ }
373+ for tabID, owner := range a.windowForTab {
374+ if owner == nil || owner.id != windowID {
375+ continue
317 }376 }
377+ if tab := tabFromBrowserWindow(owner, tabID); tab != nil {
378+ ownedTabs[tabID] = tab
379+ continue
380+ }
381+ if a.tabs != nil {
382+ ownedTabs[tabID] = a.tabs.Find(tabID)
383+ }
384+ }
385+ return ownedTabs
386+}
387+
388+func tabFromBrowserWindow(bw *browserWindow, tabID entity.TabID) *entity.Tab {
389+ if bw == nil || bw.tabs == nil {
390+ return nil
391+ }
392+ return bw.tabs.Find(tabID)
393+}
394+
395+func sortedTabIDs(tabs map[entity.TabID]*entity.Tab) []entity.TabID {
396+ ids := make([]entity.TabID, 0, len(tabs))
397+ for tabID := range tabs {
398+ ids = append(ids, tabID)
318 }399 }
319- for _, tabID := range ownedTabIDs {400+ sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
320- a.releaseFloatingSessionsForTab(context.Background(), tabID)401+ return ids
402+}
403+
404+func (a *App) cleanupStaleTabMappingsForWindow(windowID string) {
405+ for tabID, bw := range a.windowForTab {
406+ if bw == nil || bw.id != windowID {
407+ continue
408+ }
321 delete(a.workspaceViews, tabID)409 delete(a.workspaceViews, tabID)
322 delete(a.windowForTab, tabID)410 delete(a.windowForTab, tabID)
323 if a.tabs != nil && a.tabs.Find(tabID) != nil {411 if a.tabs != nil && a.tabs.Find(tabID) != nil {
324 a.tabs.Remove(tabID)412 a.tabs.Remove(tabID)
325 }413 }
326 }414 }
327- if removed != nil {415+}
328- removed.teardownForDestroy()416+
417+func (a *App) updateLastFocusedWindowAfterRemoval(removedID string, fallback *browserWindow) {
418+ if a.lastFocusedWindowID != removedID {
419+ return
329 }420 }
330- if a.contentCoord != nil {421+ if fallback != nil {
331- a.contentCoord.ClearPopupNamedContextsForWindow(id)422+ a.lastFocusedWindowID = fallback.id
423+ return
332 }424 }
333- delete(a.browserWindows, id)425+ a.lastFocusedWindowID = ""
334- fallback := a.deterministicBrowserWindowFallback()426+}
335- if a.lastFocusedWindowID == id {427+
336- if fallback != nil {428+func (a *App) clearMainBrowserWindowAfterRemoval(fallback *browserWindow) {
337- a.lastFocusedWindowID = fallback.id429+ a.clearResizeModeBorder()
338- } else {430+ if fallback != nil {
339- a.lastFocusedWindowID = ""431+ a.activateBrowserWindow(fallback)
340- }432+ return
341 }433 }
342- if wasMainWindow {434+ a.lastFocusedWindowID = ""
343- a.clearResizeModeBorder()435+ a.mainWindow = nil
344- if fallback != nil {436+ a.keyboardHandler = nil
345- a.activateBrowserWindow(fallback)437+ a.globalShortcutHandler = nil
346- return438+ if a.tabCoord != nil {
347- }439+ a.tabCoord.SetCurrentTarget(coordinator.TabTarget{})
348- a.lastFocusedWindowID = ""
349- a.mainWindow = nil
350- a.keyboardHandler = nil
351- a.globalShortcutHandler = nil
352- if a.tabCoord != nil {
353- a.tabCoord.SetCurrentTarget(coordinator.TabTarget{})
354- }
355 }440 }
356 }441 }
357 442
M internal/ui/browser_window_test.go +168 -0
@@ -8,9 +8,12 @@ import (
88 "unsafe"
99
1010 "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/application/usecase"
1112 "github.com/bnema/dumber/internal/domain/entity"
13+ "github.com/bnema/dumber/internal/infrastructure/config"
1214 "github.com/bnema/dumber/internal/shared/syncdispatch"
1315 "github.com/bnema/dumber/internal/ui/component"
16+ contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
1417 "github.com/bnema/dumber/internal/ui/focus"
1518 "github.com/bnema/dumber/internal/ui/input"
1619 "github.com/bnema/dumber/internal/ui/layout"
@@ -103,6 +106,171 @@ func TestApp_CleanupCreatedBrowserWindowsDetachesUnregisteredWindowBeforeDestroy
103106 }
104107 }
105108
109+type recordingWebViewPool struct {
110+ released []port.WebView
111+}
112+
113+func (p *recordingWebViewPool) Acquire(context.Context) (port.WebView, error) {
114+ return nil, errors.New("not implemented")
115+}
116+func (p *recordingWebViewPool) Release(wv port.WebView) { p.released = append(p.released, wv) }
117+func (p *recordingWebViewPool) Prewarm(int) {}
118+func (p *recordingWebViewPool) PrewarmAsync(context.Context, int) {}
119+func (p *recordingWebViewPool) Size() int { return 0 }
120+func (p *recordingWebViewPool) Close() {}
121+
122+func TestTabCoordinator_CloseReleasesClosedTabWorkspaceWebViews(t *testing.T) {
123+ ctx := context.Background()
124+ closedTab := entity.NewTab(entity.TabID("tab-x"), entity.WorkspaceID("workspace-x"), entity.NewPane(entity.PaneID("pane-x")))
125+ survivingTab := entity.NewTab(entity.TabID("tab-survivor"), entity.WorkspaceID("workspace-survivor"), entity.NewPane(entity.PaneID("pane-survivor")))
126+ tabs := entity.NewTabList()
127+ tabs.Add(closedTab)
128+ tabs.Add(survivingTab)
129+ tabs.SetActive(closedTab.ID)
130+
131+ pool := &recordingWebViewPool{}
132+ contentCoord := contentcoord.NewCoordinator(ctx, pool, nil, nil, nil, nil, nil, nil)
133+ closedWV := &fakeRecordingWebView{id: 101}
134+ survivingWV := &fakeRecordingWebView{id: 102}
135+ contentCoord.RegisterPopupWebView(closedTab.Workspace.ActivePaneID, closedWV)
136+ contentCoord.RegisterPopupWebView(survivingTab.Workspace.ActivePaneID, survivingWV)
137+
138+ bw := &browserWindow{id: "window-1", tabs: tabs}
139+ app := &App{
140+ deps: &Dependencies{Config: config.DefaultConfig()},
141+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
142+ contentCoord: contentCoord,
143+ browserWindows: map[string]*browserWindow{bw.id: bw},
144+ tabs: entity.NewTabList(),
145+ workspaceViews: map[entity.TabID]*component.WorkspaceView{closedTab.ID: {}, survivingTab.ID: {}},
146+ windowForTab: map[entity.TabID]*browserWindow{closedTab.ID: bw, survivingTab.ID: bw},
147+ floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{},
148+ }
149+ app.tabs.Add(closedTab)
150+ app.tabs.Add(survivingTab)
151+ app.initTabCoordinator(ctx)
152+
153+ require.NoError(t, app.tabCoord.Close(ctx, app.ensureTabTargetForBrowserWindow(bw)))
154+
155+ require.Len(t, pool.released, 1)
156+ assert.Same(t, closedWV, pool.released[0])
157+ assert.Nil(t, contentCoord.GetWebView(closedTab.Workspace.ActivePaneID))
158+ assert.Same(t, survivingWV, contentCoord.GetWebView(survivingTab.Workspace.ActivePaneID))
159+ assert.Nil(t, app.workspaceViews[closedTab.ID])
160+ assert.Nil(t, app.windowForTab[closedTab.ID])
161+ assert.Nil(t, app.tabs.Find(closedTab.ID))
162+ assert.Equal(t, survivingTab.ID, tabs.ActiveTabID)
163+}
164+
165+func TestTabCoordinator_SwitchDoesNotReleaseWorkspaceWebViews(t *testing.T) {
166+ ctx := context.Background()
167+ firstTab := entity.NewTab(entity.TabID("tab-first"), entity.WorkspaceID("workspace-first"), entity.NewPane(entity.PaneID("pane-first")))
168+ secondTab := entity.NewTab(entity.TabID("tab-second"), entity.WorkspaceID("workspace-second"), entity.NewPane(entity.PaneID("pane-second")))
169+ tabs := entity.NewTabList()
170+ tabs.Add(firstTab)
171+ tabs.Add(secondTab)
172+ tabs.SetActive(firstTab.ID)
173+
174+ pool := &recordingWebViewPool{}
175+ contentCoord := contentcoord.NewCoordinator(ctx, pool, nil, nil, nil, nil, nil, nil)
176+ firstWV := &fakeRecordingWebView{id: 111}
177+ secondWV := &fakeRecordingWebView{id: 112}
178+ contentCoord.RegisterPopupWebView(firstTab.Workspace.ActivePaneID, firstWV)
179+ contentCoord.RegisterPopupWebView(secondTab.Workspace.ActivePaneID, secondWV)
180+
181+ bw := &browserWindow{id: "window-1", tabs: tabs}
182+ app := &App{
183+ deps: &Dependencies{Config: config.DefaultConfig()},
184+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
185+ contentCoord: contentCoord,
186+ browserWindows: map[string]*browserWindow{bw.id: bw},
187+ tabs: entity.NewTabList(),
188+ workspaceViews: map[entity.TabID]*component.WorkspaceView{firstTab.ID: {}, secondTab.ID: {}},
189+ windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: bw, secondTab.ID: bw},
190+ floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{},
191+ }
192+ app.tabs.Add(firstTab)
193+ app.tabs.Add(secondTab)
194+ app.initTabCoordinator(ctx)
195+
196+ require.NoError(t, app.tabCoord.Switch(ctx, app.ensureTabTargetForBrowserWindow(bw), secondTab.ID))
197+
198+ assert.Empty(t, pool.released)
199+ assert.Same(t, firstWV, contentCoord.GetWebView(firstTab.Workspace.ActivePaneID))
200+ assert.Same(t, secondWV, contentCoord.GetWebView(secondTab.Workspace.ActivePaneID))
201+}
202+
203+func TestTabCoordinator_CloseLastTabReleasesWorkspaceBeforeWindowRemoval(t *testing.T) {
204+ ctx := context.Background()
205+ closedTab := entity.NewTab(entity.TabID("tab-only"), entity.WorkspaceID("workspace-only"), entity.NewPane(entity.PaneID("pane-only")))
206+ tabs := entity.NewTabList()
207+ tabs.Add(closedTab)
208+ tabs.SetActive(closedTab.ID)
209+
210+ pool := &recordingWebViewPool{}
211+ contentCoord := contentcoord.NewCoordinator(ctx, pool, nil, nil, nil, nil, nil, nil)
212+ closedWV := &fakeRecordingWebView{id: 121}
213+ contentCoord.RegisterPopupWebView(closedTab.Workspace.ActivePaneID, closedWV)
214+
215+ bw := &browserWindow{id: "window-1", tabs: tabs}
216+ app := &App{
217+ deps: &Dependencies{Config: config.DefaultConfig()},
218+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
219+ contentCoord: contentCoord,
220+ browserWindows: map[string]*browserWindow{bw.id: bw},
221+ tabs: entity.NewTabList(),
222+ workspaceViews: map[entity.TabID]*component.WorkspaceView{closedTab.ID: {}},
223+ windowForTab: map[entity.TabID]*browserWindow{closedTab.ID: bw},
224+ floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{},
225+ }
226+ app.tabs.Add(closedTab)
227+ app.initTabCoordinator(ctx)
228+
229+ require.NoError(t, app.tabCoord.Close(ctx, app.ensureTabTargetForBrowserWindow(bw)))
230+
231+ require.Len(t, pool.released, 1)
232+ assert.Same(t, closedWV, pool.released[0])
233+ assert.Nil(t, contentCoord.GetWebView(closedTab.Workspace.ActivePaneID))
234+ assert.Empty(t, app.browserWindows)
235+ assert.Nil(t, app.workspaceViews[closedTab.ID])
236+ assert.Nil(t, app.windowForTab[closedTab.ID])
237+}
238+
239+func TestBrowserWindow_RemoveBrowserWindowReleasesOwnedTabWorkspaceWebViews(t *testing.T) {
240+ ctx := context.Background()
241+ ownedTab := entity.NewTab(entity.TabID("tab-owned"), entity.WorkspaceID("workspace-owned"), entity.NewPane(entity.PaneID("pane-owned")))
242+ otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other")))
243+ removed := &browserWindow{id: "window-1", tabs: entity.NewTabList()}
244+ remaining := &browserWindow{id: "window-2", tabs: entity.NewTabList()}
245+ removed.tabs.Add(ownedTab)
246+ remaining.tabs.Add(otherTab)
247+
248+ pool := &recordingWebViewPool{}
249+ contentCoord := contentcoord.NewCoordinator(ctx, pool, nil, nil, nil, nil, nil, nil)
250+ ownedWV := &fakeRecordingWebView{id: 201}
251+ otherWV := &fakeRecordingWebView{id: 202}
252+ contentCoord.RegisterPopupWebView(ownedTab.Workspace.ActivePaneID, ownedWV)
253+ contentCoord.RegisterPopupWebView(otherTab.Workspace.ActivePaneID, otherWV)
254+
255+ app := &App{
256+ contentCoord: contentCoord,
257+ browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
258+ tabs: entity.NewTabList(),
259+ workspaceViews: map[entity.TabID]*component.WorkspaceView{ownedTab.ID: {}, otherTab.ID: {}},
260+ windowForTab: map[entity.TabID]*browserWindow{ownedTab.ID: removed, otherTab.ID: remaining},
261+ floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{},
262+ }
263+ app.tabs.Add(ownedTab)
264+ app.tabs.Add(otherTab)
265+
266+ app.removeBrowserWindow(removed.id)
267+
268+ require.Len(t, pool.released, 1)
269+ assert.Same(t, ownedWV, pool.released[0])
270+ assert.Nil(t, contentCoord.GetWebView(ownedTab.Workspace.ActivePaneID))
271+ assert.Same(t, otherWV, contentCoord.GetWebView(otherTab.Workspace.ActivePaneID))
272+}
273+
106274 func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) {
107275 ownedTab := entity.NewTab(entity.TabID("tab-owned"), entity.WorkspaceID("workspace-owned"), entity.NewPane(entity.PaneID("pane-owned")))
108276 otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other")))
@@ -8,9 +8,12 @@ import (
8 "unsafe"8 "unsafe"
9 9
10 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/application/usecase"
11 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
13+ "github.com/bnema/dumber/internal/infrastructure/config"
12 "github.com/bnema/dumber/internal/shared/syncdispatch"14 "github.com/bnema/dumber/internal/shared/syncdispatch"
13 "github.com/bnema/dumber/internal/ui/component"15 "github.com/bnema/dumber/internal/ui/component"
16+ contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
14 "github.com/bnema/dumber/internal/ui/focus"17 "github.com/bnema/dumber/internal/ui/focus"
15 "github.com/bnema/dumber/internal/ui/input"18 "github.com/bnema/dumber/internal/ui/input"
16 "github.com/bnema/dumber/internal/ui/layout"19 "github.com/bnema/dumber/internal/ui/layout"
@@ -103,6 +106,171 @@ func TestApp_CleanupCreatedBrowserWindowsDetachesUnregisteredWindowBeforeDestroy
103 }106 }
104 }107 }
105 108
109+type recordingWebViewPool struct {
110+ released []port.WebView
111+}
112+
113+func (p *recordingWebViewPool) Acquire(context.Context) (port.WebView, error) {
114+ return nil, errors.New("not implemented")
115+}
116+func (p *recordingWebViewPool) Release(wv port.WebView) { p.released = append(p.released, wv) }
117+func (p *recordingWebViewPool) Prewarm(int) {}
118+func (p *recordingWebViewPool) PrewarmAsync(context.Context, int) {}
119+func (p *recordingWebViewPool) Size() int { return 0 }
120+func (p *recordingWebViewPool) Close() {}
121+
122+func TestTabCoordinator_CloseReleasesClosedTabWorkspaceWebViews(t *testing.T) {
123+ ctx := context.Background()
124+ closedTab := entity.NewTab(entity.TabID("tab-x"), entity.WorkspaceID("workspace-x"), entity.NewPane(entity.PaneID("pane-x")))
125+ survivingTab := entity.NewTab(entity.TabID("tab-survivor"), entity.WorkspaceID("workspace-survivor"), entity.NewPane(entity.PaneID("pane-survivor")))
126+ tabs := entity.NewTabList()
127+ tabs.Add(closedTab)
128+ tabs.Add(survivingTab)
129+ tabs.SetActive(closedTab.ID)
130+
131+ pool := &recordingWebViewPool{}
132+ contentCoord := contentcoord.NewCoordinator(ctx, pool, nil, nil, nil, nil, nil, nil)
133+ closedWV := &fakeRecordingWebView{id: 101}
134+ survivingWV := &fakeRecordingWebView{id: 102}
135+ contentCoord.RegisterPopupWebView(closedTab.Workspace.ActivePaneID, closedWV)
136+ contentCoord.RegisterPopupWebView(survivingTab.Workspace.ActivePaneID, survivingWV)
137+
138+ bw := &browserWindow{id: "window-1", tabs: tabs}
139+ app := &App{
140+ deps: &Dependencies{Config: config.DefaultConfig()},
141+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
142+ contentCoord: contentCoord,
143+ browserWindows: map[string]*browserWindow{bw.id: bw},
144+ tabs: entity.NewTabList(),
145+ workspaceViews: map[entity.TabID]*component.WorkspaceView{closedTab.ID: {}, survivingTab.ID: {}},
146+ windowForTab: map[entity.TabID]*browserWindow{closedTab.ID: bw, survivingTab.ID: bw},
147+ floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{},
148+ }
149+ app.tabs.Add(closedTab)
150+ app.tabs.Add(survivingTab)
151+ app.initTabCoordinator(ctx)
152+
153+ require.NoError(t, app.tabCoord.Close(ctx, app.ensureTabTargetForBrowserWindow(bw)))
154+
155+ require.Len(t, pool.released, 1)
156+ assert.Same(t, closedWV, pool.released[0])
157+ assert.Nil(t, contentCoord.GetWebView(closedTab.Workspace.ActivePaneID))
158+ assert.Same(t, survivingWV, contentCoord.GetWebView(survivingTab.Workspace.ActivePaneID))
159+ assert.Nil(t, app.workspaceViews[closedTab.ID])
160+ assert.Nil(t, app.windowForTab[closedTab.ID])
161+ assert.Nil(t, app.tabs.Find(closedTab.ID))
162+ assert.Equal(t, survivingTab.ID, tabs.ActiveTabID)
163+}
164+
165+func TestTabCoordinator_SwitchDoesNotReleaseWorkspaceWebViews(t *testing.T) {
166+ ctx := context.Background()
167+ firstTab := entity.NewTab(entity.TabID("tab-first"), entity.WorkspaceID("workspace-first"), entity.NewPane(entity.PaneID("pane-first")))
168+ secondTab := entity.NewTab(entity.TabID("tab-second"), entity.WorkspaceID("workspace-second"), entity.NewPane(entity.PaneID("pane-second")))
169+ tabs := entity.NewTabList()
170+ tabs.Add(firstTab)
171+ tabs.Add(secondTab)
172+ tabs.SetActive(firstTab.ID)
173+
174+ pool := &recordingWebViewPool{}
175+ contentCoord := contentcoord.NewCoordinator(ctx, pool, nil, nil, nil, nil, nil, nil)
176+ firstWV := &fakeRecordingWebView{id: 111}
177+ secondWV := &fakeRecordingWebView{id: 112}
178+ contentCoord.RegisterPopupWebView(firstTab.Workspace.ActivePaneID, firstWV)
179+ contentCoord.RegisterPopupWebView(secondTab.Workspace.ActivePaneID, secondWV)
180+
181+ bw := &browserWindow{id: "window-1", tabs: tabs}
182+ app := &App{
183+ deps: &Dependencies{Config: config.DefaultConfig()},
184+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
185+ contentCoord: contentCoord,
186+ browserWindows: map[string]*browserWindow{bw.id: bw},
187+ tabs: entity.NewTabList(),
188+ workspaceViews: map[entity.TabID]*component.WorkspaceView{firstTab.ID: {}, secondTab.ID: {}},
189+ windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: bw, secondTab.ID: bw},
190+ floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{},
191+ }
192+ app.tabs.Add(firstTab)
193+ app.tabs.Add(secondTab)
194+ app.initTabCoordinator(ctx)
195+
196+ require.NoError(t, app.tabCoord.Switch(ctx, app.ensureTabTargetForBrowserWindow(bw), secondTab.ID))
197+
198+ assert.Empty(t, pool.released)
199+ assert.Same(t, firstWV, contentCoord.GetWebView(firstTab.Workspace.ActivePaneID))
200+ assert.Same(t, secondWV, contentCoord.GetWebView(secondTab.Workspace.ActivePaneID))
201+}
202+
203+func TestTabCoordinator_CloseLastTabReleasesWorkspaceBeforeWindowRemoval(t *testing.T) {
204+ ctx := context.Background()
205+ closedTab := entity.NewTab(entity.TabID("tab-only"), entity.WorkspaceID("workspace-only"), entity.NewPane(entity.PaneID("pane-only")))
206+ tabs := entity.NewTabList()
207+ tabs.Add(closedTab)
208+ tabs.SetActive(closedTab.ID)
209+
210+ pool := &recordingWebViewPool{}
211+ contentCoord := contentcoord.NewCoordinator(ctx, pool, nil, nil, nil, nil, nil, nil)
212+ closedWV := &fakeRecordingWebView{id: 121}
213+ contentCoord.RegisterPopupWebView(closedTab.Workspace.ActivePaneID, closedWV)
214+
215+ bw := &browserWindow{id: "window-1", tabs: tabs}
216+ app := &App{
217+ deps: &Dependencies{Config: config.DefaultConfig()},
218+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
219+ contentCoord: contentCoord,
220+ browserWindows: map[string]*browserWindow{bw.id: bw},
221+ tabs: entity.NewTabList(),
222+ workspaceViews: map[entity.TabID]*component.WorkspaceView{closedTab.ID: {}},
223+ windowForTab: map[entity.TabID]*browserWindow{closedTab.ID: bw},
224+ floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{},
225+ }
226+ app.tabs.Add(closedTab)
227+ app.initTabCoordinator(ctx)
228+
229+ require.NoError(t, app.tabCoord.Close(ctx, app.ensureTabTargetForBrowserWindow(bw)))
230+
231+ require.Len(t, pool.released, 1)
232+ assert.Same(t, closedWV, pool.released[0])
233+ assert.Nil(t, contentCoord.GetWebView(closedTab.Workspace.ActivePaneID))
234+ assert.Empty(t, app.browserWindows)
235+ assert.Nil(t, app.workspaceViews[closedTab.ID])
236+ assert.Nil(t, app.windowForTab[closedTab.ID])
237+}
238+
239+func TestBrowserWindow_RemoveBrowserWindowReleasesOwnedTabWorkspaceWebViews(t *testing.T) {
240+ ctx := context.Background()
241+ ownedTab := entity.NewTab(entity.TabID("tab-owned"), entity.WorkspaceID("workspace-owned"), entity.NewPane(entity.PaneID("pane-owned")))
242+ otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other")))
243+ removed := &browserWindow{id: "window-1", tabs: entity.NewTabList()}
244+ remaining := &browserWindow{id: "window-2", tabs: entity.NewTabList()}
245+ removed.tabs.Add(ownedTab)
246+ remaining.tabs.Add(otherTab)
247+
248+ pool := &recordingWebViewPool{}
249+ contentCoord := contentcoord.NewCoordinator(ctx, pool, nil, nil, nil, nil, nil, nil)
250+ ownedWV := &fakeRecordingWebView{id: 201}
251+ otherWV := &fakeRecordingWebView{id: 202}
252+ contentCoord.RegisterPopupWebView(ownedTab.Workspace.ActivePaneID, ownedWV)
253+ contentCoord.RegisterPopupWebView(otherTab.Workspace.ActivePaneID, otherWV)
254+
255+ app := &App{
256+ contentCoord: contentCoord,
257+ browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
258+ tabs: entity.NewTabList(),
259+ workspaceViews: map[entity.TabID]*component.WorkspaceView{ownedTab.ID: {}, otherTab.ID: {}},
260+ windowForTab: map[entity.TabID]*browserWindow{ownedTab.ID: removed, otherTab.ID: remaining},
261+ floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{},
262+ }
263+ app.tabs.Add(ownedTab)
264+ app.tabs.Add(otherTab)
265+
266+ app.removeBrowserWindow(removed.id)
267+
268+ require.Len(t, pool.released, 1)
269+ assert.Same(t, ownedWV, pool.released[0])
270+ assert.Nil(t, contentCoord.GetWebView(ownedTab.Workspace.ActivePaneID))
271+ assert.Same(t, otherWV, contentCoord.GetWebView(otherTab.Workspace.ActivePaneID))
272+}
273+
106 func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) {274 func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) {
107 ownedTab := entity.NewTab(entity.TabID("tab-owned"), entity.WorkspaceID("workspace-owned"), entity.NewPane(entity.PaneID("pane-owned")))275 ownedTab := entity.NewTab(entity.TabID("tab-owned"), entity.WorkspaceID("workspace-owned"), entity.NewPane(entity.PaneID("pane-owned")))
108 otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other")))276 otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other")))
M internal/ui/coordinator/tab.go +11 -0
@@ -32,6 +32,7 @@ type TabCoordinator struct {
3232 // Callbacks to avoid circular dependencies
3333 onTabCreated func(ctx context.Context, target TabTarget, tab *entity.Tab)
3434 onTabSwitched func(ctx context.Context, target TabTarget, tab *entity.Tab)
35+ onTabClosed func(ctx context.Context, target TabTarget, tab *entity.Tab)
3536 onQuit func()
3637 onCurrentWindowEmpty func(ctx context.Context, target TabTarget)
3738 onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) // For popup tabs
@@ -87,6 +88,11 @@ func (c *TabCoordinator) SetOnTabSwitched(fn func(ctx context.Context, target Ta
8788 c.onTabSwitched = fn
8889 }
8990
91+// SetOnTabClosed sets the callback for when a tab is removed from its target.
92+func (c *TabCoordinator) SetOnTabClosed(fn func(ctx context.Context, target TabTarget, tab *entity.Tab)) {
93+ c.onTabClosed = fn
94+}
95+
9096 // SetOnQuit sets the callback for when the last tab is closed.
9197 func (c *TabCoordinator) SetOnQuit(fn func()) {
9298 c.onQuit = fn
@@ -236,12 +242,17 @@ func (c *TabCoordinator) Close(ctx context.Context, target TabTarget) error {
236242 }
237243 }
238244
245+ closedTab := target.Tabs.Find(activeID)
239246 wasLast, err := c.tabsUC.Close(ctx, target.Tabs, activeID)
240247 if err != nil {
241248 log.Error().Err(err).Msg("failed to close tab")
242249 return err
243250 }
244251
252+ if c.onTabClosed != nil && closedTab != nil {
253+ c.onTabClosed(ctx, target, closedTab)
254+ }
255+
245256 if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
246257 target.MainWindow.TabBar().RemoveTab(activeID)
247258 }
@@ -32,6 +32,7 @@ type TabCoordinator struct {
32 // Callbacks to avoid circular dependencies32 // Callbacks to avoid circular dependencies
33 onTabCreated func(ctx context.Context, target TabTarget, tab *entity.Tab)33 onTabCreated func(ctx context.Context, target TabTarget, tab *entity.Tab)
34 onTabSwitched func(ctx context.Context, target TabTarget, tab *entity.Tab)34 onTabSwitched func(ctx context.Context, target TabTarget, tab *entity.Tab)
35+ onTabClosed func(ctx context.Context, target TabTarget, tab *entity.Tab)
35 onQuit func()36 onQuit func()
36 onCurrentWindowEmpty func(ctx context.Context, target TabTarget)37 onCurrentWindowEmpty func(ctx context.Context, target TabTarget)
37 onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) // For popup tabs38 onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) // For popup tabs
@@ -87,6 +88,11 @@ func (c *TabCoordinator) SetOnTabSwitched(fn func(ctx context.Context, target Ta
87 c.onTabSwitched = fn88 c.onTabSwitched = fn
88 }89 }
89 90
91+// SetOnTabClosed sets the callback for when a tab is removed from its target.
92+func (c *TabCoordinator) SetOnTabClosed(fn func(ctx context.Context, target TabTarget, tab *entity.Tab)) {
93+ c.onTabClosed = fn
94+}
95+
90 // SetOnQuit sets the callback for when the last tab is closed.96 // SetOnQuit sets the callback for when the last tab is closed.
91 func (c *TabCoordinator) SetOnQuit(fn func()) {97 func (c *TabCoordinator) SetOnQuit(fn func()) {
92 c.onQuit = fn98 c.onQuit = fn
@@ -236,12 +242,17 @@ func (c *TabCoordinator) Close(ctx context.Context, target TabTarget) error {
236 }242 }
237 }243 }
238 244
245+ closedTab := target.Tabs.Find(activeID)
239 wasLast, err := c.tabsUC.Close(ctx, target.Tabs, activeID)246 wasLast, err := c.tabsUC.Close(ctx, target.Tabs, activeID)
240 if err != nil {247 if err != nil {
241 log.Error().Err(err).Msg("failed to close tab")248 log.Error().Err(err).Msg("failed to close tab")
242 return err249 return err
243 }250 }
244 251
252+ if c.onTabClosed != nil && closedTab != nil {
253+ c.onTabClosed(ctx, target, closedTab)
254+ }
255+
245 if target.MainWindow != nil && target.MainWindow.TabBar() != nil {256 if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
246 target.MainWindow.TabBar().RemoveTab(activeID)257 target.MainWindow.TabBar().RemoveTab(activeID)
247 }258 }

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