detached af68b90

feat(command): add action registry

commit
af68b90cfe64641b2ab42e0d161bd18dc0174815
author
brice · · 2026-07-03 05:18 UTC
committer
brice · · 2026-07-03 07:33 UTC
parent
3e25d6d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +155 -0
A internal/usecase/command/command.go +19 -0
@@ -0,0 +1,19 @@
1+package command
2+
3+// Context is the set of application actions available to commands.
4+type Context interface {
5+ CreateTab() error
6+ CloseTab() error
7+ NextTab() error
8+ PrevTab() error
9+ Detach() error
10+ EnterCopyMode() error
11+ RenameSession() error
12+ OpenSessionPicker() error
13+}
14+
15+// Command describes an executable command.
16+type Command struct {
17+ Code, Name, Desc string
18+ Run func(Context) error
19+}
@@ -0,0 +1,19 @@
1+package command
2+
3+// Context is the set of application actions available to commands.
4+type Context interface {
5+ CreateTab() error
6+ CloseTab() error
7+ NextTab() error
8+ PrevTab() error
9+ Detach() error
10+ EnterCopyMode() error
11+ RenameSession() error
12+ OpenSessionPicker() error
13+}
14+
15+// Command describes an executable command.
16+type Command struct {
17+ Code, Name, Desc string
18+ Run func(Context) error
19+}
A internal/usecase/command/registry.go +31 -0
@@ -0,0 +1,31 @@
1+package command
2+
3+import "strings"
4+
5+// Registry returns all commands in display order.
6+func Registry() []Command {
7+ commands := []Command{
8+ {Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }},
9+ {Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }},
10+ {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},
11+ {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},
12+ {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
13+ {Code: "CPY", Name: "Copy mode", Desc: "Enter copy mode", Run: func(ctx Context) error { return ctx.EnterCopyMode() }},
14+ {Code: "RNS", Name: "Rename session", Desc: "Rename the current session", Run: func(ctx Context) error { return ctx.RenameSession() }},
15+ {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
16+ }
17+
18+ return commands
19+}
20+
21+// ByCode returns the command matching code, case-insensitively.
22+func ByCode(code string) (Command, bool) {
23+ code = strings.ToUpper(code)
24+ for _, cmd := range Registry() {
25+ if cmd.Code == code {
26+ return cmd, true
27+ }
28+ }
29+
30+ return Command{}, false
31+}
@@ -0,0 +1,31 @@
1+package command
2+
3+import "strings"
4+
5+// Registry returns all commands in display order.
6+func Registry() []Command {
7+ commands := []Command{
8+ {Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }},
9+ {Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }},
10+ {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},
11+ {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},
12+ {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
13+ {Code: "CPY", Name: "Copy mode", Desc: "Enter copy mode", Run: func(ctx Context) error { return ctx.EnterCopyMode() }},
14+ {Code: "RNS", Name: "Rename session", Desc: "Rename the current session", Run: func(ctx Context) error { return ctx.RenameSession() }},
15+ {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
16+ }
17+
18+ return commands
19+}
20+
21+// ByCode returns the command matching code, case-insensitively.
22+func ByCode(code string) (Command, bool) {
23+ code = strings.ToUpper(code)
24+ for _, cmd := range Registry() {
25+ if cmd.Code == code {
26+ return cmd, true
27+ }
28+ }
29+
30+ return Command{}, false
31+}
A internal/usecase/command/registry_test.go +105 -0
@@ -0,0 +1,105 @@
1+package command
2+
3+import (
4+ "fmt"
5+ "regexp"
6+ "testing"
7+)
8+
9+func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) {
10+ commands := Registry()
11+ wantCodes := []string{"CNT", "CLT", "NXT", "PVT", "SSP", "CPY", "RNS", "DET"}
12+
13+ if len(commands) != len(wantCodes) {
14+ t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
15+ }
16+
17+ codePattern := regexp.MustCompile(`^[A-Z]{3}$`)
18+ seen := make(map[string]bool, len(commands))
19+ for i, cmd := range commands {
20+ if cmd.Code != wantCodes[i] {
21+ t.Fatalf("Registry()[%d].Code = %q, want %q", i, cmd.Code, wantCodes[i])
22+ }
23+ if !codePattern.MatchString(cmd.Code) {
24+ t.Errorf("Registry()[%d].Code = %q, want three uppercase letters", i, cmd.Code)
25+ }
26+ if seen[cmd.Code] {
27+ t.Errorf("Registry()[%d].Code = %q, duplicate code", i, cmd.Code)
28+ }
29+ seen[cmd.Code] = true
30+ }
31+}
32+
33+func TestByCodeIsCaseInsensitive(t *testing.T) {
34+ for _, code := range []string{"CNT", "cnt", "CnT"} {
35+ cmd, ok := ByCode(code)
36+ if !ok {
37+ t.Fatalf("ByCode(%q) ok = false, want true", code)
38+ }
39+ if cmd.Code != "CNT" {
40+ t.Fatalf("ByCode(%q).Code = %q, want CNT", code, cmd.Code)
41+ }
42+ }
43+
44+ if _, ok := ByCode("missing"); ok {
45+ t.Fatal("ByCode(unknown) ok = true, want false")
46+ }
47+}
48+
49+func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
50+ tests := []struct {
51+ code string
52+ want string
53+ }{
54+ {code: "CNT", want: "CreateTab"},
55+ {code: "CLT", want: "CloseTab"},
56+ {code: "NXT", want: "NextTab"},
57+ {code: "PVT", want: "PrevTab"},
58+ {code: "SSP", want: "OpenSessionPicker"},
59+ {code: "CPY", want: "EnterCopyMode"},
60+ {code: "RNS", want: "RenameSession"},
61+ {code: "DET", want: "Detach"},
62+ }
63+
64+ for _, tt := range tests {
65+ t.Run(tt.code, func(t *testing.T) {
66+ cmd, ok := ByCode(tt.code)
67+ if !ok {
68+ t.Fatalf("ByCode(%q) ok = false, want true", tt.code)
69+ }
70+
71+ ctx := &spyContext{}
72+ if err := cmd.Run(ctx); err != nil {
73+ t.Fatalf("Run() error = %v, want nil", err)
74+ }
75+ if got := ctx.onlyCall(); got != tt.want {
76+ t.Fatalf("Run() called %q, want %q", got, tt.want)
77+ }
78+ })
79+ }
80+}
81+
82+type spyContext struct {
83+ calls []string
84+}
85+
86+func (s *spyContext) record(name string) error {
87+ s.calls = append(s.calls, name)
88+ return nil
89+}
90+
91+func (s *spyContext) onlyCall() string {
92+ if len(s.calls) != 1 {
93+ return fmt.Sprintf("%v", s.calls)
94+ }
95+ return s.calls[0]
96+}
97+
98+func (s *spyContext) CreateTab() error { return s.record("CreateTab") }
99+func (s *spyContext) CloseTab() error { return s.record("CloseTab") }
100+func (s *spyContext) NextTab() error { return s.record("NextTab") }
101+func (s *spyContext) PrevTab() error { return s.record("PrevTab") }
102+func (s *spyContext) Detach() error { return s.record("Detach") }
103+func (s *spyContext) EnterCopyMode() error { return s.record("EnterCopyMode") }
104+func (s *spyContext) RenameSession() error { return s.record("RenameSession") }
105+func (s *spyContext) OpenSessionPicker() error { return s.record("OpenSessionPicker") }
@@ -0,0 +1,105 @@
1+package command
2+
3+import (
4+ "fmt"
5+ "regexp"
6+ "testing"
7+)
8+
9+func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) {
10+ commands := Registry()
11+ wantCodes := []string{"CNT", "CLT", "NXT", "PVT", "SSP", "CPY", "RNS", "DET"}
12+
13+ if len(commands) != len(wantCodes) {
14+ t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
15+ }
16+
17+ codePattern := regexp.MustCompile(`^[A-Z]{3}$`)
18+ seen := make(map[string]bool, len(commands))
19+ for i, cmd := range commands {
20+ if cmd.Code != wantCodes[i] {
21+ t.Fatalf("Registry()[%d].Code = %q, want %q", i, cmd.Code, wantCodes[i])
22+ }
23+ if !codePattern.MatchString(cmd.Code) {
24+ t.Errorf("Registry()[%d].Code = %q, want three uppercase letters", i, cmd.Code)
25+ }
26+ if seen[cmd.Code] {
27+ t.Errorf("Registry()[%d].Code = %q, duplicate code", i, cmd.Code)
28+ }
29+ seen[cmd.Code] = true
30+ }
31+}
32+
33+func TestByCodeIsCaseInsensitive(t *testing.T) {
34+ for _, code := range []string{"CNT", "cnt", "CnT"} {
35+ cmd, ok := ByCode(code)
36+ if !ok {
37+ t.Fatalf("ByCode(%q) ok = false, want true", code)
38+ }
39+ if cmd.Code != "CNT" {
40+ t.Fatalf("ByCode(%q).Code = %q, want CNT", code, cmd.Code)
41+ }
42+ }
43+
44+ if _, ok := ByCode("missing"); ok {
45+ t.Fatal("ByCode(unknown) ok = true, want false")
46+ }
47+}
48+
49+func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
50+ tests := []struct {
51+ code string
52+ want string
53+ }{
54+ {code: "CNT", want: "CreateTab"},
55+ {code: "CLT", want: "CloseTab"},
56+ {code: "NXT", want: "NextTab"},
57+ {code: "PVT", want: "PrevTab"},
58+ {code: "SSP", want: "OpenSessionPicker"},
59+ {code: "CPY", want: "EnterCopyMode"},
60+ {code: "RNS", want: "RenameSession"},
61+ {code: "DET", want: "Detach"},
62+ }
63+
64+ for _, tt := range tests {
65+ t.Run(tt.code, func(t *testing.T) {
66+ cmd, ok := ByCode(tt.code)
67+ if !ok {
68+ t.Fatalf("ByCode(%q) ok = false, want true", tt.code)
69+ }
70+
71+ ctx := &spyContext{}
72+ if err := cmd.Run(ctx); err != nil {
73+ t.Fatalf("Run() error = %v, want nil", err)
74+ }
75+ if got := ctx.onlyCall(); got != tt.want {
76+ t.Fatalf("Run() called %q, want %q", got, tt.want)
77+ }
78+ })
79+ }
80+}
81+
82+type spyContext struct {
83+ calls []string
84+}
85+
86+func (s *spyContext) record(name string) error {
87+ s.calls = append(s.calls, name)
88+ return nil
89+}
90+
91+func (s *spyContext) onlyCall() string {
92+ if len(s.calls) != 1 {
93+ return fmt.Sprintf("%v", s.calls)
94+ }
95+ return s.calls[0]
96+}
97+
98+func (s *spyContext) CreateTab() error { return s.record("CreateTab") }
99+func (s *spyContext) CloseTab() error { return s.record("CloseTab") }
100+func (s *spyContext) NextTab() error { return s.record("NextTab") }
101+func (s *spyContext) PrevTab() error { return s.record("PrevTab") }
102+func (s *spyContext) Detach() error { return s.record("Detach") }
103+func (s *spyContext) EnterCopyMode() error { return s.record("EnterCopyMode") }
104+func (s *spyContext) RenameSession() error { return s.record("RenameSession") }
105+func (s *spyContext) OpenSessionPicker() error { return s.record("OpenSessionPicker") }

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 12

Tags 11

↑↓ moveenter openesc close