refactor: extract browser window ownership from app
- commit
- 7109c5ecd86a9f1e7ef7f7e5f7e411e188365f62
- author
- brice · · 2026-04-14 15:30 UTC
- parent
- a5232dd
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +198 -24
M internal/ui/app.go +77 -24
| @@ -65,6 +65,10 @@ type App struct { | |||
| 65 | 65 | gtkApp *gtk.Application | |
| 66 | 66 | mainWindow *window.MainWindow | |
| 67 | 67 | ||
| 68 | + | browserWindows map[string]*browserWindow | |
| 69 | + | browserWindowFactory func(context.Context, string) (*browserWindow, error) | |
| 70 | + | dispatchOnMainThread func(func()) | |
| 71 | + | ||
| 68 | 72 | // State | |
| 69 | 73 | tabs *entity.TabList | |
| 70 | 74 | tabsUC *usecase.ManageTabsUseCase | |
| @@ -126,6 +130,8 @@ type App struct { | |||
| 126 | 130 | // ID generator for tabs/panes | |
| 127 | 131 | idCounter uint64 | |
| 128 | 132 | idMu sync.Mutex | |
| 133 | + | windowIDCounter uint64 | |
| 134 | + | windowIDMu sync.Mutex | |
| 129 | 135 | firstWebViewShownOnce sync.Once | |
| 130 | 136 | ||
| 131 | 137 | movePaneToTabUC *usecase.MovePaneToTabUseCase | |
| @@ -172,14 +178,16 @@ func New(deps *Dependencies) (*App, error) { | |||
| 172 | 178 | ctx, cancel := context.WithCancelCause(deps.Ctx) | |
| 173 | 179 | ||
| 174 | 180 | app := &App{ | |
| 175 | - | deps: deps, | |
| 176 | - | tabs: entity.NewTabList(), | |
| 177 | - | tabsUC: deps.TabsUC, | |
| 178 | - | panesUC: deps.PanesUC, | |
| 179 | - | workspaceViews: make(map[entity.TabID]*component.WorkspaceView), | |
| 180 | - | floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession), | |
| 181 | - | engine: deps.Engine, | |
| 182 | - | cancel: cancel, | |
| 181 | + | deps: deps, | |
| 182 | + | tabs: entity.NewTabList(), | |
| 183 | + | tabsUC: deps.TabsUC, | |
| 184 | + | panesUC: deps.PanesUC, | |
| 185 | + | workspaceViews: make(map[entity.TabID]*component.WorkspaceView), | |
| 186 | + | floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession), | |
| 187 | + | browserWindows: make(map[string]*browserWindow), | |
| 188 | + | dispatchOnMainThread: func(fn func()) { fn() }, | |
| 189 | + | engine: deps.Engine, | |
| 190 | + | cancel: cancel, | |
| 183 | 191 | } | |
| 184 | 192 | // Register message handlers through the engine. | |
| 185 | 193 | if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{ | |
| @@ -244,6 +252,7 @@ func (a *App) Run(ctx context.Context, args []string) int { | |||
| 244 | 252 | } | |
| 245 | 253 | defer a.gtkApp.Unref() | |
| 246 | 254 | logging.Trace().Mark("gtk_app_created") | |
| 255 | + | a.dispatchOnMainThread = a.runOnMainThread | |
| 247 | 256 | ||
| 248 | 257 | // Connect activate signal | |
| 249 | 258 | activateCb := func(_ gio.Application) { | |
| @@ -273,7 +282,7 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 273 | 282 | // synchronous prewarming that delays the first navigation on cold start. | |
| 274 | 283 | a.setupPoolBackgroundColor(ctx) | |
| 275 | 284 | ||
| 276 | - | if err := a.createMainWindow(ctx); err != nil { | |
| 285 | + | if _, err := a.OpenFreshWindow(ctx, a.initialWindowURL()); err != nil { | |
| 277 | 286 | log.Error().Err(err).Msg("failed to create main window") | |
| 278 | 287 | return | |
| 279 | 288 | } | |
| @@ -380,19 +389,31 @@ func (a *App) prewarmWebViewPoolAsync(ctx context.Context) { | |||
| 380 | 389 | pool.PrewarmAsync(ctx, 0) | |
| 381 | 390 | } | |
| 382 | 391 | ||
| 383 | - | func (a *App) createMainWindow(ctx context.Context) error { | |
| 392 | + | func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*browserWindow, error) { | |
| 393 | + | if a.browserWindowFactory != nil { | |
| 394 | + | return a.browserWindowFactory(ctx, initialURL) | |
| 395 | + | } | |
| 396 | + | ||
| 384 | 397 | log := logging.FromContext(ctx) | |
| 385 | 398 | mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config.Workspace.TabBarPosition) | |
| 386 | 399 | if err != nil { | |
| 387 | - | return err | |
| 400 | + | return nil, err | |
| 401 | + | } | |
| 402 | + | browserWindow := &browserWindow{ | |
| 403 | + | id: a.generateWindowID(), | |
| 404 | + | initialURL: initialURL, | |
| 405 | + | mainWindow: mainWindow, | |
| 406 | + | } | |
| 407 | + | if a.mainWindow == nil { | |
| 408 | + | a.mainWindow = mainWindow | |
| 388 | 409 | } | |
| 389 | - | a.mainWindow = mainWindow | |
| 390 | 410 | ||
| 391 | 411 | closeRequestCb := func(_ gtk.Window) bool { | |
| 392 | - | log.Info().Msg("main window close requested") | |
| 412 | + | log.Info().Msg("browser window close requested") | |
| 413 | + | a.removeBrowserWindow(browserWindow.id) | |
| 393 | 414 | return false | |
| 394 | 415 | } | |
| 395 | - | a.mainWindow.Window().ConnectCloseRequest(&closeRequestCb) | |
| 416 | + | mainWindow.Window().ConnectCloseRequest(&closeRequestCb) | |
| 396 | 417 | ||
| 397 | 418 | // Create permission popup and dialog presenter | |
| 398 | 419 | if a.deps != nil && a.deps.PermissionUC != nil { | |
| @@ -404,7 +425,7 @@ func (a *App) createMainWindow(ctx context.Context) error { | |||
| 404 | 425 | if permPopup != nil { | |
| 405 | 426 | // Add popup to the main window's content overlay | |
| 406 | 427 | if w := permPopup.Widget(); w != nil { | |
| 407 | - | a.mainWindow.AddOverlay(w) | |
| 428 | + | mainWindow.AddOverlay(w) | |
| 408 | 429 | } | |
| 409 | 430 | permDialog := dialog.NewPermissionDialog(permPopup) | |
| 410 | 431 | a.deps.PermissionUC.SetDialogPresenter(permDialog) | |
| @@ -415,19 +436,26 @@ func (a *App) createMainWindow(ctx context.Context) error { | |||
| 415 | 436 | indicator := component.NewWebRTCPermissionIndicator() | |
| 416 | 437 | if indicator != nil { | |
| 417 | 438 | if w := indicator.Widget(); w != nil { | |
| 418 | - | a.mainWindow.AddOverlay(w) | |
| 439 | + | mainWindow.AddOverlay(w) | |
| 419 | 440 | a.webrtcIndicator = indicator | |
| 420 | 441 | } | |
| 421 | 442 | } | |
| 422 | 443 | ||
| 423 | 444 | // Apply GTK CSS styling from theme manager. | |
| 424 | 445 | if a.deps == nil || a.deps.Theme == nil { | |
| 425 | - | return nil | |
| 446 | + | return browserWindow, nil | |
| 426 | 447 | } | |
| 427 | - | if display := a.mainWindow.Window().GetDisplay(); display != nil { | |
| 448 | + | if display := mainWindow.Window().GetDisplay(); display != nil { | |
| 428 | 449 | a.deps.Theme.ApplyToDisplay(ctx, display) | |
| 429 | 450 | } | |
| 430 | - | return nil | |
| 451 | + | return browserWindow, nil | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | func (a *App) initialWindowURL() string { | |
| 455 | + | if a.deps != nil && a.deps.InitialURL != "" { | |
| 456 | + | return a.deps.InitialURL | |
| 457 | + | } | |
| 458 | + | return "dumb://home" | |
| 431 | 459 | } | |
| 432 | 460 | ||
| 433 | 461 | func (a *App) initLayoutInfrastructure() { | |
| @@ -1409,11 +1437,7 @@ func (a *App) createInitialTab(ctx context.Context) { | |||
| 1409 | 1437 | } | |
| 1410 | 1438 | ||
| 1411 | 1439 | // Create an initial tab using coordinator. | |
| 1412 | - | initialURL := "dumb://home" | |
| 1413 | - | if a.deps != nil && a.deps.InitialURL != "" { | |
| 1414 | - | initialURL = a.deps.InitialURL | |
| 1415 | - | } | |
| 1416 | - | if _, err := a.tabCoord.Create(ctx, initialURL); err != nil { | |
| 1440 | + | if _, err := a.tabCoord.Create(ctx, a.initialWindowURL()); err != nil { | |
| 1417 | 1441 | log.Error().Err(err).Msg("failed to create initial tab") | |
| 1418 | 1442 | } | |
| 1419 | 1443 | } | |
| @@ -1951,6 +1975,35 @@ func (a *App) syncWebRTCPermissionLockState(ctx context.Context, origin string, | |||
| 1951 | 1975 | a.webrtcIndicator.SetStoredDecision(permType, record.Decision, true) | |
| 1952 | 1976 | } | |
| 1953 | 1977 | ||
| 1978 | + | // generateWindowID generates a unique ID for top-level browser windows. | |
| 1979 | + | func (a *App) generateWindowID() string { | |
| 1980 | + | a.windowIDMu.Lock() | |
| 1981 | + | defer a.windowIDMu.Unlock() | |
| 1982 | + | a.windowIDCounter++ | |
| 1983 | + | return fmt.Sprintf("w%d", a.windowIDCounter) | |
| 1984 | + | } | |
| 1985 | + | ||
| 1986 | + | // runOnMainThread executes fn inline when already on the GTK main context; | |
| 1987 | + | // otherwise it dispatches fn to the GTK main loop and waits for completion. | |
| 1988 | + | func (a *App) runOnMainThread(fn func()) { | |
| 1989 | + | if fn == nil { | |
| 1990 | + | return | |
| 1991 | + | } | |
| 1992 | + | glibCtx := glib.MainContextDefault() | |
| 1993 | + | if glibCtx != nil && glibCtx.IsOwner() { | |
| 1994 | + | fn() | |
| 1995 | + | return | |
| 1996 | + | } | |
| 1997 | + | ||
| 1998 | + | done := make(chan struct{}) | |
| 1999 | + | cb := glib.SourceOnceFunc(func(_ uintptr) { | |
| 2000 | + | fn() | |
| 2001 | + | close(done) | |
| 2002 | + | }) | |
| 2003 | + | glib.IdleAddOnce(&cb, 0) | |
| 2004 | + | <-done | |
| 2005 | + | } | |
| 2006 | + | ||
| 1954 | 2007 | // generateID generates a unique ID for tabs and panes. | |
| 1955 | 2008 | // Uses monotonically increasing counter to avoid ID collisions. | |
| 1956 | 2009 | func (a *App) generateID() string { | |
| @@ -65,6 +65,10 @@ type App struct { | |||||
| 65 | gtkApp *gtk.Application | 65 | gtkApp *gtk.Application | ||
| 66 | mainWindow *window.MainWindow | 66 | mainWindow *window.MainWindow | ||
| 67 | 67 | ||||
| 68 | + | browserWindows map[string]*browserWindow | |||
| 69 | + | browserWindowFactory func(context.Context, string) (*browserWindow, error) | |||
| 70 | + | dispatchOnMainThread func(func()) | |||
| 71 | + | ||||
| 68 | // State | 72 | // State | ||
| 69 | tabs *entity.TabList | 73 | tabs *entity.TabList | ||
| 70 | tabsUC *usecase.ManageTabsUseCase | 74 | tabsUC *usecase.ManageTabsUseCase | ||
| @@ -126,6 +130,8 @@ type App struct { | |||||
| 126 | // ID generator for tabs/panes | 130 | // ID generator for tabs/panes | ||
| 127 | idCounter uint64 | 131 | idCounter uint64 | ||
| 128 | idMu sync.Mutex | 132 | idMu sync.Mutex | ||
| 133 | + | windowIDCounter uint64 | |||
| 134 | + | windowIDMu sync.Mutex | |||
| 129 | firstWebViewShownOnce sync.Once | 135 | firstWebViewShownOnce sync.Once | ||
| 130 | 136 | ||||
| 131 | movePaneToTabUC *usecase.MovePaneToTabUseCase | 137 | movePaneToTabUC *usecase.MovePaneToTabUseCase | ||
| @@ -172,14 +178,16 @@ func New(deps *Dependencies) (*App, error) { | |||||
| 172 | ctx, cancel := context.WithCancelCause(deps.Ctx) | 178 | ctx, cancel := context.WithCancelCause(deps.Ctx) | ||
| 173 | 179 | ||||
| 174 | app := &App{ | 180 | app := &App{ | ||
| 175 | - | deps: deps, | 181 | + | deps: deps, |
| 176 | - | tabs: entity.NewTabList(), | 182 | + | tabs: entity.NewTabList(), |
| 177 | - | tabsUC: deps.TabsUC, | 183 | + | tabsUC: deps.TabsUC, |
| 178 | - | panesUC: deps.PanesUC, | 184 | + | panesUC: deps.PanesUC, |
| 179 | - | workspaceViews: make(map[entity.TabID]*component.WorkspaceView), | 185 | + | workspaceViews: make(map[entity.TabID]*component.WorkspaceView), |
| 180 | - | floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession), | 186 | + | floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession), |
| 181 | - | engine: deps.Engine, | 187 | + | browserWindows: make(map[string]*browserWindow), |
| 182 | - | cancel: cancel, | 188 | + | dispatchOnMainThread: func(fn func()) { fn() }, |
| 189 | + | engine: deps.Engine, | |||
| 190 | + | cancel: cancel, | |||
| 183 | } | 191 | } | ||
| 184 | // Register message handlers through the engine. | 192 | // Register message handlers through the engine. | ||
| 185 | if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{ | 193 | if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{ | ||
| @@ -244,6 +252,7 @@ func (a *App) Run(ctx context.Context, args []string) int { | |||||
| 244 | } | 252 | } | ||
| 245 | defer a.gtkApp.Unref() | 253 | defer a.gtkApp.Unref() | ||
| 246 | logging.Trace().Mark("gtk_app_created") | 254 | logging.Trace().Mark("gtk_app_created") | ||
| 255 | + | a.dispatchOnMainThread = a.runOnMainThread | |||
| 247 | 256 | ||||
| 248 | // Connect activate signal | 257 | // Connect activate signal | ||
| 249 | activateCb := func(_ gio.Application) { | 258 | activateCb := func(_ gio.Application) { | ||
| @@ -273,7 +282,7 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 273 | // synchronous prewarming that delays the first navigation on cold start. | 282 | // synchronous prewarming that delays the first navigation on cold start. | ||
| 274 | a.setupPoolBackgroundColor(ctx) | 283 | a.setupPoolBackgroundColor(ctx) | ||
| 275 | 284 | ||||
| 276 | - | if err := a.createMainWindow(ctx); err != nil { | 285 | + | if _, err := a.OpenFreshWindow(ctx, a.initialWindowURL()); err != nil { |
| 277 | log.Error().Err(err).Msg("failed to create main window") | 286 | log.Error().Err(err).Msg("failed to create main window") | ||
| 278 | return | 287 | return | ||
| 279 | } | 288 | } | ||
| @@ -380,19 +389,31 @@ func (a *App) prewarmWebViewPoolAsync(ctx context.Context) { | |||||
| 380 | pool.PrewarmAsync(ctx, 0) | 389 | pool.PrewarmAsync(ctx, 0) | ||
| 381 | } | 390 | } | ||
| 382 | 391 | ||||
| 383 | - | func (a *App) createMainWindow(ctx context.Context) error { | 392 | + | func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*browserWindow, error) { |
| 393 | + | if a.browserWindowFactory != nil { | |||
| 394 | + | return a.browserWindowFactory(ctx, initialURL) | |||
| 395 | + | } | |||
| 396 | + | ||||
| 384 | log := logging.FromContext(ctx) | 397 | log := logging.FromContext(ctx) | ||
| 385 | mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config.Workspace.TabBarPosition) | 398 | mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config.Workspace.TabBarPosition) | ||
| 386 | if err != nil { | 399 | if err != nil { | ||
| 387 | - | return err | 400 | + | return nil, err |
| 401 | + | } | |||
| 402 | + | browserWindow := &browserWindow{ | |||
| 403 | + | id: a.generateWindowID(), | |||
| 404 | + | initialURL: initialURL, | |||
| 405 | + | mainWindow: mainWindow, | |||
| 406 | + | } | |||
| 407 | + | if a.mainWindow == nil { | |||
| 408 | + | a.mainWindow = mainWindow | |||
| 388 | } | 409 | } | ||
| 389 | - | a.mainWindow = mainWindow | |||
| 390 | 410 | ||||
| 391 | closeRequestCb := func(_ gtk.Window) bool { | 411 | closeRequestCb := func(_ gtk.Window) bool { | ||
| 392 | - | log.Info().Msg("main window close requested") | 412 | + | log.Info().Msg("browser window close requested") |
| 413 | + | a.removeBrowserWindow(browserWindow.id) | |||
| 393 | return false | 414 | return false | ||
| 394 | } | 415 | } | ||
| 395 | - | a.mainWindow.Window().ConnectCloseRequest(&closeRequestCb) | 416 | + | mainWindow.Window().ConnectCloseRequest(&closeRequestCb) |
| 396 | 417 | ||||
| 397 | // Create permission popup and dialog presenter | 418 | // Create permission popup and dialog presenter | ||
| 398 | if a.deps != nil && a.deps.PermissionUC != nil { | 419 | if a.deps != nil && a.deps.PermissionUC != nil { | ||
| @@ -404,7 +425,7 @@ func (a *App) createMainWindow(ctx context.Context) error { | |||||
| 404 | if permPopup != nil { | 425 | if permPopup != nil { | ||
| 405 | // Add popup to the main window's content overlay | 426 | // Add popup to the main window's content overlay | ||
| 406 | if w := permPopup.Widget(); w != nil { | 427 | if w := permPopup.Widget(); w != nil { | ||
| 407 | - | a.mainWindow.AddOverlay(w) | 428 | + | mainWindow.AddOverlay(w) |
| 408 | } | 429 | } | ||
| 409 | permDialog := dialog.NewPermissionDialog(permPopup) | 430 | permDialog := dialog.NewPermissionDialog(permPopup) | ||
| 410 | a.deps.PermissionUC.SetDialogPresenter(permDialog) | 431 | a.deps.PermissionUC.SetDialogPresenter(permDialog) | ||
| @@ -415,19 +436,26 @@ func (a *App) createMainWindow(ctx context.Context) error { | |||||
| 415 | indicator := component.NewWebRTCPermissionIndicator() | 436 | indicator := component.NewWebRTCPermissionIndicator() | ||
| 416 | if indicator != nil { | 437 | if indicator != nil { | ||
| 417 | if w := indicator.Widget(); w != nil { | 438 | if w := indicator.Widget(); w != nil { | ||
| 418 | - | a.mainWindow.AddOverlay(w) | 439 | + | mainWindow.AddOverlay(w) |
| 419 | a.webrtcIndicator = indicator | 440 | a.webrtcIndicator = indicator | ||
| 420 | } | 441 | } | ||
| 421 | } | 442 | } | ||
| 422 | 443 | ||||
| 423 | // Apply GTK CSS styling from theme manager. | 444 | // Apply GTK CSS styling from theme manager. | ||
| 424 | if a.deps == nil || a.deps.Theme == nil { | 445 | if a.deps == nil || a.deps.Theme == nil { | ||
| 425 | - | return nil | 446 | + | return browserWindow, nil |
| 426 | } | 447 | } | ||
| 427 | - | if display := a.mainWindow.Window().GetDisplay(); display != nil { | 448 | + | if display := mainWindow.Window().GetDisplay(); display != nil { |
| 428 | a.deps.Theme.ApplyToDisplay(ctx, display) | 449 | a.deps.Theme.ApplyToDisplay(ctx, display) | ||
| 429 | } | 450 | } | ||
| 430 | - | return nil | 451 | + | return browserWindow, nil |
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | func (a *App) initialWindowURL() string { | |||
| 455 | + | if a.deps != nil && a.deps.InitialURL != "" { | |||
| 456 | + | return a.deps.InitialURL | |||
| 457 | + | } | |||
| 458 | + | return "dumb://home" | |||
| 431 | } | 459 | } | ||
| 432 | 460 | ||||
| 433 | func (a *App) initLayoutInfrastructure() { | 461 | func (a *App) initLayoutInfrastructure() { | ||
| @@ -1409,11 +1437,7 @@ func (a *App) createInitialTab(ctx context.Context) { | |||||
| 1409 | } | 1437 | } | ||
| 1410 | 1438 | ||||
| 1411 | // Create an initial tab using coordinator. | 1439 | // Create an initial tab using coordinator. | ||
| 1412 | - | initialURL := "dumb://home" | 1440 | + | if _, err := a.tabCoord.Create(ctx, a.initialWindowURL()); err != nil { |
| 1413 | - | if a.deps != nil && a.deps.InitialURL != "" { | |||
| 1414 | - | initialURL = a.deps.InitialURL | |||
| 1415 | - | } | |||
| 1416 | - | if _, err := a.tabCoord.Create(ctx, initialURL); err != nil { | |||
| 1417 | log.Error().Err(err).Msg("failed to create initial tab") | 1441 | log.Error().Err(err).Msg("failed to create initial tab") | ||
| 1418 | } | 1442 | } | ||
| 1419 | } | 1443 | } | ||
| @@ -1951,6 +1975,35 @@ func (a *App) syncWebRTCPermissionLockState(ctx context.Context, origin string, | |||||
| 1951 | a.webrtcIndicator.SetStoredDecision(permType, record.Decision, true) | 1975 | a.webrtcIndicator.SetStoredDecision(permType, record.Decision, true) | ||
| 1952 | } | 1976 | } | ||
| 1953 | 1977 | ||||
| 1978 | + | // generateWindowID generates a unique ID for top-level browser windows. | |||
| 1979 | + | func (a *App) generateWindowID() string { | |||
| 1980 | + | a.windowIDMu.Lock() | |||
| 1981 | + | defer a.windowIDMu.Unlock() | |||
| 1982 | + | a.windowIDCounter++ | |||
| 1983 | + | return fmt.Sprintf("w%d", a.windowIDCounter) | |||
| 1984 | + | } | |||
| 1985 | + | ||||
| 1986 | + | // runOnMainThread executes fn inline when already on the GTK main context; | |||
| 1987 | + | // otherwise it dispatches fn to the GTK main loop and waits for completion. | |||
| 1988 | + | func (a *App) runOnMainThread(fn func()) { | |||
| 1989 | + | if fn == nil { | |||
| 1990 | + | return | |||
| 1991 | + | } | |||
| 1992 | + | glibCtx := glib.MainContextDefault() | |||
| 1993 | + | if glibCtx != nil && glibCtx.IsOwner() { | |||
| 1994 | + | fn() | |||
| 1995 | + | return | |||
| 1996 | + | } | |||
| 1997 | + | ||||
| 1998 | + | done := make(chan struct{}) | |||
| 1999 | + | cb := glib.SourceOnceFunc(func(_ uintptr) { | |||
| 2000 | + | fn() | |||
| 2001 | + | close(done) | |||
| 2002 | + | }) | |||
| 2003 | + | glib.IdleAddOnce(&cb, 0) | |||
| 2004 | + | <-done | |||
| 2005 | + | } | |||
| 2006 | + | ||||
| 1954 | // generateID generates a unique ID for tabs and panes. | 2007 | // generateID generates a unique ID for tabs and panes. | ||
| 1955 | // Uses monotonically increasing counter to avoid ID collisions. | 2008 | // Uses monotonically increasing counter to avoid ID collisions. | ||
| 1956 | func (a *App) generateID() string { | 2009 | func (a *App) generateID() string { | ||
A internal/ui/browser_window.go +52 -0
| @@ -0,0 +1,52 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/ui/window" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | type browserWindow struct { | |
| 10 | + | id string | |
| 11 | + | initialURL string | |
| 12 | + | mainWindow *window.MainWindow | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | func (a *App) registerBrowserWindow(bw *browserWindow) { | |
| 16 | + | if bw == nil { | |
| 17 | + | return | |
| 18 | + | } | |
| 19 | + | if a.browserWindows == nil { | |
| 20 | + | a.browserWindows = make(map[string]*browserWindow) | |
| 21 | + | } | |
| 22 | + | a.browserWindows[bw.id] = bw | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (a *App) removeBrowserWindow(id string) { | |
| 26 | + | if id == "" || a.browserWindows == nil { | |
| 27 | + | return | |
| 28 | + | } | |
| 29 | + | if bw := a.browserWindows[id]; bw != nil && bw.mainWindow == a.mainWindow { | |
| 30 | + | a.mainWindow = nil | |
| 31 | + | } | |
| 32 | + | delete(a.browserWindows, id) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (a *App) OpenFreshWindow(ctx context.Context, url string) (*browserWindow, error) { | |
| 36 | + | dispatch := a.dispatchOnMainThread | |
| 37 | + | if dispatch == nil { | |
| 38 | + | dispatch = func(fn func()) { fn() } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | var created *browserWindow | |
| 42 | + | var openErr error | |
| 43 | + | dispatch(func() { | |
| 44 | + | created, openErr = a.createBrowserWindow(ctx, url) | |
| 45 | + | if openErr != nil { | |
| 46 | + | return | |
| 47 | + | } | |
| 48 | + | a.registerBrowserWindow(created) | |
| 49 | + | }) | |
| 50 | + | ||
| 51 | + | return created, openErr | |
| 52 | + | } | |
| @@ -0,0 +1,52 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/ui/window" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | type browserWindow struct { | |||
| 10 | + | id string | |||
| 11 | + | initialURL string | |||
| 12 | + | mainWindow *window.MainWindow | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | func (a *App) registerBrowserWindow(bw *browserWindow) { | |||
| 16 | + | if bw == nil { | |||
| 17 | + | return | |||
| 18 | + | } | |||
| 19 | + | if a.browserWindows == nil { | |||
| 20 | + | a.browserWindows = make(map[string]*browserWindow) | |||
| 21 | + | } | |||
| 22 | + | a.browserWindows[bw.id] = bw | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (a *App) removeBrowserWindow(id string) { | |||
| 26 | + | if id == "" || a.browserWindows == nil { | |||
| 27 | + | return | |||
| 28 | + | } | |||
| 29 | + | if bw := a.browserWindows[id]; bw != nil && bw.mainWindow == a.mainWindow { | |||
| 30 | + | a.mainWindow = nil | |||
| 31 | + | } | |||
| 32 | + | delete(a.browserWindows, id) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (a *App) OpenFreshWindow(ctx context.Context, url string) (*browserWindow, error) { | |||
| 36 | + | dispatch := a.dispatchOnMainThread | |||
| 37 | + | if dispatch == nil { | |||
| 38 | + | dispatch = func(fn func()) { fn() } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | var created *browserWindow | |||
| 42 | + | var openErr error | |||
| 43 | + | dispatch(func() { | |||
| 44 | + | created, openErr = a.createBrowserWindow(ctx, url) | |||
| 45 | + | if openErr != nil { | |||
| 46 | + | return | |||
| 47 | + | } | |||
| 48 | + | a.registerBrowserWindow(created) | |||
| 49 | + | }) | |||
| 50 | + | ||||
| 51 | + | return created, openErr | |||
| 52 | + | } | |||
A internal/ui/browser_window_test.go +69 -0
| @@ -0,0 +1,69 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) { | |
| 9 | + | app := &App{browserWindows: make(map[string]*browserWindow)} | |
| 10 | + | first := &browserWindow{id: "window-1"} | |
| 11 | + | second := &browserWindow{id: "window-2"} | |
| 12 | + | ||
| 13 | + | app.registerBrowserWindow(first) | |
| 14 | + | app.registerBrowserWindow(second) | |
| 15 | + | ||
| 16 | + | if got := len(app.browserWindows); got != 2 { | |
| 17 | + | t.Fatalf("browserWindows length = %d, want 2", got) | |
| 18 | + | } | |
| 19 | + | if _, ok := app.browserWindows[first.id]; !ok { | |
| 20 | + | t.Fatalf("first browser window was not registered") | |
| 21 | + | } | |
| 22 | + | if _, ok := app.browserWindows[second.id]; !ok { | |
| 23 | + | t.Fatalf("second browser window was not registered") | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | app.removeBrowserWindow(first.id) | |
| 27 | + | ||
| 28 | + | if _, ok := app.browserWindows[first.id]; ok { | |
| 29 | + | t.Fatalf("first browser window was not removed") | |
| 30 | + | } | |
| 31 | + | if _, ok := app.browserWindows[second.id]; !ok { | |
| 32 | + | t.Fatalf("second browser window should remain registered") | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) { | |
| 37 | + | app := &App{browserWindows: make(map[string]*browserWindow)} | |
| 38 | + | ||
| 39 | + | dispatched := false | |
| 40 | + | createdURL := "" | |
| 41 | + | app.dispatchOnMainThread = func(fn func()) { | |
| 42 | + | dispatched = true | |
| 43 | + | fn() | |
| 44 | + | } | |
| 45 | + | app.browserWindowFactory = func(ctx context.Context, url string) (*browserWindow, error) { | |
| 46 | + | createdURL = url | |
| 47 | + | return &browserWindow{id: "window-1", initialURL: url}, nil | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | window, err := app.OpenFreshWindow(context.Background(), "https://example.com") | |
| 51 | + | if err != nil { | |
| 52 | + | t.Fatalf("OpenFreshWindow returned error: %v", err) | |
| 53 | + | } | |
| 54 | + | if !dispatched { | |
| 55 | + | t.Fatalf("OpenFreshWindow did not use the main-thread dispatcher") | |
| 56 | + | } | |
| 57 | + | if createdURL != "https://example.com" { | |
| 58 | + | t.Fatalf("browser window factory URL = %q, want %q", createdURL, "https://example.com") | |
| 59 | + | } | |
| 60 | + | if got := len(app.browserWindows); got != 1 { | |
| 61 | + | t.Fatalf("browserWindows length = %d, want 1", got) | |
| 62 | + | } | |
| 63 | + | if got := app.browserWindows[window.id]; got != window { | |
| 64 | + | t.Fatalf("browser window was not tracked") | |
| 65 | + | } | |
| 66 | + | if window.initialURL != "https://example.com" { | |
| 67 | + | t.Fatalf("browser window initialURL = %q, want %q", window.initialURL, "https://example.com") | |
| 68 | + | } | |
| 69 | + | } | |
| @@ -0,0 +1,69 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) { | |||
| 9 | + | app := &App{browserWindows: make(map[string]*browserWindow)} | |||
| 10 | + | first := &browserWindow{id: "window-1"} | |||
| 11 | + | second := &browserWindow{id: "window-2"} | |||
| 12 | + | ||||
| 13 | + | app.registerBrowserWindow(first) | |||
| 14 | + | app.registerBrowserWindow(second) | |||
| 15 | + | ||||
| 16 | + | if got := len(app.browserWindows); got != 2 { | |||
| 17 | + | t.Fatalf("browserWindows length = %d, want 2", got) | |||
| 18 | + | } | |||
| 19 | + | if _, ok := app.browserWindows[first.id]; !ok { | |||
| 20 | + | t.Fatalf("first browser window was not registered") | |||
| 21 | + | } | |||
| 22 | + | if _, ok := app.browserWindows[second.id]; !ok { | |||
| 23 | + | t.Fatalf("second browser window was not registered") | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | app.removeBrowserWindow(first.id) | |||
| 27 | + | ||||
| 28 | + | if _, ok := app.browserWindows[first.id]; ok { | |||
| 29 | + | t.Fatalf("first browser window was not removed") | |||
| 30 | + | } | |||
| 31 | + | if _, ok := app.browserWindows[second.id]; !ok { | |||
| 32 | + | t.Fatalf("second browser window should remain registered") | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) { | |||
| 37 | + | app := &App{browserWindows: make(map[string]*browserWindow)} | |||
| 38 | + | ||||
| 39 | + | dispatched := false | |||
| 40 | + | createdURL := "" | |||
| 41 | + | app.dispatchOnMainThread = func(fn func()) { | |||
| 42 | + | dispatched = true | |||
| 43 | + | fn() | |||
| 44 | + | } | |||
| 45 | + | app.browserWindowFactory = func(ctx context.Context, url string) (*browserWindow, error) { | |||
| 46 | + | createdURL = url | |||
| 47 | + | return &browserWindow{id: "window-1", initialURL: url}, nil | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | window, err := app.OpenFreshWindow(context.Background(), "https://example.com") | |||
| 51 | + | if err != nil { | |||
| 52 | + | t.Fatalf("OpenFreshWindow returned error: %v", err) | |||
| 53 | + | } | |||
| 54 | + | if !dispatched { | |||
| 55 | + | t.Fatalf("OpenFreshWindow did not use the main-thread dispatcher") | |||
| 56 | + | } | |||
| 57 | + | if createdURL != "https://example.com" { | |||
| 58 | + | t.Fatalf("browser window factory URL = %q, want %q", createdURL, "https://example.com") | |||
| 59 | + | } | |||
| 60 | + | if got := len(app.browserWindows); got != 1 { | |||
| 61 | + | t.Fatalf("browserWindows length = %d, want 1", got) | |||
| 62 | + | } | |||
| 63 | + | if got := app.browserWindows[window.id]; got != window { | |||
| 64 | + | t.Fatalf("browser window was not tracked") | |||
| 65 | + | } | |||
| 66 | + | if window.initialURL != "https://example.com" { | |||
| 67 | + | t.Fatalf("browser window initialURL = %q, want %q", window.initialURL, "https://example.com") | |||
| 68 | + | } | |||
| 69 | + | } | |||