detached b605444

fix: deduplicate remote sessions in quick switch (#215)

* Deduplicate remote palette sessions

* Refactor remote palette matching tests
commit
b6054449f1449787b7ec9253b4a9aa07f3ede5d1
author
bnema · · 2026-08-26 05:07 UTC
committer
GitHub · · 2026-08-26 05:07 UTC
parent
a20dc2b
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
2 files · +84 -67
M internal/usecase/daemon/palette.go +22 -1
@@ -66,6 +66,12 @@ type paletteSessionIdentity struct {
6666 lifecycle domain.SessionLifecycleID
6767 }
6868
69+type remotePalettePresentationIdentity struct {
70+ origin string
71+ name string
72+ lifecycle domain.SessionLifecycleID
73+}
74+
6975 func localPaletteSessionIdentity(lifecycle domain.SessionLifecycleID) paletteSessionIdentity {
7076 return paletteSessionIdentity{kind: ports.RouteKindLocal, endpoint: "local", lifecycle: lifecycle}
7177 }
@@ -137,6 +143,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
137143 result palette.Result
138144 }
139145 discovered := make([]discoveredRemote, 0, remoteSessionCount)
146+ discoveredByPresentation := make(map[remotePalettePresentationIdentity][]paletteSessionIdentity, remoteSessionCount)
140147 for _, host := range remoteCatalog {
141148 for _, session := range host.entry.Sessions {
142149 if session.Ephemeral {
@@ -148,10 +155,15 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
148155 if key.Validate() != nil || target.Validate() != nil {
149156 continue
150157 }
158+ identity := remotePaletteSessionIdentity(key.Host, session.LifecycleID)
159+ presentation := remotePalettePresentationIdentity{
160+ origin: key.DisplayOrigin, name: session.Name, lifecycle: session.LifecycleID,
161+ }
151162 discovered = append(discovered, discoveredRemote{
152- identity: remotePaletteSessionIdentity(key.Host, session.LifecycleID),
163+ identity: identity,
153164 result: palette.NewRemoteSessionResult(key, target, remotePaletteUnavailableReason(host.status, session)),
154165 })
166+ discoveredByPresentation[presentation] = append(discoveredByPresentation[presentation], identity)
155167 }
156168 }
157169 formattedRoutes := formatRecentRouteSnapshot(routeSnapshot)
@@ -174,6 +186,15 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
174186 }))
175187 if entry.Kind == ports.RouteKindLocal {
176188 represented[identity] = struct{}{}
189+ continue
190+ }
191+ presentation := remotePalettePresentationIdentity{
192+ origin: domain.RemoteDisplayOrigin(entry.HostLabel),
193+ name: entry.Target.SessionName,
194+ lifecycle: entry.Target.LifecycleID,
195+ }
196+ if matches := discoveredByPresentation[presentation]; len(matches) == 1 {
197+ represented[matches[0]] = struct{}{}
177198 }
178199 }
179200 for _, remote := range discovered {
@@ -66,6 +66,12 @@ type paletteSessionIdentity struct {
66 lifecycle domain.SessionLifecycleID66 lifecycle domain.SessionLifecycleID
67 }67 }
68 68
69+type remotePalettePresentationIdentity struct {
70+ origin string
71+ name string
72+ lifecycle domain.SessionLifecycleID
73+}
74+
69 func localPaletteSessionIdentity(lifecycle domain.SessionLifecycleID) paletteSessionIdentity {75 func localPaletteSessionIdentity(lifecycle domain.SessionLifecycleID) paletteSessionIdentity {
70 return paletteSessionIdentity{kind: ports.RouteKindLocal, endpoint: "local", lifecycle: lifecycle}76 return paletteSessionIdentity{kind: ports.RouteKindLocal, endpoint: "local", lifecycle: lifecycle}
71 }77 }
@@ -137,6 +143,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
137 result palette.Result143 result palette.Result
138 }144 }
139 discovered := make([]discoveredRemote, 0, remoteSessionCount)145 discovered := make([]discoveredRemote, 0, remoteSessionCount)
146+ discoveredByPresentation := make(map[remotePalettePresentationIdentity][]paletteSessionIdentity, remoteSessionCount)
140 for _, host := range remoteCatalog {147 for _, host := range remoteCatalog {
141 for _, session := range host.entry.Sessions {148 for _, session := range host.entry.Sessions {
142 if session.Ephemeral {149 if session.Ephemeral {
@@ -148,10 +155,15 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
148 if key.Validate() != nil || target.Validate() != nil {155 if key.Validate() != nil || target.Validate() != nil {
149 continue156 continue
150 }157 }
158+ identity := remotePaletteSessionIdentity(key.Host, session.LifecycleID)
159+ presentation := remotePalettePresentationIdentity{
160+ origin: key.DisplayOrigin, name: session.Name, lifecycle: session.LifecycleID,
161+ }
151 discovered = append(discovered, discoveredRemote{162 discovered = append(discovered, discoveredRemote{
152- identity: remotePaletteSessionIdentity(key.Host, session.LifecycleID),163+ identity: identity,
153 result: palette.NewRemoteSessionResult(key, target, remotePaletteUnavailableReason(host.status, session)),164 result: palette.NewRemoteSessionResult(key, target, remotePaletteUnavailableReason(host.status, session)),
154 })165 })
166+ discoveredByPresentation[presentation] = append(discoveredByPresentation[presentation], identity)
155 }167 }
156 }168 }
157 formattedRoutes := formatRecentRouteSnapshot(routeSnapshot)169 formattedRoutes := formatRecentRouteSnapshot(routeSnapshot)
@@ -174,6 +186,15 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
174 }))186 }))
175 if entry.Kind == ports.RouteKindLocal {187 if entry.Kind == ports.RouteKindLocal {
176 represented[identity] = struct{}{}188 represented[identity] = struct{}{}
189+ continue
190+ }
191+ presentation := remotePalettePresentationIdentity{
192+ origin: domain.RemoteDisplayOrigin(entry.HostLabel),
193+ name: entry.Target.SessionName,
194+ lifecycle: entry.Target.LifecycleID,
195+ }
196+ if matches := discoveredByPresentation[presentation]; len(matches) == 1 {
197+ represented[matches[0]] = struct{}{}
177 }198 }
178 }199 }
179 for _, remote := range discovered {200 for _, remote := range discovered {
M internal/usecase/daemon/palette_test.go +62 -66
@@ -464,36 +464,71 @@ func TestPaletteIncludesExactRemoteCatalogTargetBesideSameNameLocalSession(t *te
464464 }, remoteTarget)
465465 }
466466
467-func TestPaletteKeepsExactCatalogEndpointsWithEqualDisplayAndLifecycle(t *testing.T) {
468- now := time.Unix(1_000, 0)
469- d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now})
470- current := addControlSession(d, "current", "tab-1", "pane-1")
471- current.ephemeral = false
472- lifecycle := domain.SessionLifecycleID{22}
473- d.remoteCatalog.mu.Lock()
474- for _, endpoint := range []string{"user@arch", "admin@arch"} {
475- d.remoteCatalog.cache[endpoint] = ports.RemoteCatalogCacheEntry{
476- Host: endpoint, FetchedAt: now,
477- Sessions: []ports.RemoteCatalogSession{{
478- LifecycleID: lifecycle, Name: "vev", State: ports.RemoteCatalogSessionUp,
479- Tabs: []ports.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev",
480- }},
481- }
482- d.remoteCatalog.status[endpoint] = remoteHostFresh
467+func TestPaletteMatchesRecentRemoteRouteToCatalog(t *testing.T) {
468+ tests := []struct {
469+ name string
470+ catalogEndpoints []string
471+ wantCatalogEndpoints []string
472+ }{
473+ {
474+ name: "unique catalog match is deduplicated",
475+ catalogEndpoints: []string{"arch"},
476+ },
477+ {
478+ name: "ambiguous catalog matches remain selectable",
479+ catalogEndpoints: []string{"user@arch", "admin@arch"},
480+ wantCatalogEndpoints: []string{"user@arch", "admin@arch"},
481+ },
483482 }
484- d.remoteCatalog.mu.Unlock()
485483
486- results := d.paletteResults(current, nil, ports.RecentRouteSnapshot{})
487- var endpoints []string
488- for _, result := range results {
489- if result.DisplayText() != "Switch to session vev@arch" {
490- continue
491- }
492- target, ok := result.RemoteSessionTarget()
493- require.True(t, ok)
494- endpoints = append(endpoints, target.Endpoint)
484+ for _, test := range tests {
485+ t.Run(test.name, func(t *testing.T) {
486+ now := time.Unix(1_000, 0)
487+ d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now})
488+ current := addControlSession(d, "current", "tab-1", "pane-1")
489+ current.ephemeral = false
490+ lifecycle := domain.SessionLifecycleID{22}
491+ d.remoteCatalog.mu.Lock()
492+ for _, endpoint := range test.catalogEndpoints {
493+ d.remoteCatalog.cache[endpoint] = ports.RemoteCatalogCacheEntry{
494+ Host: endpoint, FetchedAt: now,
495+ Sessions: []ports.RemoteCatalogSession{{
496+ LifecycleID: lifecycle, Name: "vev", State: ports.RemoteCatalogSessionUp,
497+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev",
498+ }},
499+ }
500+ d.remoteCatalog.status[endpoint] = remoteHostFresh
501+ }
502+ d.remoteCatalog.mu.Unlock()
503+
504+ results := d.paletteResults(current, nil, ports.RecentRouteSnapshot{
505+ Generation: 2,
506+ Entries: []ports.RecentRouteEntry{{
507+ Key: 3, Generation: 1,
508+ Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "vev"},
509+ Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote,
510+ }},
511+ })
512+ var actions []ports.RouteNavigationAction
513+ var catalogEndpoints []string
514+ for _, result := range results {
515+ if result.DisplayText() != "Switch to session vev@arch" {
516+ continue
517+ }
518+ if action, ok := result.RouteNavigationAction(); ok {
519+ actions = append(actions, action)
520+ continue
521+ }
522+ target, ok := result.RemoteSessionTarget()
523+ require.True(t, ok)
524+ catalogEndpoints = append(catalogEndpoints, target.Endpoint)
525+ }
526+ require.Equal(t, []ports.RouteNavigationAction{{
527+ SnapshotGeneration: 2, Key: 3, Generation: 1,
528+ }}, actions)
529+ require.ElementsMatch(t, test.wantCatalogEndpoints, catalogEndpoints)
530+ })
495531 }
496- require.ElementsMatch(t, []string{"user@arch", "admin@arch"}, endpoints)
497532 }
498533
499534 func TestPaletteRemoteCatalogSelectionSendsExactAttachTarget(t *testing.T) {
@@ -642,45 +677,6 @@ func TestRemoteRefreshTracksPickerAndPaletteOnSameAttachment(t *testing.T) {
642677 d.remoteCatalog.mu.Unlock()
643678 }
644679
645-func TestPaletteKeepsCatalogTargetWhenRecentRouteHasOnlyDisplayOrigin(t *testing.T) {
646- now := time.Unix(1_200, 0)
647- d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now})
648- current := addControlSession(d, "current", "tab-1", "pane-1")
649- current.ephemeral = false
650- lifecycle := domain.SessionLifecycleID{51}
651- d.remoteCatalog.mu.Lock()
652- d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{
653- Host: "arch", FetchedAt: now,
654- Sessions: []ports.RemoteCatalogSession{{
655- LifecycleID: lifecycle, Name: "vev", State: ports.RemoteCatalogSessionUp,
656- Tabs: []ports.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev",
657- }},
658- }
659- d.remoteCatalog.status["arch"] = remoteHostFresh
660- d.remoteCatalog.mu.Unlock()
661-
662- results := d.paletteResults(current, nil, ports.RecentRouteSnapshot{
663- Generation: 2,
664- Entries: []ports.RecentRouteEntry{{
665- Key: 3, Generation: 1,
666- Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "vev"},
667- Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote,
668- }},
669- })
670- var matching []palette.Result
671- for _, result := range results {
672- if result.DisplayText() == "Switch to session vev@arch" {
673- matching = append(matching, result)
674- }
675- }
676- require.Len(t, matching, 2, "presentation-only route origin cannot deduplicate an exact catalog endpoint")
677- action, routeOK := matching[0].RouteNavigationAction()
678- _, catalogOK := matching[1].RemoteSessionTarget()
679- require.True(t, routeOK)
680- require.True(t, catalogOK)
681- require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 2, Key: 3, Generation: 1}, action)
682-}
683-
684680 func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) {
685681 d := newTestDaemon(t, nil, stubClock{})
686682 current := addControlSession(d, "hi", "tab-1", "pane-1")
@@ -464,36 +464,71 @@ func TestPaletteIncludesExactRemoteCatalogTargetBesideSameNameLocalSession(t *te
464 }, remoteTarget)464 }, remoteTarget)
465 }465 }
466 466
467-func TestPaletteKeepsExactCatalogEndpointsWithEqualDisplayAndLifecycle(t *testing.T) {467+func TestPaletteMatchesRecentRemoteRouteToCatalog(t *testing.T) {
468- now := time.Unix(1_000, 0)468+ tests := []struct {
469- d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now})469+ name string
470- current := addControlSession(d, "current", "tab-1", "pane-1")470+ catalogEndpoints []string
471- current.ephemeral = false471+ wantCatalogEndpoints []string
472- lifecycle := domain.SessionLifecycleID{22}472+ }{
473- d.remoteCatalog.mu.Lock()473+ {
474- for _, endpoint := range []string{"user@arch", "admin@arch"} {474+ name: "unique catalog match is deduplicated",
475- d.remoteCatalog.cache[endpoint] = ports.RemoteCatalogCacheEntry{475+ catalogEndpoints: []string{"arch"},
476- Host: endpoint, FetchedAt: now,476+ },
477- Sessions: []ports.RemoteCatalogSession{{477+ {
478- LifecycleID: lifecycle, Name: "vev", State: ports.RemoteCatalogSessionUp,478+ name: "ambiguous catalog matches remain selectable",
479- Tabs: []ports.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev",479+ catalogEndpoints: []string{"user@arch", "admin@arch"},
480- }},480+ wantCatalogEndpoints: []string{"user@arch", "admin@arch"},
481- }481+ },
482- d.remoteCatalog.status[endpoint] = remoteHostFresh
483 }482 }
484- d.remoteCatalog.mu.Unlock()
485 483
486- results := d.paletteResults(current, nil, ports.RecentRouteSnapshot{})484+ for _, test := range tests {
487- var endpoints []string485+ t.Run(test.name, func(t *testing.T) {
488- for _, result := range results {486+ now := time.Unix(1_000, 0)
489- if result.DisplayText() != "Switch to session vev@arch" {487+ d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now})
490- continue488+ current := addControlSession(d, "current", "tab-1", "pane-1")
491- }489+ current.ephemeral = false
492- target, ok := result.RemoteSessionTarget()490+ lifecycle := domain.SessionLifecycleID{22}
493- require.True(t, ok)491+ d.remoteCatalog.mu.Lock()
494- endpoints = append(endpoints, target.Endpoint)492+ for _, endpoint := range test.catalogEndpoints {
493+ d.remoteCatalog.cache[endpoint] = ports.RemoteCatalogCacheEntry{
494+ Host: endpoint, FetchedAt: now,
495+ Sessions: []ports.RemoteCatalogSession{{
496+ LifecycleID: lifecycle, Name: "vev", State: ports.RemoteCatalogSessionUp,
497+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev",
498+ }},
499+ }
500+ d.remoteCatalog.status[endpoint] = remoteHostFresh
501+ }
502+ d.remoteCatalog.mu.Unlock()
503+
504+ results := d.paletteResults(current, nil, ports.RecentRouteSnapshot{
505+ Generation: 2,
506+ Entries: []ports.RecentRouteEntry{{
507+ Key: 3, Generation: 1,
508+ Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "vev"},
509+ Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote,
510+ }},
511+ })
512+ var actions []ports.RouteNavigationAction
513+ var catalogEndpoints []string
514+ for _, result := range results {
515+ if result.DisplayText() != "Switch to session vev@arch" {
516+ continue
517+ }
518+ if action, ok := result.RouteNavigationAction(); ok {
519+ actions = append(actions, action)
520+ continue
521+ }
522+ target, ok := result.RemoteSessionTarget()
523+ require.True(t, ok)
524+ catalogEndpoints = append(catalogEndpoints, target.Endpoint)
525+ }
526+ require.Equal(t, []ports.RouteNavigationAction{{
527+ SnapshotGeneration: 2, Key: 3, Generation: 1,
528+ }}, actions)
529+ require.ElementsMatch(t, test.wantCatalogEndpoints, catalogEndpoints)
530+ })
495 }531 }
496- require.ElementsMatch(t, []string{"user@arch", "admin@arch"}, endpoints)
497 }532 }
498 533
499 func TestPaletteRemoteCatalogSelectionSendsExactAttachTarget(t *testing.T) {534 func TestPaletteRemoteCatalogSelectionSendsExactAttachTarget(t *testing.T) {
@@ -642,45 +677,6 @@ func TestRemoteRefreshTracksPickerAndPaletteOnSameAttachment(t *testing.T) {
642 d.remoteCatalog.mu.Unlock()677 d.remoteCatalog.mu.Unlock()
643 }678 }
644 679
645-func TestPaletteKeepsCatalogTargetWhenRecentRouteHasOnlyDisplayOrigin(t *testing.T) {
646- now := time.Unix(1_200, 0)
647- d := newTestDaemon(t, nil, fixedRemoteRefreshClock{now: now})
648- current := addControlSession(d, "current", "tab-1", "pane-1")
649- current.ephemeral = false
650- lifecycle := domain.SessionLifecycleID{51}
651- d.remoteCatalog.mu.Lock()
652- d.remoteCatalog.cache["arch"] = ports.RemoteCatalogCacheEntry{
653- Host: "arch", FetchedAt: now,
654- Sessions: []ports.RemoteCatalogSession{{
655- LifecycleID: lifecycle, Name: "vev", State: ports.RemoteCatalogSessionUp,
656- Tabs: []ports.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev",
657- }},
658- }
659- d.remoteCatalog.status["arch"] = remoteHostFresh
660- d.remoteCatalog.mu.Unlock()
661-
662- results := d.paletteResults(current, nil, ports.RecentRouteSnapshot{
663- Generation: 2,
664- Entries: []ports.RecentRouteEntry{{
665- Key: 3, Generation: 1,
666- Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "vev"},
667- Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote,
668- }},
669- })
670- var matching []palette.Result
671- for _, result := range results {
672- if result.DisplayText() == "Switch to session vev@arch" {
673- matching = append(matching, result)
674- }
675- }
676- require.Len(t, matching, 2, "presentation-only route origin cannot deduplicate an exact catalog endpoint")
677- action, routeOK := matching[0].RouteNavigationAction()
678- _, catalogOK := matching[1].RemoteSessionTarget()
679- require.True(t, routeOK)
680- require.True(t, catalogOK)
681- require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 2, Key: 3, Generation: 1}, action)
682-}
683-
684 func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) {680 func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) {
685 d := newTestDaemon(t, nil, stubClock{})681 d := newTestDaemon(t, nil, stubClock{})
686 current := addControlSession(d, "hi", "tab-1", "pane-1")682 current := addControlSession(d, "hi", "tab-1", "pane-1")

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