Merge pull request #306 from bnema/fix/webview-tab-release
fix(ui): release WebViews when closing tabs
4 files · +301 -34
M internal/ui/app.go +3 -0
| @@ -3222,6 +3222,9 @@ func (a *App) initTabCoordinator(ctx context.Context) { | |||
| 3222 | 3222 | } | |
| 3223 | 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 | 3228 | a.tabCoord.SetOnStateChanged(a.MarkDirty) | |
| 3226 | 3229 | // Per-window tab ownership is now enforced through explicit TabTarget. | |
| 3227 | 3230 | // 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) { | |||
| 296 | 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 | 323 | func (a *App) removeBrowserWindow(id string) { | |
| 300 | 324 | if id == "" || a.browserWindows == nil { | |
| 301 | 325 | return | |
| 302 | 326 | } | |
| 303 | 327 | removed := a.browserWindows[id] | |
| 328 | + | wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow | |
| 304 | 329 | ||
| 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) { | |
| 306 | 348 | for i, wid := range a.browserWindowOrder { | |
| 307 | 349 | if wid == id { | |
| 308 | 350 | a.browserWindowOrder = append(a.browserWindowOrder[:i], a.browserWindowOrder[i+1:]...) | |
| 309 | - | break | |
| 351 | + | return | |
| 310 | 352 | } | |
| 311 | 353 | } | |
| 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 | |
| 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 { | |
| 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 | + | } | |
| 321 | 409 | delete(a.workspaceViews, tabID) | |
| 322 | 410 | delete(a.windowForTab, tabID) | |
| 323 | 411 | if a.tabs != nil && a.tabs.Find(tabID) != nil { | |
| 324 | 412 | a.tabs.Remove(tabID) | |
| 325 | 413 | } | |
| 326 | 414 | } | |
| 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 | |
| 329 | 420 | } | |
| 330 | - | if a.contentCoord != nil { | |
| 331 | - | a.contentCoord.ClearPopupNamedContextsForWindow(id) | |
| 421 | + | if fallback != nil { | |
| 422 | + | a.lastFocusedWindowID = fallback.id | |
| 423 | + | return | |
| 332 | 424 | } | |
| 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 | |
| 341 | 433 | } | |
| 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{}) | |
| 355 | 440 | } | |
| 356 | 441 | } | |
| 357 | 442 | ||
| @@ -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 | return | 325 | 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 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) { | |||
| 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 | - | break | 351 | + | return |
| 310 | } | 352 | } | ||
| 311 | } | 353 | } | ||
| 312 | - | wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow | 354 | + | } |
| 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.id | 429 | + | 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 | - | return | 438 | + | 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 ( | |||
| 8 | 8 | "unsafe" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | 12 | "github.com/bnema/dumber/internal/domain/entity" | |
| 13 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 12 | 14 | "github.com/bnema/dumber/internal/shared/syncdispatch" | |
| 13 | 15 | "github.com/bnema/dumber/internal/ui/component" | |
| 16 | + | contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content" | |
| 14 | 17 | "github.com/bnema/dumber/internal/ui/focus" | |
| 15 | 18 | "github.com/bnema/dumber/internal/ui/input" | |
| 16 | 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 | 274 | func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) { | |
| 107 | 275 | ownedTab := entity.NewTab(entity.TabID("tab-owned"), entity.WorkspaceID("workspace-owned"), entity.NewPane(entity.PaneID("pane-owned"))) | |
| 108 | 276 | 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 { | |||
| 32 | 32 | // Callbacks to avoid circular dependencies | |
| 33 | 33 | onTabCreated func(ctx context.Context, target TabTarget, tab *entity.Tab) | |
| 34 | 34 | onTabSwitched func(ctx context.Context, target TabTarget, tab *entity.Tab) | |
| 35 | + | onTabClosed func(ctx context.Context, target TabTarget, tab *entity.Tab) | |
| 35 | 36 | onQuit func() | |
| 36 | 37 | onCurrentWindowEmpty func(ctx context.Context, target TabTarget) | |
| 37 | 38 | 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 | 88 | 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 | 96 | // SetOnQuit sets the callback for when the last tab is closed. | |
| 91 | 97 | func (c *TabCoordinator) SetOnQuit(fn func()) { | |
| 92 | 98 | 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 | 246 | wasLast, err := c.tabsUC.Close(ctx, target.Tabs, activeID) | |
| 240 | 247 | if err != nil { | |
| 241 | 248 | log.Error().Err(err).Msg("failed to close tab") | |
| 242 | 249 | return err | |
| 243 | 250 | } | |
| 244 | 251 | ||
| 252 | + | if c.onTabClosed != nil && closedTab != nil { | |
| 253 | + | c.onTabClosed(ctx, target, closedTab) | |
| 254 | + | } | |
| 255 | + | ||
| 245 | 256 | if target.MainWindow != nil && target.MainWindow.TabBar() != nil { | |
| 246 | 257 | target.MainWindow.TabBar().RemoveTab(activeID) | |
| 247 | 258 | } | |
| @@ -32,6 +32,7 @@ type TabCoordinator struct { | |||||
| 32 | // Callbacks to avoid circular dependencies | 32 | // 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 tabs | 38 | 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 = fn | 88 | 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 = fn | 98 | 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 err | 249 | 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 | } | ||