detached 9d575ec

fix(palette): report malformed JRS ranks distinctly and drop write-only feedback state

Parse failures now say "rank must be one positive decimal" instead of
reusing the stale-target message, and the write-only paletteFeedback
field is gone in favor of the already-rendered paletteHints.Feedback.
commit
9d575ec9a946dacaf23b6aa39eec881ab7652a26
author
brice · · 2026-07-11 14:04 UTC
parent
7a1586d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +37 -29
M internal/usecase/daemon/overlay_runtime.go +0 -3
@@ -24,7 +24,6 @@ type overlayRuntime struct {
2424 paletteRecent []recentSession // immutable for this palette interaction
2525 paletteGeneration uint64
2626 paletteHints palette.ContextualHints
27- paletteFeedback string
2827 palettePending []byte
2928
3029 promptMu sync.Mutex
@@ -221,8 +220,6 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
221220 hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...)
222221 snap.paletteHints = &hints
223222 snap.paletteRecent = append([]recentSession(nil), rt.paletteRecent...)
224- }
225- if snap.paletteActive {
226223 snap.paletteLocked = true
227224 } else {
228225 rt.paletteMu.Unlock()
@@ -24,7 +24,6 @@ type overlayRuntime struct {
24 paletteRecent []recentSession // immutable for this palette interaction24 paletteRecent []recentSession // immutable for this palette interaction
25 paletteGeneration uint6425 paletteGeneration uint64
26 paletteHints palette.ContextualHints26 paletteHints palette.ContextualHints
27- paletteFeedback string
28 palettePending []byte27 palettePending []byte
29 28
30 promptMu sync.Mutex29 promptMu sync.Mutex
@@ -221,8 +220,6 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
221 hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...)220 hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...)
222 snap.paletteHints = &hints221 snap.paletteHints = &hints
223 snap.paletteRecent = append([]recentSession(nil), rt.paletteRecent...)222 snap.paletteRecent = append([]recentSession(nil), rt.paletteRecent...)
224- }
225- if snap.paletteActive {
226 snap.paletteLocked = true223 snap.paletteLocked = true
227 } else {224 } else {
228 rt.paletteMu.Unlock()225 rt.paletteMu.Unlock()
M internal/usecase/daemon/palette.go +14 -20
@@ -40,7 +40,6 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
4040 ac.overlays.palette = palette.New(commands)
4141 ac.overlays.paletteRecent = recent
4242 ac.overlays.paletteHints = palette.ContextualHints{}
43- ac.overlays.paletteFeedback = ""
4443 ac.overlays.palettePending = nil
4544 ac.overlays.paletteMu.Unlock()
4645 d.paint(sess, ac, true)
@@ -120,12 +119,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
120119 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
121120 rune: func(r rune) {
122121 ac.overlays.palette.Insert(r)
123- ac.overlays.paletteFeedback = ""
124122 changed = true
125123 },
126124 backspace: func() {
127125 ac.overlays.palette.Backspace()
128- ac.overlays.paletteFeedback = ""
129126 changed = true
130127 },
131128 up: func() { ac.overlays.palette.Up(); changed = true },
@@ -135,7 +132,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
135132 var selected bool
136133 cmd, selected = ac.overlays.palette.Selected()
137134 if !selected {
138- ac.overlays.paletteFeedback = "invalid command arguments"
139135 changed = true
140136 return
141137 }
@@ -147,13 +143,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
147143 if cmd.Slug == "jump-recent-session" {
148144 action, valid := palette.ParseAction([]command.Command{cmd}, rawQuery)
149145 if !valid {
150- ac.overlays.paletteFeedback = "invalid command arguments"
151146 changed = true
152147 return
153148 }
154149 args = action.Args
150+ recent = append([]recentSession(nil), ac.overlays.paletteRecent...)
155151 }
156- recent = append([]recentSession(nil), ac.overlays.paletteRecent...)
157152 generation = ac.overlays.paletteGeneration
158153 execute = true
159154 },
@@ -168,7 +163,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
168163 }
169164 }
170165 if cancel {
171- d.clearPaletteLocked(ac)
166+ ac.clearPaletteLocked()
172167 }
173168 ac.overlays.paletteMu.Unlock()
174169 if cancel {
@@ -187,7 +182,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
187182 if cmd.Slug == "jump-recent-session" {
188183 rank, err := command.ParsePositiveDecimal(args)
189184 if err != nil {
190- d.paletteFailure(ac, generation, rawQuery, "requested recent session is unavailable")
185+ ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")
191186 d.paint(sess, ac, true)
192187 return
193188 }
@@ -195,22 +190,22 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
195190 // the validation above, so closing is contingent on the committed switch.
196191 exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent}
197192 if err := exec.JumpRecentSession(rank); err != nil {
198- d.paletteFailure(ac, generation, rawQuery, "requested recent session is unavailable")
193+ ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
199194 d.paint(sess, ac, true)
200195 return
201196 }
202197 // Publication remains generation-safe; do not let stale work close a
203198 // newer palette interaction.
204- if d.closeExecutedPalette(ac, generation, rawQuery) {
199+ if ac.closeExecutedPalette(generation, rawQuery) {
205200 d.recordPaletteUse(cmd.Code)
206201 d.paint(ac.currentSession(), ac, true)
207202 }
208203 return
209204 }
210- if !d.closeExecutedPalette(ac, generation, rawQuery) {
205+ if !ac.closeExecutedPalette(generation, rawQuery) {
211206 return
212207 }
213- if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, recent: recent}, args); err != nil {
208+ if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args); err != nil {
214209 d.log.Error("palette command failed", "err", err, "command", cmd.Code)
215210 } else {
216211 d.recordPaletteUse(cmd.Code)
@@ -225,35 +220,34 @@ func paletteArgs(query string, cmd command.Command) []string {
225220 return action.Args
226221 }
227222
228-func (d *Daemon) clearPaletteLocked(ac *attachedClient) {
223+func (ac *attachedClient) clearPaletteLocked() {
229224 ac.overlays.paletteGeneration++
230225 ac.overlays.palette = nil
231226 ac.overlays.paletteRecent = nil
232227 ac.overlays.paletteHints = palette.ContextualHints{}
233- ac.overlays.paletteFeedback = ""
234228 ac.overlays.palettePending = nil
235229 }
236230
237-func (d *Daemon) paletteFailure(ac *attachedClient, generation uint64, rawQuery, feedback string) {
231+func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback string) {
238232 ac.overlays.paletteMu.Lock()
239233 defer ac.overlays.paletteMu.Unlock()
240234 if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
241235 return
242236 }
243- ac.overlays.paletteFeedback = feedback
244- // Rendering consumes the immutable contextual hint snapshot, not Model's
245- // feedback state. Preserve ranks while making stale-target feedback visible.
237+ // Rendering consumes the immutable contextual hint snapshot; preserve ranks
238+ // while making stale-target feedback visible.
246239 if ac.overlays.paletteHints.Kind == command.ContextHintRecentSessions {
247240 ac.overlays.paletteHints.Feedback = feedback
248241 }
249242 }
250-func (d *Daemon) closeExecutedPalette(ac *attachedClient, generation uint64, rawQuery string) bool {
243+
244+func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery string) bool {
251245 ac.overlays.paletteMu.Lock()
252246 defer ac.overlays.paletteMu.Unlock()
253247 if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
254248 return false
255249 }
256- d.clearPaletteLocked(ac)
250+ ac.clearPaletteLocked()
257251 return true
258252 }
259253
@@ -40,7 +40,6 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
40 ac.overlays.palette = palette.New(commands)40 ac.overlays.palette = palette.New(commands)
41 ac.overlays.paletteRecent = recent41 ac.overlays.paletteRecent = recent
42 ac.overlays.paletteHints = palette.ContextualHints{}42 ac.overlays.paletteHints = palette.ContextualHints{}
43- ac.overlays.paletteFeedback = ""
44 ac.overlays.palettePending = nil43 ac.overlays.palettePending = nil
45 ac.overlays.paletteMu.Unlock()44 ac.overlays.paletteMu.Unlock()
46 d.paint(sess, ac, true)45 d.paint(sess, ac, true)
@@ -120,12 +119,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
120 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{119 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
121 rune: func(r rune) {120 rune: func(r rune) {
122 ac.overlays.palette.Insert(r)121 ac.overlays.palette.Insert(r)
123- ac.overlays.paletteFeedback = ""
124 changed = true122 changed = true
125 },123 },
126 backspace: func() {124 backspace: func() {
127 ac.overlays.palette.Backspace()125 ac.overlays.palette.Backspace()
128- ac.overlays.paletteFeedback = ""
129 changed = true126 changed = true
130 },127 },
131 up: func() { ac.overlays.palette.Up(); changed = true },128 up: func() { ac.overlays.palette.Up(); changed = true },
@@ -135,7 +132,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
135 var selected bool132 var selected bool
136 cmd, selected = ac.overlays.palette.Selected()133 cmd, selected = ac.overlays.palette.Selected()
137 if !selected {134 if !selected {
138- ac.overlays.paletteFeedback = "invalid command arguments"
139 changed = true135 changed = true
140 return136 return
141 }137 }
@@ -147,13 +143,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
147 if cmd.Slug == "jump-recent-session" {143 if cmd.Slug == "jump-recent-session" {
148 action, valid := palette.ParseAction([]command.Command{cmd}, rawQuery)144 action, valid := palette.ParseAction([]command.Command{cmd}, rawQuery)
149 if !valid {145 if !valid {
150- ac.overlays.paletteFeedback = "invalid command arguments"
151 changed = true146 changed = true
152 return147 return
153 }148 }
154 args = action.Args149 args = action.Args
150+ recent = append([]recentSession(nil), ac.overlays.paletteRecent...)
155 }151 }
156- recent = append([]recentSession(nil), ac.overlays.paletteRecent...)
157 generation = ac.overlays.paletteGeneration152 generation = ac.overlays.paletteGeneration
158 execute = true153 execute = true
159 },154 },
@@ -168,7 +163,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
168 }163 }
169 }164 }
170 if cancel {165 if cancel {
171- d.clearPaletteLocked(ac)166+ ac.clearPaletteLocked()
172 }167 }
173 ac.overlays.paletteMu.Unlock()168 ac.overlays.paletteMu.Unlock()
174 if cancel {169 if cancel {
@@ -187,7 +182,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
187 if cmd.Slug == "jump-recent-session" {182 if cmd.Slug == "jump-recent-session" {
188 rank, err := command.ParsePositiveDecimal(args)183 rank, err := command.ParsePositiveDecimal(args)
189 if err != nil {184 if err != nil {
190- d.paletteFailure(ac, generation, rawQuery, "requested recent session is unavailable")185+ ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")
191 d.paint(sess, ac, true)186 d.paint(sess, ac, true)
192 return187 return
193 }188 }
@@ -195,22 +190,22 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
195 // the validation above, so closing is contingent on the committed switch.190 // the validation above, so closing is contingent on the committed switch.
196 exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent}191 exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent}
197 if err := exec.JumpRecentSession(rank); err != nil {192 if err := exec.JumpRecentSession(rank); err != nil {
198- d.paletteFailure(ac, generation, rawQuery, "requested recent session is unavailable")193+ ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
199 d.paint(sess, ac, true)194 d.paint(sess, ac, true)
200 return195 return
201 }196 }
202 // Publication remains generation-safe; do not let stale work close a197 // Publication remains generation-safe; do not let stale work close a
203 // newer palette interaction.198 // newer palette interaction.
204- if d.closeExecutedPalette(ac, generation, rawQuery) {199+ if ac.closeExecutedPalette(generation, rawQuery) {
205 d.recordPaletteUse(cmd.Code)200 d.recordPaletteUse(cmd.Code)
206 d.paint(ac.currentSession(), ac, true)201 d.paint(ac.currentSession(), ac, true)
207 }202 }
208 return203 return
209 }204 }
210- if !d.closeExecutedPalette(ac, generation, rawQuery) {205+ if !ac.closeExecutedPalette(generation, rawQuery) {
211 return206 return
212 }207 }
213- if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, recent: recent}, args); err != nil {208+ if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args); err != nil {
214 d.log.Error("palette command failed", "err", err, "command", cmd.Code)209 d.log.Error("palette command failed", "err", err, "command", cmd.Code)
215 } else {210 } else {
216 d.recordPaletteUse(cmd.Code)211 d.recordPaletteUse(cmd.Code)
@@ -225,35 +220,34 @@ func paletteArgs(query string, cmd command.Command) []string {
225 return action.Args220 return action.Args
226 }221 }
227 222
228-func (d *Daemon) clearPaletteLocked(ac *attachedClient) {223+func (ac *attachedClient) clearPaletteLocked() {
229 ac.overlays.paletteGeneration++224 ac.overlays.paletteGeneration++
230 ac.overlays.palette = nil225 ac.overlays.palette = nil
231 ac.overlays.paletteRecent = nil226 ac.overlays.paletteRecent = nil
232 ac.overlays.paletteHints = palette.ContextualHints{}227 ac.overlays.paletteHints = palette.ContextualHints{}
233- ac.overlays.paletteFeedback = ""
234 ac.overlays.palettePending = nil228 ac.overlays.palettePending = nil
235 }229 }
236 230
237-func (d *Daemon) paletteFailure(ac *attachedClient, generation uint64, rawQuery, feedback string) {231+func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback string) {
238 ac.overlays.paletteMu.Lock()232 ac.overlays.paletteMu.Lock()
239 defer ac.overlays.paletteMu.Unlock()233 defer ac.overlays.paletteMu.Unlock()
240 if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {234 if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
241 return235 return
242 }236 }
243- ac.overlays.paletteFeedback = feedback237+ // Rendering consumes the immutable contextual hint snapshot; preserve ranks
244- // Rendering consumes the immutable contextual hint snapshot, not Model's238+ // while making stale-target feedback visible.
245- // feedback state. Preserve ranks while making stale-target feedback visible.
246 if ac.overlays.paletteHints.Kind == command.ContextHintRecentSessions {239 if ac.overlays.paletteHints.Kind == command.ContextHintRecentSessions {
247 ac.overlays.paletteHints.Feedback = feedback240 ac.overlays.paletteHints.Feedback = feedback
248 }241 }
249 }242 }
250-func (d *Daemon) closeExecutedPalette(ac *attachedClient, generation uint64, rawQuery string) bool {243+
244+func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery string) bool {
251 ac.overlays.paletteMu.Lock()245 ac.overlays.paletteMu.Lock()
252 defer ac.overlays.paletteMu.Unlock()246 defer ac.overlays.paletteMu.Unlock()
253 if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {247 if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
254 return false248 return false
255 }249 }
256- d.clearPaletteLocked(ac)250+ ac.clearPaletteLocked()
257 return true251 return true
258 }252 }
259 253
M internal/usecase/daemon/palette_test.go +23 -6
@@ -66,7 +66,7 @@ func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) {
6666 d.handleInput(sess, ac, []byte("\b\b\bRNS"))
6767 awaitFrame(t, sends, ports.MsgOutput)
6868 ac.overlays.paletteMu.Lock()
69- require.Equal(t, command.ContextHint(0), ac.overlays.paletteHints.Kind)
69+ require.Equal(t, command.ContextHintNone, ac.overlays.paletteHints.Kind)
7070 ac.overlays.paletteMu.Unlock()
7171 }
7272
@@ -94,7 +94,7 @@ func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) {
9494 d.handleInput(ac.currentSession(), ac, []byte("JRS"))
9595 awaitFrame(t, sends, ports.MsgOutput)
9696 ac.overlays.paletteMu.Lock()
97- require.Equal(t, command.ContextHint(0), ac.overlays.paletteHints.Kind)
97+ require.Equal(t, command.ContextHintNone, ac.overlays.paletteHints.Kind)
9898 ac.overlays.paletteMu.Unlock()
9999 require.Same(t, d.sessions[domain.SessionID("recent")], ac.currentSession())
100100 d.handleInput(ac.currentSession(), ac, []byte("\r"))
@@ -172,7 +172,7 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {
172172 require.True(t, ac.overlays.paletteActive())
173173 ac.overlays.paletteMu.Lock()
174174 require.Equal(t, "JRS 1", ac.overlays.palette.Query())
175- require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback)
175+ require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteHints.Feedback)
176176 ac.overlays.paletteMu.Unlock()
177177 }
178178
@@ -188,7 +188,24 @@ func TestPaletteJRSOutOfRangeKeepsPaletteOpenWithoutClamping(t *testing.T) {
188188 require.True(t, ac.overlays.paletteActive())
189189 ac.overlays.paletteMu.Lock()
190190 require.Equal(t, "JRS 3", ac.overlays.palette.Query())
191- require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback)
191+ require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteHints.Feedback)
192+ ac.overlays.paletteMu.Unlock()
193+ awaitFrame(t, sends, ports.MsgOutput)
194+}
195+
196+func TestPaletteJRSMalformedRankFeedback(t *testing.T) {
197+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
198+ defer releaseAll(releases)
199+
200+ d.handleInput(current, ac, []byte("\x1b "))
201+ awaitFrame(t, sends, ports.MsgOutput)
202+ d.handleInput(current, ac, []byte("JRS 0\r"))
203+
204+ require.Same(t, current, ac.currentSession())
205+ require.True(t, ac.overlays.paletteActive())
206+ ac.overlays.paletteMu.Lock()
207+ require.Equal(t, "JRS 0", ac.overlays.palette.Query())
208+ require.Equal(t, "rank must be one positive decimal", ac.overlays.paletteHints.Feedback)
192209 ac.overlays.paletteMu.Unlock()
193210 awaitFrame(t, sends, ports.MsgOutput)
194211 }
@@ -203,10 +220,10 @@ func TestPaletteFailureDoesNotOverwriteNewerInteraction(t *testing.T) {
203220 staleGeneration := ac.overlays.paletteGeneration
204221 ac.overlays.paletteMu.Unlock()
205222 d.enterPalette(sess, ac)
206- d.paletteFailure(ac, staleGeneration, "JRS 1", "stale failure")
223+ ac.paletteFailure(staleGeneration, "JRS 1", "stale failure")
207224
208225 ac.overlays.paletteMu.Lock()
209- require.Empty(t, ac.overlays.paletteFeedback)
226+ require.Empty(t, ac.overlays.paletteHints.Feedback)
210227 ac.overlays.paletteMu.Unlock()
211228 awaitFrame(t, sends, ports.MsgOutput)
212229 awaitFrame(t, sends, ports.MsgOutput)
@@ -66,7 +66,7 @@ func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) {
66 d.handleInput(sess, ac, []byte("\b\b\bRNS"))66 d.handleInput(sess, ac, []byte("\b\b\bRNS"))
67 awaitFrame(t, sends, ports.MsgOutput)67 awaitFrame(t, sends, ports.MsgOutput)
68 ac.overlays.paletteMu.Lock()68 ac.overlays.paletteMu.Lock()
69- require.Equal(t, command.ContextHint(0), ac.overlays.paletteHints.Kind)69+ require.Equal(t, command.ContextHintNone, ac.overlays.paletteHints.Kind)
70 ac.overlays.paletteMu.Unlock()70 ac.overlays.paletteMu.Unlock()
71 }71 }
72 72
@@ -94,7 +94,7 @@ func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) {
94 d.handleInput(ac.currentSession(), ac, []byte("JRS"))94 d.handleInput(ac.currentSession(), ac, []byte("JRS"))
95 awaitFrame(t, sends, ports.MsgOutput)95 awaitFrame(t, sends, ports.MsgOutput)
96 ac.overlays.paletteMu.Lock()96 ac.overlays.paletteMu.Lock()
97- require.Equal(t, command.ContextHint(0), ac.overlays.paletteHints.Kind)97+ require.Equal(t, command.ContextHintNone, ac.overlays.paletteHints.Kind)
98 ac.overlays.paletteMu.Unlock()98 ac.overlays.paletteMu.Unlock()
99 require.Same(t, d.sessions[domain.SessionID("recent")], ac.currentSession())99 require.Same(t, d.sessions[domain.SessionID("recent")], ac.currentSession())
100 d.handleInput(ac.currentSession(), ac, []byte("\r"))100 d.handleInput(ac.currentSession(), ac, []byte("\r"))
@@ -172,7 +172,7 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {
172 require.True(t, ac.overlays.paletteActive())172 require.True(t, ac.overlays.paletteActive())
173 ac.overlays.paletteMu.Lock()173 ac.overlays.paletteMu.Lock()
174 require.Equal(t, "JRS 1", ac.overlays.palette.Query())174 require.Equal(t, "JRS 1", ac.overlays.palette.Query())
175- require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback)175+ require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteHints.Feedback)
176 ac.overlays.paletteMu.Unlock()176 ac.overlays.paletteMu.Unlock()
177 }177 }
178 178
@@ -188,7 +188,24 @@ func TestPaletteJRSOutOfRangeKeepsPaletteOpenWithoutClamping(t *testing.T) {
188 require.True(t, ac.overlays.paletteActive())188 require.True(t, ac.overlays.paletteActive())
189 ac.overlays.paletteMu.Lock()189 ac.overlays.paletteMu.Lock()
190 require.Equal(t, "JRS 3", ac.overlays.palette.Query())190 require.Equal(t, "JRS 3", ac.overlays.palette.Query())
191- require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback)191+ require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteHints.Feedback)
192+ ac.overlays.paletteMu.Unlock()
193+ awaitFrame(t, sends, ports.MsgOutput)
194+}
195+
196+func TestPaletteJRSMalformedRankFeedback(t *testing.T) {
197+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
198+ defer releaseAll(releases)
199+
200+ d.handleInput(current, ac, []byte("\x1b "))
201+ awaitFrame(t, sends, ports.MsgOutput)
202+ d.handleInput(current, ac, []byte("JRS 0\r"))
203+
204+ require.Same(t, current, ac.currentSession())
205+ require.True(t, ac.overlays.paletteActive())
206+ ac.overlays.paletteMu.Lock()
207+ require.Equal(t, "JRS 0", ac.overlays.palette.Query())
208+ require.Equal(t, "rank must be one positive decimal", ac.overlays.paletteHints.Feedback)
192 ac.overlays.paletteMu.Unlock()209 ac.overlays.paletteMu.Unlock()
193 awaitFrame(t, sends, ports.MsgOutput)210 awaitFrame(t, sends, ports.MsgOutput)
194 }211 }
@@ -203,10 +220,10 @@ func TestPaletteFailureDoesNotOverwriteNewerInteraction(t *testing.T) {
203 staleGeneration := ac.overlays.paletteGeneration220 staleGeneration := ac.overlays.paletteGeneration
204 ac.overlays.paletteMu.Unlock()221 ac.overlays.paletteMu.Unlock()
205 d.enterPalette(sess, ac)222 d.enterPalette(sess, ac)
206- d.paletteFailure(ac, staleGeneration, "JRS 1", "stale failure")223+ ac.paletteFailure(staleGeneration, "JRS 1", "stale failure")
207 224
208 ac.overlays.paletteMu.Lock()225 ac.overlays.paletteMu.Lock()
209- require.Empty(t, ac.overlays.paletteFeedback)226+ require.Empty(t, ac.overlays.paletteHints.Feedback)
210 ac.overlays.paletteMu.Unlock()227 ac.overlays.paletteMu.Unlock()
211 awaitFrame(t, sends, ports.MsgOutput)228 awaitFrame(t, sends, ports.MsgOutput)
212 awaitFrame(t, sends, ports.MsgOutput)229 awaitFrame(t, sends, ports.MsgOutput)

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 2

Tags 13

↑↓ moveenter openesc close