1package daemon
2
3import (
4 "errors"
5 "sort"
6 "sync"
7 "time"
8
9 renderer "github.com/bnema/vev-vt"
10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/domain/layout"
12 "github.com/bnema/vev/internal/protocol"
13 "github.com/bnema/vev/internal/usecase/command"
14 "github.com/bnema/vev/internal/usecase/palette"
15 "github.com/bnema/vev/internal/usecase/picker"
16 themeui "github.com/bnema/vev/internal/usecase/theme"
17 "github.com/bnema/vev/internal/usecase/ui"
18)
19
20var (
21 // Floating palettes use a compact rail; below ui.ResponsiveDrawerBreakpoint
22 // the presentation becomes a full-width drawer regardless of this width.
23 paletteModal = ui.Modal{FixedWidth: 64, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottomRight, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}}
24 errCreateDestinationUnavailable = errors.New("that destination is no longer available")
25)
26
27func paletteModalFor(cfg domain.PaletteConfig) ui.Modal {
28 modal := paletteModal
29 if cfg.AnchorSet {
30 modal.Anchor = cfg.Anchor
31 }
32 return modal
33}
34
35func (d *Daemon) enterPalette(sess *session, ac *attachedClient) uint64 {
36 // Capture the client-owned route snapshot before taking paletteMu. The
37 // snapshot is immutable for this palette interaction and the daemon never
38 // consults its own session history for recent-route commands.
39 routeSnapshot := ac.routeSnapshotCopy()
40 commands := d.paletteCommands()
41 results := d.paletteResults(sess, commands, routeSnapshot)
42 model := palette.New(results)
43 ac.overlays.paletteMu.Lock()
44 ac.overlays.paletteGeneration++
45 ac.overlays.palette = model
46 ac.overlays.paletteRouteSnapshot = routeSnapshot
47 ac.overlays.paletteHints = palette.ContextualHints{}
48 ac.overlays.palettePreview = ""
49 ac.overlays.paletteFeedback = ""
50 ac.overlays.palettePending = nil
51 interaction := openPaletteInventory(ac.overlays)
52 ac.overlays.paletteMu.Unlock()
53 d.invalidateRender(sess, ac, true, "palette.go")
54 return interaction
55}
56
57type paletteSessionIdentity struct {
58 kind protocol.RouteKind
59 endpoint string
60 lifecycle domain.SessionLifecycleID
61}
62
63func localPaletteSessionIdentity(lifecycle domain.SessionLifecycleID) paletteSessionIdentity {
64 return paletteSessionIdentity{kind: protocol.RouteKindLocal, endpoint: "local", lifecycle: lifecycle}
65}
66
67func paletteRemoteDisplayOrigin(hostLabel string) string {
68 origin := domain.RemoteDisplayOrigin(hostLabel)
69 if origin == "" {
70 return "remote"
71 }
72 return origin
73}
74
75func paletteDaemonDisplayOrigin(snapshot protocol.RecentRouteSnapshot, lifecycle domain.SessionLifecycleID) string {
76 active, ok := activeRouteEntryForLifecycle(snapshot, lifecycle)
77 if !ok || active.Kind != protocol.RouteKindRemote {
78 return ""
79 }
80 return paletteRemoteDisplayOrigin(active.HostLabel)
81}
82
83func routeEntryForRef(snapshot protocol.RecentRouteSnapshot, ref protocol.RouteRef) (protocol.RecentRouteEntry, bool) {
84 if ref.IsZero() {
85 return protocol.RecentRouteEntry{}, false
86 }
87 if snapshot.Active == ref {
88 entryRef := protocol.RouteRef{Key: snapshot.ActiveEntry.Key, Generation: snapshot.ActiveEntry.Generation}
89 if entryRef == ref {
90 return snapshot.ActiveEntry, true
91 }
92 }
93 for _, entry := range snapshot.Entries {
94 if entry.Key == ref.Key && entry.Generation == ref.Generation {
95 return entry, true
96 }
97 }
98 return protocol.RecentRouteEntry{}, false
99}
100
101// createSessionDestinationResults lists where CNS may create a session: the
102// serving daemon itself, and every other daemon the client published as a
103// route host. Only the serving daemon creates in place; every
104// other destination is created by the client through its broker.
105func createSessionDestinationResults(snapshot protocol.RecentRouteSnapshot, currentLifecycle domain.SessionLifecycleID) []palette.Result {
106 results := make([]palette.Result, 0, len(snapshot.Hosts)+1)
107 if home, ok := routeEntryForRef(snapshot, snapshot.Home); ok && home.Kind == protocol.RouteKindLocal {
108 results = append(results, palette.NewCreateSessionDestination(
109 palette.CreateSessionOnLocalRoute, "", "", snapshot.Home, snapshot.Generation,
110 ))
111 }
112 if active, ok := activeRouteEntryForLifecycle(snapshot, currentLifecycle); ok && active.Kind == protocol.RouteKindRemote {
113 results = append(results, palette.NewCreateSessionDestination(
114 palette.CreateSessionOnServingDaemon, paletteRemoteDisplayOrigin(active.HostLabel), "", protocol.RouteRef{}, 0,
115 ))
116 }
117 for _, host := range snapshot.Hosts {
118 ref := protocol.RouteRef{Key: host.Key, Generation: host.Generation}
119 if host.Kind == protocol.RouteKindLocal {
120 results = append(results, palette.NewCreateSessionDestination(
121 palette.CreateSessionOnLocalRoute, "", "", ref, snapshot.Generation,
122 ))
123 continue
124 }
125 results = append(results, palette.NewCreateSessionDestination(
126 palette.CreateSessionOnRemoteHost, paletteRemoteDisplayOrigin(host.Label), "", ref, snapshot.Generation,
127 ))
128 }
129 return results
130}
131
132// routeHostForRef finds one creation host of the snapshot.
133func routeHostForRef(snapshot protocol.RecentRouteSnapshot, ref protocol.RouteRef) (protocol.RouteHost, bool) {
134 for _, host := range snapshot.Hosts {
135 if host.Key == ref.Key && host.Generation == ref.Generation && !ref.IsZero() {
136 return host, true
137 }
138 }
139 return protocol.RouteHost{}, false
140}
141
142// routeCreationKind reports the kind of daemon one creation reference names:
143// a route entry (or the active route) or a route host.
144func routeCreationKind(snapshot protocol.RecentRouteSnapshot, ref protocol.RouteRef) (protocol.RouteKind, bool) {
145 if entry, ok := routeEntryForRef(snapshot, ref); ok {
146 return entry.Kind, true
147 }
148 if host, ok := routeHostForRef(snapshot, ref); ok {
149 return host.Kind, true
150 }
151 return 0, false
152}
153
154func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemonDisplayOrigin string) bool {
155 if entry.Kind == protocol.RouteKindLocal {
156 return true
157 }
158 return entry.Kind == protocol.RouteKindRemote && daemonDisplayOrigin != "" &&
159 paletteRemoteDisplayOrigin(entry.HostLabel) == daemonDisplayOrigin
160}
161
162// paletteResults projects the daemon's own inventory plus the client's route
163// snapshot for the palette. It keeps quick-switch exclusions, compact labels,
164// commands, and history behavior; other daemons' sessions and hosts come only
165// from the client routes, and committing one navigates through
166// the client.
167func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot protocol.RecentRouteSnapshot) []palette.Result {
168 inv := d.captureSessionInventory(viewOptions{}, false)
169 stopped := inv.resumableStopped()
170 represented := make(map[paletteSessionIdentity]struct{}, len(inv.live)+len(inv.stopped)+len(routeSnapshot.Entries))
171 for _, entry := range inv.stopped {
172 represented[localPaletteSessionIdentity(entry.incarnation)] = struct{}{}
173 }
174 var currentLifecycle domain.SessionLifecycleID
175 if current != nil {
176 current.mu.Lock()
177 currentLifecycle = current.incarnation
178 current.mu.Unlock()
179 }
180 daemonDisplayOrigin := paletteDaemonDisplayOrigin(routeSnapshot, currentLifecycle)
181 hybrid := len(routeSnapshot.Hosts) > 0 || daemonDisplayOrigin != ""
182 for _, entry := range routeSnapshot.Entries {
183 hybrid = hybrid || entry.Kind == protocol.RouteKindRemote
184 }
185 if daemonDisplayOrigin == "" && hybrid {
186 daemonDisplayOrigin = "local"
187 }
188
189 destinations := createSessionDestinationResults(routeSnapshot, currentLifecycle)
190 results := make([]palette.Result, 0, len(commands)+len(destinations)+len(inv.live)+len(stopped)+len(routeSnapshot.Entries))
191 for _, cmd := range commands {
192 results = append(results, palette.NewCommandResult(cmd))
193 }
194 results = append(results, destinations...)
195 active := make([]palette.Result, 0, len(inv.live))
196 for _, candidate := range inv.live {
197 snap := candidate.view
198 if snap.name == "" || snap.ephemeral {
199 continue
200 }
201 represented[localPaletteSessionIdentity(snap.incarnation)] = struct{}{}
202 if candidate.sess == current {
203 continue
204 }
205 target := protocol.ExactSessionTarget{LifecycleID: snap.incarnation, SessionName: snap.name}
206 active = append(active, palette.NewActiveSessionResultWithDisplayOrigin(target, time.Unix(0, snap.createdAt), daemonDisplayOrigin))
207 }
208 sort.Slice(active, func(i, j int) bool {
209 left, _ := active[i].SessionName()
210 right, _ := active[j].SessionName()
211 return left < right
212 })
213 sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })
214 results = append(results, active...)
215 for _, candidate := range stopped {
216 target := protocol.ExactSessionTarget{LifecycleID: candidate.incarnation, SessionName: candidate.name}
217 results = append(results, palette.NewStoppedSessionResultWithDisplayOrigin(target, time.Unix(0, candidate.createdAt), daemonDisplayOrigin))
218 represented[localPaletteSessionIdentity(target.LifecycleID)] = struct{}{}
219 }
220 for _, entry := range routeSnapshot.Entries {
221 if entry.Name == "" {
222 continue
223 }
224 origin := ""
225 if hybrid {
226 origin = "local"
227 }
228 if entry.Kind == protocol.RouteKindRemote {
229 origin = paletteRemoteDisplayOrigin(entry.HostLabel)
230 }
231 label := palette.SessionDisplay(entry.Name, origin)
232 identity := localPaletteSessionIdentity(entry.Target.LifecycleID)
233 if _, exists := represented[identity]; exists && paletteRouteRepresentsDaemonSession(entry, daemonDisplayOrigin) {
234 continue
235 }
236 results = append(results, palette.NewRecentRouteResult(entry.Target.SessionName, label, protocol.RouteNavigationAction{
237 SnapshotGeneration: routeSnapshot.Generation,
238 Key: entry.Key,
239 Generation: entry.Generation,
240 }))
241 if entry.Kind == protocol.RouteKindLocal {
242 represented[identity] = struct{}{}
243 }
244 }
245 return results
246}
247
248func (d *Daemon) refreshPalette(ac *attachedClient) {
249 if d == nil || ac == nil || ac.overlays == nil {
250 return
251 }
252 sess := ac.currentAttachmentSession()
253 if sess == nil {
254 return
255 }
256 rt := ac.overlays
257 rt.paletteMu.Lock()
258 model := rt.palette
259 generation := rt.paletteGeneration
260 routeSnapshot := rt.paletteRouteSnapshot
261 routeSnapshot.Entries = append([]protocol.RecentRouteEntry(nil), routeSnapshot.Entries...)
262 rt.paletteMu.Unlock()
263 if model == nil {
264 return
265 }
266
267 results := d.paletteResults(sess, d.paletteCommands(), routeSnapshot)
268 results = appendImportedResults(results, ac)
269 rt.paletteMu.Lock()
270 defer rt.paletteMu.Unlock()
271 if rt.paletteGeneration != generation || rt.palette != model {
272 return
273 }
274 model.ReplaceResults(results)
275}
276
277func recentRouteHints(snapshot protocol.RecentRouteSnapshot, args []string) palette.ContextualHints {
278 snapshot = historyRouteSnapshot(snapshot)
279 formatted := formatRecentRouteSnapshot(snapshot)
280 names := make([]string, len(formatted))
281 for i, entry := range formatted {
282 names[i] = entry.name
283 }
284 hints := palette.BuildRecentSessionHints(names, args)
285 for i, entry := range snapshot.Entries {
286 if i >= len(hints.Recent) {
287 break
288 }
289 hints.Recent[i].SnapshotGeneration = snapshot.Generation
290 hints.Recent[i].Key = entry.Key
291 hints.Recent[i].Generation = entry.Generation
292 }
293 return hints
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.
298type paletteHistory struct {
299 mu sync.Mutex
300 recent []string
301}
302
303// snapshot returns a copy of the recent command codes, newest first.
304func (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.
311func (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
325func (d *Daemon) paletteCommands() []command.Command {
326 commands := command.PaletteRegistry()
327 recent := d.paletteHistory.snapshot()
328 overrides := d.codeOverrideSnapshot()
329
330 byCode := make(map[string]command.Command, len(commands))
331 for _, cmd := range commands {
332 cmd = commandWithOverrides(cmd, overrides)
333 byCode[cmd.Code] = cmd
334 }
335 out := make([]command.Command, 0, len(commands))
336 used := make(map[string]bool, len(recent))
337 for _, code := range recent {
338 cmd, ok := byCode[code]
339 if !ok || used[code] {
340 continue
341 }
342 out = append(out, cmd)
343 used[code] = true
344 }
345 for _, cmd := range commands {
346 cmd = commandWithOverrides(cmd, overrides)
347 if !used[cmd.Code] {
348 out = append(out, cmd)
349 }
350 }
351 return out
352}
353
354// paletteDecisionKind names what handlePaletteInput must do once the palette
355// lock is released.
356type paletteDecisionKind int
357
358const (
359 paletteDecisionNone paletteDecisionKind = iota
360 paletteDecisionRedraw
361 paletteDecisionCancel
362 paletteDecisionCreateDestination
363 paletteDecisionImportedSession
364 paletteDecisionRouteNavigation
365 paletteDecisionSessionTarget
366 paletteDecisionCommand
367)
368
369// paletteDecision is the immutable outcome of routing one input chunk through
370// the palette under paletteMu. Executing it never touches palette state without
371// retaking the lock.
372type paletteDecision struct {
373 kind paletteDecisionKind
374 generation uint64
375 rawQuery string
376 cmd command.Command
377 args []string
378 routeSnapshot protocol.RecentRouteSnapshot
379 sessionTarget palette.Result
380 importedSourceKey string
381 importedEntryKey string
382 routeTarget protocol.RouteNavigationAction
383 createDestination palette.Result
384 cancelInventoryInteraction uint64
385 hasCancelInventory bool
386}
387
388// decidePaletteInput takes paletteMu, applies data to the attachment's palette,
389// and returns the action to run after unlocking.
390func decidePaletteInput(ac *attachedClient, data []byte) paletteDecision {
391 // decision holds the executed selection; execute reports one was made.
392 var decision paletteDecision
393 changed, cancel, execute, chooseDestination := false, false, false, false
394
395 ac.overlays.paletteMu.Lock()
396 if ac.overlays.palette == nil {
397 ac.overlays.palettePending = nil
398 ac.overlays.paletteMu.Unlock()
399 return paletteDecision{}
400 }
401 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
402 rune: func(r rune) {
403 if !execute {
404 ac.overlays.palette.Insert(r)
405 changed = true
406 }
407 },
408 backspace: func() {
409 if !execute {
410 ac.overlays.palette.Backspace()
411 changed = true
412 }
413 },
414 tab: func() {
415 if !execute {
416 changed = ac.overlays.palette.CompleteSelected() || changed
417 }
418 },
419 up: func() {
420 if !execute {
421 ac.overlays.palette.Up()
422 changed = true
423 }
424 },
425 down: func() {
426 if !execute {
427 ac.overlays.palette.Down()
428 changed = true
429 }
430 },
431 cancel: func() {
432 if !execute {
433 cancel = true
434 }
435 },
436 enter: func() {
437 if execute {
438 return
439 }
440 selected, ok := ac.overlays.palette.Selected()
441 if !ok {
442 changed = true
443 chooseDestination = true
444 return
445 }
446 next := paletteDecision{rawQuery: ac.overlays.palette.Query(), generation: ac.overlays.paletteGeneration}
447 if cmd, ok := selected.Command(); ok {
448 next.kind, next.cmd = paletteDecisionCommand, cmd
449 if cmd.Arguments != command.ArgumentsNone {
450 action, valid := palette.ParseAction([]palette.Result{selected}, next.rawQuery)
451 if valid {
452 next.args = action.Args
453 } else if cmd.Arguments == command.ArgumentsRequired {
454 changed = true
455 return
456 }
457 }
458 if cmd.ContextHint == command.ContextHintRecentSessions {
459 next.routeSnapshot = ac.overlays.paletteRouteSnapshot
460 next.routeSnapshot.Entries = append([]protocol.RecentRouteEntry(nil), next.routeSnapshot.Entries...)
461 }
462 } else if sourceKey, entryKey, ok := selected.ImportedSessionKey(); ok {
463 next.kind, next.importedSourceKey, next.importedEntryKey = paletteDecisionImportedSession, sourceKey, entryKey
464 } else if action, ok := selected.RouteNavigationAction(); ok {
465 next.kind, next.routeTarget = paletteDecisionRouteNavigation, action
466 } else if _, _, _, _, _, ok := selected.CreateSessionDestination(); ok {
467 next.kind, next.createDestination = paletteDecisionCreateDestination, selected
468 } else if _, ok := selected.SessionTarget(); ok {
469 next.kind, next.sessionTarget = paletteDecisionSessionTarget, selected
470 } else {
471 changed = true
472 return
473 }
474 decision = next
475 execute = true
476 },
477 })
478 if changed {
479 ac.overlays.paletteFeedback = ""
480 if chooseDestination {
481 ac.overlays.paletteFeedback = "Choose a destination"
482 }
483 ac.overlays.palettePreview = ""
484 active, ok := ac.overlays.palette.ArgumentCommand()
485 if ok {
486 ac.overlays.palettePreview = palette.Preview(active, ac.overlays.palette.Query())
487 }
488 if ok && active.Slug == "jump-recent-session" {
489 args := paletteArgs(ac.overlays.palette.Query(), active)
490 if ac.overlays.paletteRouteSnapshot.Generation != 0 {
491 ac.overlays.paletteHints = recentRouteHints(ac.overlays.paletteRouteSnapshot, args)
492 } else {
493 ac.overlays.paletteHints = palette.ContextualHints{}
494 }
495 } else {
496 ac.overlays.paletteHints = palette.ContextualHints{}
497 }
498 }
499 if cancel {
500 cancelled := paletteDecision{kind: paletteDecisionCancel}
501 cancelled.cancelInventoryInteraction, cancelled.hasCancelInventory = takePaletteInventoryClose(ac.overlays)
502 ac.clearPaletteLocked()
503 ac.overlays.paletteMu.Unlock()
504 return cancelled
505 }
506 ac.overlays.paletteMu.Unlock()
507
508 switch {
509 case execute:
510 return decision
511 case changed:
512 return paletteDecision{kind: paletteDecisionRedraw}
513 default:
514 return paletteDecision{}
515 }
516}
517
518func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffect) {
519 entry := ac.currentAttachmentSession()
520 if entry == nil {
521 return
522 }
523 var effect *attachmentEffect
524 if len(effects) != 0 {
525 effect = effects[0]
526 }
527 d.executePaletteDecision(ac, entry, effect, decidePaletteInput(ac, data))
528}
529
530// executePaletteDecision runs the outcome of decidePaletteInput with no palette
531// lock held.
532func (d *Daemon) executePaletteDecision(ac *attachedClient, entry *session, effect *attachmentEffect, decision paletteDecision) {
533 generation, rawQuery := decision.generation, decision.rawQuery
534 switch decision.kind {
535 case paletteDecisionNone:
536 case paletteDecisionCancel:
537 if decision.hasCancelInventory {
538 d.sendPaletteInventoryDemand(ac, effect, false, decision.cancelInventoryInteraction)
539 }
540 d.invalidateRender(entry, ac, true, "palette.go")
541 case paletteDecisionRedraw:
542 d.invalidateRender(entry, ac, true, "palette.go")
543 case paletteDecisionCreateDestination:
544 d.executePaletteCreateDestination(ac, entry, effect, generation, rawQuery, decision.createDestination)
545 case paletteDecisionImportedSession:
546 d.executePaletteImportedSession(ac, entry, effect, generation, rawQuery, decision.importedSourceKey, decision.importedEntryKey)
547 case paletteDecisionRouteNavigation:
548 d.executePaletteRouteNavigation(ac, entry, effect, generation, rawQuery, decision.routeTarget)
549 case paletteDecisionSessionTarget:
550 d.executePaletteSessionTarget(ac, entry, effect, generation, rawQuery, decision.sessionTarget)
551 case paletteDecisionCommand:
552 d.executePaletteCommand(ac, entry, effect, generation, rawQuery, decision.cmd, decision.args, decision.routeSnapshot)
553 }
554}
555
556func (d *Daemon) executePaletteCreateDestination(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, createDestination palette.Result) {
557 name, _, _, _, _, _ := createDestination.CreateSessionDestination()
558 exec := paletteExec{d: d, sess: entry, attachment: entry, ac: ac, effect: effect}
559 if err := exec.validateCreateSessionDestination(effect, createDestination); err != nil {
560 ac.paletteFailure(generation, rawQuery, errCreateDestinationUnavailable.Error())
561 d.invalidateRender(entry, ac, true, "palette.go")
562 return
563 }
564 if !d.closeExecutedPalette(ac, effect, generation, rawQuery) {
565 return
566 }
567 if name == "" {
568 d.enterTransitionPrompt(entry, ac, " Create session ", "", func(name string, promptEffect *attachmentEffect) error {
569 return exec.createSessionOnDestination(promptEffect, createDestination, name)
570 })
571 d.invalidateRender(entry, ac, true, "palette.go")
572 return
573 }
574 err := exec.createSessionOnValidatedDestination(effect, createDestination, name)
575 if err != nil && !errors.Is(err, errAttachmentTransition) {
576 d.reportAttachmentError(entry, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't create session", err))
577 return
578 }
579 if current := ac.currentAttachmentSession(); current != nil {
580 d.invalidateRender(current, ac, true, "palette.go")
581 }
582}
583
584func (d *Daemon) executePaletteImportedSession(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery, importedSourceKey, importedEntryKey string) {
585 if effect == nil {
586 ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
587 d.invalidateRender(entry, ac, true, "palette.go")
588 return
589 }
590 if importedSourceKey != protocol.NavigationInventoryLocalSourceKey {
591 // Remote vision is read-only: imported remote routes surface
592 // availability but never attach.
593 ac.paletteFailure(generation, rawQuery, "remote sessions are visible but cannot be attached")
594 d.invalidateRender(entry, ac, true, "palette.go")
595 return
596 }
597 ac.overlays.paletteMu.Lock()
598 open := ac.overlays.paletteInventoryOpen
599 selection := protocol.NavigationInventorySelection{
600 CauseActionID: effect.uiActionID,
601 InteractionGeneration: ac.overlays.paletteInventoryInteraction,
602 PublicationGeneration: ac.overlays.paletteInventoryPublication,
603 SourceKey: importedSourceKey,
604 EntryKey: importedEntryKey,
605 }
606 if open {
607 freezePaletteInventorySelection(ac.overlays, selection)
608 }
609 ac.overlays.paletteMu.Unlock()
610 if !open || protocol.ValidateNavigationInventorySelection(selection) != nil {
611 ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
612 d.invalidateRender(entry, ac, true, "palette.go")
613 return
614 }
615 // Selection crosses before the close demand on the guarded serving
616 // connection; closing after selection must not cancel the client's
617 // pending navigation operation.
618 if err := effect.sendControl(selection); err != nil {
619 ac.paletteFailure(generation, rawQuery, "requested imported session is unavailable")
620 d.invalidateRender(entry, ac, true, "palette.go")
621 return
622 }
623 d.closeExecutedPalette(ac, effect, generation, rawQuery)
624}
625
626func (d *Daemon) executePaletteRouteNavigation(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, routeTarget protocol.RouteNavigationAction) {
627 exec := paletteExec{d: d, sess: entry, attachment: entry, ac: ac, effect: effect}
628 if err := exec.NavigateRecentRoute(routeTarget); err != nil {
629 if errors.Is(err, errAttachmentTransition) {
630 return
631 }
632 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
633 d.invalidateRender(entry, ac, true, "palette.go")
634 return
635 }
636 if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
637 d.invalidateRender(entry, ac, true, "palette.go")
638 }
639}
640
641func (d *Daemon) executePaletteSessionTarget(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, sessionTarget palette.Result) {
642 createdAt, _ := sessionTarget.SessionCreatedAt()
643 expectedCreatedAt := createdAt.UnixNano()
644 name, _ := sessionTarget.SessionName()
645 exactTarget, _ := sessionTarget.SessionTarget()
646 target := picker.Target{
647 Incarnation: exactTarget.LifecycleID, Name: name, TabIndex: -1,
648 ExpectedCreatedAt: &expectedCreatedAt, Stopped: sessionTarget.Kind() == palette.ResultKindStoppedSession,
649 }
650 var err error
651 if effect != nil {
652 err = d.switchToTargetForAttachment(effect, target, sessionHandoffGuard{allowSamePeer: true}, "palette-session")
653 } else {
654 err = d.switchToTarget(entry, ac, target)
655 }
656 if errors.Is(err, errAttachmentTransition) {
657 return
658 }
659 if err != nil {
660 ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
661 d.invalidateRender(entry, ac, true, "palette.go")
662 return
663 }
664 if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
665 if current := ac.currentAttachmentSession(); current != nil {
666 d.invalidateRender(current, ac, true, "palette.go")
667 }
668 }
669}
670
671func (d *Daemon) executePaletteCommand(ac *attachedClient, entry *session, effect *attachmentEffect, generation uint64, rawQuery string, cmd command.Command, args []string, routeSnapshot protocol.RecentRouteSnapshot) {
672 if cmd.Slug == "jump-recent-session" {
673 rank, err := command.ParsePositiveDecimal(args)
674 if err != nil {
675 ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")
676 d.invalidateRender(entry, ac, true, "palette.go")
677 return
678 }
679 exec := paletteExec{d: d, sess: entry, attachment: entry, ac: ac, routeSnapshot: routeSnapshot, effect: effect}
680 if err := exec.JumpRecentSession(rank); err != nil {
681 if errors.Is(err, errAttachmentTransition) {
682 return
683 }
684 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
685 d.invalidateRender(entry, ac, true, "palette.go")
686 return
687 }
688 if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
689 d.paletteHistory.record(cmd.Code)
690 if current := ac.currentAttachmentSession(); current != nil {
691 d.invalidateRender(current, ac, true, "palette.go")
692 }
693 }
694 return
695 }
696 attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"
697 if !attachmentHandoff && !d.closeExecutedPalette(ac, effect, generation, rawQuery) {
698 return
699 }
700 entry.dispatchMu.Lock()
701 err := cmd.Run(paletteExec{d: d, sess: entry, ac: ac, effect: effect, redrawClosedPalette: true}, args)
702 entry.dispatchMu.Unlock()
703 if attachmentHandoff {
704 if current := ac.currentSession(); current != nil {
705 currentToken := current.captureAttachmentCapability(ac, ac.transport())
706 fresh, admitted := ac.beginAttachmentEffect(currentToken)
707 if d.closeExecutedPalette(ac, effect, generation, rawQuery) {
708 d.invalidateRender(current, ac, true, "palette.go")
709 }
710 if admitted {
711 fresh.End()
712 }
713 } else {
714 d.closeExecutedPalette(ac, effect, generation, rawQuery)
715 }
716 }
717 if errors.Is(err, errAttachmentTransition) {
718 return
719 }
720 if err != nil {
721 d.log.Error("palette command failed", "err", err, "command", cmd.Code)
722 d.reportError(entry, paletteCommandNoticeError(cmd, err))
723 } else {
724 d.paletteHistory.record(cmd.Code)
725 }
726}
727
728func paletteCommandNoticeError(cmd command.Command, err error) error {
729 if cmd.Scope == command.CommandScopeCrossSession {
730 return movePickerUserError(err)
731 }
732 return err
733}
734
735func paletteArgs(query string, cmd command.Command) []string {
736 action, ok := palette.ParseAction(palette.CommandResults([]command.Command{cmd}), query)
737 if !ok {
738 return nil
739 }
740 return action.Args
741}
742
743// clearPaletteLocked requires ac.overlays.paletteMu.
744func (ac *attachedClient) clearPaletteLocked() {
745 ac.overlays.paletteGeneration++
746 ac.overlays.palette = nil
747 ac.overlays.paletteRouteSnapshot = protocol.RecentRouteSnapshot{}
748 ac.overlays.paletteHints = palette.ContextualHints{}
749 ac.overlays.palettePreview = ""
750 ac.overlays.paletteFeedback = ""
751 ac.overlays.palettePending = nil
752 closePaletteInventory(ac.overlays)
753}
754
755func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback string) {
756 ac.overlays.paletteMu.Lock()
757 defer ac.overlays.paletteMu.Unlock()
758 if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
759 return
760 }
761 ac.overlays.paletteFeedback = feedback
762}
763
764func (d *Daemon) closeExecutedPalette(ac *attachedClient, effect *attachmentEffect, generation uint64, rawQuery string) bool {
765 ac.overlays.paletteMu.Lock()
766 if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
767 ac.overlays.paletteMu.Unlock()
768 return false
769 }
770 // Every successful close stops relay polling: take the close demand
771 // before clearing so no execute path leaks a 1/s poll loop.
772 closeInteraction, hasClose := takePaletteInventoryClose(ac.overlays)
773 ac.clearPaletteLocked()
774 ac.overlays.paletteMu.Unlock()
775 if hasClose {
776 d.sendPaletteInventoryDemand(ac, effect, false, closeInteraction)
777 }
778 return true
779}
780
781func (d *Daemon) closePalette(ac *attachedClient) {
782 d.closePaletteIfCurrent(ac, 0)
783}
784
785func (d *Daemon) closePaletteIfCurrent(ac *attachedClient, generation uint64) bool {
786 if d == nil || ac == nil || ac.overlays == nil {
787 return false
788 }
789 ac.overlays.paletteMu.Lock()
790 if ac.overlays.palette == nil || generation != 0 && ac.overlays.paletteGeneration != generation {
791 ac.overlays.paletteMu.Unlock()
792 return false
793 }
794 ac.clearPaletteLocked()
795 ac.overlays.paletteMu.Unlock()
796 return true
797}
798
799type paletteExec struct {
800 d *Daemon
801 sess *session
802 attachment *session
803 ac *attachedClient
804 routeSnapshot protocol.RecentRouteSnapshot
805 actions daemonActionRunner
806 effect *attachmentEffect
807 redrawClosedPalette bool
808}
809
810func (e paletteExec) runAction(request daemonActionRequest) error {
811 request.effect = e.effect
812 changed, err := e.d.dispatchAction(daemonActionDispatch{
813 runner: e.actions,
814 request: request,
815 resolveFrom: e.sess,
816 attachment: e.ac,
817 producer: "palette.go",
818 })
819 if err == nil && !changed && e.redrawClosedPalette && e.d != nil {
820 // The palette closed before a no-op action; repaint what it covered.
821 e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
822 }
823 return err
824}
825
826func (e paletteExec) CreateTab() error {
827 return e.runAction(daemonActionRequest{kind: daemonActionCreateTab, viewport: e.sess.fullViewportSize()})
828}
829
830func (e paletteExec) CreateSession() error {
831 entry := e.attachment
832 if entry == nil {
833 entry = e.sess
834 }
835 e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, effect *attachmentEffect) error {
836 if effect == nil || effect.ac == nil {
837 if e.sess == nil {
838 return errAttachmentTransition
839 }
840 return e.d.createSessionAndSwitch(e.sess, e.ac, name)
841 }
842 return e.d.createSessionAndSwitchForAttachment(effect, name)
843 })
844 return nil
845}
846
847func (e paletteExec) CreateSessionNamed(name string) error {
848 if e.effect == nil {
849 return e.d.createSessionAndSwitch(e.sess, e.ac, name)
850 }
851 return e.d.createSessionAndSwitchForAttachment(e.effect, name)
852}
853
854func (e paletteExec) validateCreateSessionDestination(effect *attachmentEffect, result palette.Result) error {
855 _, kind, _, _, route, ok := result.CreateSessionDestination()
856 if !ok {
857 return errCreateDestinationUnavailable
858 }
859 switch kind {
860 case palette.CreateSessionOnServingDaemon:
861 return nil
862 case palette.CreateSessionOnLocalRoute, palette.CreateSessionOnRemoteHost:
863 // Another daemon is a client route: its reference must still name a
864 // daemon of the kind shown in the latest snapshot.
865 if e.ac == nil {
866 return errCreateDestinationUnavailable
867 }
868 snapshotGeneration, ok := result.CreateSessionSnapshotGeneration()
869 if !ok || snapshotGeneration == 0 {
870 return errCreateDestinationUnavailable
871 }
872 want := protocol.RouteKindLocal
873 if kind == palette.CreateSessionOnRemoteHost {
874 want = protocol.RouteKindRemote
875 }
876 snapshot := e.ac.routeSnapshotCopy()
877 got, valid := routeCreationKind(snapshot, route)
878 if !valid || snapshot.Generation != snapshotGeneration || got != want {
879 return errCreateDestinationUnavailable
880 }
881 if effect == nil && snapshot.Active != route {
882 return errCreateDestinationUnavailable
883 }
884 return nil
885 default:
886 return errCreateDestinationUnavailable
887 }
888}
889
890func (e paletteExec) createSessionOnDestination(effect *attachmentEffect, result palette.Result, name string) error {
891 if err := e.validateCreateSessionDestination(effect, result); err != nil {
892 return err
893 }
894 return e.createSessionOnValidatedDestination(effect, result, name)
895}
896
897func (e paletteExec) createSessionOnValidatedDestination(effect *attachmentEffect, result palette.Result, name string) error {
898 if err := domain.ValidateSessionName(name); err != nil {
899 return err
900 }
901 _, kind, _, _, route, _ := result.CreateSessionDestination()
902 if kind == palette.CreateSessionOnServingDaemon ||
903 (kind == palette.CreateSessionOnLocalRoute && e.ac != nil && e.ac.routeSnapshotCopy().Active == route) {
904 if effect == nil {
905 return e.d.createSessionAndSwitch(e.sess, e.ac, name)
906 }
907 return e.d.createSessionAndSwitchForAttachment(effect, name)
908 }
909 if effect == nil {
910 return errAttachmentTransition
911 }
912 requestID := e.d.creationRequestSeq.Add(1)
913 if requestID == 0 {
914 requestID = e.d.creationRequestSeq.Add(1)
915 }
916 switch kind {
917 case palette.CreateSessionOnLocalRoute, palette.CreateSessionOnRemoteHost:
918 // The client creates it through its broker and swaps to it.
919 snapshotGeneration, _ := result.CreateSessionSnapshotGeneration()
920 action := protocol.RouteCreateSessionAction{
921 RequestID: requestID, SnapshotGeneration: snapshotGeneration,
922 Key: route.Key, Generation: route.Generation, SessionName: name,
923 }
924 if err := effect.sendControl(action); err != nil {
925 return err
926 }
927 return nil
928 default:
929 return errAttachmentTransition
930 }
931}
932
933func (e paletteExec) CreateEphemeralSession() error {
934 if e.effect == nil {
935 return e.d.createEphemeralSessionAndSwitch(e.sess, e.ac)
936 }
937 return e.d.createEphemeralSessionAndSwitchForAttachment(e.effect)
938}
939
940func (e paletteExec) CloseTab() error {
941 return e.runAction(daemonActionRequest{kind: daemonActionCloseTab})
942}
943
944func (e paletteExec) split(direction layout.Direction) error {
945 return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction})
946}
947
948func (e paletteExec) SplitRight() error { return e.split(layout.Right) }
949func (e paletteExec) SplitLeft() error { return e.split(layout.Left) }
950func (e paletteExec) SplitUp() error { return e.split(layout.Up) }
951func (e paletteExec) SplitDown() error { return e.split(layout.Down) }
952func (e paletteExec) consumeOrExpelPane(direction layout.Direction) error {
953 return e.runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: direction})
954}
955func (e paletteExec) ConsumeOrExpelPaneLeft() error {
956 return e.consumeOrExpelPane(layout.Left)
957}
958func (e paletteExec) ConsumeOrExpelPaneRight() error {
959 return e.consumeOrExpelPane(layout.Right)
960}
961
962func (e paletteExec) StackPane() error {
963 return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
964}
965
966func (e paletteExec) ToggleStack() error {
967 return e.runAction(daemonActionRequest{kind: daemonActionToggleStack})
968}
969
970func (e paletteExec) ClosePane() error {
971 return e.runAction(daemonActionRequest{kind: daemonActionClosePane})
972}
973
974func (e paletteExec) OpenMovePanePicker() error {
975 target := resolveDaemonActionTargetForAttachment(e.sess, e.ac)
976 if target.tab == nil || target.pane == nil {
977 return errMovePaneInvalid
978 }
979 return e.openMovePicker(protocol.PickerIntentMovePane, moveSourceLocator{
980 Session: sessionMoveLocator(e.sess),
981 TabID: domain.TabStableID(target.tab.stableID),
982 PaneID: domain.PaneStableID(target.pane.stableID),
983 Attachment: e.ac,
984 })
985}
986
987func (e paletteExec) OpenMoveTabPicker() error {
988 target := resolveDaemonActionTargetForAttachment(e.sess, e.ac)
989 if target.tab == nil {
990 return errMovePaneInvalid
991 }
992 return e.openMovePicker(protocol.PickerIntentMoveTab, moveSourceLocator{
993 Session: sessionMoveLocator(e.sess),
994 TabID: domain.TabStableID(target.tab.stableID),
995 Attachment: e.ac,
996 })
997}
998
999// openMovePicker opens one move picker on an admitted effect. The captured
1000// source is daemon-side state: it is bound to the interaction at open and
1001// revalidated at commit, never supplied by the presenting client.
1002func (e paletteExec) openMovePicker(intent protocol.PickerIntent, source moveSourceLocator) error {
1003 if e.ac == nil || e.ac.overlays == nil {
1004 return errAttachmentTransition
1005 }
1006 effect := e.effect
1007 if effect == nil {
1008 current := e.ac.transportSnapshot()
1009 _, fresh, admitted := e.ac.beginCurrentAttachmentEffect(e.sess, current.transport)
1010 if !admitted {
1011 return errAttachmentTransition
1012 }
1013 defer fresh.End()
1014 effect = fresh
1015 }
1016 if !e.redrawClosedPalette {
1017 return e.d.openPickerForAttachment(e.ac, effect, intent, source, 0)
1018 }
1019 // The client overlay suppresses attachment output once the first move
1020 // snapshot arrives, so open only after the frame erasing the palette.
1021 // Refusals known now still fail the command synchronously.
1022 if err := e.d.movePickerPrecheck(e.sess, e.ac, intent, source); err != nil {
1023 return err
1024 }
1025 e.d.afterNextFrame(e.sess, e.ac, effect, func(fresh *attachmentEffect) {
1026 if err := e.d.openPickerForAttachment(e.ac, fresh, intent, source, 0); err != nil && !errors.Is(err, errAttachmentTransition) {
1027 e.d.reportError(e.sess, movePickerUserError(err))
1028 }
1029 })
1030 return nil
1031}
1032
1033func (e paletteExec) focus(direction layout.Direction) error {
1034 return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})
1035}
1036
1037func (e paletteExec) FocusPaneLeft() error { return e.focus(layout.Left) }
1038func (e paletteExec) FocusPaneRight() error { return e.focus(layout.Right) }
1039func (e paletteExec) FocusPaneUp() error { return e.focus(layout.Up) }
1040func (e paletteExec) FocusPaneDown() error { return e.focus(layout.Down) }
1041
1042func (e paletteExec) EnterResizeMode() error { return e.d.enterResizeMode(e.sess, e.ac) }
1043func (e paletteExec) resize(axis layout.Axis, delta int) error {
1044 return resizeUserError(e.runAction(daemonActionRequest{kind: daemonActionResizePane, axis: axis, delta: delta}))
1045}
1046func (e paletteExec) GrowPaneWidth() error { return e.resize(layout.Width, resizeStepCols) }
1047func (e paletteExec) ShrinkPaneWidth() error { return e.resize(layout.Width, -resizeStepCols) }
1048func (e paletteExec) GrowPaneHeight() error { return e.resize(layout.Height, resizeStepRows) }
1049func (e paletteExec) ShrinkPaneHeight() error { return e.resize(layout.Height, -resizeStepRows) }
1050func (e paletteExec) EqualizePanes() error {
1051 return resizeUserError(e.runAction(daemonActionRequest{kind: daemonActionEqualizePanes}))
1052}
1053
1054func (e paletteExec) NextTab() error {
1055 return e.runAction(daemonActionRequest{kind: daemonActionNextTab})
1056}
1057
1058func (e paletteExec) PrevTab() error {
1059 return e.runAction(daemonActionRequest{kind: daemonActionPreviousTab})
1060}
1061
1062func (e paletteExec) ToggleFloatingPane() error {
1063 return e.d.toggleFloating(e.sess, e.ac)
1064}
1065
1066func (e paletteExec) BackSession() error {
1067 if e.effect != nil {
1068 return e.d.backSessionForAttachment(e.effect)
1069 }
1070 e.d.backSession(e.sess, e.ac)
1071 return nil
1072}
1073
1074func (e paletteExec) Detach() error {
1075 if e.effect != nil {
1076 if !e.d.clientGoneForAttachment(e.effect, true) {
1077 return errAttachmentTransition
1078 }
1079 return nil
1080 }
1081 e.d.clientGone(e.sess, e.ac, e.ac.transport(), true)
1082 return nil
1083}
1084
1085func (e paletteExec) EnterVisualMode() error {
1086 e.d.enterCopyMode(e.sess, e.ac)
1087 return nil
1088}
1089
1090func (e paletteExec) RenameSession() error {
1091 e.sess.mu.Lock()
1092 currentName := e.sess.name
1093 e.sess.mu.Unlock()
1094 e.d.enterPrompt(e.sess, e.ac, " Rename session ", currentName, func(name string) error {
1095 return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name})
1096 })
1097 return nil
1098}
1099
1100func (e paletteExec) RenameSessionTo(name string) error {
1101 return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name})
1102}
1103
1104func (e paletteExec) RenameTab() error {
1105 tb := e.sess.tabForAttachment(e.ac)
1106 if e.ac == nil {
1107 tb = e.sess.firstTab()
1108 }
1109 if tb == nil {
1110 return nil
1111 }
1112 e.sess.mu.Lock()
1113 index := 0
1114 for i, candidate := range e.sess.tabs {
1115 if candidate == tb {
1116 index = i
1117 break
1118 }
1119 }
1120 currentName := tabDisplayName(tb, index)
1121 e.sess.mu.Unlock()
1122 e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error {
1123 return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name})
1124 })
1125 return nil
1126}
1127
1128func (e paletteExec) RenameTabTo(name string) error {
1129 tb := e.sess.tabForAttachment(e.ac)
1130 if e.ac == nil {
1131 tb = e.sess.firstTab()
1132 }
1133 if tb == nil {
1134 return nil
1135 }
1136 return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name})
1137}
1138
1139func (e paletteExec) OpenSessionPicker() error {
1140 if e.ac == nil {
1141 return errAttachmentTransition
1142 }
1143 effect := e.effect
1144 if effect == nil {
1145 current := e.ac.transportSnapshot()
1146 _, fresh, admitted := e.ac.beginCurrentAttachmentEffect(e.sess, current.transport)
1147 if !admitted {
1148 return errAttachmentTransition
1149 }
1150 defer fresh.End()
1151 effect = fresh
1152 }
1153 // Keep the attachment live: the client supervisor composes its own picker
1154 // over this attachment and owns its input until the user commits or
1155 // cancels. Detach-to-picker is reserved for attachment loss, session end,
1156 // and explicit detach.
1157 if !e.redrawClosedPalette {
1158 return e.d.offerClientNavigationPicker(e.ac, effect)
1159 }
1160 // The overlay suppresses attachment output, so the offer follows the
1161 // frame erasing the palette.
1162 e.d.afterNextFrame(e.sess, e.ac, effect, func(fresh *attachmentEffect) {
1163 if err := e.d.offerClientNavigationPicker(e.ac, fresh); err != nil && !errors.Is(err, errAttachmentTransition) {
1164 e.d.log.Error("palette command failed", "err", err, "command", "SSP")
1165 e.d.reportError(e.sess, err)
1166 }
1167 })
1168 return nil
1169}
1170
1171func (e paletteExec) OpenNotifications() error {
1172 e.d.enterNotices(e.sess, e.ac)
1173 return nil
1174}
1175
1176func (e paletteExec) YankLastNotification() error {
1177 entry := e.attachment
1178 if entry == nil {
1179 entry = e.sess
1180 }
1181 n, ok := e.d.notices.latest()
1182 if !ok {
1183 // Reported directly rather than returned: the generic cmd.Run error
1184 // path only logs, so a returned error here would never reach the
1185 // user as the warn toast this no-op is supposed to produce.
1186 e.d.reportAttachmentError(entry, domain.UserWarn(domain.NoticeClipboard, "no notifications yet", nil))
1187 return nil
1188 }
1189 e.d.yankNotice(entry, e.ac, n)
1190 return nil
1191}
1192
1193func (e paletteExec) JumpRecentSession(rank int) error {
1194 history := historyRouteSnapshot(e.routeSnapshot)
1195 if history.Generation == 0 || rank < 1 || rank > len(history.Entries) {
1196 return command.ErrInvalidArguments
1197 }
1198 entry := history.Entries[rank-1]
1199 return e.NavigateRecentRoute(protocol.RouteNavigationAction{
1200 SnapshotGeneration: e.routeSnapshot.Generation,
1201 Key: entry.Key,
1202 Generation: entry.Generation,
1203 })
1204}
1205
1206func (e paletteExec) NavigateRecentRoute(action protocol.RouteNavigationAction) error {
1207 if e.d.beforeRecentSessionHandoff != nil {
1208 e.d.beforeRecentSessionHandoff()
1209 }
1210 if e.effect != nil {
1211 return e.d.sendRecentRouteNavigationActionForAttachment(e.effect, action)
1212 }
1213 if e.ac == nil || e.attachment == nil {
1214 return errAttachmentTransition
1215 }
1216 capability := e.attachment.captureAttachmentCapability(e.ac, e.ac.transport())
1217 effect, admitted := e.ac.beginAttachmentEffect(capability)
1218 if !admitted {
1219 return errAttachmentTransition
1220 }
1221 defer effect.End()
1222 return e.d.sendRecentRouteNavigationActionForAttachment(effect, action)
1223}
1224
1225func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) {
1226 styleSet := resolveStyles(styles)
1227 modal := paletteModalFor(cfg)
1228 return composeModalClientFrame(base, modal, styleSet, func(size domain.Size) renderer.Frame {
1229 return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Base: styleSet.PickerBase, Row: styleSet.PickerBase, Selection: styleSet.PickerSelection, Description: styleSet.PickerDescription, SelectionDescription: styleSet.PickerSelectionMuted}, Guidance: guidance})
1230 })
1231}