registry_test.go RawHistoryFolder

1package command
2
3import (
4	"errors"
5	"regexp"
6	"strconv"
7	"testing"
8)
9
10func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
11	commands := PaletteRegistry()
12	wantCodes := []string{"CNT", "CNS", "CES", "CLT", "SPR", "SPL", "SPU", "SPD", "MPL", "MPR", "STP", "TFS", "TFP", "CFP", "MFP", "MAT", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BCK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}
13
14	if len(commands) != len(wantCodes) {
15		t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
16	}
17
18	codePattern := regexp.MustCompile(`^[A-Z]{3}$`)
19	seenCodes := make(map[string]bool, len(commands))
20	seenSlugs := make(map[string]bool, len(commands))
21	for i, cmd := range commands {
22		if cmd.Code != wantCodes[i] {
23			t.Fatalf("Registry()[%d].Code = %q, want %q", i, cmd.Code, wantCodes[i])
24		}
25		if !codePattern.MatchString(cmd.Code) {
26			t.Errorf("Registry()[%d].Code = %q, want three uppercase letters", i, cmd.Code)
27		}
28		if seenCodes[cmd.Code] {
29			t.Errorf("Registry()[%d].Code = %q, duplicate code", i, cmd.Code)
30		}
31		seenCodes[cmd.Code] = true
32		if cmd.Slug == "" {
33			t.Errorf("Registry()[%d].Slug is empty", i)
34		}
35		if seenSlugs[cmd.Slug] {
36			t.Errorf("Registry()[%d].Slug = %q, duplicate slug", i, cmd.Slug)
37		}
38		seenSlugs[cmd.Slug] = true
39	}
40}
41
42func TestMoveCommandsExposeExactRegistryContracts(t *testing.T) {
43	tests := []struct {
44		code, slug, name, desc, usage string
45		target                        TargetKind
46		args                          []string
47		wantCall                      string
48	}{
49		{
50			code: "MFP", slug: "move-pane", name: "Move pane to tab",
51			desc: "Move focused pane to tab", usage: "move-pane <destination-session> <destination-tab-id>",
52			target: TargetPane, args: []string{"work", "t_dest"}, wantCall: "move-pane:work:t_dest",
53		},
54		{
55			code: "MAT", slug: "move-tab", name: "Move tab to session",
56			desc: "Move active tab to session", usage: "move-tab <destination-session>",
57			target: TargetTab, args: []string{"work"}, wantCall: "move-tab:work",
58		},
59	}
60	for _, tt := range tests {
61		t.Run(tt.slug, func(t *testing.T) {
62			cmd, ok := BySlug(tt.slug)
63			if !ok {
64				t.Fatalf("BySlug(%q) missing", tt.slug)
65			}
66			if cmd.Code != tt.code || cmd.Name != tt.name || cmd.Desc != tt.desc || cmd.Usage != tt.usage {
67				t.Fatalf("command metadata = %#v", cmd)
68			}
69			if !cmd.PaletteVisible || !cmd.Scriptable || cmd.Target != tt.target || cmd.Scope != CommandScopeCrossSession {
70				t.Fatalf("command visibility/target/scope = palette:%v scriptable:%v target:%v scope:%v", cmd.PaletteVisible, cmd.Scriptable, cmd.Target, cmd.Scope)
71			}
72			ctx := &controlSpy{}
73			if _, err := cmd.Control(ctx, tt.args, ControlOptions{}); err != nil {
74				t.Fatalf("Control(%v): %v", tt.args, err)
75			}
76			if ctx.call != tt.wantCall {
77				t.Fatalf("Control(%v) call = %q, want %q", tt.args, ctx.call, tt.wantCall)
78			}
79		})
80	}
81}
82
83func TestMoveControlHandlersRequireExactArguments(t *testing.T) {
84	for _, tt := range []struct {
85		slug string
86		args []string
87	}{
88		{slug: "move-pane"},
89		{slug: "move-pane", args: []string{"work"}},
90		{slug: "move-pane", args: []string{"work", "t_dest", "extra"}},
91		{slug: "move-pane", args: []string{"", "t_dest"}},
92		{slug: "move-tab"},
93		{slug: "move-tab", args: []string{"work", "extra"}},
94		{slug: "move-tab", args: []string{""}},
95	} {
96		t.Run(tt.slug+"/"+strconv.Itoa(len(tt.args)), func(t *testing.T) {
97			cmd, ok := BySlug(tt.slug)
98			if !ok {
99				t.Fatalf("BySlug(%q) missing", tt.slug)
100			}
101			ctx := &controlSpy{}
102			if _, err := cmd.Control(ctx, tt.args, ControlOptions{}); !errors.Is(err, ErrInvalidArguments) {
103				t.Fatalf("Control(%v) error = %v, want ErrInvalidArguments", tt.args, err)
104			}
105			if ctx.call != "" {
106				t.Fatalf("invalid arguments delegated: %q", ctx.call)
107			}
108		})
109	}
110}
111
112func TestBySlugExactMatch(t *testing.T) {
113	tests := []struct {
114		slug string
115		ok   bool
116	}{
117		{slug: "split-right", ok: true},
118		{slug: "consume-or-expel-pane-left", ok: true},
119		{slug: "consume-or-expel-pane-right", ok: true},
120		{slug: "SPLIT-RIGHT", ok: false},
121		{slug: "toast", ok: true},
122		{slug: "list-panes", ok: true},
123		{slug: "does-not-exist", ok: false},
124	}
125	for _, tt := range tests {
126		t.Run(tt.slug, func(t *testing.T) {
127			if _, ok := BySlug(tt.slug); ok != tt.ok {
128				t.Fatalf("BySlug(%q) ok = %v, want %v", tt.slug, ok, tt.ok)
129			}
130		})
131	}
132}
133
134func TestRegistryControlMetadata(t *testing.T) {
135	scriptable := map[string]TargetKind{
136		"split-right": TargetPane, "split-left": TargetPane, "split-up": TargetPane,
137		"split-down": TargetPane, "consume-or-expel-pane-left": TargetPane,
138		"consume-or-expel-pane-right": TargetPane, "stack-pane": TargetPane, "toggle-stack": TargetPane,
139		"close-pane": TargetPane, "focus-pane-left": TargetPane, "focus-pane-right": TargetPane,
140		"focus-pane-up": TargetPane, "focus-pane-down": TargetPane,
141		"new-tab": TargetSession, "close-tab": TargetTab, "next-tab": TargetSession,
142		"previous-tab": TargetSession, "rename-session": TargetSession, "rename-tab": TargetTab,
143		"new-session": TargetSession, "toast": TargetSession,
144		"grow-pane-width": TargetPane, "shrink-pane-width": TargetPane,
145		"grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab,
146		"move-pane": TargetPane, "move-tab": TargetTab,
147		"list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab,
148		"remote-catalog": TargetNone, "env": TargetSession,
149	}
150	notScriptable := []string{
151		"session-picker", "notifications", "visual-mode", "yank-last-notification",
152		"detach", "back-session", "jump-recent-session", "toggle-floating-pane", "resize-pane",
153	}
154	for slug, target := range scriptable {
155		cmd, ok := BySlug(slug)
156		if !ok || !cmd.Scriptable {
157			t.Errorf("%s: want scriptable entry, ok=%v scriptable=%v", slug, ok, cmd.Scriptable)
158			continue
159		}
160		if cmd.Target != target || cmd.Control == nil || cmd.Usage == "" || cmd.Desc == "" {
161			t.Errorf("%s: incomplete control metadata: %#v", slug, cmd)
162		}
163	}
164	for _, slug := range notScriptable {
165		cmd, ok := BySlug(slug)
166		if !ok || cmd.Scriptable || cmd.Control != nil {
167			t.Errorf("%s: want present and non-scriptable, got %#v, ok=%v", slug, cmd, ok)
168		}
169	}
170}
171
172func TestPaletteRegistryHidesAPIOnly(t *testing.T) {
173	for _, cmd := range PaletteRegistry() {
174		if !cmd.PaletteVisible || cmd.Run == nil {
175			t.Errorf("palette command is not executable and visible: %#v", cmd)
176		}
177		if cmd.Slug == "toast" || cmd.Slug == "list-sessions" || cmd.Slug == "list-tabs" || cmd.Slug == "list-panes" || cmd.Slug == "remote-catalog" || cmd.Slug == "env" {
178			t.Errorf("API-only command %q is visible", cmd.Slug)
179		}
180	}
181}
182
183func TestSessionRecoveryCommand(t *testing.T) {
184	cmd, ok := BySlug("session-recovery")
185	if !ok || !cmd.Scriptable || cmd.Target != TargetNone {
186		t.Fatalf("session-recovery command = %#v, ok=%v", cmd, ok)
187	}
188	tests := []struct {
189		args     []string
190		wantCall string
191		wantErr  error
192	}{
193		{args: []string{"discard"}, wantCall: "session-recovery:discard"},
194		{args: []string{"retry"}, wantErr: ErrInvalidArguments},
195		{args: []string{"restore", "7"}, wantErr: ErrInvalidArguments},
196		{args: []string{"export", "/tmp/export"}, wantErr: ErrInvalidArguments},
197		{args: nil, wantErr: ErrInvalidArguments},
198	}
199	for _, test := range tests {
200		ctx := &controlSpy{recoveryOutput: "recovery output"}
201		got, err := cmd.Control(ctx, test.args, ControlOptions{})
202		wantOutput := ""
203		if test.wantCall != "" {
204			wantOutput = ctx.recoveryOutput
205		}
206		if !errors.Is(err, test.wantErr) || ctx.call != test.wantCall || got.Output != wantOutput {
207			t.Errorf("args %v: call/output/error = %q/%q/%v, want %q/%q/%v", test.args, ctx.call, got.Output, err, test.wantCall, wantOutput, test.wantErr)
208		}
209	}
210}
211
212func TestRemoteCatalogCommand(t *testing.T) {
213	cmd, ok := BySlug("remote-catalog")
214	if !ok {
215		t.Fatal("BySlug(\"remote-catalog\") missing")
216	}
217	if cmd.Name != "Remote catalog" || cmd.Desc == "" || cmd.Usage != "remote-catalog --json" {
218		t.Fatalf("remote-catalog metadata = %#v", cmd)
219	}
220	if !cmd.Scriptable || cmd.PaletteVisible || cmd.Target != TargetNone || cmd.Run != nil || cmd.Control == nil {
221		t.Fatalf("remote-catalog visibility/target = %#v", cmd)
222	}
223
224	tests := []struct {
225		name     string
226		args     []string
227		opts     ControlOptions
228		wantCall string
229		wantErr  error
230	}{
231		{name: "json delegates", opts: ControlOptions{JSON: true}, wantCall: "remote-catalog:true"},
232		{name: "rejects missing json", wantErr: ErrInvalidArguments},
233		{name: "rejects args", args: []string{"extra"}, opts: ControlOptions{JSON: true}, wantErr: ErrInvalidArguments},
234		{name: "rejects args without json", args: []string{"extra"}, wantErr: ErrInvalidArguments},
235	}
236	for _, tt := range tests {
237		t.Run(tt.name, func(t *testing.T) {
238			ctx := &controlSpy{catalogOutput: `{"protocol_version":19,"sessions":[]}` + "\n"}
239			got, err := cmd.Control(ctx, tt.args, tt.opts)
240			wantOutput := ""
241			if tt.wantCall != "" {
242				wantOutput = ctx.catalogOutput
243			}
244			if !errors.Is(err, tt.wantErr) || ctx.call != tt.wantCall || got.Output != wantOutput {
245				t.Fatalf("Control() call/output/error = %q/%q/%v, want %q/%q/%v", ctx.call, got.Output, err, tt.wantCall, wantOutput, tt.wantErr)
246			}
247		})
248	}
249}
250
251func TestControlHandlersValidateAndDelegate(t *testing.T) {
252	tests := []struct {
253		name       string
254		slug       string
255		args       []string
256		opts       ControlOptions
257		wantCall   string
258		wantOutput string
259		wantErr    error
260	}{
261		{name: "zero argument mutation delegates", slug: "split-right", wantCall: "split-right"},
262		{name: "zero argument mutation rejects args", slug: "split-right", args: []string{"extra"}, wantErr: ErrInvalidArguments},
263		{name: "consume or expel left delegates", slug: "consume-or-expel-pane-left", wantCall: "consume-or-expel-pane-left"},
264		{name: "consume or expel right delegates", slug: "consume-or-expel-pane-right", wantCall: "consume-or-expel-pane-right"},
265		{name: "grow width delegates", slug: "grow-pane-width", wantCall: "grow-pane-width"},
266		{name: "shrink width delegates", slug: "shrink-pane-width", wantCall: "shrink-pane-width"},
267		{name: "grow height delegates", slug: "grow-pane-height", wantCall: "grow-pane-height"},
268		{name: "shrink height delegates", slug: "shrink-pane-height", wantCall: "shrink-pane-height"},
269		{name: "equalize delegates", slug: "equalize-panes", wantCall: "equalize-panes"},
270		{name: "resize mutation rejects arguments", slug: "grow-pane-width", args: []string{"extra"}, wantErr: ErrInvalidArguments},
271		{name: "equalize rejects arguments", slug: "equalize-panes", args: []string{"extra"}, wantErr: ErrInvalidArguments},
272		{name: "rename delegates name", slug: "rename-tab", args: []string{"editor"}, wantCall: "rename-tab:editor"},
273		{name: "rename rejects empty name", slug: "rename-tab", args: []string{""}, wantErr: ErrInvalidArguments},
274		{name: "toast delegates severity", slug: "toast", args: []string{"-l", "warn", "hello"}, wantCall: "toast:warn:hello"},
275		{name: "toast rejects malformed flags", slug: "toast", args: []string{"-l", "warn"}, wantErr: ErrInvalidArguments},
276		{name: "query delegates JSON option", slug: "list-panes", opts: ControlOptions{JSON: true}, wantCall: "list-panes:true", wantOutput: "panes"},
277		{name: "query rejects args", slug: "list-panes", args: []string{"extra"}, wantErr: ErrInvalidArguments},
278		{name: "env delegates JSON option", slug: "env", opts: ControlOptions{JSON: true}, wantCall: "env:true", wantOutput: "env"},
279		{name: "env rejects args", slug: "env", args: []string{"extra"}, wantErr: ErrInvalidArguments},
280	}
281	for _, tt := range tests {
282		t.Run(tt.name, func(t *testing.T) {
283			cmd, ok := BySlug(tt.slug)
284			if !ok {
285				t.Fatalf("BySlug(%q) missing", tt.slug)
286			}
287			ctx := &controlSpy{}
288			got, err := cmd.Control(ctx, tt.args, tt.opts)
289			if !errors.Is(err, tt.wantErr) {
290				t.Fatalf("Control() error = %v, want %v", err, tt.wantErr)
291			}
292			if ctx.call != tt.wantCall || got.Output != tt.wantOutput {
293				t.Fatalf("Control() call/output = %q/%q, want %q/%q", ctx.call, got.Output, tt.wantCall, tt.wantOutput)
294			}
295		})
296	}
297}
298
299type controlSpy struct {
300	call           string
301	recoveryOutput string
302	catalogOutput  string
303}
304
305func (s *controlSpy) record(call string) error          { s.call = call; return nil }
306func (s *controlSpy) CreateTab() error                  { return s.record("new-tab") }
307func (s *controlSpy) CreateSessionNamed(v string) error { return s.record("new-session:" + v) }
308func (s *controlSpy) CloseTab() error                   { return s.record("close-tab") }
309func (s *controlSpy) ClosePane() error                  { return s.record("close-pane") }
310func (s *controlSpy) MovePane(session, tab string) error {
311	return s.record("move-pane:" + session + ":" + tab)
312}
313func (s *controlSpy) MoveTab(session string) error   { return s.record("move-tab:" + session) }
314func (s *controlSpy) SplitRight() error              { return s.record("split-right") }
315func (s *controlSpy) SplitLeft() error               { return s.record("split-left") }
316func (s *controlSpy) SplitUp() error                 { return s.record("split-up") }
317func (s *controlSpy) SplitDown() error               { return s.record("split-down") }
318func (s *controlSpy) ConsumeOrExpelPaneLeft() error  { return s.record("consume-or-expel-pane-left") }
319func (s *controlSpy) ConsumeOrExpelPaneRight() error { return s.record("consume-or-expel-pane-right") }
320func (s *controlSpy) StackPane() error               { return s.record("stack-pane") }
321func (s *controlSpy) ToggleStack() error             { return s.record("toggle-stack") }
322func (s *controlSpy) GrowPaneWidth() error           { return s.record("grow-pane-width") }
323func (s *controlSpy) ShrinkPaneWidth() error         { return s.record("shrink-pane-width") }
324func (s *controlSpy) GrowPaneHeight() error          { return s.record("grow-pane-height") }
325func (s *controlSpy) ShrinkPaneHeight() error        { return s.record("shrink-pane-height") }
326func (s *controlSpy) EqualizePanes() error           { return s.record("equalize-panes") }
327func (s *controlSpy) FocusPaneLeft() error           { return s.record("focus-pane-left") }
328func (s *controlSpy) FocusPaneRight() error          { return s.record("focus-pane-right") }
329func (s *controlSpy) FocusPaneUp() error             { return s.record("focus-pane-up") }
330func (s *controlSpy) FocusPaneDown() error           { return s.record("focus-pane-down") }
331func (s *controlSpy) NextTab() error                 { return s.record("next-tab") }
332func (s *controlSpy) PrevTab() error                 { return s.record("previous-tab") }
333func (s *controlSpy) RenameSessionTo(v string) error { return s.record("rename-session:" + v) }
334func (s *controlSpy) RenameTabTo(v string) error     { return s.record("rename-tab:" + v) }
335func (s *controlSpy) Toast(level, message string) error {
336	return s.record("toast:" + level + ":" + message)
337}
338func (s *controlSpy) SessionRecovery(action string) (string, error) {
339	return s.recoveryOutput, s.record("session-recovery:" + action)
340}
341func (s *controlSpy) ListSessions(json bool) (string, error) {
342	_ = s.record("list-sessions:" + strconv.FormatBool(json))
343	return "sessions", nil
344}
345func (s *controlSpy) ListTabs(json bool) (string, error) {
346	_ = s.record("list-tabs:" + strconv.FormatBool(json))
347	return "tabs", nil
348}
349func (s *controlSpy) ListPanes(json bool) (string, error) {
350	_ = s.record("list-panes:" + strconv.FormatBool(json))
351	return "panes", nil
352}
353func (s *controlSpy) SessionEnvironment(json bool) (string, error) {
354	_ = s.record("env:" + strconv.FormatBool(json))
355	return "env", nil
356}
357func (s *controlSpy) RemoteCatalog(json bool) (string, error) {
358	_ = s.record("remote-catalog:" + strconv.FormatBool(json))
359	return s.catalogOutput, nil
360}
361
362func TestRegistryIncludesFloatingPaneToggle(t *testing.T) {
363	cmd, ok := ByCode("TFP")
364	if !ok {
365		t.Fatal("ByCode(\"TFP\") ok = false, want true")
366	}
367	if cmd.Slug != "toggle-floating-pane" || cmd.Code != "TFP" || cmd.Name != "Toggle floating pane" {
368		t.Fatalf("floating command = %#v, want toggle-floating-pane/TFP/Toggle floating pane", cmd)
369	}
370}
371
372func TestRegistryIncludesNotificationCommands(t *testing.T) {
373	for _, tt := range []struct {
374		code string
375		slug string
376		name string
377	}{
378		{code: "NTC", slug: "notifications", name: "Notifications"},
379		{code: "YLN", slug: "yank-last-notification", name: "Yank last notification"},
380	} {
381		t.Run(tt.code, func(t *testing.T) {
382			cmd, ok := ByCode(tt.code)
383			if !ok {
384				t.Fatalf("ByCode(%q) ok = false, want true", tt.code)
385			}
386			if cmd.Slug != tt.slug || cmd.Code != tt.code || cmd.Name != tt.name {
387				t.Fatalf("command = %#v, want %s/%s/%s", cmd, tt.slug, tt.code, tt.name)
388			}
389		})
390	}
391}
392
393func TestByCodeIsCaseInsensitive(t *testing.T) {
394	for _, code := range []string{"CNT", "cnt", "CnT"} {
395		cmd, ok := ByCode(code)
396		if !ok {
397			t.Fatalf("ByCode(%q) ok = false, want true", code)
398		}
399		if cmd.Code != "CNT" {
400			t.Fatalf("ByCode(%q).Code = %q, want CNT", code, cmd.Code)
401		}
402	}
403
404	if _, ok := ByCode("missing"); ok {
405		t.Fatal("ByCode(unknown) ok = true, want false")
406	}
407}
408
409func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
410	tests := []struct {
411		code   string
412		expect func(*MockContext)
413	}{
414		{code: "CNT", expect: func(ctx *MockContext) { ctx.EXPECT().CreateTab().Return(nil).Once() }},
415		{code: "CNS", expect: func(ctx *MockContext) { ctx.EXPECT().CreateSession().Return(nil).Once() }},
416		{code: "CLT", expect: func(ctx *MockContext) { ctx.EXPECT().CloseTab().Return(nil).Once() }},
417		{code: "SPR", expect: func(ctx *MockContext) { ctx.EXPECT().SplitRight().Return(nil).Once() }},
418		{code: "SPL", expect: func(ctx *MockContext) { ctx.EXPECT().SplitLeft().Return(nil).Once() }},
419		{code: "SPU", expect: func(ctx *MockContext) { ctx.EXPECT().SplitUp().Return(nil).Once() }},
420		{code: "SPD", expect: func(ctx *MockContext) { ctx.EXPECT().SplitDown().Return(nil).Once() }},
421		{code: "MPL", expect: func(ctx *MockContext) { ctx.EXPECT().ConsumeOrExpelPaneLeft().Return(nil).Once() }},
422		{code: "MPR", expect: func(ctx *MockContext) { ctx.EXPECT().ConsumeOrExpelPaneRight().Return(nil).Once() }},
423		{code: "STP", expect: func(ctx *MockContext) { ctx.EXPECT().StackPane().Return(nil).Once() }},
424		{code: "TFS", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }},
425		{code: "TFP", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleFloatingPane().Return(nil).Once() }},
426		{code: "CFP", expect: func(ctx *MockContext) { ctx.EXPECT().ClosePane().Return(nil).Once() }},
427		{code: "MFP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenMovePanePicker().Return(nil).Once() }},
428		{code: "MAT", expect: func(ctx *MockContext) { ctx.EXPECT().OpenMoveTabPicker().Return(nil).Once() }},
429		{code: "FPL", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneLeft().Return(nil).Once() }},
430		{code: "FPR", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneRight().Return(nil).Once() }},
431		{code: "FPU", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneUp().Return(nil).Once() }},
432		{code: "FPD", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneDown().Return(nil).Once() }},
433		{code: "RSZ", expect: func(ctx *MockContext) { ctx.EXPECT().EnterResizeMode().Return(nil).Once() }},
434		{code: "GPW", expect: func(ctx *MockContext) { ctx.EXPECT().GrowPaneWidth().Return(nil).Once() }},
435		{code: "SPW", expect: func(ctx *MockContext) { ctx.EXPECT().ShrinkPaneWidth().Return(nil).Once() }},
436		{code: "GPH", expect: func(ctx *MockContext) { ctx.EXPECT().GrowPaneHeight().Return(nil).Once() }},
437		{code: "SPH", expect: func(ctx *MockContext) { ctx.EXPECT().ShrinkPaneHeight().Return(nil).Once() }},
438		{code: "EQP", expect: func(ctx *MockContext) { ctx.EXPECT().EqualizePanes().Return(nil).Once() }},
439		{code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }},
440		{code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }},
441		{code: "BCK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }},
442		{code: "JRS", expect: func(ctx *MockContext) { ctx.EXPECT().JumpRecentSession(1).Return(nil).Once() }},
443		{code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},
444		{code: "NTC", expect: func(ctx *MockContext) { ctx.EXPECT().OpenNotifications().Return(nil).Once() }},
445		{code: "YLN", expect: func(ctx *MockContext) { ctx.EXPECT().YankLastNotification().Return(nil).Once() }},
446		{code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},
447		{code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},
448		{code: "RNT", expect: func(ctx *MockContext) { ctx.EXPECT().RenameTab().Return(nil).Once() }},
449		{code: "DET", expect: func(ctx *MockContext) { ctx.EXPECT().Detach().Return(nil).Once() }},
450	}
451
452	for _, tt := range tests {
453		t.Run(tt.code, func(t *testing.T) {
454			cmd, ok := ByCode(tt.code)
455			if !ok {
456				t.Fatalf("ByCode(%q) ok = false, want true", tt.code)
457			}
458
459			ctx := NewMockContext(t)
460			tt.expect(ctx)
461			args := []string(nil)
462			if tt.code == "JRS" {
463				args = []string{"1"}
464			}
465			if err := cmd.Run(ctx, args); err != nil {
466				t.Fatalf("Run() error = %v, want nil", err)
467			}
468		})
469	}
470}

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