detached 9488c51

fix: keep fresh-window tabs and relay handoff responsive

commit
9488c517dfd931c353550208f72cbb0c72797d1e
author
brice · · 2026-04-14 16:52 UTC
parent
60b8e93
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +210 -29
M internal/infrastructure/desktop/browser_launch_relay.go +9 -0
@@ -87,6 +87,9 @@ func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url str
8787 if ctxErr := ctx.Err(); ctxErr != nil {
8888 return false, ctxErr
8989 }
90+ if _, ok := ctx.Deadline(); !ok {
91+ return false, decodeErr
92+ }
9093 if deadlineErr := setBrowserLaunchConnDeadline(ctx, conn); deadlineErr != nil {
9194 return false, deadlineErr
9295 }
@@ -208,6 +211,9 @@ func (l *browserLaunchRelayListener) serve(ctx context.Context, opener port.Brow
208211
209212 func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *net.UnixConn, opener port.BrowserWindowOpener) {
210213 defer func() { _ = conn.Close() }()
214+ if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
215+ return
216+ }
211217
212218 var request browserLaunchRequest
213219 if err := json.NewDecoder(conn).Decode(&request); err != nil {
@@ -219,6 +225,9 @@ func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *n
219225 response.Error = err.Error()
220226 }
221227
228+ if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
229+ return
230+ }
222231 _ = json.NewEncoder(conn).Encode(response)
223232 }
224233
@@ -87,6 +87,9 @@ func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url str
87 if ctxErr := ctx.Err(); ctxErr != nil {87 if ctxErr := ctx.Err(); ctxErr != nil {
88 return false, ctxErr88 return false, ctxErr
89 }89 }
90+ if _, ok := ctx.Deadline(); !ok {
91+ return false, decodeErr
92+ }
90 if deadlineErr := setBrowserLaunchConnDeadline(ctx, conn); deadlineErr != nil {93 if deadlineErr := setBrowserLaunchConnDeadline(ctx, conn); deadlineErr != nil {
91 return false, deadlineErr94 return false, deadlineErr
92 }95 }
@@ -208,6 +211,9 @@ func (l *browserLaunchRelayListener) serve(ctx context.Context, opener port.Brow
208 211
209 func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *net.UnixConn, opener port.BrowserWindowOpener) {212 func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *net.UnixConn, opener port.BrowserWindowOpener) {
210 defer func() { _ = conn.Close() }()213 defer func() { _ = conn.Close() }()
214+ if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
215+ return
216+ }
211 217
212 var request browserLaunchRequest218 var request browserLaunchRequest
213 if err := json.NewDecoder(conn).Decode(&request); err != nil {219 if err := json.NewDecoder(conn).Decode(&request); err != nil {
@@ -219,6 +225,9 @@ func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *n
219 response.Error = err.Error()225 response.Error = err.Error()
220 }226 }
221 227
228+ if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
229+ return
230+ }
222 _ = json.NewEncoder(conn).Encode(response)231 _ = json.NewEncoder(conn).Encode(response)
223 }232 }
224 233
M internal/infrastructure/desktop/browser_launch_relay_test.go +94 -0
@@ -245,3 +245,97 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t
245245 require.ErrorIs(t, err, context.DeadlineExceeded)
246246 assert.False(t, delivered)
247247 }
248+
249+func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsWithoutCallerDeadline(t *testing.T) {
250+ runtimeDir := shortTempDir(t)
251+ socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
252+ require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
253+
254+ listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
255+ require.NoError(t, err)
256+ defer func() {
257+ _ = listener.Close()
258+ _ = os.Remove(socketPath)
259+ }()
260+
261+ ready := make(chan struct{})
262+ go func() {
263+ close(ready)
264+ conn, acceptErr := listener.AcceptUnix()
265+ if acceptErr != nil {
266+ return
267+ }
268+ defer conn.Close()
269+ <-time.After(time.Second)
270+ }()
271+ <-ready
272+
273+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
274+
275+ type deliverResult struct {
276+ delivered bool
277+ err error
278+ }
279+ result := make(chan deliverResult, 1)
280+ go func() {
281+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/slow")
282+ result <- deliverResult{delivered: delivered, err: err}
283+ }()
284+
285+ select {
286+ case got := <-result:
287+ assert.False(t, got.delivered)
288+ require.Error(t, got.err)
289+ case <-time.After(300 * time.Millisecond):
290+ t.Fatal("DeliverOpenFreshWindow blocked without a caller deadline")
291+ }
292+}
293+
294+func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) {
295+ runtimeDir := filepath.Join(shortTempDir(t), "runtime")
296+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
297+
298+ received := make(chan string, 1)
299+ ctx, cancel := context.WithCancel(context.Background())
300+ defer cancel()
301+
302+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
303+ received <- url
304+ return nil
305+ }))
306+ require.NoError(t, err)
307+ defer closer.Close()
308+
309+ socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
310+ waitForSocket(t, socketPath)
311+
312+ conn, err := net.Dial("unix", socketPath)
313+ require.NoError(t, err)
314+ defer conn.Close()
315+ time.Sleep(20 * time.Millisecond)
316+
317+ type deliverResult struct {
318+ delivered bool
319+ err error
320+ }
321+ result := make(chan deliverResult, 1)
322+ go func() {
323+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/recovered")
324+ result <- deliverResult{delivered: delivered, err: err}
325+ }()
326+
327+ select {
328+ case got := <-result:
329+ require.NoError(t, got.err)
330+ require.True(t, got.delivered)
331+ case <-time.After(300 * time.Millisecond):
332+ t.Fatal("silent client stalled the relay listener")
333+ }
334+
335+ select {
336+ case got := <-received:
337+ assert.Equal(t, "https://example.com/recovered", got)
338+ case <-time.After(time.Second):
339+ t.Fatal("expected opener to receive the URL")
340+ }
341+}
@@ -245,3 +245,97 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t
245 require.ErrorIs(t, err, context.DeadlineExceeded)245 require.ErrorIs(t, err, context.DeadlineExceeded)
246 assert.False(t, delivered)246 assert.False(t, delivered)
247 }247 }
248+
249+func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsWithoutCallerDeadline(t *testing.T) {
250+ runtimeDir := shortTempDir(t)
251+ socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
252+ require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
253+
254+ listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
255+ require.NoError(t, err)
256+ defer func() {
257+ _ = listener.Close()
258+ _ = os.Remove(socketPath)
259+ }()
260+
261+ ready := make(chan struct{})
262+ go func() {
263+ close(ready)
264+ conn, acceptErr := listener.AcceptUnix()
265+ if acceptErr != nil {
266+ return
267+ }
268+ defer conn.Close()
269+ <-time.After(time.Second)
270+ }()
271+ <-ready
272+
273+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
274+
275+ type deliverResult struct {
276+ delivered bool
277+ err error
278+ }
279+ result := make(chan deliverResult, 1)
280+ go func() {
281+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/slow")
282+ result <- deliverResult{delivered: delivered, err: err}
283+ }()
284+
285+ select {
286+ case got := <-result:
287+ assert.False(t, got.delivered)
288+ require.Error(t, got.err)
289+ case <-time.After(300 * time.Millisecond):
290+ t.Fatal("DeliverOpenFreshWindow blocked without a caller deadline")
291+ }
292+}
293+
294+func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) {
295+ runtimeDir := filepath.Join(shortTempDir(t), "runtime")
296+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
297+
298+ received := make(chan string, 1)
299+ ctx, cancel := context.WithCancel(context.Background())
300+ defer cancel()
301+
302+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
303+ received <- url
304+ return nil
305+ }))
306+ require.NoError(t, err)
307+ defer closer.Close()
308+
309+ socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
310+ waitForSocket(t, socketPath)
311+
312+ conn, err := net.Dial("unix", socketPath)
313+ require.NoError(t, err)
314+ defer conn.Close()
315+ time.Sleep(20 * time.Millisecond)
316+
317+ type deliverResult struct {
318+ delivered bool
319+ err error
320+ }
321+ result := make(chan deliverResult, 1)
322+ go func() {
323+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/recovered")
324+ result <- deliverResult{delivered: delivered, err: err}
325+ }()
326+
327+ select {
328+ case got := <-result:
329+ require.NoError(t, got.err)
330+ require.True(t, got.delivered)
331+ case <-time.After(300 * time.Millisecond):
332+ t.Fatal("silent client stalled the relay listener")
333+ }
334+
335+ select {
336+ case got := <-received:
337+ assert.Equal(t, "https://example.com/recovered", got)
338+ case <-time.After(time.Second):
339+ t.Fatal("expected opener to receive the URL")
340+ }
341+}
M internal/ui/app.go +48 -29
@@ -529,44 +529,63 @@ func (a *App) openFreshWindow(ctx context.Context, url string) error {
529529 }
530530
531531 if a.tabCoord != nil {
532- a.tabCreationWindow = created
533- defer func() {
534- a.tabCreationWindow = nil
535- }()
532+ return a.openFreshWindowWithTabCoord(ctx, url, created)
533+ }
536534
537- createdTab, createErr := a.tabCoord.Create(ctx, url)
538- if createErr != nil {
539- a.removeBrowserWindow(created.id)
540- if created.mainWindow != nil {
541- created.mainWindow.Destroy()
542- }
543- return createErr
535+ if a.tabsUC == nil {
536+ return nil
537+ }
538+
539+ return a.openFreshWindowWithTabsUC(ctx, url, created)
540+}
541+
542+func (a *App) openFreshWindowWithTabCoord(ctx context.Context, url string, created *browserWindow) error {
543+ previousMainWindow := a.mainWindow
544+ if created.mainWindow != nil {
545+ a.tabCoord.SetMainWindow(created.mainWindow)
546+ }
547+ a.tabCreationWindow = created
548+ defer func() {
549+ a.tabCreationWindow = nil
550+ a.tabCoord.SetMainWindow(previousMainWindow)
551+ }()
552+
553+ createdTab, createErr := a.tabCoord.Create(ctx, url)
554+ if createErr != nil {
555+ a.removeBrowserWindow(created.id)
556+ if created.mainWindow != nil {
557+ created.mainWindow.Destroy()
544558 }
545- if a.workspaceViews[createdTab.ID] == nil {
546- if a.tabs != nil {
547- a.tabs.Remove(createdTab.ID)
548- }
549- if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
550- a.mainWindow.TabBar().RemoveTab(createdTab.ID)
551- a.mainWindow.TabBar().SetActive(a.tabs.ActiveTabID)
552- }
553- a.removeBrowserWindow(created.id)
554- if created.mainWindow != nil {
555- created.mainWindow.Destroy()
559+ return createErr
560+ }
561+ if a.workspaceViews[createdTab.ID] == nil {
562+ if a.tabs != nil {
563+ a.tabs.Remove(createdTab.ID)
564+ }
565+ tabBar := a.mainWindow.TabBar()
566+ if created.mainWindow != nil && a.tabCoord != nil {
567+ if targetTabBar := a.tabCoord.GetTabBar(); targetTabBar != nil {
568+ tabBar = targetTabBar
556569 }
557- return fmt.Errorf("workspace view not created for tab %s", createdTab.ID)
558570 }
559- a.setBrowserWindowForTab(createdTab.ID, created)
571+ if tabBar != nil {
572+ tabBar.RemoveTab(createdTab.ID)
573+ tabBar.SetActive(a.tabs.ActiveTabID)
574+ }
575+ a.removeBrowserWindow(created.id)
560576 if created.mainWindow != nil {
561- created.mainWindow.Show()
577+ created.mainWindow.Destroy()
562578 }
563- return nil
579+ return fmt.Errorf("workspace view not created for tab %s", createdTab.ID)
564580 }
565-
566- if a.tabsUC == nil {
567- return nil
581+ a.setBrowserWindowForTab(createdTab.ID, created)
582+ if created.mainWindow != nil {
583+ created.mainWindow.Show()
568584 }
585+ return nil
586+}
569587
588+func (a *App) openFreshWindowWithTabsUC(ctx context.Context, url string, created *browserWindow) error {
570589 output, err := a.tabsUC.Create(ctx, usecase.CreateTabInput{
571590 TabList: a.tabs,
572591 InitialURL: url,
@@ -529,44 +529,63 @@ func (a *App) openFreshWindow(ctx context.Context, url string) error {
529 }529 }
530 530
531 if a.tabCoord != nil {531 if a.tabCoord != nil {
532- a.tabCreationWindow = created532+ return a.openFreshWindowWithTabCoord(ctx, url, created)
533- defer func() {533+ }
534- a.tabCreationWindow = nil
535- }()
536 534
537- createdTab, createErr := a.tabCoord.Create(ctx, url)535+ if a.tabsUC == nil {
538- if createErr != nil {536+ return nil
539- a.removeBrowserWindow(created.id)537+ }
540- if created.mainWindow != nil {538+
541- created.mainWindow.Destroy()539+ return a.openFreshWindowWithTabsUC(ctx, url, created)
542- }540+}
543- return createErr541+
542+func (a *App) openFreshWindowWithTabCoord(ctx context.Context, url string, created *browserWindow) error {
543+ previousMainWindow := a.mainWindow
544+ if created.mainWindow != nil {
545+ a.tabCoord.SetMainWindow(created.mainWindow)
546+ }
547+ a.tabCreationWindow = created
548+ defer func() {
549+ a.tabCreationWindow = nil
550+ a.tabCoord.SetMainWindow(previousMainWindow)
551+ }()
552+
553+ createdTab, createErr := a.tabCoord.Create(ctx, url)
554+ if createErr != nil {
555+ a.removeBrowserWindow(created.id)
556+ if created.mainWindow != nil {
557+ created.mainWindow.Destroy()
544 }558 }
545- if a.workspaceViews[createdTab.ID] == nil {559+ return createErr
546- if a.tabs != nil {560+ }
547- a.tabs.Remove(createdTab.ID)561+ if a.workspaceViews[createdTab.ID] == nil {
548- }562+ if a.tabs != nil {
549- if a.mainWindow != nil && a.mainWindow.TabBar() != nil {563+ a.tabs.Remove(createdTab.ID)
550- a.mainWindow.TabBar().RemoveTab(createdTab.ID)564+ }
551- a.mainWindow.TabBar().SetActive(a.tabs.ActiveTabID)565+ tabBar := a.mainWindow.TabBar()
552- }566+ if created.mainWindow != nil && a.tabCoord != nil {
553- a.removeBrowserWindow(created.id)567+ if targetTabBar := a.tabCoord.GetTabBar(); targetTabBar != nil {
554- if created.mainWindow != nil {568+ tabBar = targetTabBar
555- created.mainWindow.Destroy()
556 }569 }
557- return fmt.Errorf("workspace view not created for tab %s", createdTab.ID)
558 }570 }
559- a.setBrowserWindowForTab(createdTab.ID, created)571+ if tabBar != nil {
572+ tabBar.RemoveTab(createdTab.ID)
573+ tabBar.SetActive(a.tabs.ActiveTabID)
574+ }
575+ a.removeBrowserWindow(created.id)
560 if created.mainWindow != nil {576 if created.mainWindow != nil {
561- created.mainWindow.Show()577+ created.mainWindow.Destroy()
562 }578 }
563- return nil579+ return fmt.Errorf("workspace view not created for tab %s", createdTab.ID)
564 }580 }
565-581+ a.setBrowserWindowForTab(createdTab.ID, created)
566- if a.tabsUC == nil {582+ if created.mainWindow != nil {
567- return nil583+ created.mainWindow.Show()
568 }584 }
585+ return nil
586+}
569 587
588+func (a *App) openFreshWindowWithTabsUC(ctx context.Context, url string, created *browserWindow) error {
570 output, err := a.tabsUC.Create(ctx, usecase.CreateTabInput{589 output, err := a.tabsUC.Create(ctx, usecase.CreateTabInput{
571 TabList: a.tabs,590 TabList: a.tabs,
572 InitialURL: url,591 InitialURL: url,
M internal/ui/app_browser_launch_test.go +59 -0
@@ -13,6 +13,8 @@ import (
1313 "github.com/bnema/dumber/internal/ui/component"
1414 "github.com/bnema/dumber/internal/ui/coordinator"
1515 "github.com/bnema/dumber/internal/ui/window"
16+ "github.com/bnema/puregotk/v4/gio"
17+ "github.com/bnema/puregotk/v4/gtk"
1618 )
1719
1820 type testBrowserLaunchRelay struct {
@@ -191,3 +193,60 @@ func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) {
191193 t.Fatalf("tab bar active tab = %q, want %q", got, existingTabID)
192194 }
193195 }
196+
197+func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) {
198+ EnsureAdwaitaInitialized()
199+ appID := AppID
200+ gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue)
201+ if gtkApp == nil {
202+ t.Fatal("gtk application creation failed")
203+ }
204+ defer gtkApp.Unref()
205+
206+ existingTabID := entity.TabID("existing-tab")
207+ oldWindow, err := window.New(context.Background(), gtkApp, "top")
208+ if err != nil {
209+ t.Fatalf("old window creation failed: %v", err)
210+ }
211+ newWindow, err := window.New(context.Background(), gtkApp, "top")
212+ if err != nil {
213+ t.Fatalf("new window creation failed: %v", err)
214+ }
215+ tabs := entity.NewTabList()
216+ tabsUC := usecase.NewManageTabsUseCase(func() string { return "id-1" })
217+
218+ app := &App{
219+ tabs: tabs,
220+ tabsUC: tabsUC,
221+ browserWindows: make(map[string]*browserWindow),
222+ browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
223+ return &browserWindow{id: "window-1", mainWindow: newWindow}, nil
224+ },
225+ mainWindow: oldWindow,
226+ tabCoord: coordinator.NewTabCoordinator(context.Background(), coordinator.TabCoordinatorConfig{
227+ TabsUC: tabsUC,
228+ Tabs: tabs,
229+ MainWindow: oldWindow,
230+ }),
231+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
232+ }
233+ app.tabs.Add(entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane"))))
234+ app.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
235+ app.workspaceViews[tab.ID] = &component.WorkspaceView{}
236+ })
237+
238+ if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
239+ t.Fatalf("OpenFreshWindow returned error: %v", err)
240+ }
241+
242+ createdTabID := app.tabs.ActiveTabID
243+ if createdTabID == "" {
244+ t.Fatalf("created tab id = %q, want non-empty", createdTabID)
245+ }
246+ if got := windowTabBarActiveID(t, newWindow); got != createdTabID {
247+ t.Fatalf("new window tab bar active tab = %q, want %q", got, createdTabID)
248+ }
249+ if got := windowTabBarActiveID(t, oldWindow); got != "" {
250+ t.Fatalf("old window tab bar active tab = %q, want empty", got)
251+ }
252+}
@@ -13,6 +13,8 @@ import (
13 "github.com/bnema/dumber/internal/ui/component"13 "github.com/bnema/dumber/internal/ui/component"
14 "github.com/bnema/dumber/internal/ui/coordinator"14 "github.com/bnema/dumber/internal/ui/coordinator"
15 "github.com/bnema/dumber/internal/ui/window"15 "github.com/bnema/dumber/internal/ui/window"
16+ "github.com/bnema/puregotk/v4/gio"
17+ "github.com/bnema/puregotk/v4/gtk"
16 )18 )
17 19
18 type testBrowserLaunchRelay struct {20 type testBrowserLaunchRelay struct {
@@ -191,3 +193,60 @@ func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) {
191 t.Fatalf("tab bar active tab = %q, want %q", got, existingTabID)193 t.Fatalf("tab bar active tab = %q, want %q", got, existingTabID)
192 }194 }
193 }195 }
196+
197+func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) {
198+ EnsureAdwaitaInitialized()
199+ appID := AppID
200+ gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue)
201+ if gtkApp == nil {
202+ t.Fatal("gtk application creation failed")
203+ }
204+ defer gtkApp.Unref()
205+
206+ existingTabID := entity.TabID("existing-tab")
207+ oldWindow, err := window.New(context.Background(), gtkApp, "top")
208+ if err != nil {
209+ t.Fatalf("old window creation failed: %v", err)
210+ }
211+ newWindow, err := window.New(context.Background(), gtkApp, "top")
212+ if err != nil {
213+ t.Fatalf("new window creation failed: %v", err)
214+ }
215+ tabs := entity.NewTabList()
216+ tabsUC := usecase.NewManageTabsUseCase(func() string { return "id-1" })
217+
218+ app := &App{
219+ tabs: tabs,
220+ tabsUC: tabsUC,
221+ browserWindows: make(map[string]*browserWindow),
222+ browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
223+ return &browserWindow{id: "window-1", mainWindow: newWindow}, nil
224+ },
225+ mainWindow: oldWindow,
226+ tabCoord: coordinator.NewTabCoordinator(context.Background(), coordinator.TabCoordinatorConfig{
227+ TabsUC: tabsUC,
228+ Tabs: tabs,
229+ MainWindow: oldWindow,
230+ }),
231+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
232+ }
233+ app.tabs.Add(entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane"))))
234+ app.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
235+ app.workspaceViews[tab.ID] = &component.WorkspaceView{}
236+ })
237+
238+ if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
239+ t.Fatalf("OpenFreshWindow returned error: %v", err)
240+ }
241+
242+ createdTabID := app.tabs.ActiveTabID
243+ if createdTabID == "" {
244+ t.Fatalf("created tab id = %q, want non-empty", createdTabID)
245+ }
246+ if got := windowTabBarActiveID(t, newWindow); got != createdTabID {
247+ t.Fatalf("new window tab bar active tab = %q, want %q", got, createdTabID)
248+ }
249+ if got := windowTabBarActiveID(t, oldWindow); got != "" {
250+ t.Fatalf("old window tab bar active tab = %q, want empty", got)
251+ }
252+}

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