detached e23c635

fix(remote): reject ambiguous catalogue routes

commit
e23c63518ff52db479e4b0c82d463daf0c07c39f
author
brice · · 2026-08-24 12:25 UTC
parent
0bca6cd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +35 -17
M internal/domain/lifecycle.go +1 -11
@@ -170,17 +170,7 @@ func (t RemoteSessionTarget) ResolveTab(tabs []TabSelectorTab) (int, bool) {
170170 }
171171 return t.StoppedTab.Resolve(tabs)
172172 }
173- found := -1
174- for i, tab := range tabs {
175- if tab.ID != t.LiveTabID {
176- continue
177- }
178- if found >= 0 {
179- return 0, false
180- }
181- found = i
182- }
183- return found, found >= 0
173+ return NewStableTabSelector(t.LiveTabID).Resolve(tabs)
184174 }
185175
186176 // ValidateRemoteDisplayOrigin validates presentation data without imposing
@@ -170,17 +170,7 @@ func (t RemoteSessionTarget) ResolveTab(tabs []TabSelectorTab) (int, bool) {
170 }170 }
171 return t.StoppedTab.Resolve(tabs)171 return t.StoppedTab.Resolve(tabs)
172 }172 }
173- found := -1173+ return NewStableTabSelector(t.LiveTabID).Resolve(tabs)
174- for i, tab := range tabs {
175- if tab.ID != t.LiveTabID {
176- continue
177- }
178- if found >= 0 {
179- return 0, false
180- }
181- found = i
182- }
183- return found, found >= 0
184 }174 }
185 175
186 // ValidateRemoteDisplayOrigin validates presentation data without imposing176 // ValidateRemoteDisplayOrigin validates presentation data without imposing
M internal/domain/lifecycle_test.go +3 -0
@@ -34,7 +34,10 @@ func TestRemoteSessionTargetResolveTab(t *testing.T) {
3434 {name: "down fresh default tab", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true}, tabs: []TabSelectorTab{{ID: "fresh"}}, ok: true},
3535 {name: "down without selector rejects ambiguity", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}},
3636 {name: "down stable selector", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true, StoppedTab: NewStableTabSelector("two")}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}, want: 1, ok: true},
37+ {name: "down invalid selector", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true, StoppedTab: TabSelector{Kind: TabSelectorByStableID, Ordinal: 1}}, tabs: []TabSelectorTab{{ID: "one"}}},
3738 {name: "live stable tab", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, LiveTabID: "two"}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}, want: 1, ok: true},
39+ {name: "live missing tab", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, LiveTabID: "missing"}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}, want: -1},
40+ {name: "live duplicate tab rejected", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, LiveTabID: "two"}, tabs: []TabSelectorTab{{ID: "two"}, {ID: "two"}}},
3841 }
3942 for _, test := range tests {
4043 t.Run(test.name, func(t *testing.T) {
@@ -34,7 +34,10 @@ func TestRemoteSessionTargetResolveTab(t *testing.T) {
34 {name: "down fresh default tab", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true}, tabs: []TabSelectorTab{{ID: "fresh"}}, ok: true},34 {name: "down fresh default tab", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true}, tabs: []TabSelectorTab{{ID: "fresh"}}, ok: true},
35 {name: "down without selector rejects ambiguity", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}},35 {name: "down without selector rejects ambiguity", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}},
36 {name: "down stable selector", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true, StoppedTab: NewStableTabSelector("two")}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}, want: 1, ok: true},36 {name: "down stable selector", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true, StoppedTab: NewStableTabSelector("two")}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}, want: 1, ok: true},
37+ {name: "down invalid selector", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, Stopped: true, StoppedTab: TabSelector{Kind: TabSelectorByStableID, Ordinal: 1}}, tabs: []TabSelectorTab{{ID: "one"}}},
37 {name: "live stable tab", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, LiveTabID: "two"}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}, want: 1, ok: true},38 {name: "live stable tab", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, LiveTabID: "two"}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}, want: 1, ok: true},
39+ {name: "live missing tab", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, LiveTabID: "missing"}, tabs: []TabSelectorTab{{ID: "one"}, {ID: "two"}}, want: -1},
40+ {name: "live duplicate tab rejected", target: RemoteSessionTarget{Endpoint: base.Endpoint, DisplayOrigin: base.DisplayOrigin, LifecycleID: base.LifecycleID, SessionName: base.SessionName, LiveTabID: "two"}, tabs: []TabSelectorTab{{ID: "two"}, {ID: "two"}}},
38 }41 }
39 for _, test := range tests {42 for _, test := range tests {
40 t.Run(test.name, func(t *testing.T) {43 t.Run(test.name, func(t *testing.T) {
M internal/usecase/daemon/control.go +5 -0
@@ -715,6 +715,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
715715 e.d.mu.Unlock()
716716
717717 rows := make([]ports.RemoteCatalogSession, 0, len(sessions)+len(stopped))
718+ liveNames := make(map[string]struct{}, len(sessions))
718719 for _, sess := range sessions {
719720 e.d.refreshSessionFocusedTitles(sess)
720721 snap := sess.snapshotView(viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: false})
@@ -735,8 +736,12 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
735736 row.ActiveTabID = tabs[min(snap.defaultTab, len(tabs)-1)].ID
736737 }
737738 rows = append(rows, row)
739+ liveNames[row.Name] = struct{}{}
738740 }
739741 for _, entry := range stopped {
742+ if _, live := liveNames[entry.name]; live {
743+ continue
744+ }
740745 tabs, err := remoteCatalogStoppedTabs(entry)
741746 if err != nil {
742747 return "", err
@@ -715,6 +715,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
715 e.d.mu.Unlock()715 e.d.mu.Unlock()
716 716
717 rows := make([]ports.RemoteCatalogSession, 0, len(sessions)+len(stopped))717 rows := make([]ports.RemoteCatalogSession, 0, len(sessions)+len(stopped))
718+ liveNames := make(map[string]struct{}, len(sessions))
718 for _, sess := range sessions {719 for _, sess := range sessions {
719 e.d.refreshSessionFocusedTitles(sess)720 e.d.refreshSessionFocusedTitles(sess)
720 snap := sess.snapshotView(viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: false})721 snap := sess.snapshotView(viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: false})
@@ -735,8 +736,12 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
735 row.ActiveTabID = tabs[min(snap.defaultTab, len(tabs)-1)].ID736 row.ActiveTabID = tabs[min(snap.defaultTab, len(tabs)-1)].ID
736 }737 }
737 rows = append(rows, row)738 rows = append(rows, row)
739+ liveNames[row.Name] = struct{}{}
738 }740 }
739 for _, entry := range stopped {741 for _, entry := range stopped {
742+ if _, live := liveNames[entry.name]; live {
743+ continue
744+ }
740 tabs, err := remoteCatalogStoppedTabs(entry)745 tabs, err := remoteCatalogStoppedTabs(entry)
741 if err != nil {746 if err != nil {
742 return "", err747 return "", err
M internal/usecase/daemon/control_test.go +2 -0
@@ -736,6 +736,8 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
736736
737737 d.mu.Lock()
738738 d.inactive["old"] = inactiveSession{name: "old", cwd: "/tmp/old", createdAt: 1, incarnation: domain.IncarnationID{3}, state: ports.SessionDown}
739+ // Live runtime authority wins if both registries contain the same name.
740+ d.inactive["work"] = inactiveSession{name: "work", cwd: "/tmp/work", createdAt: 1, incarnation: domain.IncarnationID{4}, state: ports.SessionDown}
739741 d.mu.Unlock()
740742
741743 listBefore := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
@@ -736,6 +736,8 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
736 736
737 d.mu.Lock()737 d.mu.Lock()
738 d.inactive["old"] = inactiveSession{name: "old", cwd: "/tmp/old", createdAt: 1, incarnation: domain.IncarnationID{3}, state: ports.SessionDown}738 d.inactive["old"] = inactiveSession{name: "old", cwd: "/tmp/old", createdAt: 1, incarnation: domain.IncarnationID{3}, state: ports.SessionDown}
739+ // Live runtime authority wins if both registries contain the same name.
740+ d.inactive["work"] = inactiveSession{name: "work", cwd: "/tmp/work", createdAt: 1, incarnation: domain.IncarnationID{4}, state: ports.SessionDown}
739 d.mu.Unlock()741 d.mu.Unlock()
740 742
741 listBefore := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})743 listBefore := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
M internal/usecase/daemon/remote_stopped_route_test.go +3 -3
@@ -85,7 +85,7 @@ func TestRouteRemoteTargetRejectsLiveIntentForInactiveSession(t *testing.T) {
8585
8686 var protocol *protoErr
8787 require.ErrorAs(t, err, &protocol)
88- require.Equal(t, uint16(ports.ErrNoSuchTarget), protocol.code)
88+ require.Equal(t, ports.ErrNoSuchTarget, protocol.code)
8989 d.mu.Lock()
9090 defer d.mu.Unlock()
9191 require.Empty(t, d.sessions)
@@ -117,7 +117,7 @@ func TestRouteRemoteTargetMapsUnavailableInactiveStatesToNoSuchTarget(t *testing
117117
118118 var protocol *protoErr
119119 require.ErrorAs(t, err, &protocol)
120- require.Equal(t, uint16(ports.ErrNoSuchTarget), protocol.code)
120+ require.Equal(t, ports.ErrNoSuchTarget, protocol.code)
121121 })
122122 }
123123 }
@@ -140,7 +140,7 @@ func TestRouteRemoteTargetMapsConcurrentInactiveResumeToNoSuchTarget(t *testing.
140140
141141 var protocol *protoErr
142142 require.ErrorAs(t, err, &protocol)
143- require.Equal(t, uint16(ports.ErrNoSuchTarget), protocol.code)
143+ require.Equal(t, ports.ErrNoSuchTarget, protocol.code)
144144 }
145145
146146 func TestRouteRemoteTargetPreservesInactiveResumeFailures(t *testing.T) {
@@ -85,7 +85,7 @@ func TestRouteRemoteTargetRejectsLiveIntentForInactiveSession(t *testing.T) {
85 85
86 var protocol *protoErr86 var protocol *protoErr
87 require.ErrorAs(t, err, &protocol)87 require.ErrorAs(t, err, &protocol)
88- require.Equal(t, uint16(ports.ErrNoSuchTarget), protocol.code)88+ require.Equal(t, ports.ErrNoSuchTarget, protocol.code)
89 d.mu.Lock()89 d.mu.Lock()
90 defer d.mu.Unlock()90 defer d.mu.Unlock()
91 require.Empty(t, d.sessions)91 require.Empty(t, d.sessions)
@@ -117,7 +117,7 @@ func TestRouteRemoteTargetMapsUnavailableInactiveStatesToNoSuchTarget(t *testing
117 117
118 var protocol *protoErr118 var protocol *protoErr
119 require.ErrorAs(t, err, &protocol)119 require.ErrorAs(t, err, &protocol)
120- require.Equal(t, uint16(ports.ErrNoSuchTarget), protocol.code)120+ require.Equal(t, ports.ErrNoSuchTarget, protocol.code)
121 })121 })
122 }122 }
123 }123 }
@@ -140,7 +140,7 @@ func TestRouteRemoteTargetMapsConcurrentInactiveResumeToNoSuchTarget(t *testing.
140 140
141 var protocol *protoErr141 var protocol *protoErr
142 require.ErrorAs(t, err, &protocol)142 require.ErrorAs(t, err, &protocol)
143- require.Equal(t, uint16(ports.ErrNoSuchTarget), protocol.code)143+ require.Equal(t, ports.ErrNoSuchTarget, protocol.code)
144 }144 }
145 145
146 func TestRouteRemoteTargetPreservesInactiveResumeFailures(t *testing.T) {146 func TestRouteRemoteTargetPreservesInactiveResumeFailures(t *testing.T) {
M internal/usecase/picker/model.go +10 -3
@@ -317,6 +317,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
317317 continue
318318 }
319319 tabRow := common
320+ remoteTargetResolvable := true
320321 tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention
321322 tabRow.tabID, tabRow.tabIndex = tab.TabID, i
322323 tabRow.selectable = tabRow.kind.selectable(config.Mode)
@@ -330,8 +331,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
330331 if tab.TabID != "" {
331332 remoteTarget.StoppedTab = domain.NewStableTabSelector(tab.TabID)
332333 } else if remoteTarget.StoppedTab != (domain.TabSelector{}) {
333- tabCount := min(len(session.Tabs), math.MaxUint16)
334- remoteTarget.StoppedTab = domain.NewOrdinalTabSelector(uint16(i), tab.RawName, uint16(tabCount))
334+ remoteTarget.StoppedTab, remoteTargetResolvable = remoteStoppedOrdinalSelector(i, tab.RawName, len(session.Tabs))
335335 }
336336 } else {
337337 remoteTarget.StoppedTab = domain.TabSelector{}
@@ -348,7 +348,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
348348 tabRow.selectable = false
349349 } else {
350350 tabRow.focusable = true
351- tabRow.selectable = tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteActivatable(session)
351+ tabRow.selectable = remoteTargetResolvable && tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteActivatable(session)
352352 }
353353 tabRow.dim = session.RemoteActivation == RemoteUnavailable
354354 }
@@ -363,6 +363,13 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
363363 return rows
364364 }
365365
366+func remoteStoppedOrdinalSelector(index int, rawName string, tabCount int) (domain.TabSelector, bool) {
367+ if tabCount > math.MaxUint16 {
368+ return domain.TabSelector{}, false
369+ }
370+ return domain.NewOrdinalTabSelector(uint16(index), rawName, uint16(tabCount)), true
371+}
372+
366373 func remoteActivatable(session SessionView) bool {
367374 return session.RemoteActivation == RemoteAttach || session.RemoteActivation == RemoteRestart
368375 }
@@ -317,6 +317,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
317 continue317 continue
318 }318 }
319 tabRow := common319 tabRow := common
320+ remoteTargetResolvable := true
320 tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention321 tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention
321 tabRow.tabID, tabRow.tabIndex = tab.TabID, i322 tabRow.tabID, tabRow.tabIndex = tab.TabID, i
322 tabRow.selectable = tabRow.kind.selectable(config.Mode)323 tabRow.selectable = tabRow.kind.selectable(config.Mode)
@@ -330,8 +331,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
330 if tab.TabID != "" {331 if tab.TabID != "" {
331 remoteTarget.StoppedTab = domain.NewStableTabSelector(tab.TabID)332 remoteTarget.StoppedTab = domain.NewStableTabSelector(tab.TabID)
332 } else if remoteTarget.StoppedTab != (domain.TabSelector{}) {333 } else if remoteTarget.StoppedTab != (domain.TabSelector{}) {
333- tabCount := min(len(session.Tabs), math.MaxUint16)334+ remoteTarget.StoppedTab, remoteTargetResolvable = remoteStoppedOrdinalSelector(i, tab.RawName, len(session.Tabs))
334- remoteTarget.StoppedTab = domain.NewOrdinalTabSelector(uint16(i), tab.RawName, uint16(tabCount))
335 }335 }
336 } else {336 } else {
337 remoteTarget.StoppedTab = domain.TabSelector{}337 remoteTarget.StoppedTab = domain.TabSelector{}
@@ -348,7 +348,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
348 tabRow.selectable = false348 tabRow.selectable = false
349 } else {349 } else {
350 tabRow.focusable = true350 tabRow.focusable = true
351- tabRow.selectable = tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteActivatable(session)351+ tabRow.selectable = remoteTargetResolvable && tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteActivatable(session)
352 }352 }
353 tabRow.dim = session.RemoteActivation == RemoteUnavailable353 tabRow.dim = session.RemoteActivation == RemoteUnavailable
354 }354 }
@@ -363,6 +363,13 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
363 return rows363 return rows
364 }364 }
365 365
366+func remoteStoppedOrdinalSelector(index int, rawName string, tabCount int) (domain.TabSelector, bool) {
367+ if tabCount > math.MaxUint16 {
368+ return domain.TabSelector{}, false
369+ }
370+ return domain.NewOrdinalTabSelector(uint16(index), rawName, uint16(tabCount)), true
371+}
372+
366 func remoteActivatable(session SessionView) bool {373 func remoteActivatable(session SessionView) bool {
367 return session.RemoteActivation == RemoteAttach || session.RemoteActivation == RemoteRestart374 return session.RemoteActivation == RemoteAttach || session.RemoteActivation == RemoteRestart
368 }375 }
M internal/usecase/picker/model_test.go +11 -0
@@ -1,6 +1,7 @@
11 package picker
22
33 import (
4+ "math"
45 "reflect"
56 "strings"
67 "testing"
@@ -776,6 +777,16 @@ func TestStructuredRemoteTargetOwnsStoppedState(t *testing.T) {
776777 }
777778 }
778779
780+func TestRemoteStoppedOrdinalSelectorRejectsUnrepresentableTabCount(t *testing.T) {
781+ selector, ok := remoteStoppedOrdinalSelector(math.MaxUint16-1, "last", math.MaxUint16)
782+ require.True(t, ok)
783+ require.Equal(t, domain.NewOrdinalTabSelector(math.MaxUint16-1, "last", math.MaxUint16), selector)
784+
785+ selector, ok = remoteStoppedOrdinalSelector(math.MaxUint16, "wrapped", math.MaxUint16+1)
786+ require.False(t, ok)
787+ require.Equal(t, domain.TabSelector{}, selector)
788+}
789+
779790 func TestRemoteRowsCannotAcceptMovesInEitherMoveMode(t *testing.T) {
780791 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
781792 local := SessionView{ID: "local", Name: "local", Tabs: []TabEntry{{TabID: "local-tab", Name: "local"}}}
@@ -1,6 +1,7 @@
1 package picker1 package picker
2 2
3 import (3 import (
4+ "math"
4 "reflect"5 "reflect"
5 "strings"6 "strings"
6 "testing"7 "testing"
@@ -776,6 +777,16 @@ func TestStructuredRemoteTargetOwnsStoppedState(t *testing.T) {
776 }777 }
777 }778 }
778 779
780+func TestRemoteStoppedOrdinalSelectorRejectsUnrepresentableTabCount(t *testing.T) {
781+ selector, ok := remoteStoppedOrdinalSelector(math.MaxUint16-1, "last", math.MaxUint16)
782+ require.True(t, ok)
783+ require.Equal(t, domain.NewOrdinalTabSelector(math.MaxUint16-1, "last", math.MaxUint16), selector)
784+
785+ selector, ok = remoteStoppedOrdinalSelector(math.MaxUint16, "wrapped", math.MaxUint16+1)
786+ require.False(t, ok)
787+ require.Equal(t, domain.TabSelector{}, selector)
788+}
789+
779 func TestRemoteRowsCannotAcceptMovesInEitherMoveMode(t *testing.T) {790 func TestRemoteRowsCannotAcceptMovesInEitherMoveMode(t *testing.T) {
780 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}791 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
781 local := SessionView{ID: "local", Name: "local", Tabs: []TabEntry{{TabID: "local-tab", Name: "local"}}}792 local := SessionView{ID: "local", Name: "local", Tabs: []TabEntry{{TabID: "local-tab", Name: "local"}}}

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