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