detached 7109c5e

refactor: extract browser window ownership from app

commit
7109c5ecd86a9f1e7ef7f7e5f7e411e188365f62
author
brice · · 2026-04-14 15:30 UTC
parent
a5232dd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
6565 gtkApp *gtk.Application
6666 mainWindow *window.MainWindow
6767
68+ browserWindows map[string]*browserWindow
69+ browserWindowFactory func(context.Context, string) (*browserWindow, error)
70+ dispatchOnMainThread func(func())
71+
6872 // State
6973 tabs *entity.TabList
7074 tabsUC *usecase.ManageTabsUseCase
@@ -126,6 +130,8 @@ type App struct {
126130 // ID generator for tabs/panes
127131 idCounter uint64
128132 idMu sync.Mutex
133+ windowIDCounter uint64
134+ windowIDMu sync.Mutex
129135 firstWebViewShownOnce sync.Once
130136
131137 movePaneToTabUC *usecase.MovePaneToTabUseCase
@@ -172,14 +178,16 @@ func New(deps *Dependencies) (*App, error) {
172178 ctx, cancel := context.WithCancelCause(deps.Ctx)
173179
174180 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,
183191 }
184192 // Register message handlers through the engine.
185193 if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{
@@ -244,6 +252,7 @@ func (a *App) Run(ctx context.Context, args []string) int {
244252 }
245253 defer a.gtkApp.Unref()
246254 logging.Trace().Mark("gtk_app_created")
255+ a.dispatchOnMainThread = a.runOnMainThread
247256
248257 // Connect activate signal
249258 activateCb := func(_ gio.Application) {
@@ -273,7 +282,7 @@ func (a *App) onActivate(ctx context.Context) {
273282 // synchronous prewarming that delays the first navigation on cold start.
274283 a.setupPoolBackgroundColor(ctx)
275284
276- if err := a.createMainWindow(ctx); err != nil {
285+ if _, err := a.OpenFreshWindow(ctx, a.initialWindowURL()); err != nil {
277286 log.Error().Err(err).Msg("failed to create main window")
278287 return
279288 }
@@ -380,19 +389,31 @@ func (a *App) prewarmWebViewPoolAsync(ctx context.Context) {
380389 pool.PrewarmAsync(ctx, 0)
381390 }
382391
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+
384397 log := logging.FromContext(ctx)
385398 mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config.Workspace.TabBarPosition)
386399 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
388409 }
389- a.mainWindow = mainWindow
390410
391411 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)
393414 return false
394415 }
395- a.mainWindow.Window().ConnectCloseRequest(&closeRequestCb)
416+ mainWindow.Window().ConnectCloseRequest(&closeRequestCb)
396417
397418 // Create permission popup and dialog presenter
398419 if a.deps != nil && a.deps.PermissionUC != nil {
@@ -404,7 +425,7 @@ func (a *App) createMainWindow(ctx context.Context) error {
404425 if permPopup != nil {
405426 // Add popup to the main window's content overlay
406427 if w := permPopup.Widget(); w != nil {
407- a.mainWindow.AddOverlay(w)
428+ mainWindow.AddOverlay(w)
408429 }
409430 permDialog := dialog.NewPermissionDialog(permPopup)
410431 a.deps.PermissionUC.SetDialogPresenter(permDialog)
@@ -415,19 +436,26 @@ func (a *App) createMainWindow(ctx context.Context) error {
415436 indicator := component.NewWebRTCPermissionIndicator()
416437 if indicator != nil {
417438 if w := indicator.Widget(); w != nil {
418- a.mainWindow.AddOverlay(w)
439+ mainWindow.AddOverlay(w)
419440 a.webrtcIndicator = indicator
420441 }
421442 }
422443
423444 // Apply GTK CSS styling from theme manager.
424445 if a.deps == nil || a.deps.Theme == nil {
425- return nil
446+ return browserWindow, nil
426447 }
427- if display := a.mainWindow.Window().GetDisplay(); display != nil {
448+ if display := mainWindow.Window().GetDisplay(); display != nil {
428449 a.deps.Theme.ApplyToDisplay(ctx, display)
429450 }
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"
431459 }
432460
433461 func (a *App) initLayoutInfrastructure() {
@@ -1409,11 +1437,7 @@ func (a *App) createInitialTab(ctx context.Context) {
14091437 }
14101438
14111439 // 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 {
14171441 log.Error().Err(err).Msg("failed to create initial tab")
14181442 }
14191443 }
@@ -1951,6 +1975,35 @@ func (a *App) syncWebRTCPermissionLockState(ctx context.Context, origin string,
19511975 a.webrtcIndicator.SetStoredDecision(permType, record.Decision, true)
19521976 }
19531977
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+
19542007 // generateID generates a unique ID for tabs and panes.
19552008 // Uses monotonically increasing counter to avoid ID collisions.
19562009 func (a *App) generateID() string {
@@ -65,6 +65,10 @@ type App struct {
65 gtkApp *gtk.Application65 gtkApp *gtk.Application
66 mainWindow *window.MainWindow66 mainWindow *window.MainWindow
67 67
68+ browserWindows map[string]*browserWindow
69+ browserWindowFactory func(context.Context, string) (*browserWindow, error)
70+ dispatchOnMainThread func(func())
71+
68 // State72 // State
69 tabs *entity.TabList73 tabs *entity.TabList
70 tabsUC *usecase.ManageTabsUseCase74 tabsUC *usecase.ManageTabsUseCase
@@ -126,6 +130,8 @@ type App struct {
126 // ID generator for tabs/panes130 // ID generator for tabs/panes
127 idCounter uint64131 idCounter uint64
128 idMu sync.Mutex132 idMu sync.Mutex
133+ windowIDCounter uint64
134+ windowIDMu sync.Mutex
129 firstWebViewShownOnce sync.Once135 firstWebViewShownOnce sync.Once
130 136
131 movePaneToTabUC *usecase.MovePaneToTabUseCase137 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 signal257 // 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 return287 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 err400+ 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 false414 return false
394 }415 }
395- a.mainWindow.Window().ConnectCloseRequest(&closeRequestCb)416+ mainWindow.Window().ConnectCloseRequest(&closeRequestCb)
396 417
397 // Create permission popup and dialog presenter418 // 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 overlay426 // 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 = indicator440 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 nil446+ 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 nil451+ 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+}

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