detached a13c2fe

fix(remote): bound catalog command lifetime

Signed-off-by: brice <b@bnema.dev>
commit
a13c2fe27392d0e7e9d7a3e33ed13ad86da4c292
author
brice · · 2026-07-30 10:13 UTC
parent
b71ae30
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +160 -32
M internal/adapters/remote/catalog.go +25 -6
@@ -10,6 +10,7 @@ import (
1010 "log/slog"
1111 "os/exec"
1212 "strings"
13+ "time"
1314 "unicode"
1415 "unicode/utf8"
1516
@@ -21,6 +22,8 @@ import (
2122 const (
2223 maxCatalogBytes = 1 << 20
2324 maxCatalogDiagnosticBytes = 4 << 10
25+ catalogCommandTimeout = 30 * time.Second
26+ catalogCommandWaitDelay = 500 * time.Millisecond
2427 )
2528
2629 var (
@@ -35,6 +38,8 @@ var (
3538 // CatalogClient fetches remote session catalogs over SSH.
3639 type CatalogClient struct {
3740 command func(ctx context.Context, name string, args ...string) *exec.Cmd
41+ // timeout bounds each List when > 0; otherwise catalogCommandTimeout is used.
42+ timeout time.Duration
3843 }
3944
4045 var _ ports.RemoteCatalogClient = (*CatalogClient)(nil)
@@ -44,8 +49,17 @@ func NewCatalogClient() ports.RemoteCatalogClient {
4449 return &CatalogClient{command: exec.CommandContext}
4550 }
4651
52+func (c *CatalogClient) listTimeout() time.Duration {
53+ if c != nil && c.timeout > 0 {
54+ return c.timeout
55+ }
56+ return catalogCommandTimeout
57+}
58+
4759 // List runs `ssh -- <target> 'vev' 'cmd' 'remote-catalog' '--json'` and decodes
48-// exactly one versioned catalog envelope from stdout.
60+// exactly one versioned catalog envelope from stdout. List always applies a
61+// bounded command timeout derived from ctx so a caller with no deadline cannot
62+// hang indefinitely, while still honoring caller cancellation.
4963 func (c *CatalogClient) List(ctx context.Context, target string) (ports.RemoteCatalog, error) {
5064 if err := ctx.Err(); err != nil {
5165 return ports.RemoteCatalog{}, err
@@ -55,20 +69,25 @@ func (c *CatalogClient) List(ctx context.Context, target string) (ports.RemoteCa
5569 return ports.RemoteCatalog{}, err
5670 }
5771
72+ runCtx, cancel := context.WithTimeout(ctx, c.listTimeout())
73+ defer cancel()
74+
5875 command := c.command
5976 if command == nil {
6077 command = exec.CommandContext
6178 }
6279 spec := sshstdio.BuildCommandForRemoteCommand(target, "vev", "cmd", "remote-catalog", "--json")
63- cmd := command(ctx, spec.Path, spec.Args...)
80+ cmd := command(runCtx, spec.Path, spec.Args...)
6481 stdout := boundedBuffer{limit: maxCatalogBytes}
6582 stderr := boundedBuffer{limit: maxCatalogDiagnosticBytes}
6683 cmd.Stdout = &stdout
6784 cmd.Stderr = &stderr
68- if err := cmd.Run(); err != nil {
69- if ctxErr := ctx.Err(); ctxErr != nil {
70- return ports.RemoteCatalog{}, ctxErr
71- }
85+ cmd.WaitDelay = catalogCommandWaitDelay
86+ err := cmd.Run()
87+ if ctxErr := runCtx.Err(); ctxErr != nil {
88+ return ports.RemoteCatalog{}, ctxErr
89+ }
90+ if err != nil {
7291 if stdout.overflow || stderr.overflow {
7392 slog.Debug("remote catalog output too large", "target", target, "stdout_limit", maxCatalogBytes, "stderr_limit", maxCatalogDiagnosticBytes)
7493 return ports.RemoteCatalog{}, errCatalogTooLarge
@@ -10,6 +10,7 @@ import (
10 "log/slog"10 "log/slog"
11 "os/exec"11 "os/exec"
12 "strings"12 "strings"
13+ "time"
13 "unicode"14 "unicode"
14 "unicode/utf8"15 "unicode/utf8"
15 16
@@ -21,6 +22,8 @@ import (
21 const (22 const (
22 maxCatalogBytes = 1 << 2023 maxCatalogBytes = 1 << 20
23 maxCatalogDiagnosticBytes = 4 << 1024 maxCatalogDiagnosticBytes = 4 << 10
25+ catalogCommandTimeout = 30 * time.Second
26+ catalogCommandWaitDelay = 500 * time.Millisecond
24 )27 )
25 28
26 var (29 var (
@@ -35,6 +38,8 @@ var (
35 // CatalogClient fetches remote session catalogs over SSH.38 // CatalogClient fetches remote session catalogs over SSH.
36 type CatalogClient struct {39 type CatalogClient struct {
37 command func(ctx context.Context, name string, args ...string) *exec.Cmd40 command func(ctx context.Context, name string, args ...string) *exec.Cmd
41+ // timeout bounds each List when > 0; otherwise catalogCommandTimeout is used.
42+ timeout time.Duration
38 }43 }
39 44
40 var _ ports.RemoteCatalogClient = (*CatalogClient)(nil)45 var _ ports.RemoteCatalogClient = (*CatalogClient)(nil)
@@ -44,8 +49,17 @@ func NewCatalogClient() ports.RemoteCatalogClient {
44 return &CatalogClient{command: exec.CommandContext}49 return &CatalogClient{command: exec.CommandContext}
45 }50 }
46 51
52+func (c *CatalogClient) listTimeout() time.Duration {
53+ if c != nil && c.timeout > 0 {
54+ return c.timeout
55+ }
56+ return catalogCommandTimeout
57+}
58+
47 // List runs `ssh -- <target> 'vev' 'cmd' 'remote-catalog' '--json'` and decodes59 // List runs `ssh -- <target> 'vev' 'cmd' 'remote-catalog' '--json'` and decodes
48-// exactly one versioned catalog envelope from stdout.60+// exactly one versioned catalog envelope from stdout. List always applies a
61+// bounded command timeout derived from ctx so a caller with no deadline cannot
62+// hang indefinitely, while still honoring caller cancellation.
49 func (c *CatalogClient) List(ctx context.Context, target string) (ports.RemoteCatalog, error) {63 func (c *CatalogClient) List(ctx context.Context, target string) (ports.RemoteCatalog, error) {
50 if err := ctx.Err(); err != nil {64 if err := ctx.Err(); err != nil {
51 return ports.RemoteCatalog{}, err65 return ports.RemoteCatalog{}, err
@@ -55,20 +69,25 @@ func (c *CatalogClient) List(ctx context.Context, target string) (ports.RemoteCa
55 return ports.RemoteCatalog{}, err69 return ports.RemoteCatalog{}, err
56 }70 }
57 71
72+ runCtx, cancel := context.WithTimeout(ctx, c.listTimeout())
73+ defer cancel()
74+
58 command := c.command75 command := c.command
59 if command == nil {76 if command == nil {
60 command = exec.CommandContext77 command = exec.CommandContext
61 }78 }
62 spec := sshstdio.BuildCommandForRemoteCommand(target, "vev", "cmd", "remote-catalog", "--json")79 spec := sshstdio.BuildCommandForRemoteCommand(target, "vev", "cmd", "remote-catalog", "--json")
63- cmd := command(ctx, spec.Path, spec.Args...)80+ cmd := command(runCtx, spec.Path, spec.Args...)
64 stdout := boundedBuffer{limit: maxCatalogBytes}81 stdout := boundedBuffer{limit: maxCatalogBytes}
65 stderr := boundedBuffer{limit: maxCatalogDiagnosticBytes}82 stderr := boundedBuffer{limit: maxCatalogDiagnosticBytes}
66 cmd.Stdout = &stdout83 cmd.Stdout = &stdout
67 cmd.Stderr = &stderr84 cmd.Stderr = &stderr
68- if err := cmd.Run(); err != nil {85+ cmd.WaitDelay = catalogCommandWaitDelay
69- if ctxErr := ctx.Err(); ctxErr != nil {86+ err := cmd.Run()
70- return ports.RemoteCatalog{}, ctxErr87+ if ctxErr := runCtx.Err(); ctxErr != nil {
71- }88+ return ports.RemoteCatalog{}, ctxErr
89+ }
90+ if err != nil {
72 if stdout.overflow || stderr.overflow {91 if stdout.overflow || stderr.overflow {
73 slog.Debug("remote catalog output too large", "target", target, "stdout_limit", maxCatalogBytes, "stderr_limit", maxCatalogDiagnosticBytes)92 slog.Debug("remote catalog output too large", "target", target, "stdout_limit", maxCatalogBytes, "stderr_limit", maxCatalogDiagnosticBytes)
74 return ports.RemoteCatalog{}, errCatalogTooLarge93 return ports.RemoteCatalog{}, errCatalogTooLarge
M internal/adapters/remote/catalog_test.go +104 -0
@@ -8,6 +8,7 @@ import (
88 "os/exec"
99 "strings"
1010 "testing"
11+ "time"
1112
1213 "github.com/bnema/vev/internal/adapters/sshstdio"
1314 "github.com/bnema/vev/internal/ports"
@@ -220,6 +221,109 @@ func TestRemoteCatalogClientContextCanceled(t *testing.T) {
220221 }
221222 }
222223
224+func TestRemoteCatalogClientAppliesLifetimeAndWaitDelay(t *testing.T) {
225+ t.Parallel()
226+
227+ var commandCtx context.Context
228+ var cmd *exec.Cmd
229+ client := &CatalogClient{
230+ command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
231+ commandCtx = ctx
232+ cmd = stdoutCmd(ctx, mustCatalogJSON(t, ports.RemoteCatalog{
233+ ProtocolVersion: ports.ProtocolVersion,
234+ Sessions: []ports.RemoteCatalogSession{},
235+ }))
236+ return cmd
237+ },
238+ }
239+ if _, err := client.List(context.Background(), "arch"); err != nil {
240+ t.Fatalf("List() error = %v", err)
241+ }
242+ deadline, ok := commandCtx.Deadline()
243+ if !ok {
244+ t.Fatal("command context missing default lifetime deadline")
245+ }
246+ if remaining := time.Until(deadline); remaining <= 0 || remaining > catalogCommandTimeout {
247+ t.Fatalf("deadline remaining = %v, want within (0, %v]", remaining, catalogCommandTimeout)
248+ }
249+ if cmd.WaitDelay != catalogCommandWaitDelay {
250+ t.Fatalf("WaitDelay = %v, want %v", cmd.WaitDelay, catalogCommandWaitDelay)
251+ }
252+}
253+
254+func TestRemoteCatalogClientClassifiesRunContextErrors(t *testing.T) {
255+ tests := []struct {
256+ name string
257+ newContext func() (context.Context, context.CancelFunc)
258+ timeout time.Duration
259+ cancel bool
260+ want error
261+ }{
262+ {
263+ name: "own deadline",
264+ newContext: func() (context.Context, context.CancelFunc) {
265+ return context.WithCancel(context.Background())
266+ },
267+ timeout: 20 * time.Millisecond,
268+ want: context.DeadlineExceeded,
269+ },
270+ {
271+ name: "parent deadline",
272+ newContext: func() (context.Context, context.CancelFunc) {
273+ return context.WithTimeout(context.Background(), 20*time.Millisecond)
274+ },
275+ timeout: time.Minute,
276+ want: context.DeadlineExceeded,
277+ },
278+ {
279+ name: "parent cancellation",
280+ newContext: func() (context.Context, context.CancelFunc) {
281+ return context.WithCancel(context.Background())
282+ },
283+ timeout: time.Minute,
284+ cancel: true,
285+ want: context.Canceled,
286+ },
287+ }
288+
289+ for _, tt := range tests {
290+ t.Run(tt.name, func(t *testing.T) {
291+ ctx, cancel := tt.newContext()
292+ defer cancel()
293+ started := make(chan struct{})
294+ client := &CatalogClient{
295+ timeout: tt.timeout,
296+ command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
297+ close(started)
298+ return exec.CommandContext(ctx, "sleep", "10")
299+ },
300+ }
301+
302+ errCh := make(chan error, 1)
303+ go func() {
304+ _, err := client.List(ctx, "arch")
305+ errCh <- err
306+ }()
307+ select {
308+ case <-started:
309+ case <-time.After(2 * time.Second):
310+ t.Fatal("command did not start")
311+ }
312+ if tt.cancel {
313+ cancel()
314+ }
315+ select {
316+ case err := <-errCh:
317+ if !errors.Is(err, tt.want) {
318+ t.Fatalf("List() error = %v, want %v", err, tt.want)
319+ }
320+ case <-time.After(2 * time.Second):
321+ t.Fatal("List() did not return after context ended")
322+ }
323+ })
324+ }
325+}
326+
223327 func TestCatalogOutputSafety(t *testing.T) {
224328 t.Parallel()
225329
@@ -8,6 +8,7 @@ import (
8 "os/exec"8 "os/exec"
9 "strings"9 "strings"
10 "testing"10 "testing"
11+ "time"
11 12
12 "github.com/bnema/vev/internal/adapters/sshstdio"13 "github.com/bnema/vev/internal/adapters/sshstdio"
13 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
@@ -220,6 +221,109 @@ func TestRemoteCatalogClientContextCanceled(t *testing.T) {
220 }221 }
221 }222 }
222 223
224+func TestRemoteCatalogClientAppliesLifetimeAndWaitDelay(t *testing.T) {
225+ t.Parallel()
226+
227+ var commandCtx context.Context
228+ var cmd *exec.Cmd
229+ client := &CatalogClient{
230+ command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
231+ commandCtx = ctx
232+ cmd = stdoutCmd(ctx, mustCatalogJSON(t, ports.RemoteCatalog{
233+ ProtocolVersion: ports.ProtocolVersion,
234+ Sessions: []ports.RemoteCatalogSession{},
235+ }))
236+ return cmd
237+ },
238+ }
239+ if _, err := client.List(context.Background(), "arch"); err != nil {
240+ t.Fatalf("List() error = %v", err)
241+ }
242+ deadline, ok := commandCtx.Deadline()
243+ if !ok {
244+ t.Fatal("command context missing default lifetime deadline")
245+ }
246+ if remaining := time.Until(deadline); remaining <= 0 || remaining > catalogCommandTimeout {
247+ t.Fatalf("deadline remaining = %v, want within (0, %v]", remaining, catalogCommandTimeout)
248+ }
249+ if cmd.WaitDelay != catalogCommandWaitDelay {
250+ t.Fatalf("WaitDelay = %v, want %v", cmd.WaitDelay, catalogCommandWaitDelay)
251+ }
252+}
253+
254+func TestRemoteCatalogClientClassifiesRunContextErrors(t *testing.T) {
255+ tests := []struct {
256+ name string
257+ newContext func() (context.Context, context.CancelFunc)
258+ timeout time.Duration
259+ cancel bool
260+ want error
261+ }{
262+ {
263+ name: "own deadline",
264+ newContext: func() (context.Context, context.CancelFunc) {
265+ return context.WithCancel(context.Background())
266+ },
267+ timeout: 20 * time.Millisecond,
268+ want: context.DeadlineExceeded,
269+ },
270+ {
271+ name: "parent deadline",
272+ newContext: func() (context.Context, context.CancelFunc) {
273+ return context.WithTimeout(context.Background(), 20*time.Millisecond)
274+ },
275+ timeout: time.Minute,
276+ want: context.DeadlineExceeded,
277+ },
278+ {
279+ name: "parent cancellation",
280+ newContext: func() (context.Context, context.CancelFunc) {
281+ return context.WithCancel(context.Background())
282+ },
283+ timeout: time.Minute,
284+ cancel: true,
285+ want: context.Canceled,
286+ },
287+ }
288+
289+ for _, tt := range tests {
290+ t.Run(tt.name, func(t *testing.T) {
291+ ctx, cancel := tt.newContext()
292+ defer cancel()
293+ started := make(chan struct{})
294+ client := &CatalogClient{
295+ timeout: tt.timeout,
296+ command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
297+ close(started)
298+ return exec.CommandContext(ctx, "sleep", "10")
299+ },
300+ }
301+
302+ errCh := make(chan error, 1)
303+ go func() {
304+ _, err := client.List(ctx, "arch")
305+ errCh <- err
306+ }()
307+ select {
308+ case <-started:
309+ case <-time.After(2 * time.Second):
310+ t.Fatal("command did not start")
311+ }
312+ if tt.cancel {
313+ cancel()
314+ }
315+ select {
316+ case err := <-errCh:
317+ if !errors.Is(err, tt.want) {
318+ t.Fatalf("List() error = %v, want %v", err, tt.want)
319+ }
320+ case <-time.After(2 * time.Second):
321+ t.Fatal("List() did not return after context ended")
322+ }
323+ })
324+ }
325+}
326+
223 func TestCatalogOutputSafety(t *testing.T) {327 func TestCatalogOutputSafety(t *testing.T) {
224 t.Parallel()328 t.Parallel()
225 329
M internal/ports/remote.go +11 -0
@@ -48,6 +48,17 @@ type RemoteCatalogSession struct {
4848 Attached bool `json:"attached"`
4949 }
5050
51+// SaturateUint16 clamps n to the uint16 range.
52+func SaturateUint16(n int) uint16 {
53+ if n <= 0 {
54+ return 0
55+ }
56+ if n >= 1<<16 {
57+ return ^uint16(0)
58+ }
59+ return uint16(n)
60+}
61+
5162 // RemoteCatalog is the versioned JSON envelope returned by remote-catalog --json.
5263 type RemoteCatalog struct {
5364 ProtocolVersion uint16 `json:"protocol_version"`
@@ -48,6 +48,17 @@ type RemoteCatalogSession struct {
48 Attached bool `json:"attached"`48 Attached bool `json:"attached"`
49 }49 }
50 50
51+// SaturateUint16 clamps n to the uint16 range.
52+func SaturateUint16(n int) uint16 {
53+ if n <= 0 {
54+ return 0
55+ }
56+ if n >= 1<<16 {
57+ return ^uint16(0)
58+ }
59+ return uint16(n)
60+}
61+
51 // RemoteCatalog is the versioned JSON envelope returned by remote-catalog --json.62 // RemoteCatalog is the versioned JSON envelope returned by remote-catalog --json.
52 type RemoteCatalog struct {63 type RemoteCatalog struct {
53 ProtocolVersion uint16 `json:"protocol_version"`64 ProtocolVersion uint16 `json:"protocol_version"`
M internal/usecase/daemon/control.go +3 -13
@@ -616,13 +616,14 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
616616 }
617617 e.d.mu.Unlock()
618618 rows := make([]ports.RemoteCatalogSession, 0, len(sessions))
619+ // Stopped sessions live in d.stopped and are intentionally not in this listing.
619620 for _, sess := range sessions {
620621 snap := sess.snapshotView(viewOptions{})
621622 rows = append(rows, ports.RemoteCatalogSession{
622623 Name: snap.name,
623- State: remoteCatalogState(ports.SessionRunning),
624+ State: "running",
624625 Ephemeral: snap.ephemeral,
625- Tabs: uint16(snap.tabCount),
626+ Tabs: ports.SaturateUint16(snap.tabCount),
626627 Attached: snap.attached,
627628 })
628629 }
@@ -633,17 +634,6 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
633634 })
634635 }
635636
636-func remoteCatalogState(state ports.SessionState) string {
637- switch state {
638- case ports.SessionStopped:
639- return "stopped"
640- case ports.SessionBroken:
641- return "broken"
642- default:
643- return "running"
644- }
645-}
646-
647637 func (e controlExec) ListTabs(asJSON bool) (string, error) {
648638 type row struct {
649639 Index int `json:"index"`
@@ -616,13 +616,14 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
616 }616 }
617 e.d.mu.Unlock()617 e.d.mu.Unlock()
618 rows := make([]ports.RemoteCatalogSession, 0, len(sessions))618 rows := make([]ports.RemoteCatalogSession, 0, len(sessions))
619+ // Stopped sessions live in d.stopped and are intentionally not in this listing.
619 for _, sess := range sessions {620 for _, sess := range sessions {
620 snap := sess.snapshotView(viewOptions{})621 snap := sess.snapshotView(viewOptions{})
621 rows = append(rows, ports.RemoteCatalogSession{622 rows = append(rows, ports.RemoteCatalogSession{
622 Name: snap.name,623 Name: snap.name,
623- State: remoteCatalogState(ports.SessionRunning),624+ State: "running",
624 Ephemeral: snap.ephemeral,625 Ephemeral: snap.ephemeral,
625- Tabs: uint16(snap.tabCount),626+ Tabs: ports.SaturateUint16(snap.tabCount),
626 Attached: snap.attached,627 Attached: snap.attached,
627 })628 })
628 }629 }
@@ -633,17 +634,6 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
633 })634 })
634 }635 }
635 636
636-func remoteCatalogState(state ports.SessionState) string {
637- switch state {
638- case ports.SessionStopped:
639- return "stopped"
640- case ports.SessionBroken:
641- return "broken"
642- default:
643- return "running"
644- }
645-}
646-
647 func (e controlExec) ListTabs(asJSON bool) (string, error) {637 func (e controlExec) ListTabs(asJSON bool) (string, error) {
648 type row struct {638 type row struct {
649 Index int `json:"index"`639 Index int `json:"index"`
M internal/usecase/daemon/control_test.go +17 -13
@@ -712,20 +712,24 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
712712 require.NotContains(t, listAfter.Output, "old")
713713 }
714714
715-func TestRemoteCatalogStateMapping(t *testing.T) {
716- tests := []struct {
717- state ports.SessionState
718- want string
719- }{
720- {state: ports.SessionRunning, want: "running"},
721- {state: ports.SessionStopped, want: "stopped"},
722- {state: ports.SessionBroken, want: "broken"},
723- }
724- for _, tt := range tests {
725- t.Run(tt.want, func(t *testing.T) {
726- require.Equal(t, tt.want, remoteCatalogState(tt.state))
727- })
715+func TestRemoteCatalogTabCountSaturates(t *testing.T) {
716+ d := newTestDaemon(t, nil, stubClock{})
717+ sess := addControlSession(d, "work", "t_work", "p_work")
718+ sess.mu.Lock()
719+ base := sess.tabs[0]
720+ sess.tabs = make([]*tab, int(^uint16(0))+1)
721+ for i := range sess.tabs {
722+ sess.tabs[i] = base
728723 }
724+ sess.mu.Unlock()
725+
726+ result := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", JSON: true})
727+ require.True(t, result.OK, result.Text)
728+ var catalog ports.RemoteCatalog
729+ require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
730+ require.Equal(t, []ports.RemoteCatalogSession{{
731+ Name: "work", State: "running", Ephemeral: true, Tabs: ^uint16(0), Attached: false,
732+ }}, catalog.Sessions)
729733 }
730734
731735 func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
@@ -712,20 +712,24 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
712 require.NotContains(t, listAfter.Output, "old")712 require.NotContains(t, listAfter.Output, "old")
713 }713 }
714 714
715-func TestRemoteCatalogStateMapping(t *testing.T) {715+func TestRemoteCatalogTabCountSaturates(t *testing.T) {
716- tests := []struct {716+ d := newTestDaemon(t, nil, stubClock{})
717- state ports.SessionState717+ sess := addControlSession(d, "work", "t_work", "p_work")
718- want string718+ sess.mu.Lock()
719- }{719+ base := sess.tabs[0]
720- {state: ports.SessionRunning, want: "running"},720+ sess.tabs = make([]*tab, int(^uint16(0))+1)
721- {state: ports.SessionStopped, want: "stopped"},721+ for i := range sess.tabs {
722- {state: ports.SessionBroken, want: "broken"},722+ sess.tabs[i] = base
723- }
724- for _, tt := range tests {
725- t.Run(tt.want, func(t *testing.T) {
726- require.Equal(t, tt.want, remoteCatalogState(tt.state))
727- })
728 }723 }
724+ sess.mu.Unlock()
725+
726+ result := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", JSON: true})
727+ require.True(t, result.OK, result.Text)
728+ var catalog ports.RemoteCatalog
729+ require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
730+ require.Equal(t, []ports.RemoteCatalogSession{{
731+ Name: "work", State: "running", Ephemeral: true, Tabs: ^uint16(0), Attached: false,
732+ }}, catalog.Sessions)
729 }733 }
730 734
731 func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {735 func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {

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