detached f4d7e27

fix(palette): validate session picker targets

commit
f4d7e2751a3f46abff31e0f0502cf65eac434564
author
brice · · 2026-07-15 08:48 UTC
parent
5033bbe
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +138 -48
M internal/usecase/daemon/picker.go +3 -2
@@ -48,9 +48,10 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
4848 curTab := 0
4949 for _, s := range sessions {
5050 s.mu.Lock()
51- view := picker.SessionView{ID: s.id, Name: s.name, TargetName: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))}
51+ view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))}
5252 if !s.ephemeral {
5353 createdAt := s.createdAt
54+ view.TargetName = s.name
5455 view.ExpectedCreatedAt = &createdAt
5556 }
5657 sessionAttention := false
@@ -468,7 +469,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
468469 if targetSess == from {
469470 targetSess.mu.Lock()
470471 defer targetSess.mu.Unlock()
471- if !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
472+ if targetSess.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
472473 return nil, nil, nil, false
473474 }
474475 targetSess.active = target.TabIndex
@@ -48,9 +48,10 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
48 curTab := 048 curTab := 0
49 for _, s := range sessions {49 for _, s := range sessions {
50 s.mu.Lock()50 s.mu.Lock()
51- view := picker.SessionView{ID: s.id, Name: s.name, TargetName: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))}51+ view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))}
52 if !s.ephemeral {52 if !s.ephemeral {
53 createdAt := s.createdAt53 createdAt := s.createdAt
54+ view.TargetName = s.name
54 view.ExpectedCreatedAt = &createdAt55 view.ExpectedCreatedAt = &createdAt
55 }56 }
56 sessionAttention := false57 sessionAttention := false
@@ -468,7 +469,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
468 if targetSess == from {469 if targetSess == from {
469 targetSess.mu.Lock()470 targetSess.mu.Lock()
470 defer targetSess.mu.Unlock()471 defer targetSess.mu.Unlock()
471- if !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {472+ if targetSess.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
472 return nil, nil, nil, false473 return nil, nil, nil, false
473 }474 }
474 targetSess.active = target.TabIndex475 targetSess.active = target.TabIndex
M internal/usecase/daemon/picker_test.go +41 -0
@@ -94,6 +94,47 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
9494 require.Equal(t, int64(24), *views[1].ExpectedCreatedAt)
9595 }
9696
97+func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
98+ p, release := newBlockingPTY(t)
99+ defer release()
100+ d, current, ac, _ := newManualSessionWithPTYs(t, p)
101+ ctx, cancel := context.WithCancel(d.serveCtx)
102+ defer cancel()
103+ original := &session{id: "ephemeral-1", name: "2", ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
104+ d.sessions[original.id] = original
105+
106+ views, _ := d.pickerViews(current)
107+ var originalView picker.SessionView
108+ for _, view := range views {
109+ if view.ID == original.id {
110+ originalView = view
111+ break
112+ }
113+ }
114+ model := picker.New([]picker.SessionView{originalView}, original.id, 0)
115+ target, ok := model.Selected()
116+ require.True(t, ok)
117+
118+ delete(d.sessions, original.id)
119+ replacement := &session{id: "ephemeral-2", name: original.name, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
120+ d.sessions[replacement.id] = replacement
121+
122+ require.False(t, d.switchToTarget(current, ac, target))
123+ require.Same(t, current, ac.currentSession())
124+}
125+
126+func TestSameSessionSwitchRejectsReplacedClient(t *testing.T) {
127+ p1, release1 := newBlockingPTY(t)
128+ p2, release2 := newBlockingPTY(t)
129+ defer release1()
130+ defer release2()
131+ d, current, ac, _ := newManualSessionWithPTYs(t, p1, p2)
132+ current.client = &attachedClient{}
133+
134+ require.False(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1}))
135+ require.Equal(t, 0, current.active)
136+}
137+
97138 func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
98139 p1, releasePTY1 := newBlockingPTY(t)
99140 p2, releasePTY2 := newBlockingPTY(t)
@@ -94,6 +94,47 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
94 require.Equal(t, int64(24), *views[1].ExpectedCreatedAt)94 require.Equal(t, int64(24), *views[1].ExpectedCreatedAt)
95 }95 }
96 96
97+func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
98+ p, release := newBlockingPTY(t)
99+ defer release()
100+ d, current, ac, _ := newManualSessionWithPTYs(t, p)
101+ ctx, cancel := context.WithCancel(d.serveCtx)
102+ defer cancel()
103+ original := &session{id: "ephemeral-1", name: "2", ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
104+ d.sessions[original.id] = original
105+
106+ views, _ := d.pickerViews(current)
107+ var originalView picker.SessionView
108+ for _, view := range views {
109+ if view.ID == original.id {
110+ originalView = view
111+ break
112+ }
113+ }
114+ model := picker.New([]picker.SessionView{originalView}, original.id, 0)
115+ target, ok := model.Selected()
116+ require.True(t, ok)
117+
118+ delete(d.sessions, original.id)
119+ replacement := &session{id: "ephemeral-2", name: original.name, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
120+ d.sessions[replacement.id] = replacement
121+
122+ require.False(t, d.switchToTarget(current, ac, target))
123+ require.Same(t, current, ac.currentSession())
124+}
125+
126+func TestSameSessionSwitchRejectsReplacedClient(t *testing.T) {
127+ p1, release1 := newBlockingPTY(t)
128+ p2, release2 := newBlockingPTY(t)
129+ defer release1()
130+ defer release2()
131+ d, current, ac, _ := newManualSessionWithPTYs(t, p1, p2)
132+ current.client = &attachedClient{}
133+
134+ require.False(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1}))
135+ require.Equal(t, 0, current.active)
136+}
137+
97 func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {138 func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
98 p1, releasePTY1 := newBlockingPTY(t)139 p1, releasePTY1 := newBlockingPTY(t)
99 p2, releasePTY2 := newBlockingPTY(t)140 p2, releasePTY2 := newBlockingPTY(t)
M internal/usecase/palette/fuzzy_test.go +67 -43
@@ -78,53 +78,77 @@ func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) {
7878 require.Equal(t, []string{"B", "A"}, codes(matches))
7979 }
8080
81-func TestFuzzyMixedResultsPrioritizeCommandShortcodeAndRankSessions(t *testing.T) {
81+func TestFuzzyOrdersMixedResults(t *testing.T) {
8282 created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
83- results := []Result{
84- NewStoppedSessionResult("work", created),
85- NewCommandResult(cmd("WORK", "", "Create a workspace")),
86- NewActiveSessionResult("work", created, domain.SessionID("work-id")),
87- NewCommandResult(cmd("WQORRK", "", "")),
88- NewCommandResult(cmd("ZZZ", "", "work tools")),
83+ tests := []struct {
84+ name string
85+ results []Result
86+ query string
87+ wantText []string
88+ wantKinds []ResultKind
89+ wantPositions [][]int
90+ wantActiveIDs map[int]domain.SessionID
91+ }{
92+ {
93+ name: "command shortcode precedes sessions and description matches",
94+ results: []Result{
95+ NewStoppedSessionResult("work", created),
96+ NewCommandResult(cmd("WORK", "", "Create a workspace")),
97+ NewActiveSessionResult("work", created, domain.SessionID("work-id")),
98+ NewCommandResult(cmd("WQORRK", "", "")),
99+ NewCommandResult(cmd("ZZZ", "", "work tools")),
100+ },
101+ query: "work",
102+ wantText: []string{"WORK", "work", "work", "WQORRK", "ZZZ"},
103+ wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindStoppedSession, ResultKindCommand, ResultKindCommand},
104+ wantPositions: [][]int{{0, 1, 2, 3}, {0, 1, 2, 3}, {0, 1, 2, 3}, {0, 2, 3, 5}, nil},
105+ },
106+ {
107+ name: "equivalent sessions sort by normalized text",
108+ results: []Result{
109+ NewStoppedSessionResult("aBravo", time.Time{}),
110+ NewStoppedSessionResult("aAlpha", time.Time{}),
111+ },
112+ query: "a",
113+ wantText: []string{"aAlpha", "aBravo"},
114+ wantKinds: []ResultKind{ResultKindStoppedSession, ResultKindStoppedSession},
115+ wantPositions: [][]int{{0}, {0}},
116+ },
117+ {
118+ name: "kind then normalized text then stable order",
119+ results: []Result{
120+ NewStoppedSessionResult("aBravo", time.Time{}),
121+ NewStoppedSessionResult("aAlpha", time.Time{}),
122+ NewActiveSessionResult("aZulu", time.Time{}, "z"),
123+ NewActiveSessionResult("aEcho", time.Time{}, "e"),
124+ NewActiveSessionResult("aEcho", time.Time{}, "e2"),
125+ NewCommandResult(cmd("AX", "", "")),
126+ },
127+ query: "a",
128+ wantText: []string{"AX", "aEcho", "aEcho", "aZulu", "aAlpha", "aBravo"},
129+ wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindStoppedSession, ResultKindStoppedSession},
130+ wantPositions: [][]int{{0}, {0}, {0}, {0}, {0}, {0}},
131+ wantActiveIDs: map[int]domain.SessionID{1: "e", 2: "e2", 3: "z"},
132+ },
89133 }
90134
91- matches := Fuzzy(results, "work")
92- require.Equal(t, []string{"WORK", "work", "work", "WQORRK", "ZZZ"}, matchSearchText(matches))
93- require.Equal(t, []ResultKind{
94- ResultKindCommand,
95- ResultKindActiveSession,
96- ResultKindStoppedSession,
97- ResultKindCommand,
98- ResultKindCommand,
99- }, matchKinds(matches))
100- require.Equal(t, []int{0, 1, 2, 3}, matches[0].Positions)
101- require.Equal(t, []int{0, 1, 2, 3}, matches[1].Positions)
102- require.Empty(t, matches[4].Positions)
103-}
104-
105-func TestFuzzySortsEquivalentSessionMatchesByNormalizedText(t *testing.T) {
106- created := time.Time{}
107- matches := Fuzzy([]Result{
108- NewStoppedSessionResult("aBravo", created),
109- NewStoppedSessionResult("aAlpha", created),
110- }, "a")
111-
112- require.Equal(t, []string{"aAlpha", "aBravo"}, matchSearchText(matches))
113-}
114-
115-func TestFuzzyMixedResultsUsesKindThenNormalizedTextThenStableOrder(t *testing.T) {
116- created := time.Time{}
117- results := []Result{
118- NewStoppedSessionResult("aBravo", created),
119- NewStoppedSessionResult("aAlpha", created),
120- NewActiveSessionResult("aZulu", created, "z"),
121- NewActiveSessionResult("aEcho", created, "e"),
122- NewActiveSessionResult("aEcho", created, "e2"),
123- NewCommandResult(cmd("AX", "", "")),
135+ for _, tt := range tests {
136+ t.Run(tt.name, func(t *testing.T) {
137+ matches := Fuzzy(tt.results, tt.query)
138+ require.Equal(t, tt.wantText, matchSearchText(matches))
139+ require.Equal(t, tt.wantKinds, matchKinds(matches))
140+ positions := make([][]int, len(matches))
141+ for i := range matches {
142+ positions[i] = matches[i].Positions
143+ }
144+ require.Equal(t, tt.wantPositions, positions)
145+ for index, wantID := range tt.wantActiveIDs {
146+ gotID, ok := matches[index].Result.SessionID()
147+ require.True(t, ok)
148+ require.Equal(t, wantID, gotID)
149+ }
150+ })
124151 }
125-
126- matches := Fuzzy(results, "a")
127- require.Equal(t, []string{"AX", "aEcho", "aEcho", "aZulu", "aAlpha", "aBravo"}, matchSearchText(matches))
128152 }
129153
130154 func matchSearchText(matches []Match) []string {
@@ -78,53 +78,77 @@ func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) {
78 require.Equal(t, []string{"B", "A"}, codes(matches))78 require.Equal(t, []string{"B", "A"}, codes(matches))
79 }79 }
80 80
81-func TestFuzzyMixedResultsPrioritizeCommandShortcodeAndRankSessions(t *testing.T) {81+func TestFuzzyOrdersMixedResults(t *testing.T) {
82 created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)82 created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
83- results := []Result{83+ tests := []struct {
84- NewStoppedSessionResult("work", created),84+ name string
85- NewCommandResult(cmd("WORK", "", "Create a workspace")),85+ results []Result
86- NewActiveSessionResult("work", created, domain.SessionID("work-id")),86+ query string
87- NewCommandResult(cmd("WQORRK", "", "")),87+ wantText []string
88- NewCommandResult(cmd("ZZZ", "", "work tools")),88+ wantKinds []ResultKind
89+ wantPositions [][]int
90+ wantActiveIDs map[int]domain.SessionID
91+ }{
92+ {
93+ name: "command shortcode precedes sessions and description matches",
94+ results: []Result{
95+ NewStoppedSessionResult("work", created),
96+ NewCommandResult(cmd("WORK", "", "Create a workspace")),
97+ NewActiveSessionResult("work", created, domain.SessionID("work-id")),
98+ NewCommandResult(cmd("WQORRK", "", "")),
99+ NewCommandResult(cmd("ZZZ", "", "work tools")),
100+ },
101+ query: "work",
102+ wantText: []string{"WORK", "work", "work", "WQORRK", "ZZZ"},
103+ wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindStoppedSession, ResultKindCommand, ResultKindCommand},
104+ wantPositions: [][]int{{0, 1, 2, 3}, {0, 1, 2, 3}, {0, 1, 2, 3}, {0, 2, 3, 5}, nil},
105+ },
106+ {
107+ name: "equivalent sessions sort by normalized text",
108+ results: []Result{
109+ NewStoppedSessionResult("aBravo", time.Time{}),
110+ NewStoppedSessionResult("aAlpha", time.Time{}),
111+ },
112+ query: "a",
113+ wantText: []string{"aAlpha", "aBravo"},
114+ wantKinds: []ResultKind{ResultKindStoppedSession, ResultKindStoppedSession},
115+ wantPositions: [][]int{{0}, {0}},
116+ },
117+ {
118+ name: "kind then normalized text then stable order",
119+ results: []Result{
120+ NewStoppedSessionResult("aBravo", time.Time{}),
121+ NewStoppedSessionResult("aAlpha", time.Time{}),
122+ NewActiveSessionResult("aZulu", time.Time{}, "z"),
123+ NewActiveSessionResult("aEcho", time.Time{}, "e"),
124+ NewActiveSessionResult("aEcho", time.Time{}, "e2"),
125+ NewCommandResult(cmd("AX", "", "")),
126+ },
127+ query: "a",
128+ wantText: []string{"AX", "aEcho", "aEcho", "aZulu", "aAlpha", "aBravo"},
129+ wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindStoppedSession, ResultKindStoppedSession},
130+ wantPositions: [][]int{{0}, {0}, {0}, {0}, {0}, {0}},
131+ wantActiveIDs: map[int]domain.SessionID{1: "e", 2: "e2", 3: "z"},
132+ },
89 }133 }
90 134
91- matches := Fuzzy(results, "work")135+ for _, tt := range tests {
92- require.Equal(t, []string{"WORK", "work", "work", "WQORRK", "ZZZ"}, matchSearchText(matches))136+ t.Run(tt.name, func(t *testing.T) {
93- require.Equal(t, []ResultKind{137+ matches := Fuzzy(tt.results, tt.query)
94- ResultKindCommand,138+ require.Equal(t, tt.wantText, matchSearchText(matches))
95- ResultKindActiveSession,139+ require.Equal(t, tt.wantKinds, matchKinds(matches))
96- ResultKindStoppedSession,140+ positions := make([][]int, len(matches))
97- ResultKindCommand,141+ for i := range matches {
98- ResultKindCommand,142+ positions[i] = matches[i].Positions
99- }, matchKinds(matches))143+ }
100- require.Equal(t, []int{0, 1, 2, 3}, matches[0].Positions)144+ require.Equal(t, tt.wantPositions, positions)
101- require.Equal(t, []int{0, 1, 2, 3}, matches[1].Positions)145+ for index, wantID := range tt.wantActiveIDs {
102- require.Empty(t, matches[4].Positions)146+ gotID, ok := matches[index].Result.SessionID()
103-}147+ require.True(t, ok)
104-148+ require.Equal(t, wantID, gotID)
105-func TestFuzzySortsEquivalentSessionMatchesByNormalizedText(t *testing.T) {149+ }
106- created := time.Time{}150+ })
107- matches := Fuzzy([]Result{
108- NewStoppedSessionResult("aBravo", created),
109- NewStoppedSessionResult("aAlpha", created),
110- }, "a")
111-
112- require.Equal(t, []string{"aAlpha", "aBravo"}, matchSearchText(matches))
113-}
114-
115-func TestFuzzyMixedResultsUsesKindThenNormalizedTextThenStableOrder(t *testing.T) {
116- created := time.Time{}
117- results := []Result{
118- NewStoppedSessionResult("aBravo", created),
119- NewStoppedSessionResult("aAlpha", created),
120- NewActiveSessionResult("aZulu", created, "z"),
121- NewActiveSessionResult("aEcho", created, "e"),
122- NewActiveSessionResult("aEcho", created, "e2"),
123- NewCommandResult(cmd("AX", "", "")),
124 }151 }
125-
126- matches := Fuzzy(results, "a")
127- require.Equal(t, []string{"AX", "aEcho", "aEcho", "aZulu", "aAlpha", "aBravo"}, matchSearchText(matches))
128 }152 }
129 153
130 func matchSearchText(matches []Match) []string {154 func matchSearchText(matches []Match) []string {
M internal/usecase/palette/model.go +1 -1
@@ -313,7 +313,7 @@ func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, des
313313
314314 func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) {
315315 x, nextHighlight := 0, 0
316- prefixWidth := utf8.RuneCountInString(sessionDisplayPrefix)
316+ prefixWidth := utf8.RuneCountInString(match.Result.sessionDisplayPrefix())
317317 for _, r := range match.Result.DisplayText() {
318318 cellStyle := style
319319 if nextHighlight < len(match.Positions) && prefixWidth+match.Positions[nextHighlight] == x {
@@ -313,7 +313,7 @@ func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, des
313 313
314 func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) {314 func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) {
315 x, nextHighlight := 0, 0315 x, nextHighlight := 0, 0
316- prefixWidth := utf8.RuneCountInString(sessionDisplayPrefix)316+ prefixWidth := utf8.RuneCountInString(match.Result.sessionDisplayPrefix())
317 for _, r := range match.Result.DisplayText() {317 for _, r := range match.Result.DisplayText() {
318 cellStyle := style318 cellStyle := style
319 if nextHighlight < len(match.Positions) && prefixWidth+match.Positions[nextHighlight] == x {319 if nextHighlight < len(match.Positions) && prefixWidth+match.Positions[nextHighlight] == x {
M internal/usecase/palette/model_test.go +13 -0
@@ -366,6 +366,19 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T
366366 require.Equal(t, "Switch to session work ", frameRow(frame, 1))
367367 }
368368
369+func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) {
370+ m := New([]Result{NewStoppedSessionResult("work", time.Unix(0, 1))})
371+ m.Insert('w')
372+ m.Insert('k')
373+
374+ frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
375+
376+ require.Equal(t, "Resume session work ", frameRow(frame, 1))
377+ require.False(t, frame.At(14, 1).Style.Bold, "resume prefix is not highlighted")
378+ require.True(t, frame.At(15, 1).Style.Bold, "first matched session rune is highlighted")
379+ require.True(t, frame.At(18, 1).Style.Bold, "last matched session rune is highlighted")
380+}
381+
369382 func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) {
370383 m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), "work")})
371384 frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"})
@@ -366,6 +366,19 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T
366 require.Equal(t, "Switch to session work ", frameRow(frame, 1))366 require.Equal(t, "Switch to session work ", frameRow(frame, 1))
367 }367 }
368 368
369+func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) {
370+ m := New([]Result{NewStoppedSessionResult("work", time.Unix(0, 1))})
371+ m.Insert('w')
372+ m.Insert('k')
373+
374+ frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
375+
376+ require.Equal(t, "Resume session work ", frameRow(frame, 1))
377+ require.False(t, frame.At(14, 1).Style.Bold, "resume prefix is not highlighted")
378+ require.True(t, frame.At(15, 1).Style.Bold, "first matched session rune is highlighted")
379+ require.True(t, frame.At(18, 1).Style.Bold, "last matched session rune is highlighted")
380+}
381+
369 func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) {382 func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) {
370 m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), "work")})383 m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), "work")})
371 frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"})384 frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"})
M internal/usecase/palette/result.go +12 -2
@@ -57,7 +57,14 @@ func (r Result) DisplayText() string {
5757 if r.kind == ResultKindCommand {
5858 return r.command.Code
5959 }
60- return sessionDisplayPrefix + r.session.name
60+ return r.sessionDisplayPrefix() + r.session.name
61+}
62+
63+func (r Result) sessionDisplayPrefix() string {
64+ if r.kind == ResultKindStoppedSession {
65+ return stoppedSessionDisplayPrefix
66+ }
67+ return activeSessionDisplayPrefix
6168 }
6269
6370 func (r Result) SearchText() string {
@@ -87,7 +94,10 @@ func (r Result) SessionID() (domain.SessionID, bool) {
8794 return r.session.sessionID, r.kind == ResultKindActiveSession
8895 }
8996
90-const sessionDisplayPrefix = "Switch to session "
97+const (
98+ activeSessionDisplayPrefix = "Switch to session "
99+ stoppedSessionDisplayPrefix = "Resume session "
100+)
91101
92102 // CommandResults converts static commands to immutable palette targets.
93103 func CommandResults(commands []command.Command) []Result {
@@ -57,7 +57,14 @@ func (r Result) DisplayText() string {
57 if r.kind == ResultKindCommand {57 if r.kind == ResultKindCommand {
58 return r.command.Code58 return r.command.Code
59 }59 }
60- return sessionDisplayPrefix + r.session.name60+ return r.sessionDisplayPrefix() + r.session.name
61+}
62+
63+func (r Result) sessionDisplayPrefix() string {
64+ if r.kind == ResultKindStoppedSession {
65+ return stoppedSessionDisplayPrefix
66+ }
67+ return activeSessionDisplayPrefix
61 }68 }
62 69
63 func (r Result) SearchText() string {70 func (r Result) SearchText() string {
@@ -87,7 +94,10 @@ func (r Result) SessionID() (domain.SessionID, bool) {
87 return r.session.sessionID, r.kind == ResultKindActiveSession94 return r.session.sessionID, r.kind == ResultKindActiveSession
88 }95 }
89 96
90-const sessionDisplayPrefix = "Switch to session "97+const (
98+ activeSessionDisplayPrefix = "Switch to session "
99+ stoppedSessionDisplayPrefix = "Resume session "
100+)
91 101
92 // CommandResults converts static commands to immutable palette targets.102 // CommandResults converts static commands to immutable palette targets.
93 func CommandResults(commands []command.Command) []Result {103 func CommandResults(commands []command.Command) []Result {
M internal/usecase/palette/result_test.go +1 -0
@@ -42,6 +42,7 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
4242 createdAt, ok = stopped.SessionCreatedAt()
4343 require.True(t, ok)
4444 require.Equal(t, created, createdAt)
45+ require.Equal(t, "Resume session archive", stopped.DisplayText())
4546 _, ok = stopped.SessionID()
4647 require.False(t, ok)
4748 }
@@ -42,6 +42,7 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
42 createdAt, ok = stopped.SessionCreatedAt()42 createdAt, ok = stopped.SessionCreatedAt()
43 require.True(t, ok)43 require.True(t, ok)
44 require.Equal(t, created, createdAt)44 require.Equal(t, created, createdAt)
45+ require.Equal(t, "Resume session archive", stopped.DisplayText())
45 _, ok = stopped.SessionID()46 _, ok = stopped.SessionID()
46 require.False(t, ok)47 require.False(t, ok)
47 }48 }

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