detached 0bb8a8e

refactor(daemon): own palette history and split palette input

commit
0bb8a8e578e5a4cd49a24dbfc0dac3ae38382c22
author
brice · · 2026-10-08 06:09 UTC
committer
brice · · 2026-10-08 08:12 UTC
parent
a1296c0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +247 -140
M internal/usecase/daemon/client.go +3 -0
@@ -29,6 +29,9 @@
2929 // snapshotWorker.noticeMu guards the active persistence-failure signature; it
3030 // is a leaf and is never held together with snapshotWorker.mu. Restoration
3131 // takes d.mu only outside snapshotWorker.mu.
32+//
33+// paletteHistory.mu guards the daemon-wide recent-command list. It is a leaf:
34+// it is only held to copy or rewrite that slice, never while calling out.
3235 package daemon
3336
3437 import (
@@ -29,6 +29,9 @@
29 // snapshotWorker.noticeMu guards the active persistence-failure signature; it29 // snapshotWorker.noticeMu guards the active persistence-failure signature; it
30 // is a leaf and is never held together with snapshotWorker.mu. Restoration30 // is a leaf and is never held together with snapshotWorker.mu. Restoration
31 // takes d.mu only outside snapshotWorker.mu.31 // takes d.mu only outside snapshotWorker.mu.
32+//
33+// paletteHistory.mu guards the daemon-wide recent-command list. It is a leaf:
34+// it is only held to copy or rewrite that slice, never while calling out.
32 package daemon35 package daemon
33 36
34 import (37 import (
M internal/usecase/daemon/daemon.go +2 -2
@@ -156,8 +156,8 @@ type Daemon struct {
156156 animFrame int
157157 animWake chan struct{}
158158
159- paletteRecentMu sync.Mutex
160- paletteRecent []string
159+ // paletteHistory is the daemon-wide recent-command order; its mu is a leaf.
160+ paletteHistory paletteHistory
161161 // daemonTestHooks holds deterministic test seams; production leaves every
162162 // hook nil.
163163 daemonTestHooks
@@ -156,8 +156,8 @@ type Daemon struct {
156 animFrame int156 animFrame int
157 animWake chan struct{}157 animWake chan struct{}
158 158
159- paletteRecentMu sync.Mutex159+ // paletteHistory is the daemon-wide recent-command order; its mu is a leaf.
160- paletteRecent []string160+ paletteHistory paletteHistory
161 // daemonTestHooks holds deterministic test seams; production leaves every161 // daemonTestHooks holds deterministic test seams; production leaves every
162 // hook nil.162 // hook nil.
163 daemonTestHooks163 daemonTestHooks
M internal/usecase/daemon/palette.go +241 -137
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "errors"
55 "sort"
6+ "sync"
67 "time"
78
89 renderer "github.com/bnema/vev-vt"
@@ -292,11 +293,38 @@ func recentRouteHints(snapshot protocol.RecentRouteSnapshot, args []string) pale
292293 return hints
293294 }
294295
296+// paletteHistory is the daemon-wide most-recently-used command order. mu is a
297+// leaf lock: nothing is acquired while holding it. The zero value is usable.
298+type paletteHistory struct {
299+ mu sync.Mutex
300+ recent []string
301+}
302+
303+// snapshot returns a copy of the recent command codes, newest first.
304+func (h *paletteHistory) snapshot() []string {
305+ h.mu.Lock()
306+ defer h.mu.Unlock()
307+ return append([]string(nil), h.recent...)
308+}
309+
310+// record moves code to the front of the recent list.
311+func (h *paletteHistory) record(code string) {
312+ h.mu.Lock()
313+ defer h.mu.Unlock()
314+
315+ recent := make([]string, 0, len(h.recent)+1)
316+ recent = append(recent, code)
317+ for _, existing := range h.recent {
318+ if existing != code {
319+ recent = append(recent, existing)
320+ }
321+ }
322+ h.recent = recent
323+}
324+
295325 func (d *Daemon) paletteCommands() []command.Command {
296326 commands := command.PaletteRegistry()
297- d.paletteRecentMu.Lock()
298- recent := append([]string(nil), d.paletteRecent...)
299- d.paletteRecentMu.Unlock()
327+ recent := d.paletteHistory.snapshot()
300328 overrides := d.codeOverrideSnapshot()
301329
302330 byCode := make(map[string]command.Command, len(commands))
@@ -324,25 +352,46 @@ func (d *Daemon) paletteCommands() []command.Command {
324352 }
325353
326354 func (d *Daemon) recordPaletteUse(code string) {
327- d.paletteRecentMu.Lock()
328- defer d.paletteRecentMu.Unlock()
355+ d.paletteHistory.record(code)
356+}
329357
330- recent := make([]string, 0, len(d.paletteRecent)+1)
331- recent = append(recent, code)
332- for _, existing := range d.paletteRecent {
333- if existing != code {
334- recent = append(recent, existing)
335- }
336- }
337- d.paletteRecent = recent
358+// paletteDecisionKind names what handlePaletteInput must do once the palette
359+// lock is released.
360+type paletteDecisionKind int
361+
362+const (
363+ paletteDecisionNone paletteDecisionKind = iota
364+ paletteDecisionRedraw
365+ paletteDecisionCancel
366+ paletteDecisionCreateDestination
367+ paletteDecisionImportedSession
368+ paletteDecisionRouteNavigation
369+ paletteDecisionSessionTarget
370+ paletteDecisionCommand
371+)
372+
373+// paletteDecision is the immutable outcome of routing one input chunk through
374+// the palette under paletteMu. Executing it never touches palette state without
375+// retaking the lock.
376+type paletteDecision struct {
377+ kind paletteDecisionKind
378+ generation uint64
379+ rawQuery string
380+ cmd command.Command
381+ args []string
382+ routeSnapshot protocol.RecentRouteSnapshot
383+ sessionTarget palette.Result
384+ importedSourceKey string
385+ importedEntryKey string
386+ routeTarget protocol.RouteNavigationAction
387+ createDestination palette.Result
388+ cancelInventoryInteraction uint64
389+ hasCancelInventory bool
338390 }
339391
340-func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffect) {
341- entry := ac.currentAttachmentSession()
342- if entry == nil {
343- return
344- }
345- sess := entry
392+// decidePaletteInput applies data to the attachment's palette while holding
393+// paletteMu and returns what the caller must do after the lock is released.
394+func decidePaletteInput(ac *attachedClient, data []byte) paletteDecision {
346395 var cmd command.Command
347396 var sessionTarget palette.Result
348397 var hasSessionTarget bool
@@ -359,16 +408,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
359408 var cancelInventoryInteraction uint64
360409 var hasCancelInventory bool
361410 changed, cancel, execute, chooseDestination := false, false, false, false
362- var effect *attachmentEffect
363- if len(effects) != 0 {
364- effect = effects[0]
365- }
366411
367412 ac.overlays.paletteMu.Lock()
368413 if ac.overlays.palette == nil {
369414 ac.overlays.palettePending = nil
370415 ac.overlays.paletteMu.Unlock()
371- return
416+ return paletteDecision{}
372417 }
373418 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
374419 rune: func(r rune) {
@@ -478,139 +523,198 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
478523 ac.clearPaletteLocked()
479524 }
480525 ac.overlays.paletteMu.Unlock()
481- if cancel {
482- if hasCancelInventory {
483- d.sendPaletteInventoryDemand(ac, effect, false, cancelInventoryInteraction)
526+
527+ switch {
528+ case cancel:
529+ return paletteDecision{kind: paletteDecisionCancel, cancelInventoryInteraction: cancelInventoryInteraction, hasCancelInventory: hasCancelInventory}
530+ case !execute && changed:
531+ return paletteDecision{kind: paletteDecisionRedraw}
532+ case !execute:
533+ return paletteDecision{}
534+ }
535+ decision := paletteDecision{generation: generation, rawQuery: rawQuery}
536+ switch {
537+ case hasCreateDestination:
538+ decision.kind = paletteDecisionCreateDestination
539+ decision.createDestination = createDestination
540+ case hasImportedTarget:
541+ decision.kind = paletteDecisionImportedSession
542+ decision.importedSourceKey = importedSourceKey
543+ decision.importedEntryKey = importedEntryKey
544+ case hasRouteTarget:
545+ decision.kind = paletteDecisionRouteNavigation
546+ decision.routeTarget = routeTarget
547+ case hasSessionTarget:
548+ decision.kind = paletteDecisionSessionTarget
549+ decision.sessionTarget = sessionTarget
550+ default:
551+ decision.kind = paletteDecisionCommand
552+ decision.cmd = cmd
553+ decision.args = args
554+ decision.routeSnapshot = routeSnapshot
555+ }
556+ return decision
557+}
558+
559+func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffect) {
560+ entry := ac.currentAttachmentSession()
561+ if entry == nil {
562+ return
563+ }
564+ var effect *attachmentEffect
565+ if len(effects) != 0 {
566+ effect = effects[0]
567+ }
568+ d.executePaletteDecision(ac, entry, effect, decidePaletteInput(ac, data))
569+}
570+
571+// executePaletteDecision runs the outcome of decidePaletteInput with no palette
572+// lock held.
573+func (d *Daemon) executePaletteDecision(ac *attachedClient, entry *session, effect *attachmentEffect, decision paletteDecision) {
574+ generation, rawQuery := decision.generation, decision.rawQuery
575+ switch decision.kind {
576+ case paletteDecisionNone:
577+ case paletteDecisionCancel:
578+ if decision.hasCancelInventory {
579+ d.sendPaletteInventoryDemand(ac, effect, false, decision.cancelInventoryInteraction)
484580 }
485581 d.invalidateRender(entry, ac, true, "palette.go")
582+ case paletteDecisionRedraw:
583+ d.invalidateRender(entry, ac, true, "palette.go")
584+ case paletteDecisionCreateDestination:
585+ d.executePaletteCreateDestination(ac, entry, effect, generation, rawQuery, decision.createDestination)
586+ case paletteDecisionImportedSession:
587+ d.executePaletteImportedSession(ac, entry, effect, generation, rawQuery, decision.importedSourceKey, decision.importedEntryKey)
588+ case paletteDecisionRouteNavigation:
589+ d.executePaletteRouteNavigation(ac, entry, effect, generation, rawQuery, decision.routeTarget)
590+ case paletteDecisionSessionTarget:
591+ d.executePaletteSessionTarget(ac, entry, effect, generation, rawQuery, decision.sessionTarget)
592+ case paletteDecisionCommand:
593+ d.executePaletteCommand(ac, entry, effect, generation, rawQuery, decision)
594+ }
595+}
596+
597+func (d *Daemon) executePaletteCreateDestination(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, createDestination palette.Result) {
598+ sess := entry
599+ name, _, _, _, _, _ := createDestination.CreateSessionDestination()
600+ exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect}
601+ if err := exec.validateCreateSessionDestination(effect, createDestination); err != nil {
602+ ac.paletteFailure(generation, rawQuery, errCreateDestinationUnavailable.Error())
603+ d.invalidateRender(entry, ac, true, "palette.go")
486604 return
487605 }
488- if !execute {
489- if changed {
490- d.invalidateRender(entry, ac, true, "palette.go")
491- }
606+ if !d.closeExecutedPalette(ac, effect, generation, rawQuery) {
492607 return
493608 }
494-
495- if hasCreateDestination {
496- name, _, _, _, _, _ := createDestination.CreateSessionDestination()
497- exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect}
498- if err := exec.validateCreateSessionDestination(effect, createDestination); err != nil {
499- ac.paletteFailure(generation, rawQuery, errCreateDestinationUnavailable.Error())
500- d.invalidateRender(entry, ac, true, "palette.go")
501- return
502- }
503- if !d.closeExecutedPalette(ac, effect, generation, rawQuery) {
504- return
505- }
506- if name == "" {
507- d.enterTransitionPrompt(entry, ac, " Create session ", "", func(name string, promptEffect *attachmentEffect) error {
508- return exec.createSessionOnDestination(promptEffect, createDestination, name)
509- })
510- d.invalidateRender(entry, ac, true, "palette.go")
511- return
512- }
513- err := exec.createSessionOnValidatedDestination(effect, createDestination, name)
514- if err != nil && !errors.Is(err, errAttachmentTransition) {
515- d.reportAttachmentError(entry, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't create session", err))
516- return
517- }
518- if current := ac.currentAttachmentSession(); current != nil {
519- d.invalidateRender(current, ac, true, "palette.go")
520- }
609+ if name == "" {
610+ d.enterTransitionPrompt(entry, ac, " Create session ", "", func(name string, promptEffect *attachmentEffect) error {
611+ return exec.createSessionOnDestination(promptEffect, createDestination, name)
612+ })
613+ d.invalidateRender(entry, ac, true, "palette.go")
521614 return
522615 }
523-
524- if hasImportedTarget {
525- if effect == nil {
526- ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
527- d.invalidateRender(entry, ac, true, "palette.go")
528- return
529- }
530- if importedSourceKey != protocol.NavigationInventoryLocalSourceKey {
531- // Remote vision is read-only: imported remote routes surface
532- // availability but never attach.
533- ac.paletteFailure(generation, rawQuery, "remote sessions are visible but cannot be attached")
534- d.invalidateRender(entry, ac, true, "palette.go")
535- return
536- }
537- ac.overlays.paletteMu.Lock()
538- open := ac.overlays.paletteInventoryOpen
539- selection := protocol.NavigationInventorySelection{
540- CauseActionID: effect.uiActionID,
541- InteractionGeneration: ac.overlays.paletteInventoryInteraction,
542- PublicationGeneration: ac.overlays.paletteInventoryPublication,
543- SourceKey: importedSourceKey,
544- EntryKey: importedEntryKey,
545- }
546- if open {
547- freezePaletteInventorySelection(ac.overlays, selection)
548- }
549- ac.overlays.paletteMu.Unlock()
550- if !open || protocol.ValidateNavigationInventorySelection(selection) != nil {
551- ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
552- d.invalidateRender(entry, ac, true, "palette.go")
553- return
554- }
555- // Selection crosses before the close demand on the guarded serving
556- // connection; closing after selection must not cancel the client's
557- // pending navigation operation.
558- if err := effect.sendControl(selection); err != nil {
559- ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
560- d.invalidateRender(entry, ac, true, "palette.go")
561- return
562- }
563- d.closeExecutedPalette(ac, effect, generation, rawQuery)
616+ err := exec.createSessionOnValidatedDestination(effect, createDestination, name)
617+ if err != nil && !errors.Is(err, errAttachmentTransition) {
618+ d.reportAttachmentError(entry, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't create session", err))
564619 return
565620 }
621+ if current := ac.currentAttachmentSession(); current != nil {
622+ d.invalidateRender(current, ac, true, "palette.go")
623+ }
624+}
566625
567- if hasRouteTarget {
568- exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect}
569- if err := exec.NavigateRecentRoute(routeTarget); err != nil {
570- if errors.Is(err, errAttachmentTransition) {
571- return
572- }
573- ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
574- d.invalidateRender(entry, ac, true, "palette.go")
575- return
576- }
577- if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
578- d.invalidateRender(entry, ac, true, "palette.go")
579- }
626+func (d *Daemon) executePaletteImportedSession(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery, importedSourceKey, importedEntryKey string) {
627+ if effect == nil {
628+ ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
629+ d.invalidateRender(entry, ac, true, "palette.go")
580630 return
581631 }
632+ if importedSourceKey != protocol.NavigationInventoryLocalSourceKey {
633+ // Remote vision is read-only: imported remote routes surface
634+ // availability but never attach.
635+ ac.paletteFailure(generation, rawQuery, "remote sessions are visible but cannot be attached")
636+ d.invalidateRender(entry, ac, true, "palette.go")
637+ return
638+ }
639+ ac.overlays.paletteMu.Lock()
640+ open := ac.overlays.paletteInventoryOpen
641+ selection := protocol.NavigationInventorySelection{
642+ CauseActionID: effect.uiActionID,
643+ InteractionGeneration: ac.overlays.paletteInventoryInteraction,
644+ PublicationGeneration: ac.overlays.paletteInventoryPublication,
645+ SourceKey: importedSourceKey,
646+ EntryKey: importedEntryKey,
647+ }
648+ if open {
649+ freezePaletteInventorySelection(ac.overlays, selection)
650+ }
651+ ac.overlays.paletteMu.Unlock()
652+ if !open || protocol.ValidateNavigationInventorySelection(selection) != nil {
653+ ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
654+ d.invalidateRender(entry, ac, true, "palette.go")
655+ return
656+ }
657+ // Selection crosses before the close demand on the guarded serving
658+ // connection; closing after selection must not cancel the client's
659+ // pending navigation operation.
660+ if err := effect.sendControl(selection); err != nil {
661+ ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
662+ d.invalidateRender(entry, ac, true, "palette.go")
663+ return
664+ }
665+ d.closeExecutedPalette(ac, effect, generation, rawQuery)
666+}
582667
583- if hasSessionTarget {
584- createdAt, _ := sessionTarget.SessionCreatedAt()
585- expectedCreatedAt := createdAt.UnixNano()
586- name, _ := sessionTarget.SessionName()
587- exactTarget, _ := sessionTarget.SessionTarget()
588- target := picker.Target{
589- Incarnation: exactTarget.LifecycleID, Name: name, TabIndex: -1,
590- ExpectedCreatedAt: &expectedCreatedAt, Stopped: sessionTarget.Kind() == palette.ResultKindStoppedSession,
591- }
592- var err error
593- if effect != nil {
594- err = d.switchToTargetForAttachment(effect, target, sessionHandoffGuard{allowSamePeer: true}, "palette-session")
595- } else {
596- err = d.switchToTarget(sess, ac, target)
597- }
668+func (d *Daemon) executePaletteRouteNavigation(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, routeTarget protocol.RouteNavigationAction) {
669+ sess := entry
670+ exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect}
671+ if err := exec.NavigateRecentRoute(routeTarget); err != nil {
598672 if errors.Is(err, errAttachmentTransition) {
599673 return
600674 }
601- if err != nil {
602- ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
603- d.invalidateRender(entry, ac, true, "palette.go")
604- return
605- }
606- if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
607- if current := ac.currentAttachmentSession(); current != nil {
608- d.invalidateRender(current, ac, true, "palette.go")
609- }
610- }
675+ ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
676+ d.invalidateRender(entry, ac, true, "palette.go")
611677 return
612678 }
679+ if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
680+ d.invalidateRender(entry, ac, true, "palette.go")
681+ }
682+}
683+
684+func (d *Daemon) executePaletteSessionTarget(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, sessionTarget palette.Result) {
685+ sess := entry
686+ createdAt, _ := sessionTarget.SessionCreatedAt()
687+ expectedCreatedAt := createdAt.UnixNano()
688+ name, _ := sessionTarget.SessionName()
689+ exactTarget, _ := sessionTarget.SessionTarget()
690+ target := picker.Target{
691+ Incarnation: exactTarget.LifecycleID, Name: name, TabIndex: -1,
692+ ExpectedCreatedAt: &expectedCreatedAt, Stopped: sessionTarget.Kind() == palette.ResultKindStoppedSession,
693+ }
694+ var err error
695+ if effect != nil {
696+ err = d.switchToTargetForAttachment(effect, target, sessionHandoffGuard{allowSamePeer: true}, "palette-session")
697+ } else {
698+ err = d.switchToTarget(sess, ac, target)
699+ }
700+ if errors.Is(err, errAttachmentTransition) {
701+ return
702+ }
703+ if err != nil {
704+ ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
705+ d.invalidateRender(entry, ac, true, "palette.go")
706+ return
707+ }
708+ if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
709+ if current := ac.currentAttachmentSession(); current != nil {
710+ d.invalidateRender(current, ac, true, "palette.go")
711+ }
712+ }
713+}
613714
715+func (d *Daemon) executePaletteCommand(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, decision paletteDecision) {
716+ sess := entry
717+ cmd, args, routeSnapshot := decision.cmd, decision.args, decision.routeSnapshot
614718 if cmd.Slug == "jump-recent-session" {
615719 rank, err := command.ParsePositiveDecimal(args)
616720 if err != nil {
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "errors"4 "errors"
5 "sort"5 "sort"
6+ "sync"
6 "time"7 "time"
7 8
8 renderer "github.com/bnema/vev-vt"9 renderer "github.com/bnema/vev-vt"
@@ -292,11 +293,38 @@ func recentRouteHints(snapshot protocol.RecentRouteSnapshot, args []string) pale
292 return hints293 return hints
293 }294 }
294 295
296+// paletteHistory is the daemon-wide most-recently-used command order. mu is a
297+// leaf lock: nothing is acquired while holding it. The zero value is usable.
298+type paletteHistory struct {
299+ mu sync.Mutex
300+ recent []string
301+}
302+
303+// snapshot returns a copy of the recent command codes, newest first.
304+func (h *paletteHistory) snapshot() []string {
305+ h.mu.Lock()
306+ defer h.mu.Unlock()
307+ return append([]string(nil), h.recent...)
308+}
309+
310+// record moves code to the front of the recent list.
311+func (h *paletteHistory) record(code string) {
312+ h.mu.Lock()
313+ defer h.mu.Unlock()
314+
315+ recent := make([]string, 0, len(h.recent)+1)
316+ recent = append(recent, code)
317+ for _, existing := range h.recent {
318+ if existing != code {
319+ recent = append(recent, existing)
320+ }
321+ }
322+ h.recent = recent
323+}
324+
295 func (d *Daemon) paletteCommands() []command.Command {325 func (d *Daemon) paletteCommands() []command.Command {
296 commands := command.PaletteRegistry()326 commands := command.PaletteRegistry()
297- d.paletteRecentMu.Lock()327+ recent := d.paletteHistory.snapshot()
298- recent := append([]string(nil), d.paletteRecent...)
299- d.paletteRecentMu.Unlock()
300 overrides := d.codeOverrideSnapshot()328 overrides := d.codeOverrideSnapshot()
301 329
302 byCode := make(map[string]command.Command, len(commands))330 byCode := make(map[string]command.Command, len(commands))
@@ -324,25 +352,46 @@ func (d *Daemon) paletteCommands() []command.Command {
324 }352 }
325 353
326 func (d *Daemon) recordPaletteUse(code string) {354 func (d *Daemon) recordPaletteUse(code string) {
327- d.paletteRecentMu.Lock()355+ d.paletteHistory.record(code)
328- defer d.paletteRecentMu.Unlock()356+}
329 357
330- recent := make([]string, 0, len(d.paletteRecent)+1)358+// paletteDecisionKind names what handlePaletteInput must do once the palette
331- recent = append(recent, code)359+// lock is released.
332- for _, existing := range d.paletteRecent {360+type paletteDecisionKind int
333- if existing != code {361+
334- recent = append(recent, existing)362+const (
335- }363+ paletteDecisionNone paletteDecisionKind = iota
336- }364+ paletteDecisionRedraw
337- d.paletteRecent = recent365+ paletteDecisionCancel
366+ paletteDecisionCreateDestination
367+ paletteDecisionImportedSession
368+ paletteDecisionRouteNavigation
369+ paletteDecisionSessionTarget
370+ paletteDecisionCommand
371+)
372+
373+// paletteDecision is the immutable outcome of routing one input chunk through
374+// the palette under paletteMu. Executing it never touches palette state without
375+// retaking the lock.
376+type paletteDecision struct {
377+ kind paletteDecisionKind
378+ generation uint64
379+ rawQuery string
380+ cmd command.Command
381+ args []string
382+ routeSnapshot protocol.RecentRouteSnapshot
383+ sessionTarget palette.Result
384+ importedSourceKey string
385+ importedEntryKey string
386+ routeTarget protocol.RouteNavigationAction
387+ createDestination palette.Result
388+ cancelInventoryInteraction uint64
389+ hasCancelInventory bool
338 }390 }
339 391
340-func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffect) {392+// decidePaletteInput applies data to the attachment's palette while holding
341- entry := ac.currentAttachmentSession()393+// paletteMu and returns what the caller must do after the lock is released.
342- if entry == nil {394+func decidePaletteInput(ac *attachedClient, data []byte) paletteDecision {
343- return
344- }
345- sess := entry
346 var cmd command.Command395 var cmd command.Command
347 var sessionTarget palette.Result396 var sessionTarget palette.Result
348 var hasSessionTarget bool397 var hasSessionTarget bool
@@ -359,16 +408,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
359 var cancelInventoryInteraction uint64408 var cancelInventoryInteraction uint64
360 var hasCancelInventory bool409 var hasCancelInventory bool
361 changed, cancel, execute, chooseDestination := false, false, false, false410 changed, cancel, execute, chooseDestination := false, false, false, false
362- var effect *attachmentEffect
363- if len(effects) != 0 {
364- effect = effects[0]
365- }
366 411
367 ac.overlays.paletteMu.Lock()412 ac.overlays.paletteMu.Lock()
368 if ac.overlays.palette == nil {413 if ac.overlays.palette == nil {
369 ac.overlays.palettePending = nil414 ac.overlays.palettePending = nil
370 ac.overlays.paletteMu.Unlock()415 ac.overlays.paletteMu.Unlock()
371- return416+ return paletteDecision{}
372 }417 }
373 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{418 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
374 rune: func(r rune) {419 rune: func(r rune) {
@@ -478,139 +523,198 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
478 ac.clearPaletteLocked()523 ac.clearPaletteLocked()
479 }524 }
480 ac.overlays.paletteMu.Unlock()525 ac.overlays.paletteMu.Unlock()
481- if cancel {526+
482- if hasCancelInventory {527+ switch {
483- d.sendPaletteInventoryDemand(ac, effect, false, cancelInventoryInteraction)528+ case cancel:
529+ return paletteDecision{kind: paletteDecisionCancel, cancelInventoryInteraction: cancelInventoryInteraction, hasCancelInventory: hasCancelInventory}
530+ case !execute && changed:
531+ return paletteDecision{kind: paletteDecisionRedraw}
532+ case !execute:
533+ return paletteDecision{}
534+ }
535+ decision := paletteDecision{generation: generation, rawQuery: rawQuery}
536+ switch {
537+ case hasCreateDestination:
538+ decision.kind = paletteDecisionCreateDestination
539+ decision.createDestination = createDestination
540+ case hasImportedTarget:
541+ decision.kind = paletteDecisionImportedSession
542+ decision.importedSourceKey = importedSourceKey
543+ decision.importedEntryKey = importedEntryKey
544+ case hasRouteTarget:
545+ decision.kind = paletteDecisionRouteNavigation
546+ decision.routeTarget = routeTarget
547+ case hasSessionTarget:
548+ decision.kind = paletteDecisionSessionTarget
549+ decision.sessionTarget = sessionTarget
550+ default:
551+ decision.kind = paletteDecisionCommand
552+ decision.cmd = cmd
553+ decision.args = args
554+ decision.routeSnapshot = routeSnapshot
555+ }
556+ return decision
557+}
558+
559+func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffect) {
560+ entry := ac.currentAttachmentSession()
561+ if entry == nil {
562+ return
563+ }
564+ var effect *attachmentEffect
565+ if len(effects) != 0 {
566+ effect = effects[0]
567+ }
568+ d.executePaletteDecision(ac, entry, effect, decidePaletteInput(ac, data))
569+}
570+
571+// executePaletteDecision runs the outcome of decidePaletteInput with no palette
572+// lock held.
573+func (d *Daemon) executePaletteDecision(ac *attachedClient, entry *session, effect *attachmentEffect, decision paletteDecision) {
574+ generation, rawQuery := decision.generation, decision.rawQuery
575+ switch decision.kind {
576+ case paletteDecisionNone:
577+ case paletteDecisionCancel:
578+ if decision.hasCancelInventory {
579+ d.sendPaletteInventoryDemand(ac, effect, false, decision.cancelInventoryInteraction)
484 }580 }
485 d.invalidateRender(entry, ac, true, "palette.go")581 d.invalidateRender(entry, ac, true, "palette.go")
582+ case paletteDecisionRedraw:
583+ d.invalidateRender(entry, ac, true, "palette.go")
584+ case paletteDecisionCreateDestination:
585+ d.executePaletteCreateDestination(ac, entry, effect, generation, rawQuery, decision.createDestination)
586+ case paletteDecisionImportedSession:
587+ d.executePaletteImportedSession(ac, entry, effect, generation, rawQuery, decision.importedSourceKey, decision.importedEntryKey)
588+ case paletteDecisionRouteNavigation:
589+ d.executePaletteRouteNavigation(ac, entry, effect, generation, rawQuery, decision.routeTarget)
590+ case paletteDecisionSessionTarget:
591+ d.executePaletteSessionTarget(ac, entry, effect, generation, rawQuery, decision.sessionTarget)
592+ case paletteDecisionCommand:
593+ d.executePaletteCommand(ac, entry, effect, generation, rawQuery, decision)
594+ }
595+}
596+
597+func (d *Daemon) executePaletteCreateDestination(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, createDestination palette.Result) {
598+ sess := entry
599+ name, _, _, _, _, _ := createDestination.CreateSessionDestination()
600+ exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect}
601+ if err := exec.validateCreateSessionDestination(effect, createDestination); err != nil {
602+ ac.paletteFailure(generation, rawQuery, errCreateDestinationUnavailable.Error())
603+ d.invalidateRender(entry, ac, true, "palette.go")
486 return604 return
487 }605 }
488- if !execute {606+ if !d.closeExecutedPalette(ac, effect, generation, rawQuery) {
489- if changed {
490- d.invalidateRender(entry, ac, true, "palette.go")
491- }
492 return607 return
493 }608 }
494-609+ if name == "" {
495- if hasCreateDestination {610+ d.enterTransitionPrompt(entry, ac, " Create session ", "", func(name string, promptEffect *attachmentEffect) error {
496- name, _, _, _, _, _ := createDestination.CreateSessionDestination()611+ return exec.createSessionOnDestination(promptEffect, createDestination, name)
497- exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect}612+ })
498- if err := exec.validateCreateSessionDestination(effect, createDestination); err != nil {613+ d.invalidateRender(entry, ac, true, "palette.go")
499- ac.paletteFailure(generation, rawQuery, errCreateDestinationUnavailable.Error())
500- d.invalidateRender(entry, ac, true, "palette.go")
501- return
502- }
503- if !d.closeExecutedPalette(ac, effect, generation, rawQuery) {
504- return
505- }
506- if name == "" {
507- d.enterTransitionPrompt(entry, ac, " Create session ", "", func(name string, promptEffect *attachmentEffect) error {
508- return exec.createSessionOnDestination(promptEffect, createDestination, name)
509- })
510- d.invalidateRender(entry, ac, true, "palette.go")
511- return
512- }
513- err := exec.createSessionOnValidatedDestination(effect, createDestination, name)
514- if err != nil && !errors.Is(err, errAttachmentTransition) {
515- d.reportAttachmentError(entry, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't create session", err))
516- return
517- }
518- if current := ac.currentAttachmentSession(); current != nil {
519- d.invalidateRender(current, ac, true, "palette.go")
520- }
521 return614 return
522 }615 }
523-616+ err := exec.createSessionOnValidatedDestination(effect, createDestination, name)
524- if hasImportedTarget {617+ if err != nil && !errors.Is(err, errAttachmentTransition) {
525- if effect == nil {618+ d.reportAttachmentError(entry, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't create session", err))
526- ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
527- d.invalidateRender(entry, ac, true, "palette.go")
528- return
529- }
530- if importedSourceKey != protocol.NavigationInventoryLocalSourceKey {
531- // Remote vision is read-only: imported remote routes surface
532- // availability but never attach.
533- ac.paletteFailure(generation, rawQuery, "remote sessions are visible but cannot be attached")
534- d.invalidateRender(entry, ac, true, "palette.go")
535- return
536- }
537- ac.overlays.paletteMu.Lock()
538- open := ac.overlays.paletteInventoryOpen
539- selection := protocol.NavigationInventorySelection{
540- CauseActionID: effect.uiActionID,
541- InteractionGeneration: ac.overlays.paletteInventoryInteraction,
542- PublicationGeneration: ac.overlays.paletteInventoryPublication,
543- SourceKey: importedSourceKey,
544- EntryKey: importedEntryKey,
545- }
546- if open {
547- freezePaletteInventorySelection(ac.overlays, selection)
548- }
549- ac.overlays.paletteMu.Unlock()
550- if !open || protocol.ValidateNavigationInventorySelection(selection) != nil {
551- ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
552- d.invalidateRender(entry, ac, true, "palette.go")
553- return
554- }
555- // Selection crosses before the close demand on the guarded serving
556- // connection; closing after selection must not cancel the client's
557- // pending navigation operation.
558- if err := effect.sendControl(selection); err != nil {
559- ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
560- d.invalidateRender(entry, ac, true, "palette.go")
561- return
562- }
563- d.closeExecutedPalette(ac, effect, generation, rawQuery)
564 return619 return
565 }620 }
621+ if current := ac.currentAttachmentSession(); current != nil {
622+ d.invalidateRender(current, ac, true, "palette.go")
623+ }
624+}
566 625
567- if hasRouteTarget {626+func (d *Daemon) executePaletteImportedSession(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery, importedSourceKey, importedEntryKey string) {
568- exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect}627+ if effect == nil {
569- if err := exec.NavigateRecentRoute(routeTarget); err != nil {628+ ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
570- if errors.Is(err, errAttachmentTransition) {629+ d.invalidateRender(entry, ac, true, "palette.go")
571- return
572- }
573- ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
574- d.invalidateRender(entry, ac, true, "palette.go")
575- return
576- }
577- if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
578- d.invalidateRender(entry, ac, true, "palette.go")
579- }
580 return630 return
581 }631 }
632+ if importedSourceKey != protocol.NavigationInventoryLocalSourceKey {
633+ // Remote vision is read-only: imported remote routes surface
634+ // availability but never attach.
635+ ac.paletteFailure(generation, rawQuery, "remote sessions are visible but cannot be attached")
636+ d.invalidateRender(entry, ac, true, "palette.go")
637+ return
638+ }
639+ ac.overlays.paletteMu.Lock()
640+ open := ac.overlays.paletteInventoryOpen
641+ selection := protocol.NavigationInventorySelection{
642+ CauseActionID: effect.uiActionID,
643+ InteractionGeneration: ac.overlays.paletteInventoryInteraction,
644+ PublicationGeneration: ac.overlays.paletteInventoryPublication,
645+ SourceKey: importedSourceKey,
646+ EntryKey: importedEntryKey,
647+ }
648+ if open {
649+ freezePaletteInventorySelection(ac.overlays, selection)
650+ }
651+ ac.overlays.paletteMu.Unlock()
652+ if !open || protocol.ValidateNavigationInventorySelection(selection) != nil {
653+ ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
654+ d.invalidateRender(entry, ac, true, "palette.go")
655+ return
656+ }
657+ // Selection crosses before the close demand on the guarded serving
658+ // connection; closing after selection must not cancel the client's
659+ // pending navigation operation.
660+ if err := effect.sendControl(selection); err != nil {
661+ ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
662+ d.invalidateRender(entry, ac, true, "palette.go")
663+ return
664+ }
665+ d.closeExecutedPalette(ac, effect, generation, rawQuery)
666+}
582 667
583- if hasSessionTarget {668+func (d *Daemon) executePaletteRouteNavigation(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, routeTarget protocol.RouteNavigationAction) {
584- createdAt, _ := sessionTarget.SessionCreatedAt()669+ sess := entry
585- expectedCreatedAt := createdAt.UnixNano()670+ exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect}
586- name, _ := sessionTarget.SessionName()671+ if err := exec.NavigateRecentRoute(routeTarget); err != nil {
587- exactTarget, _ := sessionTarget.SessionTarget()
588- target := picker.Target{
589- Incarnation: exactTarget.LifecycleID, Name: name, TabIndex: -1,
590- ExpectedCreatedAt: &expectedCreatedAt, Stopped: sessionTarget.Kind() == palette.ResultKindStoppedSession,
591- }
592- var err error
593- if effect != nil {
594- err = d.switchToTargetForAttachment(effect, target, sessionHandoffGuard{allowSamePeer: true}, "palette-session")
595- } else {
596- err = d.switchToTarget(sess, ac, target)
597- }
598 if errors.Is(err, errAttachmentTransition) {672 if errors.Is(err, errAttachmentTransition) {
599 return673 return
600 }674 }
601- if err != nil {675+ ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
602- ac.paletteFailure(generation, rawQuery, "requested session is unavailable")676+ d.invalidateRender(entry, ac, true, "palette.go")
603- d.invalidateRender(entry, ac, true, "palette.go")
604- return
605- }
606- if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
607- if current := ac.currentAttachmentSession(); current != nil {
608- d.invalidateRender(current, ac, true, "palette.go")
609- }
610- }
611 return677 return
612 }678 }
679+ if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
680+ d.invalidateRender(entry, ac, true, "palette.go")
681+ }
682+}
683+
684+func (d *Daemon) executePaletteSessionTarget(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, sessionTarget palette.Result) {
685+ sess := entry
686+ createdAt, _ := sessionTarget.SessionCreatedAt()
687+ expectedCreatedAt := createdAt.UnixNano()
688+ name, _ := sessionTarget.SessionName()
689+ exactTarget, _ := sessionTarget.SessionTarget()
690+ target := picker.Target{
691+ Incarnation: exactTarget.LifecycleID, Name: name, TabIndex: -1,
692+ ExpectedCreatedAt: &expectedCreatedAt, Stopped: sessionTarget.Kind() == palette.ResultKindStoppedSession,
693+ }
694+ var err error
695+ if effect != nil {
696+ err = d.switchToTargetForAttachment(effect, target, sessionHandoffGuard{allowSamePeer: true}, "palette-session")
697+ } else {
698+ err = d.switchToTarget(sess, ac, target)
699+ }
700+ if errors.Is(err, errAttachmentTransition) {
701+ return
702+ }
703+ if err != nil {
704+ ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
705+ d.invalidateRender(entry, ac, true, "palette.go")
706+ return
707+ }
708+ if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
709+ if current := ac.currentAttachmentSession(); current != nil {
710+ d.invalidateRender(current, ac, true, "palette.go")
711+ }
712+ }
713+}
613 714
715+func (d *Daemon) executePaletteCommand(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, decision paletteDecision) {
716+ sess := entry
717+ cmd, args, routeSnapshot := decision.cmd, decision.args, decision.routeSnapshot
614 if cmd.Slug == "jump-recent-session" {718 if cmd.Slug == "jump-recent-session" {
615 rank, err := command.ParsePositiveDecimal(args)719 rank, err := command.ParsePositiveDecimal(args)
616 if err != nil {720 if err != nil {
M internal/usecase/daemon/palette_test.go +1 -1
@@ -387,7 +387,7 @@ func TestPaletteSelectedActiveSessionSwitchesWithoutRecordingCommandRecency(t *t
387387 require.Same(t, target, ac.currentSession())
388388 require.Contains(t, target.snapshotAttachments(), ac, "canonical handoff reuses the attached client")
389389 require.False(t, ac.overlays.paletteActive())
390- require.Empty(t, d.paletteRecent, "session selections are not command recency")
390+ require.Empty(t, d.paletteHistory.recent, "session selections are not command recency")
391391 awaitFrame(t, sends, "Output")
392392 awaitFrame(t, sends, "Output")
393393 }
@@ -387,7 +387,7 @@ func TestPaletteSelectedActiveSessionSwitchesWithoutRecordingCommandRecency(t *t
387 require.Same(t, target, ac.currentSession())387 require.Same(t, target, ac.currentSession())
388 require.Contains(t, target.snapshotAttachments(), ac, "canonical handoff reuses the attached client")388 require.Contains(t, target.snapshotAttachments(), ac, "canonical handoff reuses the attached client")
389 require.False(t, ac.overlays.paletteActive())389 require.False(t, ac.overlays.paletteActive())
390- require.Empty(t, d.paletteRecent, "session selections are not command recency")390+ require.Empty(t, d.paletteHistory.recent, "session selections are not command recency")
391 awaitFrame(t, sends, "Output")391 awaitFrame(t, sends, "Output")
392 awaitFrame(t, sends, "Output")392 awaitFrame(t, sends, "Output")
393 }393 }

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