detached 1475275

fix(ui): create missing tabs for Alt+number shortcuts

Route indexed tab shortcuts through the tab coordinator so Alt+number can open missing tabs on demand. Keep existing-tab switching independent from the new-tab URL requirement and lock the behavior down with dispatcher tests.
commit
147527552d7761a675509a42ac4be6b4b289ecfc
author
brice · · 2026-04-11 18:12 UTC
parent
48a7b55
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +153 -10
M internal/ui/coordinator/tab.go +29 -0
@@ -2,6 +2,7 @@ package coordinator
22
33 import (
44 "context"
5+ "fmt"
56
67 "github.com/bnema/dumber/internal/application/port"
78 "github.com/bnema/dumber/internal/application/usecase"
@@ -274,6 +275,34 @@ func (c *TabCoordinator) SwitchByIndex(ctx context.Context, index int) error {
274275 return nil
275276 }
276277
278+// EnsureTabByIndex creates tabs until the requested index exists, then switches to it.
279+func (c *TabCoordinator) EnsureTabByIndex(ctx context.Context, index int, initialURL string) error {
280+ log := logging.FromContext(ctx)
281+
282+ if index < 0 {
283+ log.Debug().Int("index", index).Msg("invalid tab index")
284+ return nil
285+ }
286+
287+ if c.tabs == nil {
288+ return fmt.Errorf("tab list is required")
289+ }
290+
291+ if c.tabs.Count() <= index && initialURL == "" {
292+ log.Debug().Int("index", index).Msg("cannot auto-create missing tabs without initial URL")
293+ return fmt.Errorf("initial URL is required to auto-create missing tabs")
294+ }
295+
296+ for c.tabs.Count() <= index {
297+ if _, err := c.Create(ctx, initialURL); err != nil {
298+ log.Error().Err(err).Int("index", index).Msg("failed to create tab while ensuring tab index")
299+ return err
300+ }
301+ }
302+
303+ return c.SwitchByIndex(ctx, index)
304+}
305+
277306 // SwitchToLastActive switches to the previously active tab (Alt+Tab style).
278307 func (c *TabCoordinator) SwitchToLastActive(ctx context.Context) error {
279308 log := logging.FromContext(ctx)
@@ -2,6 +2,7 @@ package coordinator
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
5 6
6 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
7 "github.com/bnema/dumber/internal/application/usecase"8 "github.com/bnema/dumber/internal/application/usecase"
@@ -274,6 +275,34 @@ func (c *TabCoordinator) SwitchByIndex(ctx context.Context, index int) error {
274 return nil275 return nil
275 }276 }
276 277
278+// EnsureTabByIndex creates tabs until the requested index exists, then switches to it.
279+func (c *TabCoordinator) EnsureTabByIndex(ctx context.Context, index int, initialURL string) error {
280+ log := logging.FromContext(ctx)
281+
282+ if index < 0 {
283+ log.Debug().Int("index", index).Msg("invalid tab index")
284+ return nil
285+ }
286+
287+ if c.tabs == nil {
288+ return fmt.Errorf("tab list is required")
289+ }
290+
291+ if c.tabs.Count() <= index && initialURL == "" {
292+ log.Debug().Int("index", index).Msg("cannot auto-create missing tabs without initial URL")
293+ return fmt.Errorf("initial URL is required to auto-create missing tabs")
294+ }
295+
296+ for c.tabs.Count() <= index {
297+ if _, err := c.Create(ctx, initialURL); err != nil {
298+ log.Error().Err(err).Int("index", index).Msg("failed to create tab while ensuring tab index")
299+ return err
300+ }
301+ }
302+
303+ return c.SwitchByIndex(ctx, index)
304+}
305+
277 // SwitchToLastActive switches to the previously active tab (Alt+Tab style).306 // SwitchToLastActive switches to the previously active tab (Alt+Tab style).
278 func (c *TabCoordinator) SwitchToLastActive(ctx context.Context) error {307 func (c *TabCoordinator) SwitchToLastActive(ctx context.Context) error {
279 log := logging.FromContext(ctx)308 log := logging.FromContext(ctx)
M internal/ui/dispatcher/keyboard.go +14 -10
@@ -143,16 +143,16 @@ func (d *KeyboardDispatcher) initActionHandlers() {
143143 input.ActionNextTab: d.tabCoord.SwitchNext,
144144 input.ActionPreviousTab: d.tabCoord.SwitchPrev,
145145 input.ActionSwitchLastTab: d.tabCoord.SwitchToLastActive,
146- input.ActionSwitchTabIndex1: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, firstTabIndex) },
147- input.ActionSwitchTabIndex2: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, secondTabIndex) },
148- input.ActionSwitchTabIndex3: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, thirdTabIndex) },
149- input.ActionSwitchTabIndex4: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, fourthTabIndex) },
150- input.ActionSwitchTabIndex5: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, fifthTabIndex) },
151- input.ActionSwitchTabIndex6: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, sixthTabIndex) },
152- input.ActionSwitchTabIndex7: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, seventhTabIndex) },
153- input.ActionSwitchTabIndex8: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, eighthTabIndex) },
154- input.ActionSwitchTabIndex9: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, ninthTabIndex) },
155- input.ActionSwitchTabIndex10: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, tenthTabIndex) },
146+ input.ActionSwitchTabIndex1: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, firstTabIndex) },
147+ input.ActionSwitchTabIndex2: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, secondTabIndex) },
148+ input.ActionSwitchTabIndex3: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, thirdTabIndex) },
149+ input.ActionSwitchTabIndex4: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, fourthTabIndex) },
150+ input.ActionSwitchTabIndex5: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, fifthTabIndex) },
151+ input.ActionSwitchTabIndex6: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, sixthTabIndex) },
152+ input.ActionSwitchTabIndex7: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, seventhTabIndex) },
153+ input.ActionSwitchTabIndex8: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, eighthTabIndex) },
154+ input.ActionSwitchTabIndex9: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, ninthTabIndex) },
155+ input.ActionSwitchTabIndex10: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, tenthTabIndex) },
156156 input.ActionRenameTab: func(ctx context.Context) error {
157157 return d.logNoop(ctx, "rename tab action (not yet implemented)")
158158 },
@@ -311,6 +311,10 @@ func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error
311311 return nil
312312 }
313313
314+func (d *KeyboardDispatcher) handleSwitchTabIndex(ctx context.Context, index int) error {
315+ return d.tabCoord.EnsureTabByIndex(ctx, index, domainurl.Normalize(d.newPaneURL))
316+}
317+
314318 func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {
315319 if ctx == nil {
316320 return fmt.Errorf("missing context")
@@ -143,16 +143,16 @@ func (d *KeyboardDispatcher) initActionHandlers() {
143 input.ActionNextTab: d.tabCoord.SwitchNext,143 input.ActionNextTab: d.tabCoord.SwitchNext,
144 input.ActionPreviousTab: d.tabCoord.SwitchPrev,144 input.ActionPreviousTab: d.tabCoord.SwitchPrev,
145 input.ActionSwitchLastTab: d.tabCoord.SwitchToLastActive,145 input.ActionSwitchLastTab: d.tabCoord.SwitchToLastActive,
146- input.ActionSwitchTabIndex1: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, firstTabIndex) },146+ input.ActionSwitchTabIndex1: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, firstTabIndex) },
147- input.ActionSwitchTabIndex2: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, secondTabIndex) },147+ input.ActionSwitchTabIndex2: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, secondTabIndex) },
148- input.ActionSwitchTabIndex3: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, thirdTabIndex) },148+ input.ActionSwitchTabIndex3: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, thirdTabIndex) },
149- input.ActionSwitchTabIndex4: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, fourthTabIndex) },149+ input.ActionSwitchTabIndex4: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, fourthTabIndex) },
150- input.ActionSwitchTabIndex5: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, fifthTabIndex) },150+ input.ActionSwitchTabIndex5: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, fifthTabIndex) },
151- input.ActionSwitchTabIndex6: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, sixthTabIndex) },151+ input.ActionSwitchTabIndex6: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, sixthTabIndex) },
152- input.ActionSwitchTabIndex7: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, seventhTabIndex) },152+ input.ActionSwitchTabIndex7: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, seventhTabIndex) },
153- input.ActionSwitchTabIndex8: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, eighthTabIndex) },153+ input.ActionSwitchTabIndex8: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, eighthTabIndex) },
154- input.ActionSwitchTabIndex9: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, ninthTabIndex) },154+ input.ActionSwitchTabIndex9: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, ninthTabIndex) },
155- input.ActionSwitchTabIndex10: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, tenthTabIndex) },155+ input.ActionSwitchTabIndex10: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, tenthTabIndex) },
156 input.ActionRenameTab: func(ctx context.Context) error {156 input.ActionRenameTab: func(ctx context.Context) error {
157 return d.logNoop(ctx, "rename tab action (not yet implemented)")157 return d.logNoop(ctx, "rename tab action (not yet implemented)")
158 },158 },
@@ -311,6 +311,10 @@ func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error
311 return nil311 return nil
312 }312 }
313 313
314+func (d *KeyboardDispatcher) handleSwitchTabIndex(ctx context.Context, index int) error {
315+ return d.tabCoord.EnsureTabByIndex(ctx, index, domainurl.Normalize(d.newPaneURL))
316+}
317+
314 func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {318 func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {
315 if ctx == nil {319 if ctx == nil {
316 return fmt.Errorf("missing context")320 return fmt.Errorf("missing context")
A internal/ui/dispatcher/keyboard_test.go +110 -0
@@ -0,0 +1,110 @@
1+package dispatcher
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ domainurl "github.com/bnema/dumber/internal/domain/url"
10+ "github.com/bnema/dumber/internal/ui/coordinator"
11+ "github.com/bnema/dumber/internal/ui/input"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestKeyboardDispatcher_AltNumberCreatesTabsUntilRequestedIndexExists(t *testing.T) {
17+ ctx := context.Background()
18+ ids := []string{
19+ "tab-1", "ws-1", "pane-1",
20+ "tab-2", "ws-2", "pane-2",
21+ "tab-3", "ws-3", "pane-3",
22+ }
23+ idx := 0
24+ tabsUC := usecase.NewManageTabsUseCase(func() string {
25+ id := ids[idx]
26+ idx++
27+ return id
28+ })
29+ tabs := entity.NewTabList()
30+ tabCoord := coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
31+ TabsUC: tabsUC,
32+ Tabs: tabs,
33+ })
34+
35+ _, err := tabCoord.Create(ctx, "https://initial.example")
36+ require.NoError(t, err)
37+
38+ d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "https://new.example")
39+
40+ err = d.Dispatch(ctx, input.ActionSwitchTabIndex3)
41+ require.NoError(t, err)
42+
43+ require.Len(t, tabs.Tabs, 3)
44+ require.NotNil(t, tabs.ActiveTab())
45+ assert.Equal(t, tabs.TabAt(2).ID, tabs.ActiveTabID)
46+ assert.Equal(t, 2, tabs.ActiveTab().Position)
47+ assert.Equal(t, domainurl.Normalize("https://new.example"), tabs.TabAt(1).Workspace.Root.Pane.URI)
48+ assert.Equal(t, domainurl.Normalize("https://new.example"), tabs.TabAt(2).Workspace.Root.Pane.URI)
49+}
50+
51+func TestKeyboardDispatcher_AltNumberSwitchesToExistingTabWithoutNewPaneURL(t *testing.T) {
52+ ctx := context.Background()
53+ ids := []string{
54+ "tab-1", "ws-1", "pane-1",
55+ "tab-2", "ws-2", "pane-2",
56+ }
57+ idx := 0
58+ tabsUC := usecase.NewManageTabsUseCase(func() string {
59+ id := ids[idx]
60+ idx++
61+ return id
62+ })
63+ tabs := entity.NewTabList()
64+ tabCoord := coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
65+ TabsUC: tabsUC,
66+ Tabs: tabs,
67+ })
68+
69+ _, err := tabCoord.Create(ctx, "https://first.example")
70+ require.NoError(t, err)
71+ _, err = tabCoord.Create(ctx, "https://second.example")
72+ require.NoError(t, err)
73+
74+ d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "")
75+
76+ err = d.Dispatch(ctx, input.ActionSwitchTabIndex2)
77+ require.NoError(t, err)
78+
79+ require.Len(t, tabs.Tabs, 2)
80+ assert.Equal(t, tabs.TabAt(1).ID, tabs.ActiveTabID)
81+ assert.Equal(t, domainurl.Normalize("https://second.example"), tabs.TabAt(1).Workspace.Root.Pane.URI)
82+}
83+
84+func TestKeyboardDispatcher_AltNumberErrorsWhenMissingTabRequiresCreationWithoutNewPaneURL(t *testing.T) {
85+ ctx := context.Background()
86+ ids := []string{
87+ "tab-1", "ws-1", "pane-1",
88+ }
89+ idx := 0
90+ tabsUC := usecase.NewManageTabsUseCase(func() string {
91+ id := ids[idx]
92+ idx++
93+ return id
94+ })
95+ tabs := entity.NewTabList()
96+ tabCoord := coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
97+ TabsUC: tabsUC,
98+ Tabs: tabs,
99+ })
100+
101+ _, err := tabCoord.Create(ctx, "https://first.example")
102+ require.NoError(t, err)
103+
104+ d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "")
105+
106+ err = d.Dispatch(ctx, input.ActionSwitchTabIndex2)
107+ require.Error(t, err)
108+ require.Len(t, tabs.Tabs, 1)
109+ assert.Equal(t, tabs.TabAt(0).ID, tabs.ActiveTabID)
110+}
@@ -0,0 +1,110 @@
1+package dispatcher
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ domainurl "github.com/bnema/dumber/internal/domain/url"
10+ "github.com/bnema/dumber/internal/ui/coordinator"
11+ "github.com/bnema/dumber/internal/ui/input"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestKeyboardDispatcher_AltNumberCreatesTabsUntilRequestedIndexExists(t *testing.T) {
17+ ctx := context.Background()
18+ ids := []string{
19+ "tab-1", "ws-1", "pane-1",
20+ "tab-2", "ws-2", "pane-2",
21+ "tab-3", "ws-3", "pane-3",
22+ }
23+ idx := 0
24+ tabsUC := usecase.NewManageTabsUseCase(func() string {
25+ id := ids[idx]
26+ idx++
27+ return id
28+ })
29+ tabs := entity.NewTabList()
30+ tabCoord := coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
31+ TabsUC: tabsUC,
32+ Tabs: tabs,
33+ })
34+
35+ _, err := tabCoord.Create(ctx, "https://initial.example")
36+ require.NoError(t, err)
37+
38+ d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "https://new.example")
39+
40+ err = d.Dispatch(ctx, input.ActionSwitchTabIndex3)
41+ require.NoError(t, err)
42+
43+ require.Len(t, tabs.Tabs, 3)
44+ require.NotNil(t, tabs.ActiveTab())
45+ assert.Equal(t, tabs.TabAt(2).ID, tabs.ActiveTabID)
46+ assert.Equal(t, 2, tabs.ActiveTab().Position)
47+ assert.Equal(t, domainurl.Normalize("https://new.example"), tabs.TabAt(1).Workspace.Root.Pane.URI)
48+ assert.Equal(t, domainurl.Normalize("https://new.example"), tabs.TabAt(2).Workspace.Root.Pane.URI)
49+}
50+
51+func TestKeyboardDispatcher_AltNumberSwitchesToExistingTabWithoutNewPaneURL(t *testing.T) {
52+ ctx := context.Background()
53+ ids := []string{
54+ "tab-1", "ws-1", "pane-1",
55+ "tab-2", "ws-2", "pane-2",
56+ }
57+ idx := 0
58+ tabsUC := usecase.NewManageTabsUseCase(func() string {
59+ id := ids[idx]
60+ idx++
61+ return id
62+ })
63+ tabs := entity.NewTabList()
64+ tabCoord := coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
65+ TabsUC: tabsUC,
66+ Tabs: tabs,
67+ })
68+
69+ _, err := tabCoord.Create(ctx, "https://first.example")
70+ require.NoError(t, err)
71+ _, err = tabCoord.Create(ctx, "https://second.example")
72+ require.NoError(t, err)
73+
74+ d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "")
75+
76+ err = d.Dispatch(ctx, input.ActionSwitchTabIndex2)
77+ require.NoError(t, err)
78+
79+ require.Len(t, tabs.Tabs, 2)
80+ assert.Equal(t, tabs.TabAt(1).ID, tabs.ActiveTabID)
81+ assert.Equal(t, domainurl.Normalize("https://second.example"), tabs.TabAt(1).Workspace.Root.Pane.URI)
82+}
83+
84+func TestKeyboardDispatcher_AltNumberErrorsWhenMissingTabRequiresCreationWithoutNewPaneURL(t *testing.T) {
85+ ctx := context.Background()
86+ ids := []string{
87+ "tab-1", "ws-1", "pane-1",
88+ }
89+ idx := 0
90+ tabsUC := usecase.NewManageTabsUseCase(func() string {
91+ id := ids[idx]
92+ idx++
93+ return id
94+ })
95+ tabs := entity.NewTabList()
96+ tabCoord := coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
97+ TabsUC: tabsUC,
98+ Tabs: tabs,
99+ })
100+
101+ _, err := tabCoord.Create(ctx, "https://first.example")
102+ require.NoError(t, err)
103+
104+ d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "")
105+
106+ err = d.Dispatch(ctx, input.ActionSwitchTabIndex2)
107+ require.Error(t, err)
108+ require.Len(t, tabs.Tabs, 1)
109+ assert.Equal(t, tabs.TabAt(0).ID, tabs.ActiveTabID)
110+}

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