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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +247 -140
M internal/usecase/daemon/client.go +3 -0
| @@ -29,6 +29,9 @@ | |||
| 29 | 29 | // snapshotWorker.noticeMu guards the active persistence-failure signature; it | |
| 30 | 30 | // is a leaf and is never held together with snapshotWorker.mu. Restoration | |
| 31 | 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 | 35 | package daemon | |
| 33 | 36 | ||
| 34 | 37 | import ( | |
| @@ -29,6 +29,9 @@ | |||||
| 29 | // snapshotWorker.noticeMu guards the active persistence-failure signature; it | 29 | // snapshotWorker.noticeMu guards the active persistence-failure signature; it | ||
| 30 | // is a leaf and is never held together with snapshotWorker.mu. Restoration | 30 | // 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 daemon | 35 | package daemon | ||
| 33 | 36 | ||||
| 34 | import ( | 37 | import ( | ||
M internal/usecase/daemon/daemon.go +2 -2
| @@ -156,8 +156,8 @@ type Daemon struct { | |||
| 156 | 156 | animFrame int | |
| 157 | 157 | animWake chan struct{} | |
| 158 | 158 | ||
| 159 | - | paletteRecentMu sync.Mutex | |
| 160 | - | paletteRecent []string | |
| 159 | + | // paletteHistory is the daemon-wide recent-command order; its mu is a leaf. | |
| 160 | + | paletteHistory paletteHistory | |
| 161 | 161 | // daemonTestHooks holds deterministic test seams; production leaves every | |
| 162 | 162 | // hook nil. | |
| 163 | 163 | daemonTestHooks | |
| @@ -156,8 +156,8 @@ type Daemon struct { | |||||
| 156 | animFrame int | 156 | animFrame int | ||
| 157 | animWake chan struct{} | 157 | animWake chan struct{} | ||
| 158 | 158 | ||||
| 159 | - | paletteRecentMu sync.Mutex | 159 | + | // paletteHistory is the daemon-wide recent-command order; its mu is a leaf. |
| 160 | - | paletteRecent []string | 160 | + | paletteHistory paletteHistory |
| 161 | // daemonTestHooks holds deterministic test seams; production leaves every | 161 | // daemonTestHooks holds deterministic test seams; production leaves every | ||
| 162 | // hook nil. | 162 | // hook nil. | ||
| 163 | daemonTestHooks | 163 | daemonTestHooks | ||
M internal/usecase/daemon/palette.go +241 -137
| @@ -3,6 +3,7 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | "sort" | |
| 6 | + | "sync" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| 8 | 9 | renderer "github.com/bnema/vev-vt" | |
| @@ -292,11 +293,38 @@ func recentRouteHints(snapshot protocol.RecentRouteSnapshot, args []string) pale | |||
| 292 | 293 | 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 | 325 | func (d *Daemon) paletteCommands() []command.Command { | |
| 296 | 326 | commands := command.PaletteRegistry() | |
| 297 | - | d.paletteRecentMu.Lock() | |
| 298 | - | recent := append([]string(nil), d.paletteRecent...) | |
| 299 | - | d.paletteRecentMu.Unlock() | |
| 327 | + | recent := d.paletteHistory.snapshot() | |
| 300 | 328 | overrides := d.codeOverrideSnapshot() | |
| 301 | 329 | ||
| 302 | 330 | byCode := make(map[string]command.Command, len(commands)) | |
| @@ -324,25 +352,46 @@ func (d *Daemon) paletteCommands() []command.Command { | |||
| 324 | 352 | } | |
| 325 | 353 | ||
| 326 | 354 | func (d *Daemon) recordPaletteUse(code string) { | |
| 327 | - | d.paletteRecentMu.Lock() | |
| 328 | - | defer d.paletteRecentMu.Unlock() | |
| 355 | + | d.paletteHistory.record(code) | |
| 356 | + | } | |
| 329 | 357 | ||
| 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 | |
| 338 | 390 | } | |
| 339 | 391 | ||
| 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 { | |
| 346 | 395 | var cmd command.Command | |
| 347 | 396 | var sessionTarget palette.Result | |
| 348 | 397 | var hasSessionTarget bool | |
| @@ -359,16 +408,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 359 | 408 | var cancelInventoryInteraction uint64 | |
| 360 | 409 | var hasCancelInventory bool | |
| 361 | 410 | 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 | 412 | ac.overlays.paletteMu.Lock() | |
| 368 | 413 | if ac.overlays.palette == nil { | |
| 369 | 414 | ac.overlays.palettePending = nil | |
| 370 | 415 | ac.overlays.paletteMu.Unlock() | |
| 371 | - | return | |
| 416 | + | return paletteDecision{} | |
| 372 | 417 | } | |
| 373 | 418 | routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{ | |
| 374 | 419 | rune: func(r rune) { | |
| @@ -478,139 +523,198 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 478 | 523 | ac.clearPaletteLocked() | |
| 479 | 524 | } | |
| 480 | 525 | 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) | |
| 484 | 580 | } | |
| 485 | 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 | 604 | return | |
| 487 | 605 | } | |
| 488 | - | if !execute { | |
| 489 | - | if changed { | |
| 490 | - | d.invalidateRender(entry, ac, true, "palette.go") | |
| 491 | - | } | |
| 606 | + | if !d.closeExecutedPalette(ac, effect, generation, rawQuery) { | |
| 492 | 607 | return | |
| 493 | 608 | } | |
| 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") | |
| 521 | 614 | return | |
| 522 | 615 | } | |
| 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)) | |
| 564 | 619 | 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 { | |
| 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") | |
| 580 | 630 | 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 { | |
| 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 { | |
| 598 | 672 | if errors.Is(err, errAttachmentTransition) { | |
| 599 | 673 | return | |
| 600 | 674 | } | |
| 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") | |
| 611 | 677 | 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 | 718 | if cmd.Slug == "jump-recent-session" { | |
| 615 | 719 | rank, err := command.ParsePositiveDecimal(args) | |
| 616 | 720 | 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 hints | 293 | 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 = recent | 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 | |||
| 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.Command | 395 | var cmd command.Command | ||
| 347 | var sessionTarget palette.Result | 396 | var sessionTarget palette.Result | ||
| 348 | var hasSessionTarget bool | 397 | var hasSessionTarget bool | ||
| @@ -359,16 +408,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 359 | var cancelInventoryInteraction uint64 | 408 | var cancelInventoryInteraction uint64 | ||
| 360 | var hasCancelInventory bool | 409 | var hasCancelInventory bool | ||
| 361 | changed, cancel, execute, chooseDestination := false, false, false, false | 410 | 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 = nil | 414 | ac.overlays.palettePending = nil | ||
| 370 | ac.overlays.paletteMu.Unlock() | 415 | ac.overlays.paletteMu.Unlock() | ||
| 371 | - | return | 416 | + | 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 | return | 604 | 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 | return | 607 | 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 | return | 614 | 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 | return | 619 | 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 | return | 630 | 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 | return | 673 | 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 | return | 677 | 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 | |||
| 387 | 387 | require.Same(t, target, ac.currentSession()) | |
| 388 | 388 | require.Contains(t, target.snapshotAttachments(), ac, "canonical handoff reuses the attached client") | |
| 389 | 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 | 391 | awaitFrame(t, sends, "Output") | |
| 392 | 392 | awaitFrame(t, sends, "Output") | |
| 393 | 393 | } | |
| @@ -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 | } | ||