feat(client): restore picker parity with main
Port the daemon-built picker behavior from main onto the client-owned catalogue (Plan 003 B1-B4, C1): - Tab rows: a session with tabs is a non-focusable header; each tab row is a destination with its pane-title detail, searchable by tab name and pane title, resolving to its exact tab (live: preferred stable tab; stopped remote: exact TabSelector). Committing another tab of the attached session sends SelectTab on the live attachment instead of reconnecting. - Projections: Recent is flat (live local by LastUsedSeq, then remote, then stopped by recency); Grouped is sectioned per daemon; `s` toggles between them. The cursor starts on the attached tab, else the attached session's active tab, else the most recent session's. - Kill: `x` on a local row kills the session (or deletes a stopped history record) through BrokerOperations on its own control stream, off the run goroutine, with a typed outcome notice and a local reconcile. An unknown outcome is reported and never replayed. - Hosts: failure toasts once per failure episode; a remote host the broker observed failing refuses instantly with main's notice; host status rows only appear for a host with no session that is not reachable. - Attention: a session header rings when any tab does, tab rows carry their own bell, and the picker model draws both.
- commit
- 09d92d35d005c7444efd4c4c1a92228877786222
- author
- brice · · 2026-09-22 20:24 UTC
- parent
- 8e35ea9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
18 files · +2160 -271
M internal/usecase/client/attachment_session.go +24 -4
| @@ -76,6 +76,9 @@ type sessionAttachmentConfig struct { | |||
| 76 | 76 | // the worker queries the terminal palette and sends protocol.Theme; when | |
| 77 | 77 | // nil, replies are still stripped from input but no query is written. | |
| 78 | 78 | Theme *terminalThemeState | |
| 79 | + | // Tab is the exact tab a picker tab row committed; its zero value | |
| 80 | + | // attaches at session level. | |
| 81 | + | Tab attachmentTab | |
| 79 | 82 | } | |
| 80 | 83 | ||
| 81 | 84 | // sessionAttachmentWorker implements AttachmentWorker for one broker logical | |
| @@ -199,7 +202,7 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f | |||
| 199 | 202 | return state, &event | |
| 200 | 203 | } | |
| 201 | 204 | state = next | |
| 202 | - | attachmentNoteCommitted(fg, state.context.Route.Target) | |
| 205 | + | attachmentNoteCommitted(fg, state.context) | |
| 203 | 206 | // The frame transaction is the pre-attach publication: it commits the first | |
| 204 | 207 | // session bytes with the Connecting presentation and, through the foreground | |
| 205 | 208 | // shape rule, a public generation of zero. Attached is published only after | |
| @@ -264,9 +267,9 @@ func attachmentOverlay(fg AttachmentForeground) attachmentOverlayForeground { | |||
| 264 | 267 | return overlay | |
| 265 | 268 | } | |
| 266 | 269 | ||
| 267 | - | func attachmentNoteCommitted(fg AttachmentForeground, target protocol.ExactSessionTarget) { | |
| 270 | + | func attachmentNoteCommitted(fg AttachmentForeground, view protocol.ViewContext) { | |
| 268 | 271 | if overlay := attachmentOverlay(fg); overlay != nil { | |
| 269 | - | overlay.noteCommitted(target) | |
| 272 | + | overlay.noteCommitted(view.Route.Target, view.TabID) | |
| 270 | 273 | } | |
| 271 | 274 | } | |
| 272 | 275 | ||
| @@ -287,8 +290,10 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 287 | 290 | outputResetRequested := false | |
| 288 | 291 | overlay := attachmentOverlay(fg) | |
| 289 | 292 | var repaint <-chan struct{} | |
| 293 | + | var tabSelections <-chan domain.TabStableID | |
| 290 | 294 | if overlay != nil { | |
| 291 | 295 | repaint = overlay.overlayRepaint() | |
| 296 | + | tabSelections = overlay.tabSelections() | |
| 292 | 297 | } | |
| 293 | 298 | picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)} | |
| 294 | 299 | defer picker.stopEscape() | |
| @@ -323,6 +328,12 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 323 | 328 | return w.settle(ctx, fg, stream, token, err) | |
| 324 | 329 | } | |
| 325 | 330 | outputResetRequested = true | |
| 331 | + | case tab := <-tabSelections: | |
| 332 | + | // The picker overlay committed another tab of this session: switch | |
| 333 | + | // the attachment's view in place instead of reconnecting. | |
| 334 | + | if err := w.send(ctx, fg, stream, protocol.SelectTab{TabID: tab}); err != nil { | |
| 335 | + | return w.settle(ctx, fg, stream, token, err) | |
| 336 | + | } | |
| 326 | 337 | case <-picker.escape(): | |
| 327 | 338 | picker.escapeFired() | |
| 328 | 339 | op, changed := picker.move.flush() | |
| @@ -341,7 +352,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 341 | 352 | } | |
| 342 | 353 | state = next | |
| 343 | 354 | outputResetRequested = false | |
| 344 | - | attachmentNoteCommitted(fg, state.context.Route.Target) | |
| 355 | + | attachmentNoteCommitted(fg, state.context) | |
| 345 | 356 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil { | |
| 346 | 357 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)} | |
| 347 | 358 | } | |
| @@ -360,6 +371,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 360 | 371 | continue | |
| 361 | 372 | } | |
| 362 | 373 | state = next | |
| 374 | + | attachmentNoteCommitted(fg, state.context) | |
| 363 | 375 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), nil); err != nil { | |
| 364 | 376 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing view update: %w", err)} | |
| 365 | 377 | } | |
| @@ -618,6 +630,14 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr | |||
| 618 | 630 | // the name is carried rather than left empty. It is copied from the | |
| 619 | 631 | // request's own validated target, never invented. | |
| 620 | 632 | hello.Name = target.SessionName | |
| 633 | + | switch { | |
| 634 | + | case w.cfg.Tab.stopped != nil && !request.Local: | |
| 635 | + | // A stopped remote tab is restored through its exact selector. | |
| 636 | + | selector := *w.cfg.Tab.stopped | |
| 637 | + | hello.SessionTarget = &selector | |
| 638 | + | case w.cfg.Tab.preferred != "": | |
| 639 | + | hello.PreferredTabID = w.cfg.Tab.preferred | |
| 640 | + | } | |
| 621 | 641 | case ports.BrokerAdmissionCreateNamed: | |
| 622 | 642 | hello.Intent = protocol.IntentNew | |
| 623 | 643 | hello.Name = request.Name | |
| @@ -76,6 +76,9 @@ type sessionAttachmentConfig struct { | |||||
| 76 | // the worker queries the terminal palette and sends protocol.Theme; when | 76 | // the worker queries the terminal palette and sends protocol.Theme; when | ||
| 77 | // nil, replies are still stripped from input but no query is written. | 77 | // nil, replies are still stripped from input but no query is written. | ||
| 78 | Theme *terminalThemeState | 78 | Theme *terminalThemeState | ||
| 79 | + | // Tab is the exact tab a picker tab row committed; its zero value | |||
| 80 | + | // attaches at session level. | |||
| 81 | + | Tab attachmentTab | |||
| 79 | } | 82 | } | ||
| 80 | 83 | ||||
| 81 | // sessionAttachmentWorker implements AttachmentWorker for one broker logical | 84 | // sessionAttachmentWorker implements AttachmentWorker for one broker logical | ||
| @@ -199,7 +202,7 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f | |||||
| 199 | return state, &event | 202 | return state, &event | ||
| 200 | } | 203 | } | ||
| 201 | state = next | 204 | state = next | ||
| 202 | - | attachmentNoteCommitted(fg, state.context.Route.Target) | 205 | + | attachmentNoteCommitted(fg, state.context) |
| 203 | // The frame transaction is the pre-attach publication: it commits the first | 206 | // The frame transaction is the pre-attach publication: it commits the first | ||
| 204 | // session bytes with the Connecting presentation and, through the foreground | 207 | // session bytes with the Connecting presentation and, through the foreground | ||
| 205 | // shape rule, a public generation of zero. Attached is published only after | 208 | // shape rule, a public generation of zero. Attached is published only after | ||
| @@ -264,9 +267,9 @@ func attachmentOverlay(fg AttachmentForeground) attachmentOverlayForeground { | |||||
| 264 | return overlay | 267 | return overlay | ||
| 265 | } | 268 | } | ||
| 266 | 269 | ||||
| 267 | - | func attachmentNoteCommitted(fg AttachmentForeground, target protocol.ExactSessionTarget) { | 270 | + | func attachmentNoteCommitted(fg AttachmentForeground, view protocol.ViewContext) { |
| 268 | if overlay := attachmentOverlay(fg); overlay != nil { | 271 | if overlay := attachmentOverlay(fg); overlay != nil { | ||
| 269 | - | overlay.noteCommitted(target) | 272 | + | overlay.noteCommitted(view.Route.Target, view.TabID) |
| 270 | } | 273 | } | ||
| 271 | } | 274 | } | ||
| 272 | 275 | ||||
| @@ -287,8 +290,10 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 287 | outputResetRequested := false | 290 | outputResetRequested := false | ||
| 288 | overlay := attachmentOverlay(fg) | 291 | overlay := attachmentOverlay(fg) | ||
| 289 | var repaint <-chan struct{} | 292 | var repaint <-chan struct{} | ||
| 293 | + | var tabSelections <-chan domain.TabStableID | |||
| 290 | if overlay != nil { | 294 | if overlay != nil { | ||
| 291 | repaint = overlay.overlayRepaint() | 295 | repaint = overlay.overlayRepaint() | ||
| 296 | + | tabSelections = overlay.tabSelections() | |||
| 292 | } | 297 | } | ||
| 293 | picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)} | 298 | picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)} | ||
| 294 | defer picker.stopEscape() | 299 | defer picker.stopEscape() | ||
| @@ -323,6 +328,12 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 323 | return w.settle(ctx, fg, stream, token, err) | 328 | return w.settle(ctx, fg, stream, token, err) | ||
| 324 | } | 329 | } | ||
| 325 | outputResetRequested = true | 330 | outputResetRequested = true | ||
| 331 | + | case tab := <-tabSelections: | |||
| 332 | + | // The picker overlay committed another tab of this session: switch | |||
| 333 | + | // the attachment's view in place instead of reconnecting. | |||
| 334 | + | if err := w.send(ctx, fg, stream, protocol.SelectTab{TabID: tab}); err != nil { | |||
| 335 | + | return w.settle(ctx, fg, stream, token, err) | |||
| 336 | + | } | |||
| 326 | case <-picker.escape(): | 337 | case <-picker.escape(): | ||
| 327 | picker.escapeFired() | 338 | picker.escapeFired() | ||
| 328 | op, changed := picker.move.flush() | 339 | op, changed := picker.move.flush() | ||
| @@ -341,7 +352,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 341 | } | 352 | } | ||
| 342 | state = next | 353 | state = next | ||
| 343 | outputResetRequested = false | 354 | outputResetRequested = false | ||
| 344 | - | attachmentNoteCommitted(fg, state.context.Route.Target) | 355 | + | attachmentNoteCommitted(fg, state.context) |
| 345 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil { | 356 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil { | ||
| 346 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)} | 357 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)} | ||
| 347 | } | 358 | } | ||
| @@ -360,6 +371,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 360 | continue | 371 | continue | ||
| 361 | } | 372 | } | ||
| 362 | state = next | 373 | state = next | ||
| 374 | + | attachmentNoteCommitted(fg, state.context) | |||
| 363 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), nil); err != nil { | 375 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), nil); err != nil { | ||
| 364 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing view update: %w", err)} | 376 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing view update: %w", err)} | ||
| 365 | } | 377 | } | ||
| @@ -618,6 +630,14 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr | |||||
| 618 | // the name is carried rather than left empty. It is copied from the | 630 | // the name is carried rather than left empty. It is copied from the | ||
| 619 | // request's own validated target, never invented. | 631 | // request's own validated target, never invented. | ||
| 620 | hello.Name = target.SessionName | 632 | hello.Name = target.SessionName | ||
| 633 | + | switch { | |||
| 634 | + | case w.cfg.Tab.stopped != nil && !request.Local: | |||
| 635 | + | // A stopped remote tab is restored through its exact selector. | |||
| 636 | + | selector := *w.cfg.Tab.stopped | |||
| 637 | + | hello.SessionTarget = &selector | |||
| 638 | + | case w.cfg.Tab.preferred != "": | |||
| 639 | + | hello.PreferredTabID = w.cfg.Tab.preferred | |||
| 640 | + | } | |||
| 621 | case ports.BrokerAdmissionCreateNamed: | 641 | case ports.BrokerAdmissionCreateNamed: | ||
| 622 | hello.Intent = protocol.IntentNew | 642 | hello.Intent = protocol.IntentNew | ||
| 623 | hello.Name = request.Name | 643 | hello.Name = request.Name | ||
M internal/usecase/client/attachment_session_test.go +1 -1
| @@ -509,7 +509,7 @@ func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) { | |||
| 509 | 509 | attached := newSessionTestAttachments() | |
| 510 | 510 | supervisor := &Supervisor{cfg: SupervisorConfig{Terminal: terminal, AttachmentEnvironment: tt.env}, clientID: [16]byte{1}} | |
| 511 | 511 | request := sessionTestRequest(true) | |
| 512 | - | worker, err := supervisor.newAttachmentWorker(request, nil, SessionEnvironment{Provenance: SessionEnvironmentLocalCLI, Cwd: tt.env.Cwd, Env: append([]string(nil), request.Env...)}) | |
| 512 | + | worker, err := supervisor.newAttachmentWorker(request, attachmentTab{}, nil, SessionEnvironment{Provenance: SessionEnvironmentLocalCLI, Cwd: tt.env.Cwd, Env: append([]string(nil), request.Env...)}) | |
| 513 | 513 | require.NoError(t, err) | |
| 514 | 514 | events := make(chan AttachmentEvent, 1) | |
| 515 | 515 | host := newWorkerTestHost(terminal, nil, attached.record) | |
| @@ -509,7 +509,7 @@ func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) { | |||||
| 509 | attached := newSessionTestAttachments() | 509 | attached := newSessionTestAttachments() | ||
| 510 | supervisor := &Supervisor{cfg: SupervisorConfig{Terminal: terminal, AttachmentEnvironment: tt.env}, clientID: [16]byte{1}} | 510 | supervisor := &Supervisor{cfg: SupervisorConfig{Terminal: terminal, AttachmentEnvironment: tt.env}, clientID: [16]byte{1}} | ||
| 511 | request := sessionTestRequest(true) | 511 | request := sessionTestRequest(true) | ||
| 512 | - | worker, err := supervisor.newAttachmentWorker(request, nil, SessionEnvironment{Provenance: SessionEnvironmentLocalCLI, Cwd: tt.env.Cwd, Env: append([]string(nil), request.Env...)}) | 512 | + | worker, err := supervisor.newAttachmentWorker(request, attachmentTab{}, nil, SessionEnvironment{Provenance: SessionEnvironmentLocalCLI, Cwd: tt.env.Cwd, Env: append([]string(nil), request.Env...)}) |
| 513 | require.NoError(t, err) | 513 | require.NoError(t, err) | ||
| 514 | events := make(chan AttachmentEvent, 1) | 514 | events := make(chan AttachmentEvent, 1) | ||
| 515 | host := newWorkerTestHost(terminal, nil, attached.record) | 515 | host := newWorkerTestHost(terminal, nil, attached.record) | ||
M internal/usecase/client/attachment_worker.go +49 -5
| @@ -609,6 +609,7 @@ func (h *attachmentHost) newForeground(token AttachmentToken, stream ports.Broke | |||
| 609 | 609 | decided: true, | |
| 610 | 610 | onAttached: h.onAttach, | |
| 611 | 611 | repaint: make(chan struct{}, 1), | |
| 612 | + | tabSelect: make(chan domain.TabStableID, 1), | |
| 612 | 613 | } | |
| 613 | 614 | } | |
| 614 | 615 | ||
| @@ -664,16 +665,23 @@ func (h *attachmentHost) endNavigationOverlay() { | |||
| 664 | 665 | // output for, so the supervisor can tell a commit to the attached session from | |
| 665 | 666 | // a commit elsewhere. | |
| 666 | 667 | func (h *attachmentHost) committedTarget() (protocol.ExactSessionTarget, bool) { | |
| 668 | + | target, _, ok := h.committedView() | |
| 669 | + | return target, ok | |
| 670 | + | } | |
| 671 | + | ||
| 672 | + | // committedView reports the session and the tab the current foreground last | |
| 673 | + | // committed output for. | |
| 674 | + | func (h *attachmentHost) committedView() (protocol.ExactSessionTarget, domain.TabStableID, bool) { | |
| 667 | 675 | if h == nil { | |
| 668 | - | return protocol.ExactSessionTarget{}, false | |
| 676 | + | return protocol.ExactSessionTarget{}, "", false | |
| 669 | 677 | } | |
| 670 | 678 | fg := h.authority.foreground() | |
| 671 | 679 | if fg == nil { | |
| 672 | - | return protocol.ExactSessionTarget{}, false | |
| 680 | + | return protocol.ExactSessionTarget{}, "", false | |
| 673 | 681 | } | |
| 674 | 682 | fg.overlayMu.Lock() | |
| 675 | 683 | defer fg.overlayMu.Unlock() | |
| 676 | - | return fg.committed, fg.committedKnown | |
| 684 | + | return fg.committed, fg.committedTab, fg.committedKnown | |
| 677 | 685 | } | |
| 678 | 686 | ||
| 679 | 687 | // committedTargetOrZero is committedTarget with an unknown target as zero. | |
| @@ -685,6 +693,29 @@ func (h *attachmentHost) committedTargetOrZero() protocol.ExactSessionTarget { | |||
| 685 | 693 | return target | |
| 686 | 694 | } | |
| 687 | 695 | ||
| 696 | + | // requestTabSelection asks the current foreground's worker to show one tab of | |
| 697 | + | // its attached session in place. The newest request replaces an unsent one. | |
| 698 | + | func (h *attachmentHost) requestTabSelection(token AttachmentToken, tab domain.TabStableID) bool { | |
| 699 | + | if h == nil || tab == "" { | |
| 700 | + | return false | |
| 701 | + | } | |
| 702 | + | fg := h.authority.foreground() | |
| 703 | + | if fg == nil || fg.token != token { | |
| 704 | + | return false | |
| 705 | + | } | |
| 706 | + | for { | |
| 707 | + | select { | |
| 708 | + | case fg.tabSelect <- tab: | |
| 709 | + | return true | |
| 710 | + | default: | |
| 711 | + | } | |
| 712 | + | select { | |
| 713 | + | case <-fg.tabSelect: | |
| 714 | + | default: | |
| 715 | + | } | |
| 716 | + | } | |
| 717 | + | } | |
| 718 | + | ||
| 688 | 719 | // requestDetach asks the current foreground's worker to detach explicitly, so | |
| 689 | 720 | // the daemon observes a clean Detach before the supervisor swaps attachments. | |
| 690 | 721 | func (h *attachmentHost) requestDetach(token AttachmentToken) { | |
| @@ -721,7 +752,8 @@ type attachmentOverlayForeground interface { | |||
| 721 | 752 | divertInput(data []byte) bool | |
| 722 | 753 | overlayRepaint() <-chan struct{} | |
| 723 | 754 | overlayOutput(uiContext ports.UIContext, data []byte) error | |
| 724 | - | noteCommitted(target protocol.ExactSessionTarget) | |
| 755 | + | noteCommitted(target protocol.ExactSessionTarget, tab domain.TabStableID) | |
| 756 | + | tabSelections() <-chan domain.TabStableID | |
| 725 | 757 | } | |
| 726 | 758 | ||
| 727 | 759 | var _ attachmentOverlayForeground = (*attachmentForeground)(nil) | |
| @@ -833,16 +865,25 @@ func (f *attachmentForeground) overlayRepaint() <-chan struct{} { | |||
| 833 | 865 | return f.repaint | |
| 834 | 866 | } | |
| 835 | 867 | ||
| 836 | - | func (f *attachmentForeground) noteCommitted(target protocol.ExactSessionTarget) { | |
| 868 | + | func (f *attachmentForeground) noteCommitted(target protocol.ExactSessionTarget, tab domain.TabStableID) { | |
| 837 | 869 | if f == nil { | |
| 838 | 870 | return | |
| 839 | 871 | } | |
| 840 | 872 | f.overlayMu.Lock() | |
| 841 | 873 | f.committed = target | |
| 874 | + | f.committedTab = tab | |
| 842 | 875 | f.committedKnown = true | |
| 843 | 876 | f.overlayMu.Unlock() | |
| 844 | 877 | } | |
| 845 | 878 | ||
| 879 | + | // tabSelections carries the supervisor's in-place tab switches to the worker. | |
| 880 | + | func (f *attachmentForeground) tabSelections() <-chan domain.TabStableID { | |
| 881 | + | if f == nil { | |
| 882 | + | return nil | |
| 883 | + | } | |
| 884 | + | return f.tabSelect | |
| 885 | + | } | |
| 886 | + | ||
| 846 | 887 | // overlayOutput writes one overlay frame through the same lease and UI | |
| 847 | 888 | // transaction as attachment output, bypassing only the overlay suppression. | |
| 848 | 889 | func (f *attachmentForeground) overlayOutput(uiContext ports.UIContext, data []byte) error { | |
| @@ -1026,10 +1067,13 @@ type attachmentForeground struct { | |||
| 1026 | 1067 | overlay attachmentOverlayKind | |
| 1027 | 1068 | overlaySink pickerInputConsumer | |
| 1028 | 1069 | committed protocol.ExactSessionTarget | |
| 1070 | + | committedTab domain.TabStableID | |
| 1029 | 1071 | committedKnown bool | |
| 1030 | 1072 | // repaint wakes the worker to request an authoritative repaint once an | |
| 1031 | 1073 | // overlay released the terminal. | |
| 1032 | 1074 | repaint chan struct{} | |
| 1075 | + | // tabSelect carries one pending in-place tab switch to the worker. | |
| 1076 | + | tabSelect chan domain.TabStableID | |
| 1033 | 1077 | } | |
| 1034 | 1078 | ||
| 1035 | 1079 | // Token is the generation/attempt identity of this grant. | |
| @@ -609,6 +609,7 @@ func (h *attachmentHost) newForeground(token AttachmentToken, stream ports.Broke | |||||
| 609 | decided: true, | 609 | decided: true, | ||
| 610 | onAttached: h.onAttach, | 610 | onAttached: h.onAttach, | ||
| 611 | repaint: make(chan struct{}, 1), | 611 | repaint: make(chan struct{}, 1), | ||
| 612 | + | tabSelect: make(chan domain.TabStableID, 1), | |||
| 612 | } | 613 | } | ||
| 613 | } | 614 | } | ||
| 614 | 615 | ||||
| @@ -664,16 +665,23 @@ func (h *attachmentHost) endNavigationOverlay() { | |||||
| 664 | // output for, so the supervisor can tell a commit to the attached session from | 665 | // output for, so the supervisor can tell a commit to the attached session from | ||
| 665 | // a commit elsewhere. | 666 | // a commit elsewhere. | ||
| 666 | func (h *attachmentHost) committedTarget() (protocol.ExactSessionTarget, bool) { | 667 | func (h *attachmentHost) committedTarget() (protocol.ExactSessionTarget, bool) { | ||
| 668 | + | target, _, ok := h.committedView() | |||
| 669 | + | return target, ok | |||
| 670 | + | } | |||
| 671 | + | ||||
| 672 | + | // committedView reports the session and the tab the current foreground last | |||
| 673 | + | // committed output for. | |||
| 674 | + | func (h *attachmentHost) committedView() (protocol.ExactSessionTarget, domain.TabStableID, bool) { | |||
| 667 | if h == nil { | 675 | if h == nil { | ||
| 668 | - | return protocol.ExactSessionTarget{}, false | 676 | + | return protocol.ExactSessionTarget{}, "", false |
| 669 | } | 677 | } | ||
| 670 | fg := h.authority.foreground() | 678 | fg := h.authority.foreground() | ||
| 671 | if fg == nil { | 679 | if fg == nil { | ||
| 672 | - | return protocol.ExactSessionTarget{}, false | 680 | + | return protocol.ExactSessionTarget{}, "", false |
| 673 | } | 681 | } | ||
| 674 | fg.overlayMu.Lock() | 682 | fg.overlayMu.Lock() | ||
| 675 | defer fg.overlayMu.Unlock() | 683 | defer fg.overlayMu.Unlock() | ||
| 676 | - | return fg.committed, fg.committedKnown | 684 | + | return fg.committed, fg.committedTab, fg.committedKnown |
| 677 | } | 685 | } | ||
| 678 | 686 | ||||
| 679 | // committedTargetOrZero is committedTarget with an unknown target as zero. | 687 | // committedTargetOrZero is committedTarget with an unknown target as zero. | ||
| @@ -685,6 +693,29 @@ func (h *attachmentHost) committedTargetOrZero() protocol.ExactSessionTarget { | |||||
| 685 | return target | 693 | return target | ||
| 686 | } | 694 | } | ||
| 687 | 695 | ||||
| 696 | + | // requestTabSelection asks the current foreground's worker to show one tab of | |||
| 697 | + | // its attached session in place. The newest request replaces an unsent one. | |||
| 698 | + | func (h *attachmentHost) requestTabSelection(token AttachmentToken, tab domain.TabStableID) bool { | |||
| 699 | + | if h == nil || tab == "" { | |||
| 700 | + | return false | |||
| 701 | + | } | |||
| 702 | + | fg := h.authority.foreground() | |||
| 703 | + | if fg == nil || fg.token != token { | |||
| 704 | + | return false | |||
| 705 | + | } | |||
| 706 | + | for { | |||
| 707 | + | select { | |||
| 708 | + | case fg.tabSelect <- tab: | |||
| 709 | + | return true | |||
| 710 | + | default: | |||
| 711 | + | } | |||
| 712 | + | select { | |||
| 713 | + | case <-fg.tabSelect: | |||
| 714 | + | default: | |||
| 715 | + | } | |||
| 716 | + | } | |||
| 717 | + | } | |||
| 718 | + | ||||
| 688 | // requestDetach asks the current foreground's worker to detach explicitly, so | 719 | // requestDetach asks the current foreground's worker to detach explicitly, so | ||
| 689 | // the daemon observes a clean Detach before the supervisor swaps attachments. | 720 | // the daemon observes a clean Detach before the supervisor swaps attachments. | ||
| 690 | func (h *attachmentHost) requestDetach(token AttachmentToken) { | 721 | func (h *attachmentHost) requestDetach(token AttachmentToken) { | ||
| @@ -721,7 +752,8 @@ type attachmentOverlayForeground interface { | |||||
| 721 | divertInput(data []byte) bool | 752 | divertInput(data []byte) bool | ||
| 722 | overlayRepaint() <-chan struct{} | 753 | overlayRepaint() <-chan struct{} | ||
| 723 | overlayOutput(uiContext ports.UIContext, data []byte) error | 754 | overlayOutput(uiContext ports.UIContext, data []byte) error | ||
| 724 | - | noteCommitted(target protocol.ExactSessionTarget) | 755 | + | noteCommitted(target protocol.ExactSessionTarget, tab domain.TabStableID) |
| 756 | + | tabSelections() <-chan domain.TabStableID | |||
| 725 | } | 757 | } | ||
| 726 | 758 | ||||
| 727 | var _ attachmentOverlayForeground = (*attachmentForeground)(nil) | 759 | var _ attachmentOverlayForeground = (*attachmentForeground)(nil) | ||
| @@ -833,16 +865,25 @@ func (f *attachmentForeground) overlayRepaint() <-chan struct{} { | |||||
| 833 | return f.repaint | 865 | return f.repaint | ||
| 834 | } | 866 | } | ||
| 835 | 867 | ||||
| 836 | - | func (f *attachmentForeground) noteCommitted(target protocol.ExactSessionTarget) { | 868 | + | func (f *attachmentForeground) noteCommitted(target protocol.ExactSessionTarget, tab domain.TabStableID) { |
| 837 | if f == nil { | 869 | if f == nil { | ||
| 838 | return | 870 | return | ||
| 839 | } | 871 | } | ||
| 840 | f.overlayMu.Lock() | 872 | f.overlayMu.Lock() | ||
| 841 | f.committed = target | 873 | f.committed = target | ||
| 874 | + | f.committedTab = tab | |||
| 842 | f.committedKnown = true | 875 | f.committedKnown = true | ||
| 843 | f.overlayMu.Unlock() | 876 | f.overlayMu.Unlock() | ||
| 844 | } | 877 | } | ||
| 845 | 878 | ||||
| 879 | + | // tabSelections carries the supervisor's in-place tab switches to the worker. | |||
| 880 | + | func (f *attachmentForeground) tabSelections() <-chan domain.TabStableID { | |||
| 881 | + | if f == nil { | |||
| 882 | + | return nil | |||
| 883 | + | } | |||
| 884 | + | return f.tabSelect | |||
| 885 | + | } | |||
| 886 | + | ||||
| 846 | // overlayOutput writes one overlay frame through the same lease and UI | 887 | // overlayOutput writes one overlay frame through the same lease and UI | ||
| 847 | // transaction as attachment output, bypassing only the overlay suppression. | 888 | // transaction as attachment output, bypassing only the overlay suppression. | ||
| 848 | func (f *attachmentForeground) overlayOutput(uiContext ports.UIContext, data []byte) error { | 889 | func (f *attachmentForeground) overlayOutput(uiContext ports.UIContext, data []byte) error { | ||
| @@ -1026,10 +1067,13 @@ type attachmentForeground struct { | |||||
| 1026 | overlay attachmentOverlayKind | 1067 | overlay attachmentOverlayKind | ||
| 1027 | overlaySink pickerInputConsumer | 1068 | overlaySink pickerInputConsumer | ||
| 1028 | committed protocol.ExactSessionTarget | 1069 | committed protocol.ExactSessionTarget | ||
| 1070 | + | committedTab domain.TabStableID | |||
| 1029 | committedKnown bool | 1071 | committedKnown bool | ||
| 1030 | // repaint wakes the worker to request an authoritative repaint once an | 1072 | // repaint wakes the worker to request an authoritative repaint once an | ||
| 1031 | // overlay released the terminal. | 1073 | // overlay released the terminal. | ||
| 1032 | repaint chan struct{} | 1074 | repaint chan struct{} | ||
| 1075 | + | // tabSelect carries one pending in-place tab switch to the worker. | |||
| 1076 | + | tabSelect chan domain.TabStableID | |||
| 1033 | } | 1077 | } | ||
| 1034 | 1078 | ||||
| 1035 | // Token is the generation/attempt identity of this grant. | 1079 | // Token is the generation/attempt identity of this grant. | ||
M internal/usecase/client/picker_catalogue.go +129 -149
| @@ -105,6 +105,18 @@ func (c pickerCatalogueErrorCode) String() string { | |||
| 105 | 105 | type pickerCatalogueError struct { | |
| 106 | 106 | Code pickerCatalogueErrorCode | |
| 107 | 107 | Text string | |
| 108 | + | // Notice is the bounded user-facing sentence shown for this refusal when | |
| 109 | + | // the generic "picker selection <code>" wording would name the wrong | |
| 110 | + | // thing, as for a remote host known to be failing. | |
| 111 | + | Notice string | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // noticeText is the sentence a picker toast shows for this refusal. | |
| 115 | + | func (e pickerCatalogueError) noticeText() string { | |
| 116 | + | if e.Notice != "" { | |
| 117 | + | return e.Notice | |
| 118 | + | } | |
| 119 | + | return e.Error() | |
| 108 | 120 | } | |
| 109 | 121 | ||
| 110 | 122 | func (e pickerCatalogueError) Error() string { | |
| @@ -146,6 +158,20 @@ type pickerSelectionRef struct { | |||
| 146 | 158 | lifecycle domain.SessionLifecycleID | |
| 147 | 159 | name string | |
| 148 | 160 | createName string | |
| 161 | + | // tab names one tab row inside the session; its zero value is a | |
| 162 | + | // session-level selection. | |
| 163 | + | tab pickerTabRef | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // pickerTabRef is the exact tab a tab row names, captured when it was | |
| 167 | + | // projected: the stable ID when the catalogue carries one, and the ordinal, | |
| 168 | + | // raw name, and tab count a stopped remote selector needs otherwise. | |
| 169 | + | type pickerTabRef struct { | |
| 170 | + | present bool | |
| 171 | + | id domain.TabStableID | |
| 172 | + | index int | |
| 173 | + | name string | |
| 174 | + | count int | |
| 149 | 175 | } | |
| 150 | 176 | ||
| 151 | 177 | // pickerResolveBase carries the live broker connection identity a resolved | |
| @@ -185,8 +211,15 @@ type pickerCatalogue struct { | |||
| 185 | 211 | refs map[string]pickerSelectionRef | |
| 186 | 212 | retired map[string]pickerSelectionRef | |
| 187 | 213 | retiredOrder []string | |
| 214 | + | // lines/cursor are the grouped projection; recent/recentCursor the flat | |
| 215 | + | // recency projection. Both carry the same keys and actions. | |
| 188 | 216 | lines []protocol.PickerLine | |
| 189 | 217 | cursor protocol.PickerCursor | |
| 218 | + | recent []protocol.PickerLine | |
| 219 | + | recentCursor protocol.PickerCursor | |
| 220 | + | // current is the attachment the picker is presented over; it only | |
| 221 | + | // decides where a fresh cursor starts. | |
| 222 | + | current pickerCurrent | |
| 190 | 223 | } | |
| 191 | 224 | ||
| 192 | 225 | // newPickerCatalogue builds an empty projection. | |
| @@ -291,14 +324,44 @@ func (c *pickerCatalogue) Cursor() protocol.PickerCursor { | |||
| 291 | 324 | return c.cursor | |
| 292 | 325 | } | |
| 293 | 326 | ||
| 294 | - | // projection returns the current rows and cursor hint together. | |
| 295 | - | func (c *pickerCatalogue) projection() ([]protocol.PickerLine, protocol.PickerCursor) { | |
| 327 | + | // projections returns the recent and grouped projections together, so both | |
| 328 | + | // always describe the same publication. | |
| 329 | + | func (c *pickerCatalogue) projections() (recent, grouped protocol.PickerProjection) { | |
| 330 | + | if c == nil { | |
| 331 | + | return protocol.PickerProjection{}, protocol.PickerProjection{} | |
| 332 | + | } | |
| 333 | + | c.mu.Lock() | |
| 334 | + | defer c.mu.Unlock() | |
| 335 | + | recent = protocol.PickerProjection{Lines: append([]protocol.PickerLine(nil), c.recent...), Cursor: c.recentCursor} | |
| 336 | + | grouped = protocol.PickerProjection{Lines: append([]protocol.PickerLine(nil), c.lines...), Cursor: c.cursor} | |
| 337 | + | return recent, grouped | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | // RecentLines returns a copy of the flat recency projection. | |
| 341 | + | func (c *pickerCatalogue) RecentLines() []protocol.PickerLine { | |
| 296 | 342 | if c == nil { | |
| 297 | - | return nil, protocol.PickerCursor{} | |
| 343 | + | return nil | |
| 298 | 344 | } | |
| 299 | 345 | c.mu.Lock() | |
| 300 | 346 | defer c.mu.Unlock() | |
| 301 | - | return append([]protocol.PickerLine(nil), c.lines...), c.cursor | |
| 347 | + | return append([]protocol.PickerLine(nil), c.recent...) | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | // SetCurrent records the attachment the picker is presented over (or none) | |
| 351 | + | // and recomputes the cursor hints. | |
| 352 | + | func (c *pickerCatalogue) SetCurrent(current pickerCurrent) { | |
| 353 | + | if c == nil { | |
| 354 | + | return | |
| 355 | + | } | |
| 356 | + | c.mu.Lock() | |
| 357 | + | defer c.mu.Unlock() | |
| 358 | + | if c.current == current { | |
| 359 | + | return | |
| 360 | + | } | |
| 361 | + | c.current = current | |
| 362 | + | if c.snapshot.Epoch != 0 { | |
| 363 | + | c.projectLocked() | |
| 364 | + | } | |
| 302 | 365 | } | |
| 303 | 366 | ||
| 304 | 367 | // Ref returns the exact selection identity recorded for one row key, including | |
| @@ -336,6 +399,24 @@ func (c *pickerCatalogue) Resolve(key string, base pickerResolveBase) (ports.Bro | |||
| 336 | 399 | return c.resolveRefLocked(ref, base) | |
| 337 | 400 | } | |
| 338 | 401 | ||
| 402 | + | // ResolveTarget is Resolve plus the exact tab the row names, both revalidated | |
| 403 | + | // under one lock against the same publication. | |
| 404 | + | func (c *pickerCatalogue) ResolveTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) { | |
| 405 | + | if c == nil { | |
| 406 | + | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"} | |
| 407 | + | } | |
| 408 | + | c.mu.Lock() | |
| 409 | + | defer c.mu.Unlock() | |
| 410 | + | ref, ok := c.refs[key] | |
| 411 | + | if !ok { | |
| 412 | + | ref, ok = c.retired[key] | |
| 413 | + | } | |
| 414 | + | if !ok { | |
| 415 | + | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"} | |
| 416 | + | } | |
| 417 | + | return resolvePickerTarget(c.snapshot.Epoch, c.authorityForRefLocked(ref), ref, base, true) | |
| 418 | + | } | |
| 419 | + | ||
| 339 | 420 | // ResolveRef revalidates an exact selection identity a driver captured when it | |
| 340 | 421 | // displayed the row. It is the entry point for a selection held across a | |
| 341 | 422 | // broker epoch or an equivalent output boundary, where the key alone would be | |
| @@ -357,6 +438,8 @@ func (c *pickerCatalogue) resetLocked() { | |||
| 357 | 438 | c.retiredOrder = nil | |
| 358 | 439 | c.lines = nil | |
| 359 | 440 | c.cursor = protocol.PickerCursor{} | |
| 441 | + | c.recent = nil | |
| 442 | + | c.recentCursor = protocol.PickerCursor{} | |
| 360 | 443 | } | |
| 361 | 444 | ||
| 362 | 445 | // mergeHostsLocked folds the newest publication into the per-daemon state. A | |
| @@ -391,96 +474,6 @@ func (c *pickerCatalogue) mergeHostsLocked() { | |||
| 391 | 474 | } | |
| 392 | 475 | } | |
| 393 | 476 | ||
| 394 | - | // projectLocked rebuilds the ordered rows and their opaque keys from the | |
| 395 | - | // current host state. It is deterministic: the same inputs always produce the | |
| 396 | - | // same keys, order, and actions. | |
| 397 | - | func (c *pickerCatalogue) projectLocked() { | |
| 398 | - | now := c.clock.Now() | |
| 399 | - | refs := make(map[string]pickerSelectionRef) | |
| 400 | - | lines := make([]protocol.PickerLine, 0, len(c.hosts)*2) | |
| 401 | - | for _, key := range c.order { | |
| 402 | - | host, ok := c.hosts[key] | |
| 403 | - | if !ok { | |
| 404 | - | continue | |
| 405 | - | } | |
| 406 | - | observation := host.observation | |
| 407 | - | fresh := c.freshLocked(observation, now) | |
| 408 | - | compatible := pickerObservationCompatible(observation) | |
| 409 | - | available := observation.Availability == domain.RemoteAvailabilityReachable | |
| 410 | - | eligible := compatible && available && fresh | |
| 411 | - | origin := pickerOriginLabel(observation) | |
| 412 | - | // Admission is deliberately more permissive than eligibility: a stale, | |
| 413 | - | // unavailable, or not-yet-observed daemon is still selectable, because | |
| 414 | - | // only the destination revalidates the exact identity and only it may | |
| 415 | - | // start a stopped target under the resolved policy. Only a known | |
| 416 | - | // incompatibility refuses immediately. Eligibility still drives the | |
| 417 | - | // informational dimming and badges. | |
| 418 | - | admissible := !pickerObservationKnownIncompatible(observation) | |
| 419 | - | ||
| 420 | - | lines = append(lines, protocol.PickerLine{Kind: protocol.PickerLineSection, Label: origin, Dim: true}) | |
| 421 | - | lines = append(lines, protocol.PickerLine{ | |
| 422 | - | Key: pickerHostRowKey(observation), | |
| 423 | - | Kind: protocol.PickerLineHost, | |
| 424 | - | Label: "status", | |
| 425 | - | Detail: pickerHostDetail(observation), | |
| 426 | - | Status: pickerObservationStatus(observation, fresh), | |
| 427 | - | StatusDetail: pickerObservationReason(observation, fresh), | |
| 428 | - | Focusable: false, | |
| 429 | - | }) | |
| 430 | - | ||
| 431 | - | for i := range observation.Sessions { | |
| 432 | - | session := observation.Sessions[i] | |
| 433 | - | if session.LifecycleID == (domain.SessionLifecycleID{}) { | |
| 434 | - | // A session without a lifecycle cannot be addressed exactly and | |
| 435 | - | // is never invented into one. | |
| 436 | - | continue | |
| 437 | - | } | |
| 438 | - | ref := pickerSelectionRef{ | |
| 439 | - | kind: pickerSelectionExact, | |
| 440 | - | epoch: c.snapshot.Epoch, | |
| 441 | - | local: observation.Local, | |
| 442 | - | endpoint: observation.Endpoint, | |
| 443 | - | registration: observation.Registration, | |
| 444 | - | lifecycle: session.LifecycleID, | |
| 445 | - | name: session.Name, | |
| 446 | - | } | |
| 447 | - | rowKey := pickerSessionRowKey(ref) | |
| 448 | - | refs[rowKey] = ref | |
| 449 | - | selectable := admissible && session.State != catalogue.RemoteCatalogSessionBroken | |
| 450 | - | actions := protocol.PickerLineActions(0) | |
| 451 | - | if selectable { | |
| 452 | - | actions = protocol.PickerCanNavigate | |
| 453 | - | } | |
| 454 | - | status := pickerSessionStatus(session) | |
| 455 | - | reason := pickerObservationReason(observation, fresh) | |
| 456 | - | if reason == "" { | |
| 457 | - | reason = session.Reason | |
| 458 | - | } | |
| 459 | - | lines = append(lines, protocol.PickerLine{ | |
| 460 | - | Key: rowKey, | |
| 461 | - | Kind: protocol.PickerLineSession, | |
| 462 | - | Label: session.Name, | |
| 463 | - | Detail: pickerSessionDetail(session), | |
| 464 | - | Status: status, | |
| 465 | - | StatusDetail: reason, | |
| 466 | - | Stopped: session.State == catalogue.RemoteCatalogSessionDown, | |
| 467 | - | // Dim keeps the informational eligibility signal (fresh, | |
| 468 | - | // compatible, reachable) and the hard non-committable state | |
| 469 | - | // (known incompatibility, broken session). It never removes the | |
| 470 | - | // ability to try an explicit bounded attach or creation. | |
| 471 | - | Dim: !eligible || !selectable, | |
| 472 | - | Focusable: true, | |
| 473 | - | Actions: actions, | |
| 474 | - | Ephemeral: session.Ephemeral, | |
| 475 | - | }) | |
| 476 | - | } | |
| 477 | - | } | |
| 478 | - | c.moveRetiredLocked(refs) | |
| 479 | - | c.refs = refs | |
| 480 | - | c.lines = lines | |
| 481 | - | c.cursor = pickerCatalogueCursor(lines) | |
| 482 | - | } | |
| 483 | - | ||
| 484 | 477 | // moveRetiredLocked keeps the last known ref of a key that left the current | |
| 485 | 478 | // projection so a late selection can still be classified as gone or replaced. | |
| 486 | 479 | func (c *pickerCatalogue) moveRetiredLocked(next map[string]pickerSelectionRef) { | |
| @@ -503,7 +496,8 @@ func (c *pickerCatalogue) moveRetiredLocked(next map[string]pickerSelectionRef) | |||
| 503 | 496 | // resolveRefLocked revalidates exactly one captured selection against the | |
| 504 | 497 | // latest applied publication. | |
| 505 | 498 | func (c *pickerCatalogue) resolveRefLocked(ref pickerSelectionRef, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | |
| 506 | - | return resolvePickerRequest(c.snapshot.Epoch, c.authorityForRefLocked(ref), ref, base) | |
| 499 | + | request, _, err := resolvePickerTarget(c.snapshot.Epoch, c.authorityForRefLocked(ref), ref, base, true) | |
| 500 | + | return request, err | |
| 507 | 501 | } | |
| 508 | 502 | ||
| 509 | 503 | // authorityForRefLocked selects the merged observation one selection names. The | |
| @@ -581,21 +575,42 @@ func pickerAuthorityInSnapshot(snapshot ports.BrokerSnapshot, ref pickerSelectio | |||
| 581 | 575 | // start-if-needed authorization; a policy that forbids launching still refuses | |
| 582 | 576 | // visibly at the destination. | |
| 583 | 577 | func resolvePickerRequest(epoch ports.BrokerEpoch, authority pickerResolveAuthority, ref pickerSelectionRef, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | |
| 578 | + | request, _, err := resolvePickerTarget(epoch, authority, ref, base, false) | |
| 579 | + | return request, err | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | // resolvePickerTarget is resolvePickerRequest plus the exact tab a tab row | |
| 583 | + | // names. refuseFailing is the picker's instant refusal (Plan 003 B4): a | |
| 584 | + | // remote host the broker observed failing (unreachable, authentication, or an | |
| 585 | + | // invalid response) refuses with a notice instead of a slow attempt that | |
| 586 | + | // would fail the same way. An explicit CLI target keeps the attempt. | |
| 587 | + | func resolvePickerTarget(epoch ports.BrokerEpoch, authority pickerResolveAuthority, ref pickerSelectionRef, base pickerResolveBase, refuseFailing bool) (ports.BrokerOpenStreamRequest, attachmentTab, error) { | |
| 588 | + | fail := func(err error) (ports.BrokerOpenStreamRequest, attachmentTab, error) { | |
| 589 | + | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err | |
| 590 | + | } | |
| 584 | 591 | if epoch == 0 { | |
| 585 | - | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "broker catalogue is unavailable"} | |
| 592 | + | return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "broker catalogue is unavailable"}) | |
| 586 | 593 | } | |
| 587 | 594 | if ref.epoch != 0 && ref.epoch != epoch { | |
| 588 | - | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueEpochStale, Text: "broker epoch changed"} | |
| 595 | + | return fail(pickerCatalogueError{Code: pickerCatalogueEpochStale, Text: "broker epoch changed"}) | |
| 589 | 596 | } | |
| 590 | 597 | if !authority.found { | |
| 591 | - | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "host is no longer in the catalogue"} | |
| 598 | + | return fail(pickerCatalogueError{Code: pickerCatalogueGone, Text: "host is no longer in the catalogue"}) | |
| 592 | 599 | } | |
| 593 | 600 | observation := authority.observation | |
| 594 | 601 | if !ref.local && !observation.Registration.Equal(ref.registration) { | |
| 595 | - | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "host identity was replaced"} | |
| 602 | + | return fail(pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "host identity was replaced"}) | |
| 596 | 603 | } | |
| 597 | 604 | if observation.Availability == domain.RemoteAvailabilityIncompatible || pickerObservationVersionMismatch(observation) { | |
| 598 | - | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueIncompatible, Text: "host protocol is incompatible"} | |
| 605 | + | return fail(pickerCatalogueError{Code: pickerCatalogueIncompatible, Text: "host protocol is incompatible"}) | |
| 606 | + | } | |
| 607 | + | if refuseFailing && !ref.local && pickerObservationFailing(observation) { | |
| 608 | + | reason := pickerObservationReason(observation, false) | |
| 609 | + | return fail(pickerCatalogueError{ | |
| 610 | + | Code: pickerCatalogueUnavailable, | |
| 611 | + | Text: "host " + reason, | |
| 612 | + | Notice: pickerUnavailableNotice(ref, observation, reason), | |
| 613 | + | }) | |
| 599 | 614 | } | |
| 600 | 615 | request := ports.BrokerOpenStreamRequest{ | |
| 601 | 616 | Epoch: epoch, | |
| @@ -614,40 +629,46 @@ func resolvePickerRequest(epoch ports.BrokerEpoch, authority pickerResolveAuthor | |||
| 614 | 629 | if !ref.local { | |
| 615 | 630 | request.Endpoint = observation.Endpoint | |
| 616 | 631 | } | |
| 632 | + | var tab attachmentTab | |
| 617 | 633 | switch ref.kind { | |
| 618 | 634 | case pickerSelectionExact: | |
| 619 | 635 | session, ok := pickerFindSession(observation.Sessions, ref.lifecycle) | |
| 620 | 636 | if !ok { | |
| 621 | 637 | if pickerSessionReplaced(observation.Sessions, ref) { | |
| 622 | - | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"} | |
| 638 | + | return fail(pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"}) | |
| 623 | 639 | } | |
| 624 | - | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "session is no longer in the catalogue"} | |
| 640 | + | return fail(pickerCatalogueError{Code: pickerCatalogueGone, Text: "session is no longer in the catalogue"}) | |
| 625 | 641 | } | |
| 626 | 642 | if session.Name != ref.name { | |
| 627 | 643 | // The exact identity is the lifecycle/name pair the daemon | |
| 628 | 644 | // revalidates: a renamed lifecycle is a different selection. | |
| 629 | - | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"} | |
| 645 | + | return fail(pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"}) | |
| 630 | 646 | } | |
| 631 | 647 | if session.State == catalogue.RemoteCatalogSessionBroken { | |
| 632 | - | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "session is broken"} | |
| 648 | + | return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "session is broken"}) | |
| 633 | 649 | } | |
| 650 | + | resolved, err := pickerResolveTab(ref, session) | |
| 651 | + | if err != nil { | |
| 652 | + | return fail(err) | |
| 653 | + | } | |
| 654 | + | tab = resolved | |
| 634 | 655 | request.Admission = ports.BrokerAdmissionExact | |
| 635 | 656 | request.Target = protocol.ExactSessionTarget{LifecycleID: session.LifecycleID, SessionName: session.Name} | |
| 636 | 657 | case pickerSelectionCreateNamed: | |
| 637 | 658 | if err := domain.ValidateSessionName(ref.createName); err != nil { | |
| 638 | - | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueInvalidName, Text: "session name is not valid"} | |
| 659 | + | return fail(pickerCatalogueError{Code: pickerCatalogueInvalidName, Text: "session name is not valid"}) | |
| 639 | 660 | } | |
| 640 | 661 | request.Admission = ports.BrokerAdmissionCreateNamed | |
| 641 | 662 | request.Name = ref.createName | |
| 642 | 663 | case pickerSelectionCreateEphemeral: | |
| 643 | 664 | request.Admission = ports.BrokerAdmissionCreateEphemeral | |
| 644 | 665 | default: | |
| 645 | - | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker selection kind is unknown"} | |
| 666 | + | return fail(pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker selection kind is unknown"}) | |
| 646 | 667 | } | |
| 647 | 668 | if err := request.Validate(); err != nil { | |
| 648 | - | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker stream request is not valid"} | |
| 669 | + | return fail(pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker stream request is not valid"}) | |
| 649 | 670 | } | |
| 650 | - | return request, nil | |
| 671 | + | return request, tab, nil | |
| 651 | 672 | } | |
| 652 | 673 | ||
| 653 | 674 | // freshLocked reports whether LastSuccess is inside the freshness window. An | |
| @@ -659,47 +680,6 @@ func (c *pickerCatalogue) freshLocked(observation ports.BrokerDaemonObservation, | |||
| 659 | 680 | return now.Sub(observation.LastSuccess) <= c.freshness | |
| 660 | 681 | } | |
| 661 | 682 | ||
| 662 | - | // pickerHostDiagnostic is one bounded, presentation-safe catalogue observation | |
| 663 | - | // the controller surfaces as a toast. | |
| 664 | - | type pickerHostDiagnostic struct { | |
| 665 | - | Key string | |
| 666 | - | Origin string | |
| 667 | - | Status protocol.PickerLineStatus | |
| 668 | - | Message string | |
| 669 | - | } | |
| 670 | - | ||
| 671 | - | // diagnostics reports actionable host failures. Ordinary observation progress | |
| 672 | - | // and catalogue aging remain visible on the host status row and never become | |
| 673 | - | // intrusive toasts. | |
| 674 | - | func (c *pickerCatalogue) diagnostics() []pickerHostDiagnostic { | |
| 675 | - | if c == nil { | |
| 676 | - | return nil | |
| 677 | - | } | |
| 678 | - | c.mu.Lock() | |
| 679 | - | defer c.mu.Unlock() | |
| 680 | - | now := c.clock.Now() | |
| 681 | - | diagnostics := make([]pickerHostDiagnostic, 0, len(c.hosts)) | |
| 682 | - | for _, key := range c.order { | |
| 683 | - | host, ok := c.hosts[key] | |
| 684 | - | if !ok { | |
| 685 | - | continue | |
| 686 | - | } | |
| 687 | - | observation := host.observation | |
| 688 | - | fresh := c.freshLocked(observation, now) | |
| 689 | - | status := pickerObservationStatus(observation, fresh) | |
| 690 | - | if status != protocol.PickerLineStatusDown && status != protocol.PickerLineStatusError && status != protocol.PickerLineStatusVersion { | |
| 691 | - | continue | |
| 692 | - | } | |
| 693 | - | diagnostics = append(diagnostics, pickerHostDiagnostic{ | |
| 694 | - | Key: pickerHostRowKey(observation), | |
| 695 | - | Origin: pickerOriginLabel(observation), | |
| 696 | - | Status: status, | |
| 697 | - | Message: pickerObservationReason(observation, fresh), | |
| 698 | - | }) | |
| 699 | - | } | |
| 700 | - | return diagnostics | |
| 701 | - | } | |
| 702 | - | ||
| 703 | 683 | func pickerDaemonKey(local bool, endpoint string) string { | |
| 704 | 684 | if local { | |
| 705 | 685 | return "local" | |
| @@ -105,6 +105,18 @@ func (c pickerCatalogueErrorCode) String() string { | |||||
| 105 | type pickerCatalogueError struct { | 105 | type pickerCatalogueError struct { | ||
| 106 | Code pickerCatalogueErrorCode | 106 | Code pickerCatalogueErrorCode | ||
| 107 | Text string | 107 | Text string | ||
| 108 | + | // Notice is the bounded user-facing sentence shown for this refusal when | |||
| 109 | + | // the generic "picker selection <code>" wording would name the wrong | |||
| 110 | + | // thing, as for a remote host known to be failing. | |||
| 111 | + | Notice string | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // noticeText is the sentence a picker toast shows for this refusal. | |||
| 115 | + | func (e pickerCatalogueError) noticeText() string { | |||
| 116 | + | if e.Notice != "" { | |||
| 117 | + | return e.Notice | |||
| 118 | + | } | |||
| 119 | + | return e.Error() | |||
| 108 | } | 120 | } | ||
| 109 | 121 | ||||
| 110 | func (e pickerCatalogueError) Error() string { | 122 | func (e pickerCatalogueError) Error() string { | ||
| @@ -146,6 +158,20 @@ type pickerSelectionRef struct { | |||||
| 146 | lifecycle domain.SessionLifecycleID | 158 | lifecycle domain.SessionLifecycleID | ||
| 147 | name string | 159 | name string | ||
| 148 | createName string | 160 | createName string | ||
| 161 | + | // tab names one tab row inside the session; its zero value is a | |||
| 162 | + | // session-level selection. | |||
| 163 | + | tab pickerTabRef | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // pickerTabRef is the exact tab a tab row names, captured when it was | |||
| 167 | + | // projected: the stable ID when the catalogue carries one, and the ordinal, | |||
| 168 | + | // raw name, and tab count a stopped remote selector needs otherwise. | |||
| 169 | + | type pickerTabRef struct { | |||
| 170 | + | present bool | |||
| 171 | + | id domain.TabStableID | |||
| 172 | + | index int | |||
| 173 | + | name string | |||
| 174 | + | count int | |||
| 149 | } | 175 | } | ||
| 150 | 176 | ||||
| 151 | // pickerResolveBase carries the live broker connection identity a resolved | 177 | // pickerResolveBase carries the live broker connection identity a resolved | ||
| @@ -185,8 +211,15 @@ type pickerCatalogue struct { | |||||
| 185 | refs map[string]pickerSelectionRef | 211 | refs map[string]pickerSelectionRef | ||
| 186 | retired map[string]pickerSelectionRef | 212 | retired map[string]pickerSelectionRef | ||
| 187 | retiredOrder []string | 213 | retiredOrder []string | ||
| 214 | + | // lines/cursor are the grouped projection; recent/recentCursor the flat | |||
| 215 | + | // recency projection. Both carry the same keys and actions. | |||
| 188 | lines []protocol.PickerLine | 216 | lines []protocol.PickerLine | ||
| 189 | cursor protocol.PickerCursor | 217 | cursor protocol.PickerCursor | ||
| 218 | + | recent []protocol.PickerLine | |||
| 219 | + | recentCursor protocol.PickerCursor | |||
| 220 | + | // current is the attachment the picker is presented over; it only | |||
| 221 | + | // decides where a fresh cursor starts. | |||
| 222 | + | current pickerCurrent | |||
| 190 | } | 223 | } | ||
| 191 | 224 | ||||
| 192 | // newPickerCatalogue builds an empty projection. | 225 | // newPickerCatalogue builds an empty projection. | ||
| @@ -291,14 +324,44 @@ func (c *pickerCatalogue) Cursor() protocol.PickerCursor { | |||||
| 291 | return c.cursor | 324 | return c.cursor | ||
| 292 | } | 325 | } | ||
| 293 | 326 | ||||
| 294 | - | // projection returns the current rows and cursor hint together. | 327 | + | // projections returns the recent and grouped projections together, so both |
| 295 | - | func (c *pickerCatalogue) projection() ([]protocol.PickerLine, protocol.PickerCursor) { | 328 | + | // always describe the same publication. |
| 329 | + | func (c *pickerCatalogue) projections() (recent, grouped protocol.PickerProjection) { | |||
| 330 | + | if c == nil { | |||
| 331 | + | return protocol.PickerProjection{}, protocol.PickerProjection{} | |||
| 332 | + | } | |||
| 333 | + | c.mu.Lock() | |||
| 334 | + | defer c.mu.Unlock() | |||
| 335 | + | recent = protocol.PickerProjection{Lines: append([]protocol.PickerLine(nil), c.recent...), Cursor: c.recentCursor} | |||
| 336 | + | grouped = protocol.PickerProjection{Lines: append([]protocol.PickerLine(nil), c.lines...), Cursor: c.cursor} | |||
| 337 | + | return recent, grouped | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | // RecentLines returns a copy of the flat recency projection. | |||
| 341 | + | func (c *pickerCatalogue) RecentLines() []protocol.PickerLine { | |||
| 296 | if c == nil { | 342 | if c == nil { | ||
| 297 | - | return nil, protocol.PickerCursor{} | 343 | + | return nil |
| 298 | } | 344 | } | ||
| 299 | c.mu.Lock() | 345 | c.mu.Lock() | ||
| 300 | defer c.mu.Unlock() | 346 | defer c.mu.Unlock() | ||
| 301 | - | return append([]protocol.PickerLine(nil), c.lines...), c.cursor | 347 | + | return append([]protocol.PickerLine(nil), c.recent...) |
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | // SetCurrent records the attachment the picker is presented over (or none) | |||
| 351 | + | // and recomputes the cursor hints. | |||
| 352 | + | func (c *pickerCatalogue) SetCurrent(current pickerCurrent) { | |||
| 353 | + | if c == nil { | |||
| 354 | + | return | |||
| 355 | + | } | |||
| 356 | + | c.mu.Lock() | |||
| 357 | + | defer c.mu.Unlock() | |||
| 358 | + | if c.current == current { | |||
| 359 | + | return | |||
| 360 | + | } | |||
| 361 | + | c.current = current | |||
| 362 | + | if c.snapshot.Epoch != 0 { | |||
| 363 | + | c.projectLocked() | |||
| 364 | + | } | |||
| 302 | } | 365 | } | ||
| 303 | 366 | ||||
| 304 | // Ref returns the exact selection identity recorded for one row key, including | 367 | // Ref returns the exact selection identity recorded for one row key, including | ||
| @@ -336,6 +399,24 @@ func (c *pickerCatalogue) Resolve(key string, base pickerResolveBase) (ports.Bro | |||||
| 336 | return c.resolveRefLocked(ref, base) | 399 | return c.resolveRefLocked(ref, base) | ||
| 337 | } | 400 | } | ||
| 338 | 401 | ||||
| 402 | + | // ResolveTarget is Resolve plus the exact tab the row names, both revalidated | |||
| 403 | + | // under one lock against the same publication. | |||
| 404 | + | func (c *pickerCatalogue) ResolveTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) { | |||
| 405 | + | if c == nil { | |||
| 406 | + | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"} | |||
| 407 | + | } | |||
| 408 | + | c.mu.Lock() | |||
| 409 | + | defer c.mu.Unlock() | |||
| 410 | + | ref, ok := c.refs[key] | |||
| 411 | + | if !ok { | |||
| 412 | + | ref, ok = c.retired[key] | |||
| 413 | + | } | |||
| 414 | + | if !ok { | |||
| 415 | + | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"} | |||
| 416 | + | } | |||
| 417 | + | return resolvePickerTarget(c.snapshot.Epoch, c.authorityForRefLocked(ref), ref, base, true) | |||
| 418 | + | } | |||
| 419 | + | ||||
| 339 | // ResolveRef revalidates an exact selection identity a driver captured when it | 420 | // ResolveRef revalidates an exact selection identity a driver captured when it | ||
| 340 | // displayed the row. It is the entry point for a selection held across a | 421 | // displayed the row. It is the entry point for a selection held across a | ||
| 341 | // broker epoch or an equivalent output boundary, where the key alone would be | 422 | // broker epoch or an equivalent output boundary, where the key alone would be | ||
| @@ -357,6 +438,8 @@ func (c *pickerCatalogue) resetLocked() { | |||||
| 357 | c.retiredOrder = nil | 438 | c.retiredOrder = nil | ||
| 358 | c.lines = nil | 439 | c.lines = nil | ||
| 359 | c.cursor = protocol.PickerCursor{} | 440 | c.cursor = protocol.PickerCursor{} | ||
| 441 | + | c.recent = nil | |||
| 442 | + | c.recentCursor = protocol.PickerCursor{} | |||
| 360 | } | 443 | } | ||
| 361 | 444 | ||||
| 362 | // mergeHostsLocked folds the newest publication into the per-daemon state. A | 445 | // mergeHostsLocked folds the newest publication into the per-daemon state. A | ||
| @@ -391,96 +474,6 @@ func (c *pickerCatalogue) mergeHostsLocked() { | |||||
| 391 | } | 474 | } | ||
| 392 | } | 475 | } | ||
| 393 | 476 | ||||
| 394 | - | // projectLocked rebuilds the ordered rows and their opaque keys from the | |||
| 395 | - | // current host state. It is deterministic: the same inputs always produce the | |||
| 396 | - | // same keys, order, and actions. | |||
| 397 | - | func (c *pickerCatalogue) projectLocked() { | |||
| 398 | - | now := c.clock.Now() | |||
| 399 | - | refs := make(map[string]pickerSelectionRef) | |||
| 400 | - | lines := make([]protocol.PickerLine, 0, len(c.hosts)*2) | |||
| 401 | - | for _, key := range c.order { | |||
| 402 | - | host, ok := c.hosts[key] | |||
| 403 | - | if !ok { | |||
| 404 | - | continue | |||
| 405 | - | } | |||
| 406 | - | observation := host.observation | |||
| 407 | - | fresh := c.freshLocked(observation, now) | |||
| 408 | - | compatible := pickerObservationCompatible(observation) | |||
| 409 | - | available := observation.Availability == domain.RemoteAvailabilityReachable | |||
| 410 | - | eligible := compatible && available && fresh | |||
| 411 | - | origin := pickerOriginLabel(observation) | |||
| 412 | - | // Admission is deliberately more permissive than eligibility: a stale, | |||
| 413 | - | // unavailable, or not-yet-observed daemon is still selectable, because | |||
| 414 | - | // only the destination revalidates the exact identity and only it may | |||
| 415 | - | // start a stopped target under the resolved policy. Only a known | |||
| 416 | - | // incompatibility refuses immediately. Eligibility still drives the | |||
| 417 | - | // informational dimming and badges. | |||
| 418 | - | admissible := !pickerObservationKnownIncompatible(observation) | |||
| 419 | - | ||||
| 420 | - | lines = append(lines, protocol.PickerLine{Kind: protocol.PickerLineSection, Label: origin, Dim: true}) | |||
| 421 | - | lines = append(lines, protocol.PickerLine{ | |||
| 422 | - | Key: pickerHostRowKey(observation), | |||
| 423 | - | Kind: protocol.PickerLineHost, | |||
| 424 | - | Label: "status", | |||
| 425 | - | Detail: pickerHostDetail(observation), | |||
| 426 | - | Status: pickerObservationStatus(observation, fresh), | |||
| 427 | - | StatusDetail: pickerObservationReason(observation, fresh), | |||
| 428 | - | Focusable: false, | |||
| 429 | - | }) | |||
| 430 | - | ||||
| 431 | - | for i := range observation.Sessions { | |||
| 432 | - | session := observation.Sessions[i] | |||
| 433 | - | if session.LifecycleID == (domain.SessionLifecycleID{}) { | |||
| 434 | - | // A session without a lifecycle cannot be addressed exactly and | |||
| 435 | - | // is never invented into one. | |||
| 436 | - | continue | |||
| 437 | - | } | |||
| 438 | - | ref := pickerSelectionRef{ | |||
| 439 | - | kind: pickerSelectionExact, | |||
| 440 | - | epoch: c.snapshot.Epoch, | |||
| 441 | - | local: observation.Local, | |||
| 442 | - | endpoint: observation.Endpoint, | |||
| 443 | - | registration: observation.Registration, | |||
| 444 | - | lifecycle: session.LifecycleID, | |||
| 445 | - | name: session.Name, | |||
| 446 | - | } | |||
| 447 | - | rowKey := pickerSessionRowKey(ref) | |||
| 448 | - | refs[rowKey] = ref | |||
| 449 | - | selectable := admissible && session.State != catalogue.RemoteCatalogSessionBroken | |||
| 450 | - | actions := protocol.PickerLineActions(0) | |||
| 451 | - | if selectable { | |||
| 452 | - | actions = protocol.PickerCanNavigate | |||
| 453 | - | } | |||
| 454 | - | status := pickerSessionStatus(session) | |||
| 455 | - | reason := pickerObservationReason(observation, fresh) | |||
| 456 | - | if reason == "" { | |||
| 457 | - | reason = session.Reason | |||
| 458 | - | } | |||
| 459 | - | lines = append(lines, protocol.PickerLine{ | |||
| 460 | - | Key: rowKey, | |||
| 461 | - | Kind: protocol.PickerLineSession, | |||
| 462 | - | Label: session.Name, | |||
| 463 | - | Detail: pickerSessionDetail(session), | |||
| 464 | - | Status: status, | |||
| 465 | - | StatusDetail: reason, | |||
| 466 | - | Stopped: session.State == catalogue.RemoteCatalogSessionDown, | |||
| 467 | - | // Dim keeps the informational eligibility signal (fresh, | |||
| 468 | - | // compatible, reachable) and the hard non-committable state | |||
| 469 | - | // (known incompatibility, broken session). It never removes the | |||
| 470 | - | // ability to try an explicit bounded attach or creation. | |||
| 471 | - | Dim: !eligible || !selectable, | |||
| 472 | - | Focusable: true, | |||
| 473 | - | Actions: actions, | |||
| 474 | - | Ephemeral: session.Ephemeral, | |||
| 475 | - | }) | |||
| 476 | - | } | |||
| 477 | - | } | |||
| 478 | - | c.moveRetiredLocked(refs) | |||
| 479 | - | c.refs = refs | |||
| 480 | - | c.lines = lines | |||
| 481 | - | c.cursor = pickerCatalogueCursor(lines) | |||
| 482 | - | } | |||
| 483 | - | ||||
| 484 | // moveRetiredLocked keeps the last known ref of a key that left the current | 477 | // moveRetiredLocked keeps the last known ref of a key that left the current | ||
| 485 | // projection so a late selection can still be classified as gone or replaced. | 478 | // projection so a late selection can still be classified as gone or replaced. | ||
| 486 | func (c *pickerCatalogue) moveRetiredLocked(next map[string]pickerSelectionRef) { | 479 | func (c *pickerCatalogue) moveRetiredLocked(next map[string]pickerSelectionRef) { | ||
| @@ -503,7 +496,8 @@ func (c *pickerCatalogue) moveRetiredLocked(next map[string]pickerSelectionRef) | |||||
| 503 | // resolveRefLocked revalidates exactly one captured selection against the | 496 | // resolveRefLocked revalidates exactly one captured selection against the | ||
| 504 | // latest applied publication. | 497 | // latest applied publication. | ||
| 505 | func (c *pickerCatalogue) resolveRefLocked(ref pickerSelectionRef, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | 498 | func (c *pickerCatalogue) resolveRefLocked(ref pickerSelectionRef, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | ||
| 506 | - | return resolvePickerRequest(c.snapshot.Epoch, c.authorityForRefLocked(ref), ref, base) | 499 | + | request, _, err := resolvePickerTarget(c.snapshot.Epoch, c.authorityForRefLocked(ref), ref, base, true) |
| 500 | + | return request, err | |||
| 507 | } | 501 | } | ||
| 508 | 502 | ||||
| 509 | // authorityForRefLocked selects the merged observation one selection names. The | 503 | // authorityForRefLocked selects the merged observation one selection names. The | ||
| @@ -581,21 +575,42 @@ func pickerAuthorityInSnapshot(snapshot ports.BrokerSnapshot, ref pickerSelectio | |||||
| 581 | // start-if-needed authorization; a policy that forbids launching still refuses | 575 | // start-if-needed authorization; a policy that forbids launching still refuses | ||
| 582 | // visibly at the destination. | 576 | // visibly at the destination. | ||
| 583 | func resolvePickerRequest(epoch ports.BrokerEpoch, authority pickerResolveAuthority, ref pickerSelectionRef, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | 577 | func resolvePickerRequest(epoch ports.BrokerEpoch, authority pickerResolveAuthority, ref pickerSelectionRef, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | ||
| 578 | + | request, _, err := resolvePickerTarget(epoch, authority, ref, base, false) | |||
| 579 | + | return request, err | |||
| 580 | + | } | |||
| 581 | + | ||||
| 582 | + | // resolvePickerTarget is resolvePickerRequest plus the exact tab a tab row | |||
| 583 | + | // names. refuseFailing is the picker's instant refusal (Plan 003 B4): a | |||
| 584 | + | // remote host the broker observed failing (unreachable, authentication, or an | |||
| 585 | + | // invalid response) refuses with a notice instead of a slow attempt that | |||
| 586 | + | // would fail the same way. An explicit CLI target keeps the attempt. | |||
| 587 | + | func resolvePickerTarget(epoch ports.BrokerEpoch, authority pickerResolveAuthority, ref pickerSelectionRef, base pickerResolveBase, refuseFailing bool) (ports.BrokerOpenStreamRequest, attachmentTab, error) { | |||
| 588 | + | fail := func(err error) (ports.BrokerOpenStreamRequest, attachmentTab, error) { | |||
| 589 | + | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err | |||
| 590 | + | } | |||
| 584 | if epoch == 0 { | 591 | if epoch == 0 { | ||
| 585 | - | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "broker catalogue is unavailable"} | 592 | + | return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "broker catalogue is unavailable"}) |
| 586 | } | 593 | } | ||
| 587 | if ref.epoch != 0 && ref.epoch != epoch { | 594 | if ref.epoch != 0 && ref.epoch != epoch { | ||
| 588 | - | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueEpochStale, Text: "broker epoch changed"} | 595 | + | return fail(pickerCatalogueError{Code: pickerCatalogueEpochStale, Text: "broker epoch changed"}) |
| 589 | } | 596 | } | ||
| 590 | if !authority.found { | 597 | if !authority.found { | ||
| 591 | - | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "host is no longer in the catalogue"} | 598 | + | return fail(pickerCatalogueError{Code: pickerCatalogueGone, Text: "host is no longer in the catalogue"}) |
| 592 | } | 599 | } | ||
| 593 | observation := authority.observation | 600 | observation := authority.observation | ||
| 594 | if !ref.local && !observation.Registration.Equal(ref.registration) { | 601 | if !ref.local && !observation.Registration.Equal(ref.registration) { | ||
| 595 | - | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "host identity was replaced"} | 602 | + | return fail(pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "host identity was replaced"}) |
| 596 | } | 603 | } | ||
| 597 | if observation.Availability == domain.RemoteAvailabilityIncompatible || pickerObservationVersionMismatch(observation) { | 604 | if observation.Availability == domain.RemoteAvailabilityIncompatible || pickerObservationVersionMismatch(observation) { | ||
| 598 | - | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueIncompatible, Text: "host protocol is incompatible"} | 605 | + | return fail(pickerCatalogueError{Code: pickerCatalogueIncompatible, Text: "host protocol is incompatible"}) |
| 606 | + | } | |||
| 607 | + | if refuseFailing && !ref.local && pickerObservationFailing(observation) { | |||
| 608 | + | reason := pickerObservationReason(observation, false) | |||
| 609 | + | return fail(pickerCatalogueError{ | |||
| 610 | + | Code: pickerCatalogueUnavailable, | |||
| 611 | + | Text: "host " + reason, | |||
| 612 | + | Notice: pickerUnavailableNotice(ref, observation, reason), | |||
| 613 | + | }) | |||
| 599 | } | 614 | } | ||
| 600 | request := ports.BrokerOpenStreamRequest{ | 615 | request := ports.BrokerOpenStreamRequest{ | ||
| 601 | Epoch: epoch, | 616 | Epoch: epoch, | ||
| @@ -614,40 +629,46 @@ func resolvePickerRequest(epoch ports.BrokerEpoch, authority pickerResolveAuthor | |||||
| 614 | if !ref.local { | 629 | if !ref.local { | ||
| 615 | request.Endpoint = observation.Endpoint | 630 | request.Endpoint = observation.Endpoint | ||
| 616 | } | 631 | } | ||
| 632 | + | var tab attachmentTab | |||
| 617 | switch ref.kind { | 633 | switch ref.kind { | ||
| 618 | case pickerSelectionExact: | 634 | case pickerSelectionExact: | ||
| 619 | session, ok := pickerFindSession(observation.Sessions, ref.lifecycle) | 635 | session, ok := pickerFindSession(observation.Sessions, ref.lifecycle) | ||
| 620 | if !ok { | 636 | if !ok { | ||
| 621 | if pickerSessionReplaced(observation.Sessions, ref) { | 637 | if pickerSessionReplaced(observation.Sessions, ref) { | ||
| 622 | - | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"} | 638 | + | return fail(pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"}) |
| 623 | } | 639 | } | ||
| 624 | - | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "session is no longer in the catalogue"} | 640 | + | return fail(pickerCatalogueError{Code: pickerCatalogueGone, Text: "session is no longer in the catalogue"}) |
| 625 | } | 641 | } | ||
| 626 | if session.Name != ref.name { | 642 | if session.Name != ref.name { | ||
| 627 | // The exact identity is the lifecycle/name pair the daemon | 643 | // The exact identity is the lifecycle/name pair the daemon | ||
| 628 | // revalidates: a renamed lifecycle is a different selection. | 644 | // revalidates: a renamed lifecycle is a different selection. | ||
| 629 | - | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"} | 645 | + | return fail(pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"}) |
| 630 | } | 646 | } | ||
| 631 | if session.State == catalogue.RemoteCatalogSessionBroken { | 647 | if session.State == catalogue.RemoteCatalogSessionBroken { | ||
| 632 | - | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "session is broken"} | 648 | + | return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "session is broken"}) |
| 633 | } | 649 | } | ||
| 650 | + | resolved, err := pickerResolveTab(ref, session) | |||
| 651 | + | if err != nil { | |||
| 652 | + | return fail(err) | |||
| 653 | + | } | |||
| 654 | + | tab = resolved | |||
| 634 | request.Admission = ports.BrokerAdmissionExact | 655 | request.Admission = ports.BrokerAdmissionExact | ||
| 635 | request.Target = protocol.ExactSessionTarget{LifecycleID: session.LifecycleID, SessionName: session.Name} | 656 | request.Target = protocol.ExactSessionTarget{LifecycleID: session.LifecycleID, SessionName: session.Name} | ||
| 636 | case pickerSelectionCreateNamed: | 657 | case pickerSelectionCreateNamed: | ||
| 637 | if err := domain.ValidateSessionName(ref.createName); err != nil { | 658 | if err := domain.ValidateSessionName(ref.createName); err != nil { | ||
| 638 | - | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueInvalidName, Text: "session name is not valid"} | 659 | + | return fail(pickerCatalogueError{Code: pickerCatalogueInvalidName, Text: "session name is not valid"}) |
| 639 | } | 660 | } | ||
| 640 | request.Admission = ports.BrokerAdmissionCreateNamed | 661 | request.Admission = ports.BrokerAdmissionCreateNamed | ||
| 641 | request.Name = ref.createName | 662 | request.Name = ref.createName | ||
| 642 | case pickerSelectionCreateEphemeral: | 663 | case pickerSelectionCreateEphemeral: | ||
| 643 | request.Admission = ports.BrokerAdmissionCreateEphemeral | 664 | request.Admission = ports.BrokerAdmissionCreateEphemeral | ||
| 644 | default: | 665 | default: | ||
| 645 | - | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker selection kind is unknown"} | 666 | + | return fail(pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker selection kind is unknown"}) |
| 646 | } | 667 | } | ||
| 647 | if err := request.Validate(); err != nil { | 668 | if err := request.Validate(); err != nil { | ||
| 648 | - | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker stream request is not valid"} | 669 | + | return fail(pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker stream request is not valid"}) |
| 649 | } | 670 | } | ||
| 650 | - | return request, nil | 671 | + | return request, tab, nil |
| 651 | } | 672 | } | ||
| 652 | 673 | ||||
| 653 | // freshLocked reports whether LastSuccess is inside the freshness window. An | 674 | // freshLocked reports whether LastSuccess is inside the freshness window. An | ||
| @@ -659,47 +680,6 @@ func (c *pickerCatalogue) freshLocked(observation ports.BrokerDaemonObservation, | |||||
| 659 | return now.Sub(observation.LastSuccess) <= c.freshness | 680 | return now.Sub(observation.LastSuccess) <= c.freshness | ||
| 660 | } | 681 | } | ||
| 661 | 682 | ||||
| 662 | - | // pickerHostDiagnostic is one bounded, presentation-safe catalogue observation | |||
| 663 | - | // the controller surfaces as a toast. | |||
| 664 | - | type pickerHostDiagnostic struct { | |||
| 665 | - | Key string | |||
| 666 | - | Origin string | |||
| 667 | - | Status protocol.PickerLineStatus | |||
| 668 | - | Message string | |||
| 669 | - | } | |||
| 670 | - | ||||
| 671 | - | // diagnostics reports actionable host failures. Ordinary observation progress | |||
| 672 | - | // and catalogue aging remain visible on the host status row and never become | |||
| 673 | - | // intrusive toasts. | |||
| 674 | - | func (c *pickerCatalogue) diagnostics() []pickerHostDiagnostic { | |||
| 675 | - | if c == nil { | |||
| 676 | - | return nil | |||
| 677 | - | } | |||
| 678 | - | c.mu.Lock() | |||
| 679 | - | defer c.mu.Unlock() | |||
| 680 | - | now := c.clock.Now() | |||
| 681 | - | diagnostics := make([]pickerHostDiagnostic, 0, len(c.hosts)) | |||
| 682 | - | for _, key := range c.order { | |||
| 683 | - | host, ok := c.hosts[key] | |||
| 684 | - | if !ok { | |||
| 685 | - | continue | |||
| 686 | - | } | |||
| 687 | - | observation := host.observation | |||
| 688 | - | fresh := c.freshLocked(observation, now) | |||
| 689 | - | status := pickerObservationStatus(observation, fresh) | |||
| 690 | - | if status != protocol.PickerLineStatusDown && status != protocol.PickerLineStatusError && status != protocol.PickerLineStatusVersion { | |||
| 691 | - | continue | |||
| 692 | - | } | |||
| 693 | - | diagnostics = append(diagnostics, pickerHostDiagnostic{ | |||
| 694 | - | Key: pickerHostRowKey(observation), | |||
| 695 | - | Origin: pickerOriginLabel(observation), | |||
| 696 | - | Status: status, | |||
| 697 | - | Message: pickerObservationReason(observation, fresh), | |||
| 698 | - | }) | |||
| 699 | - | } | |||
| 700 | - | return diagnostics | |||
| 701 | - | } | |||
| 702 | - | ||||
| 703 | func pickerDaemonKey(local bool, endpoint string) string { | 683 | func pickerDaemonKey(local bool, endpoint string) string { | ||
| 704 | if local { | 684 | if local { | ||
| 705 | return "local" | 685 | return "local" | ||
A internal/usecase/client/picker_catalogue_projection.go +601 -0
| @@ -0,0 +1,601 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "sort" | |
| 6 | + | "strconv" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | "github.com/bnema/vev/internal/protocol" | |
| 11 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Picker projections (Plan 003 B2/B3/C1), ported from the daemon-built picker | |
| 15 | + | // on main (picker.go, picker_lines.go). | |
| 16 | + | // | |
| 17 | + | // The catalogue publishes two projections of the same rows, keys, and | |
| 18 | + | // actions: | |
| 19 | + | // | |
| 20 | + | // - Recent is flat: live local sessions by recency (LastUsedSeq), then each | |
| 21 | + | // remote host's sessions in catalogue order, then stopped local sessions | |
| 22 | + | // by recency. Remote rows carry their origin in the detail because no | |
| 23 | + | // section names it. | |
| 24 | + | // - Grouped is sectioned by daemon: the local section (live by recency, | |
| 25 | + | // then stopped), then one section per remote host. | |
| 26 | + | // | |
| 27 | + | // A session with tabs is a non-focusable header; its tab rows are the | |
| 28 | + | // destinations, each resolving to its exact tab. A session without tab | |
| 29 | + | // metadata is its own destination. A host status row is published only when | |
| 30 | + | // it adds information: the host lists no session and is not reachable. | |
| 31 | + | // | |
| 32 | + | // The cursor starts on the attachment the picker is presented over (its tab, | |
| 33 | + | // else its session), otherwise on the most recent session's active tab. | |
| 34 | + | ||
| 35 | + | // pickerCurrent names the attachment the picker is presented over. Only its | |
| 36 | + | // presence changes the cursor hint; it is never a selection authority. | |
| 37 | + | type pickerCurrent struct { | |
| 38 | + | known bool | |
| 39 | + | local bool | |
| 40 | + | endpoint string | |
| 41 | + | lifecycle domain.SessionLifecycleID | |
| 42 | + | tab domain.TabStableID | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // pickerRow is one projected line plus the facts its cursor hint needs. | |
| 46 | + | type pickerRow struct { | |
| 47 | + | line protocol.PickerLine | |
| 48 | + | ref pickerSelectionRef | |
| 49 | + | hasRef bool | |
| 50 | + | defaultCursor bool | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // pickerSessionBlock is one session header and its tab rows. | |
| 54 | + | type pickerSessionBlock struct { | |
| 55 | + | rows []pickerRow | |
| 56 | + | lastUsedSeq uint64 | |
| 57 | + | name string | |
| 58 | + | lifecycle domain.SessionLifecycleID | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // projectLocked rebuilds both projections and their opaque keys from the | |
| 62 | + | // current host state. It is deterministic: the same inputs always produce the | |
| 63 | + | // same keys, order, and actions. | |
| 64 | + | func (c *pickerCatalogue) projectLocked() { | |
| 65 | + | now := c.clock.Now() | |
| 66 | + | refs := make(map[string]pickerSelectionRef) | |
| 67 | + | var localLive, localStopped []pickerSessionBlock | |
| 68 | + | localOrigin := "" | |
| 69 | + | hasLocal := false | |
| 70 | + | type remoteGroup struct { | |
| 71 | + | origin string | |
| 72 | + | rows []pickerRow | |
| 73 | + | } | |
| 74 | + | var remotes []remoteGroup | |
| 75 | + | var localHost []pickerRow | |
| 76 | + | for _, key := range c.order { | |
| 77 | + | host, ok := c.hosts[key] | |
| 78 | + | if !ok { | |
| 79 | + | continue | |
| 80 | + | } | |
| 81 | + | observation := host.observation | |
| 82 | + | fresh := c.freshLocked(observation, now) | |
| 83 | + | origin := pickerOriginLabel(observation) | |
| 84 | + | var hostRows []pickerRow | |
| 85 | + | for i := range observation.Sessions { | |
| 86 | + | session := observation.Sessions[i] | |
| 87 | + | if session.LifecycleID == (domain.SessionLifecycleID{}) { | |
| 88 | + | // A session without a lifecycle cannot be addressed exactly and | |
| 89 | + | // is never invented into one. | |
| 90 | + | continue | |
| 91 | + | } | |
| 92 | + | block := c.sessionBlockLocked(observation, session, fresh, refs) | |
| 93 | + | switch { | |
| 94 | + | case !observation.Local: | |
| 95 | + | hostRows = append(hostRows, block.rows...) | |
| 96 | + | case session.State == catalogue.RemoteCatalogSessionUp: | |
| 97 | + | localLive = append(localLive, block) | |
| 98 | + | default: | |
| 99 | + | localStopped = append(localStopped, block) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | if len(observation.Sessions) == 0 && observation.Availability != domain.RemoteAvailabilityReachable { | |
| 103 | + | hostRows = append(hostRows, pickerRow{line: protocol.PickerLine{ | |
| 104 | + | Key: pickerHostRowKey(observation), | |
| 105 | + | Kind: protocol.PickerLineHost, | |
| 106 | + | Label: origin, | |
| 107 | + | Detail: pickerHostDetail(observation), | |
| 108 | + | Status: pickerObservationStatus(observation, fresh), | |
| 109 | + | StatusDetail: pickerObservationReason(observation, fresh), | |
| 110 | + | Dim: true, | |
| 111 | + | // A host row stays reachable for inspection and never commits. | |
| 112 | + | Focusable: true, | |
| 113 | + | }}) | |
| 114 | + | } | |
| 115 | + | if observation.Local { | |
| 116 | + | hasLocal = true | |
| 117 | + | localOrigin = origin | |
| 118 | + | localHost = hostRows | |
| 119 | + | continue | |
| 120 | + | } | |
| 121 | + | if len(hostRows) != 0 { | |
| 122 | + | remotes = append(remotes, remoteGroup{origin: origin, rows: hostRows}) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | sortPickerBlocks(localLive) | |
| 126 | + | sortPickerBlocks(localStopped) | |
| 127 | + | ||
| 128 | + | var recent, grouped []pickerRow | |
| 129 | + | localRows := make([]pickerRow, 0) | |
| 130 | + | for _, block := range localLive { | |
| 131 | + | localRows = append(localRows, block.rows...) | |
| 132 | + | } | |
| 133 | + | localRows = append(localRows, localHost...) | |
| 134 | + | recent = append(recent, localRows...) | |
| 135 | + | for _, remote := range remotes { | |
| 136 | + | for _, row := range remote.rows { | |
| 137 | + | if row.line.Kind == protocol.PickerLineSession && row.line.Detail == "" { | |
| 138 | + | row.line.Detail = "@" + remote.origin | |
| 139 | + | } else if row.line.Kind == protocol.PickerLineSession { | |
| 140 | + | row.line.Detail = "@" + remote.origin + " · " + row.line.Detail | |
| 141 | + | } | |
| 142 | + | recent = append(recent, row) | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | stoppedRows := make([]pickerRow, 0) | |
| 146 | + | for _, block := range localStopped { | |
| 147 | + | stoppedRows = append(stoppedRows, block.rows...) | |
| 148 | + | } | |
| 149 | + | recent = append(recent, stoppedRows...) | |
| 150 | + | ||
| 151 | + | localRows = append(localRows, stoppedRows...) | |
| 152 | + | if hasLocal && len(localRows) != 0 { | |
| 153 | + | grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: localOrigin, Dim: true}}) | |
| 154 | + | grouped = append(grouped, localRows...) | |
| 155 | + | } | |
| 156 | + | for _, remote := range remotes { | |
| 157 | + | grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: remote.origin, Dim: true}}) | |
| 158 | + | grouped = append(grouped, remote.rows...) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | c.moveRetiredLocked(refs) | |
| 162 | + | c.refs = refs | |
| 163 | + | c.lines, c.cursor = c.pickerProjectionLocked(grouped) | |
| 164 | + | c.recent, c.recentCursor = c.pickerProjectionLocked(recent) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // sessionBlockLocked projects one session into its header and tab rows. | |
| 168 | + | func (c *pickerCatalogue) sessionBlockLocked(observation ports.BrokerDaemonObservation, session catalogue.RemoteCatalogSession, fresh bool, refs map[string]pickerSelectionRef) pickerSessionBlock { | |
| 169 | + | compatible := pickerObservationCompatible(observation) | |
| 170 | + | available := observation.Availability == domain.RemoteAvailabilityReachable | |
| 171 | + | eligible := compatible && available && fresh | |
| 172 | + | // Admission is deliberately more permissive than eligibility: a stale or | |
| 173 | + | // not-yet-observed daemon is still selectable, because only the | |
| 174 | + | // destination revalidates the exact identity and only it may start a | |
| 175 | + | // stopped target under the resolved policy. A known incompatibility and a | |
| 176 | + | // broken session refuse immediately; a remote host known to be failing | |
| 177 | + | // refuses at resolution with a notice instead of a slow attempt. | |
| 178 | + | admissible := !pickerObservationKnownIncompatible(observation) | |
| 179 | + | selectable := admissible && session.State != catalogue.RemoteCatalogSessionBroken | |
| 180 | + | stopped := session.State == catalogue.RemoteCatalogSessionDown | |
| 181 | + | reason := pickerObservationReason(observation, fresh) | |
| 182 | + | if reason == "" { | |
| 183 | + | reason = session.Reason | |
| 184 | + | } | |
| 185 | + | ref := pickerSelectionRef{ | |
| 186 | + | kind: pickerSelectionExact, | |
| 187 | + | epoch: c.snapshot.Epoch, | |
| 188 | + | local: observation.Local, | |
| 189 | + | endpoint: observation.Endpoint, | |
| 190 | + | registration: observation.Registration, | |
| 191 | + | lifecycle: session.LifecycleID, | |
| 192 | + | name: session.Name, | |
| 193 | + | } | |
| 194 | + | actions := pickerRowActions(selectable, observation.Local) | |
| 195 | + | attention := false | |
| 196 | + | for _, tab := range session.Tabs { | |
| 197 | + | attention = attention || tab.Attention | |
| 198 | + | } | |
| 199 | + | headerKey := pickerSessionRowKey(ref) | |
| 200 | + | refs[headerKey] = ref | |
| 201 | + | header := pickerRow{ | |
| 202 | + | line: protocol.PickerLine{ | |
| 203 | + | Key: headerKey, | |
| 204 | + | Kind: protocol.PickerLineSession, | |
| 205 | + | Label: session.Name, | |
| 206 | + | Detail: pickerSessionDetail(session), | |
| 207 | + | Status: pickerSessionStatus(session), | |
| 208 | + | StatusDetail: reason, | |
| 209 | + | Attention: attention, | |
| 210 | + | Stopped: stopped, | |
| 211 | + | // Dim keeps the informational eligibility signal (fresh, | |
| 212 | + | // compatible, reachable) and the hard non-committable state. | |
| 213 | + | Dim: !eligible || !selectable, | |
| 214 | + | Ephemeral: session.Ephemeral, | |
| 215 | + | }, | |
| 216 | + | ref: ref, | |
| 217 | + | hasRef: true, | |
| 218 | + | } | |
| 219 | + | block := pickerSessionBlock{lastUsedSeq: session.LastUsedSeq, name: session.Name, lifecycle: session.LifecycleID} | |
| 220 | + | if len(session.Tabs) == 0 { | |
| 221 | + | // Without tab metadata the session is its own destination. | |
| 222 | + | header.line.Focusable = true | |
| 223 | + | header.line.Actions = actions | |
| 224 | + | header.defaultCursor = true | |
| 225 | + | block.rows = append(block.rows, header) | |
| 226 | + | return block | |
| 227 | + | } | |
| 228 | + | // A session with tabs names a group: its tab rows are the destinations. | |
| 229 | + | block.rows = append(block.rows, header) | |
| 230 | + | active := pickerActiveTabIndex(session) | |
| 231 | + | for i, tab := range session.Tabs { | |
| 232 | + | tabRef := ref | |
| 233 | + | tabRef.tab = pickerTabRef{present: true, id: domain.TabStableID(tab.ID), index: i, name: tab.Name, count: len(session.Tabs)} | |
| 234 | + | key := pickerTabRowKey(tabRef) | |
| 235 | + | refs[key] = tabRef | |
| 236 | + | tabActions := actions | |
| 237 | + | if !stopped && tab.ID == "" { | |
| 238 | + | // A live tab without a stable identity cannot be targeted exactly. | |
| 239 | + | tabActions &^= protocol.PickerCanNavigate | |
| 240 | + | } | |
| 241 | + | label := tab.Name | |
| 242 | + | if label == "" { | |
| 243 | + | label = strconv.Itoa(int(tab.Index) + 1) | |
| 244 | + | } | |
| 245 | + | block.rows = append(block.rows, pickerRow{ | |
| 246 | + | line: protocol.PickerLine{ | |
| 247 | + | Key: key, | |
| 248 | + | Kind: protocol.PickerLineTab, | |
| 249 | + | Label: label, | |
| 250 | + | Detail: tab.Detail, | |
| 251 | + | StatusDetail: reason, | |
| 252 | + | Attention: tab.Attention, | |
| 253 | + | Stopped: stopped, | |
| 254 | + | Dim: header.line.Dim, | |
| 255 | + | Focusable: true, | |
| 256 | + | Actions: tabActions, | |
| 257 | + | Ephemeral: session.Ephemeral, | |
| 258 | + | }, | |
| 259 | + | ref: tabRef, | |
| 260 | + | hasRef: true, | |
| 261 | + | defaultCursor: i == active, | |
| 262 | + | }) | |
| 263 | + | } | |
| 264 | + | return block | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // pickerRowActions maps eligibility to the actions a row authorises. Kill is | |
| 268 | + | // offered on local rows only, exactly as on main; the daemon revalidates the | |
| 269 | + | // name, and a stopped row deletes its history record. | |
| 270 | + | func pickerRowActions(selectable, local bool) protocol.PickerLineActions { | |
| 271 | + | var actions protocol.PickerLineActions | |
| 272 | + | if selectable { | |
| 273 | + | actions |= protocol.PickerCanNavigate | |
| 274 | + | } | |
| 275 | + | if local { | |
| 276 | + | actions |= protocol.PickerCanKill | |
| 277 | + | } | |
| 278 | + | return actions | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | // pickerActiveTabIndex is the session's active tab, or its first tab. | |
| 282 | + | func pickerActiveTabIndex(session catalogue.RemoteCatalogSession) int { | |
| 283 | + | for i, tab := range session.Tabs { | |
| 284 | + | if session.ActiveTabID != "" && tab.ID == session.ActiveTabID { | |
| 285 | + | return i | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | return 0 | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // sortPickerBlocks orders sessions by recency, then name, then lifecycle. | |
| 292 | + | func sortPickerBlocks(blocks []pickerSessionBlock) { | |
| 293 | + | sort.SliceStable(blocks, func(i, j int) bool { | |
| 294 | + | if blocks[i].lastUsedSeq != blocks[j].lastUsedSeq { | |
| 295 | + | return blocks[i].lastUsedSeq > blocks[j].lastUsedSeq | |
| 296 | + | } | |
| 297 | + | if blocks[i].name != blocks[j].name { | |
| 298 | + | return blocks[i].name < blocks[j].name | |
| 299 | + | } | |
| 300 | + | return blocks[i].lifecycle.String() < blocks[j].lifecycle.String() | |
| 301 | + | }) | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | // pickerProjectionLocked flattens one projection and computes its cursor: | |
| 305 | + | // the current attachment's tab, else its session, else the first default row | |
| 306 | + | // (the most recent session's active tab), else the first actionable row. | |
| 307 | + | func (c *pickerCatalogue) pickerProjectionLocked(rows []pickerRow) ([]protocol.PickerLine, protocol.PickerCursor) { | |
| 308 | + | lines := make([]protocol.PickerLine, len(rows)) | |
| 309 | + | for i := range rows { | |
| 310 | + | lines[i] = rows[i].line | |
| 311 | + | } | |
| 312 | + | if cursor, ok := c.currentCursorLocked(rows); ok { | |
| 313 | + | return lines, cursor | |
| 314 | + | } | |
| 315 | + | for i, row := range rows { | |
| 316 | + | if row.defaultCursor && row.line.Focusable && row.line.Actions&protocol.PickerCanNavigate != 0 { | |
| 317 | + | return lines, protocol.PickerCursor{Key: row.line.Key, Index: i} | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | return lines, pickerCatalogueCursor(lines) | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | func (c *pickerCatalogue) currentCursorLocked(rows []pickerRow) (protocol.PickerCursor, bool) { | |
| 324 | + | current := c.current | |
| 325 | + | if !current.known { | |
| 326 | + | return protocol.PickerCursor{}, false | |
| 327 | + | } | |
| 328 | + | matches := func(ref pickerSelectionRef) bool { | |
| 329 | + | if ref.lifecycle != current.lifecycle || ref.local != current.local { | |
| 330 | + | return false | |
| 331 | + | } | |
| 332 | + | return ref.local || ref.endpoint == current.endpoint | |
| 333 | + | } | |
| 334 | + | // Exact tab first, then the session's own row (no tab metadata), then its | |
| 335 | + | // active tab. | |
| 336 | + | session, active := -1, -1 | |
| 337 | + | for i, row := range rows { | |
| 338 | + | if !row.hasRef || !row.line.Focusable || !matches(row.ref) { | |
| 339 | + | continue | |
| 340 | + | } | |
| 341 | + | if !row.ref.tab.present { | |
| 342 | + | session = i | |
| 343 | + | continue | |
| 344 | + | } | |
| 345 | + | if current.tab != "" && row.ref.tab.id == current.tab { | |
| 346 | + | return protocol.PickerCursor{Key: row.line.Key, Index: i}, true | |
| 347 | + | } | |
| 348 | + | if row.defaultCursor && active < 0 { | |
| 349 | + | active = i | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | for _, index := range []int{session, active} { | |
| 353 | + | if index >= 0 { | |
| 354 | + | return protocol.PickerCursor{Key: rows[index].line.Key, Index: index}, true | |
| 355 | + | } | |
| 356 | + | } | |
| 357 | + | return protocol.PickerCursor{}, false | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | // pickerTabRowKey is the stable, opaque key of one tab row: the session | |
| 361 | + | // identity plus the tab's stable ID, or its ordinal and raw name when the | |
| 362 | + | // catalogue carries no ID. | |
| 363 | + | func pickerTabRowKey(ref pickerSelectionRef) string { | |
| 364 | + | tab := string(ref.tab.id) | |
| 365 | + | if tab == "" { | |
| 366 | + | tab = fmt.Sprintf("#%d:%s", ref.tab.index, ref.tab.name) | |
| 367 | + | } | |
| 368 | + | lifecycle := append(append([]byte(nil), ref.lifecycle[:]...), 0) | |
| 369 | + | lifecycle = append(lifecycle, tab...) | |
| 370 | + | return pickerRowKey("t", ref.local, ref.endpoint, ref.registration.Incarnation[:], ref.registration.Generation, lifecycle) | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | // attachmentTab is the client-owned tab selection an attachment Hello | |
| 374 | + | // carries: a stable tab to prefer on a live or stopped-local session, or an | |
| 375 | + | // exact stopped-remote selector. Its zero value attaches at session level. | |
| 376 | + | type attachmentTab struct { | |
| 377 | + | preferred domain.TabStableID | |
| 378 | + | stopped *protocol.SessionAttachTarget | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | // pickerResolveTab turns a tab ref into the attachment tab it names, checked | |
| 382 | + | // against the latest session metadata. A tab that left the session is gone. | |
| 383 | + | func pickerResolveTab(ref pickerSelectionRef, session catalogue.RemoteCatalogSession) (attachmentTab, error) { | |
| 384 | + | if !ref.tab.present { | |
| 385 | + | return attachmentTab{}, nil | |
| 386 | + | } | |
| 387 | + | gone := pickerCatalogueError{Code: pickerCatalogueGone, Text: "tab is no longer in the catalogue"} | |
| 388 | + | stopped := session.State == catalogue.RemoteCatalogSessionDown | |
| 389 | + | if ref.tab.id != "" { | |
| 390 | + | found := false | |
| 391 | + | for _, tab := range session.Tabs { | |
| 392 | + | if domain.TabStableID(tab.ID) == ref.tab.id { | |
| 393 | + | found = true | |
| 394 | + | break | |
| 395 | + | } | |
| 396 | + | } | |
| 397 | + | if !found { | |
| 398 | + | return attachmentTab{}, gone | |
| 399 | + | } | |
| 400 | + | if stopped && !ref.local { | |
| 401 | + | target := protocol.SessionAttachTarget{LifecycleID: session.LifecycleID, SessionName: session.Name, TabID: ref.tab.id, TabIndex: protocol.NoTabIndex, Stopped: true} | |
| 402 | + | if target.Validate() != nil { | |
| 403 | + | return attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "stopped tab cannot be addressed"} | |
| 404 | + | } | |
| 405 | + | return attachmentTab{stopped: &target}, nil | |
| 406 | + | } | |
| 407 | + | return attachmentTab{preferred: ref.tab.id}, nil | |
| 408 | + | } | |
| 409 | + | if ref.tab.index >= len(session.Tabs) || session.Tabs[ref.tab.index].Name != ref.tab.name || len(session.Tabs) != ref.tab.count { | |
| 410 | + | return attachmentTab{}, gone | |
| 411 | + | } | |
| 412 | + | if !stopped { | |
| 413 | + | return attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "tab has no stable identity"} | |
| 414 | + | } | |
| 415 | + | if ref.local || ref.tab.count > int(^uint16(0)) { | |
| 416 | + | // A local restore without a stable tab falls back to the session. | |
| 417 | + | return attachmentTab{}, nil | |
| 418 | + | } | |
| 419 | + | target := protocol.SessionAttachTarget{ | |
| 420 | + | LifecycleID: session.LifecycleID, SessionName: session.Name, Stopped: true, | |
| 421 | + | TabIndex: int32(ref.tab.index), TabRawName: ref.tab.name, TabExpectedCount: uint16(ref.tab.count), | |
| 422 | + | } | |
| 423 | + | if target.Validate() != nil { | |
| 424 | + | return attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "stopped tab cannot be addressed"} | |
| 425 | + | } | |
| 426 | + | return attachmentTab{stopped: &target}, nil | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | // pickerObservationFailing reports a host the broker observed failing: its | |
| 430 | + | // last probe could not reach it, authenticate, or read its catalogue. | |
| 431 | + | func pickerObservationFailing(observation ports.BrokerDaemonObservation) bool { | |
| 432 | + | switch observation.Availability { | |
| 433 | + | case domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityAuthFailed, domain.RemoteAvailabilityInvalidResponse: | |
| 434 | + | return true | |
| 435 | + | default: | |
| 436 | + | return false | |
| 437 | + | } | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | // pickerUnavailableNotice is main's remote-unavailable notice: the session | |
| 441 | + | // and its origin, then the reason in words. | |
| 442 | + | func pickerUnavailableNotice(ref pickerSelectionRef, observation ports.BrokerDaemonObservation, reason string) string { | |
| 443 | + | message := "Remote session unavailable" | |
| 444 | + | origin := pickerOriginLabel(observation) | |
| 445 | + | if ref.name != "" { | |
| 446 | + | message += ": " + ref.name + "@" + origin | |
| 447 | + | } else { | |
| 448 | + | message += ": " + origin | |
| 449 | + | } | |
| 450 | + | return message + " — " + pickerReasonText(reason) | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | // pickerReasonText renders one domain.RemoteReason in words (main's | |
| 454 | + | // remotePickerReasonText). | |
| 455 | + | func pickerReasonText(reason string) string { | |
| 456 | + | switch reason { | |
| 457 | + | case domain.RemoteReasonCatalogStale: | |
| 458 | + | return "catalog stale" | |
| 459 | + | case domain.RemoteReasonHostUnreachable: | |
| 460 | + | return "host unreachable" | |
| 461 | + | case domain.RemoteReasonVersionMismatch: | |
| 462 | + | return "version mismatch" | |
| 463 | + | case domain.RemoteReasonSessionStopped: | |
| 464 | + | return "session stopped" | |
| 465 | + | case domain.RemoteReasonSessionBroken: | |
| 466 | + | return "session broken" | |
| 467 | + | case domain.RemoteReasonMalformed: | |
| 468 | + | return "catalog malformed" | |
| 469 | + | case domain.RemoteReasonAuthFailure: | |
| 470 | + | return "authentication failed" | |
| 471 | + | case domain.RemoteReasonRefreshing: | |
| 472 | + | return "refreshing" | |
| 473 | + | case domain.RemoteReasonIdentityChanged: | |
| 474 | + | return "session identity changed" | |
| 475 | + | default: | |
| 476 | + | return "unavailable" | |
| 477 | + | } | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | // pickerKillTarget is one resolved kill: the exact local control route and | |
| 481 | + | // the session name the daemon kills (live) or deletes from history | |
| 482 | + | // (stopped). | |
| 483 | + | type pickerKillTarget struct { | |
| 484 | + | route BrokerOperationRoute | |
| 485 | + | name string | |
| 486 | + | stopped bool | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | // ResolveKill revalidates one row key for destruction against the latest | |
| 490 | + | // publication. Only local sessions are killable, exactly as on main; the row | |
| 491 | + | // must still name the same lifecycle and name. | |
| 492 | + | func (c *pickerCatalogue) ResolveKill(key string) (pickerKillTarget, error) { | |
| 493 | + | if c == nil { | |
| 494 | + | return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"} | |
| 495 | + | } | |
| 496 | + | c.mu.Lock() | |
| 497 | + | defer c.mu.Unlock() | |
| 498 | + | ref, ok := c.refs[key] | |
| 499 | + | if !ok { | |
| 500 | + | ref, ok = c.retired[key] | |
| 501 | + | } | |
| 502 | + | if !ok || ref.kind != pickerSelectionExact { | |
| 503 | + | return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"} | |
| 504 | + | } | |
| 505 | + | if !ref.local { | |
| 506 | + | return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "remote sessions cannot be killed from the picker"} | |
| 507 | + | } | |
| 508 | + | epoch := c.snapshot.Epoch | |
| 509 | + | if epoch == 0 { | |
| 510 | + | return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "broker catalogue is unavailable"} | |
| 511 | + | } | |
| 512 | + | if ref.epoch != 0 && ref.epoch != epoch { | |
| 513 | + | return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueEpochStale, Text: "broker epoch changed"} | |
| 514 | + | } | |
| 515 | + | authority := c.authorityForRefLocked(ref) | |
| 516 | + | if !authority.found { | |
| 517 | + | return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "host is no longer in the catalogue"} | |
| 518 | + | } | |
| 519 | + | session, found := pickerFindSession(authority.observation.Sessions, ref.lifecycle) | |
| 520 | + | if !found { | |
| 521 | + | if pickerSessionReplaced(authority.observation.Sessions, ref) { | |
| 522 | + | return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"} | |
| 523 | + | } | |
| 524 | + | return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "session is no longer in the catalogue"} | |
| 525 | + | } | |
| 526 | + | if session.Name != ref.name { | |
| 527 | + | return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"} | |
| 528 | + | } | |
| 529 | + | if domain.ValidateSessionName(session.Name) != nil { | |
| 530 | + | return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueInvalidName, Text: "session name is not valid"} | |
| 531 | + | } | |
| 532 | + | return pickerKillTarget{ | |
| 533 | + | route: BrokerOperationRoute{Epoch: epoch, Destination: ports.BrokerEndpointFence{Local: true}}, | |
| 534 | + | name: session.Name, | |
| 535 | + | stopped: session.State != catalogue.RemoteCatalogSessionUp, | |
| 536 | + | }, nil | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | // pickerHostFailure is one remote host failure episode worth one toast. | |
| 540 | + | type pickerHostFailure struct { | |
| 541 | + | key string | |
| 542 | + | episode uint64 | |
| 543 | + | message string | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | // failures reports every remote host currently in a failure episode. The | |
| 547 | + | // controller toasts each (host, episode) once, as main's | |
| 548 | + | // notifyNewRemoteFailures did. | |
| 549 | + | func (c *pickerCatalogue) failures() []pickerHostFailure { | |
| 550 | + | if c == nil { | |
| 551 | + | return nil | |
| 552 | + | } | |
| 553 | + | c.mu.Lock() | |
| 554 | + | defer c.mu.Unlock() | |
| 555 | + | failures := make([]pickerHostFailure, 0) | |
| 556 | + | for _, key := range c.order { | |
| 557 | + | host, ok := c.hosts[key] | |
| 558 | + | if !ok || host.observation.Local { | |
| 559 | + | continue | |
| 560 | + | } | |
| 561 | + | observation := host.observation | |
| 562 | + | if !pickerObservationFailing(observation) && observation.Availability != domain.RemoteAvailabilityIncompatible && !pickerObservationVersionMismatch(observation) { | |
| 563 | + | continue | |
| 564 | + | } | |
| 565 | + | failures = append(failures, pickerHostFailure{ | |
| 566 | + | key: key, | |
| 567 | + | episode: observation.FailureEpisode, | |
| 568 | + | message: pickerHostFailureMessage(observation), | |
| 569 | + | }) | |
| 570 | + | } | |
| 571 | + | return failures | |
| 572 | + | } | |
| 573 | + | ||
| 574 | + | // pickerHostFailureMessage is main's remoteFailureNoticeMessage, named by the | |
| 575 | + | // host's display origin rather than a raw endpoint. | |
| 576 | + | func pickerHostFailureMessage(observation ports.BrokerDaemonObservation) string { | |
| 577 | + | prefix := "Remote check failed: " + pickerOriginLabel(observation) + " — " | |
| 578 | + | if observation.Availability == domain.RemoteAvailabilityIncompatible || pickerObservationVersionMismatch(observation) { | |
| 579 | + | return prefix + "remote vev version is incompatible" | |
| 580 | + | } | |
| 581 | + | switch observation.LastFailure.Kind { | |
| 582 | + | case domain.RemoteFailureAuthentication: | |
| 583 | + | return prefix + "SSH authentication failed; verify non-interactive SSH access" | |
| 584 | + | case domain.RemoteFailureTrust: | |
| 585 | + | return prefix + "SSH host verification failed; verify the host key policy" | |
| 586 | + | case domain.RemoteFailureIncompatible: | |
| 587 | + | return prefix + "remote vev version is incompatible" | |
| 588 | + | case domain.RemoteFailureInvalidResponse: | |
| 589 | + | return prefix + "remote catalog response is invalid" | |
| 590 | + | case domain.RemoteFailureTimeout: | |
| 591 | + | return prefix + "SSH timed out" | |
| 592 | + | } | |
| 593 | + | switch observation.Availability { | |
| 594 | + | case domain.RemoteAvailabilityAuthFailed: | |
| 595 | + | return prefix + "SSH authentication failed; verify non-interactive SSH access" | |
| 596 | + | case domain.RemoteAvailabilityInvalidResponse: | |
| 597 | + | return prefix + "remote catalog response is invalid" | |
| 598 | + | default: | |
| 599 | + | return prefix + "SSH connection failed; verify SSH access" | |
| 600 | + | } | |
| 601 | + | } | |
| @@ -0,0 +1,601 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "sort" | |||
| 6 | + | "strconv" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | "github.com/bnema/vev/internal/ports" | |||
| 10 | + | "github.com/bnema/vev/internal/protocol" | |||
| 11 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // Picker projections (Plan 003 B2/B3/C1), ported from the daemon-built picker | |||
| 15 | + | // on main (picker.go, picker_lines.go). | |||
| 16 | + | // | |||
| 17 | + | // The catalogue publishes two projections of the same rows, keys, and | |||
| 18 | + | // actions: | |||
| 19 | + | // | |||
| 20 | + | // - Recent is flat: live local sessions by recency (LastUsedSeq), then each | |||
| 21 | + | // remote host's sessions in catalogue order, then stopped local sessions | |||
| 22 | + | // by recency. Remote rows carry their origin in the detail because no | |||
| 23 | + | // section names it. | |||
| 24 | + | // - Grouped is sectioned by daemon: the local section (live by recency, | |||
| 25 | + | // then stopped), then one section per remote host. | |||
| 26 | + | // | |||
| 27 | + | // A session with tabs is a non-focusable header; its tab rows are the | |||
| 28 | + | // destinations, each resolving to its exact tab. A session without tab | |||
| 29 | + | // metadata is its own destination. A host status row is published only when | |||
| 30 | + | // it adds information: the host lists no session and is not reachable. | |||
| 31 | + | // | |||
| 32 | + | // The cursor starts on the attachment the picker is presented over (its tab, | |||
| 33 | + | // else its session), otherwise on the most recent session's active tab. | |||
| 34 | + | ||||
| 35 | + | // pickerCurrent names the attachment the picker is presented over. Only its | |||
| 36 | + | // presence changes the cursor hint; it is never a selection authority. | |||
| 37 | + | type pickerCurrent struct { | |||
| 38 | + | known bool | |||
| 39 | + | local bool | |||
| 40 | + | endpoint string | |||
| 41 | + | lifecycle domain.SessionLifecycleID | |||
| 42 | + | tab domain.TabStableID | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // pickerRow is one projected line plus the facts its cursor hint needs. | |||
| 46 | + | type pickerRow struct { | |||
| 47 | + | line protocol.PickerLine | |||
| 48 | + | ref pickerSelectionRef | |||
| 49 | + | hasRef bool | |||
| 50 | + | defaultCursor bool | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // pickerSessionBlock is one session header and its tab rows. | |||
| 54 | + | type pickerSessionBlock struct { | |||
| 55 | + | rows []pickerRow | |||
| 56 | + | lastUsedSeq uint64 | |||
| 57 | + | name string | |||
| 58 | + | lifecycle domain.SessionLifecycleID | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // projectLocked rebuilds both projections and their opaque keys from the | |||
| 62 | + | // current host state. It is deterministic: the same inputs always produce the | |||
| 63 | + | // same keys, order, and actions. | |||
| 64 | + | func (c *pickerCatalogue) projectLocked() { | |||
| 65 | + | now := c.clock.Now() | |||
| 66 | + | refs := make(map[string]pickerSelectionRef) | |||
| 67 | + | var localLive, localStopped []pickerSessionBlock | |||
| 68 | + | localOrigin := "" | |||
| 69 | + | hasLocal := false | |||
| 70 | + | type remoteGroup struct { | |||
| 71 | + | origin string | |||
| 72 | + | rows []pickerRow | |||
| 73 | + | } | |||
| 74 | + | var remotes []remoteGroup | |||
| 75 | + | var localHost []pickerRow | |||
| 76 | + | for _, key := range c.order { | |||
| 77 | + | host, ok := c.hosts[key] | |||
| 78 | + | if !ok { | |||
| 79 | + | continue | |||
| 80 | + | } | |||
| 81 | + | observation := host.observation | |||
| 82 | + | fresh := c.freshLocked(observation, now) | |||
| 83 | + | origin := pickerOriginLabel(observation) | |||
| 84 | + | var hostRows []pickerRow | |||
| 85 | + | for i := range observation.Sessions { | |||
| 86 | + | session := observation.Sessions[i] | |||
| 87 | + | if session.LifecycleID == (domain.SessionLifecycleID{}) { | |||
| 88 | + | // A session without a lifecycle cannot be addressed exactly and | |||
| 89 | + | // is never invented into one. | |||
| 90 | + | continue | |||
| 91 | + | } | |||
| 92 | + | block := c.sessionBlockLocked(observation, session, fresh, refs) | |||
| 93 | + | switch { | |||
| 94 | + | case !observation.Local: | |||
| 95 | + | hostRows = append(hostRows, block.rows...) | |||
| 96 | + | case session.State == catalogue.RemoteCatalogSessionUp: | |||
| 97 | + | localLive = append(localLive, block) | |||
| 98 | + | default: | |||
| 99 | + | localStopped = append(localStopped, block) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | if len(observation.Sessions) == 0 && observation.Availability != domain.RemoteAvailabilityReachable { | |||
| 103 | + | hostRows = append(hostRows, pickerRow{line: protocol.PickerLine{ | |||
| 104 | + | Key: pickerHostRowKey(observation), | |||
| 105 | + | Kind: protocol.PickerLineHost, | |||
| 106 | + | Label: origin, | |||
| 107 | + | Detail: pickerHostDetail(observation), | |||
| 108 | + | Status: pickerObservationStatus(observation, fresh), | |||
| 109 | + | StatusDetail: pickerObservationReason(observation, fresh), | |||
| 110 | + | Dim: true, | |||
| 111 | + | // A host row stays reachable for inspection and never commits. | |||
| 112 | + | Focusable: true, | |||
| 113 | + | }}) | |||
| 114 | + | } | |||
| 115 | + | if observation.Local { | |||
| 116 | + | hasLocal = true | |||
| 117 | + | localOrigin = origin | |||
| 118 | + | localHost = hostRows | |||
| 119 | + | continue | |||
| 120 | + | } | |||
| 121 | + | if len(hostRows) != 0 { | |||
| 122 | + | remotes = append(remotes, remoteGroup{origin: origin, rows: hostRows}) | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | sortPickerBlocks(localLive) | |||
| 126 | + | sortPickerBlocks(localStopped) | |||
| 127 | + | ||||
| 128 | + | var recent, grouped []pickerRow | |||
| 129 | + | localRows := make([]pickerRow, 0) | |||
| 130 | + | for _, block := range localLive { | |||
| 131 | + | localRows = append(localRows, block.rows...) | |||
| 132 | + | } | |||
| 133 | + | localRows = append(localRows, localHost...) | |||
| 134 | + | recent = append(recent, localRows...) | |||
| 135 | + | for _, remote := range remotes { | |||
| 136 | + | for _, row := range remote.rows { | |||
| 137 | + | if row.line.Kind == protocol.PickerLineSession && row.line.Detail == "" { | |||
| 138 | + | row.line.Detail = "@" + remote.origin | |||
| 139 | + | } else if row.line.Kind == protocol.PickerLineSession { | |||
| 140 | + | row.line.Detail = "@" + remote.origin + " · " + row.line.Detail | |||
| 141 | + | } | |||
| 142 | + | recent = append(recent, row) | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | stoppedRows := make([]pickerRow, 0) | |||
| 146 | + | for _, block := range localStopped { | |||
| 147 | + | stoppedRows = append(stoppedRows, block.rows...) | |||
| 148 | + | } | |||
| 149 | + | recent = append(recent, stoppedRows...) | |||
| 150 | + | ||||
| 151 | + | localRows = append(localRows, stoppedRows...) | |||
| 152 | + | if hasLocal && len(localRows) != 0 { | |||
| 153 | + | grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: localOrigin, Dim: true}}) | |||
| 154 | + | grouped = append(grouped, localRows...) | |||
| 155 | + | } | |||
| 156 | + | for _, remote := range remotes { | |||
| 157 | + | grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: remote.origin, Dim: true}}) | |||
| 158 | + | grouped = append(grouped, remote.rows...) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | c.moveRetiredLocked(refs) | |||
| 162 | + | c.refs = refs | |||
| 163 | + | c.lines, c.cursor = c.pickerProjectionLocked(grouped) | |||
| 164 | + | c.recent, c.recentCursor = c.pickerProjectionLocked(recent) | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | // sessionBlockLocked projects one session into its header and tab rows. | |||
| 168 | + | func (c *pickerCatalogue) sessionBlockLocked(observation ports.BrokerDaemonObservation, session catalogue.RemoteCatalogSession, fresh bool, refs map[string]pickerSelectionRef) pickerSessionBlock { | |||
| 169 | + | compatible := pickerObservationCompatible(observation) | |||
| 170 | + | available := observation.Availability == domain.RemoteAvailabilityReachable | |||
| 171 | + | eligible := compatible && available && fresh | |||
| 172 | + | // Admission is deliberately more permissive than eligibility: a stale or | |||
| 173 | + | // not-yet-observed daemon is still selectable, because only the | |||
| 174 | + | // destination revalidates the exact identity and only it may start a | |||
| 175 | + | // stopped target under the resolved policy. A known incompatibility and a | |||
| 176 | + | // broken session refuse immediately; a remote host known to be failing | |||
| 177 | + | // refuses at resolution with a notice instead of a slow attempt. | |||
| 178 | + | admissible := !pickerObservationKnownIncompatible(observation) | |||
| 179 | + | selectable := admissible && session.State != catalogue.RemoteCatalogSessionBroken | |||
| 180 | + | stopped := session.State == catalogue.RemoteCatalogSessionDown | |||
| 181 | + | reason := pickerObservationReason(observation, fresh) | |||
| 182 | + | if reason == "" { | |||
| 183 | + | reason = session.Reason | |||
| 184 | + | } | |||
| 185 | + | ref := pickerSelectionRef{ | |||
| 186 | + | kind: pickerSelectionExact, | |||
| 187 | + | epoch: c.snapshot.Epoch, | |||
| 188 | + | local: observation.Local, | |||
| 189 | + | endpoint: observation.Endpoint, | |||
| 190 | + | registration: observation.Registration, | |||
| 191 | + | lifecycle: session.LifecycleID, | |||
| 192 | + | name: session.Name, | |||
| 193 | + | } | |||
| 194 | + | actions := pickerRowActions(selectable, observation.Local) | |||
| 195 | + | attention := false | |||
| 196 | + | for _, tab := range session.Tabs { | |||
| 197 | + | attention = attention || tab.Attention | |||
| 198 | + | } | |||
| 199 | + | headerKey := pickerSessionRowKey(ref) | |||
| 200 | + | refs[headerKey] = ref | |||
| 201 | + | header := pickerRow{ | |||
| 202 | + | line: protocol.PickerLine{ | |||
| 203 | + | Key: headerKey, | |||
| 204 | + | Kind: protocol.PickerLineSession, | |||
| 205 | + | Label: session.Name, | |||
| 206 | + | Detail: pickerSessionDetail(session), | |||
| 207 | + | Status: pickerSessionStatus(session), | |||
| 208 | + | StatusDetail: reason, | |||
| 209 | + | Attention: attention, | |||
| 210 | + | Stopped: stopped, | |||
| 211 | + | // Dim keeps the informational eligibility signal (fresh, | |||
| 212 | + | // compatible, reachable) and the hard non-committable state. | |||
| 213 | + | Dim: !eligible || !selectable, | |||
| 214 | + | Ephemeral: session.Ephemeral, | |||
| 215 | + | }, | |||
| 216 | + | ref: ref, | |||
| 217 | + | hasRef: true, | |||
| 218 | + | } | |||
| 219 | + | block := pickerSessionBlock{lastUsedSeq: session.LastUsedSeq, name: session.Name, lifecycle: session.LifecycleID} | |||
| 220 | + | if len(session.Tabs) == 0 { | |||
| 221 | + | // Without tab metadata the session is its own destination. | |||
| 222 | + | header.line.Focusable = true | |||
| 223 | + | header.line.Actions = actions | |||
| 224 | + | header.defaultCursor = true | |||
| 225 | + | block.rows = append(block.rows, header) | |||
| 226 | + | return block | |||
| 227 | + | } | |||
| 228 | + | // A session with tabs names a group: its tab rows are the destinations. | |||
| 229 | + | block.rows = append(block.rows, header) | |||
| 230 | + | active := pickerActiveTabIndex(session) | |||
| 231 | + | for i, tab := range session.Tabs { | |||
| 232 | + | tabRef := ref | |||
| 233 | + | tabRef.tab = pickerTabRef{present: true, id: domain.TabStableID(tab.ID), index: i, name: tab.Name, count: len(session.Tabs)} | |||
| 234 | + | key := pickerTabRowKey(tabRef) | |||
| 235 | + | refs[key] = tabRef | |||
| 236 | + | tabActions := actions | |||
| 237 | + | if !stopped && tab.ID == "" { | |||
| 238 | + | // A live tab without a stable identity cannot be targeted exactly. | |||
| 239 | + | tabActions &^= protocol.PickerCanNavigate | |||
| 240 | + | } | |||
| 241 | + | label := tab.Name | |||
| 242 | + | if label == "" { | |||
| 243 | + | label = strconv.Itoa(int(tab.Index) + 1) | |||
| 244 | + | } | |||
| 245 | + | block.rows = append(block.rows, pickerRow{ | |||
| 246 | + | line: protocol.PickerLine{ | |||
| 247 | + | Key: key, | |||
| 248 | + | Kind: protocol.PickerLineTab, | |||
| 249 | + | Label: label, | |||
| 250 | + | Detail: tab.Detail, | |||
| 251 | + | StatusDetail: reason, | |||
| 252 | + | Attention: tab.Attention, | |||
| 253 | + | Stopped: stopped, | |||
| 254 | + | Dim: header.line.Dim, | |||
| 255 | + | Focusable: true, | |||
| 256 | + | Actions: tabActions, | |||
| 257 | + | Ephemeral: session.Ephemeral, | |||
| 258 | + | }, | |||
| 259 | + | ref: tabRef, | |||
| 260 | + | hasRef: true, | |||
| 261 | + | defaultCursor: i == active, | |||
| 262 | + | }) | |||
| 263 | + | } | |||
| 264 | + | return block | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | // pickerRowActions maps eligibility to the actions a row authorises. Kill is | |||
| 268 | + | // offered on local rows only, exactly as on main; the daemon revalidates the | |||
| 269 | + | // name, and a stopped row deletes its history record. | |||
| 270 | + | func pickerRowActions(selectable, local bool) protocol.PickerLineActions { | |||
| 271 | + | var actions protocol.PickerLineActions | |||
| 272 | + | if selectable { | |||
| 273 | + | actions |= protocol.PickerCanNavigate | |||
| 274 | + | } | |||
| 275 | + | if local { | |||
| 276 | + | actions |= protocol.PickerCanKill | |||
| 277 | + | } | |||
| 278 | + | return actions | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | // pickerActiveTabIndex is the session's active tab, or its first tab. | |||
| 282 | + | func pickerActiveTabIndex(session catalogue.RemoteCatalogSession) int { | |||
| 283 | + | for i, tab := range session.Tabs { | |||
| 284 | + | if session.ActiveTabID != "" && tab.ID == session.ActiveTabID { | |||
| 285 | + | return i | |||
| 286 | + | } | |||
| 287 | + | } | |||
| 288 | + | return 0 | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | // sortPickerBlocks orders sessions by recency, then name, then lifecycle. | |||
| 292 | + | func sortPickerBlocks(blocks []pickerSessionBlock) { | |||
| 293 | + | sort.SliceStable(blocks, func(i, j int) bool { | |||
| 294 | + | if blocks[i].lastUsedSeq != blocks[j].lastUsedSeq { | |||
| 295 | + | return blocks[i].lastUsedSeq > blocks[j].lastUsedSeq | |||
| 296 | + | } | |||
| 297 | + | if blocks[i].name != blocks[j].name { | |||
| 298 | + | return blocks[i].name < blocks[j].name | |||
| 299 | + | } | |||
| 300 | + | return blocks[i].lifecycle.String() < blocks[j].lifecycle.String() | |||
| 301 | + | }) | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | // pickerProjectionLocked flattens one projection and computes its cursor: | |||
| 305 | + | // the current attachment's tab, else its session, else the first default row | |||
| 306 | + | // (the most recent session's active tab), else the first actionable row. | |||
| 307 | + | func (c *pickerCatalogue) pickerProjectionLocked(rows []pickerRow) ([]protocol.PickerLine, protocol.PickerCursor) { | |||
| 308 | + | lines := make([]protocol.PickerLine, len(rows)) | |||
| 309 | + | for i := range rows { | |||
| 310 | + | lines[i] = rows[i].line | |||
| 311 | + | } | |||
| 312 | + | if cursor, ok := c.currentCursorLocked(rows); ok { | |||
| 313 | + | return lines, cursor | |||
| 314 | + | } | |||
| 315 | + | for i, row := range rows { | |||
| 316 | + | if row.defaultCursor && row.line.Focusable && row.line.Actions&protocol.PickerCanNavigate != 0 { | |||
| 317 | + | return lines, protocol.PickerCursor{Key: row.line.Key, Index: i} | |||
| 318 | + | } | |||
| 319 | + | } | |||
| 320 | + | return lines, pickerCatalogueCursor(lines) | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | func (c *pickerCatalogue) currentCursorLocked(rows []pickerRow) (protocol.PickerCursor, bool) { | |||
| 324 | + | current := c.current | |||
| 325 | + | if !current.known { | |||
| 326 | + | return protocol.PickerCursor{}, false | |||
| 327 | + | } | |||
| 328 | + | matches := func(ref pickerSelectionRef) bool { | |||
| 329 | + | if ref.lifecycle != current.lifecycle || ref.local != current.local { | |||
| 330 | + | return false | |||
| 331 | + | } | |||
| 332 | + | return ref.local || ref.endpoint == current.endpoint | |||
| 333 | + | } | |||
| 334 | + | // Exact tab first, then the session's own row (no tab metadata), then its | |||
| 335 | + | // active tab. | |||
| 336 | + | session, active := -1, -1 | |||
| 337 | + | for i, row := range rows { | |||
| 338 | + | if !row.hasRef || !row.line.Focusable || !matches(row.ref) { | |||
| 339 | + | continue | |||
| 340 | + | } | |||
| 341 | + | if !row.ref.tab.present { | |||
| 342 | + | session = i | |||
| 343 | + | continue | |||
| 344 | + | } | |||
| 345 | + | if current.tab != "" && row.ref.tab.id == current.tab { | |||
| 346 | + | return protocol.PickerCursor{Key: row.line.Key, Index: i}, true | |||
| 347 | + | } | |||
| 348 | + | if row.defaultCursor && active < 0 { | |||
| 349 | + | active = i | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | for _, index := range []int{session, active} { | |||
| 353 | + | if index >= 0 { | |||
| 354 | + | return protocol.PickerCursor{Key: rows[index].line.Key, Index: index}, true | |||
| 355 | + | } | |||
| 356 | + | } | |||
| 357 | + | return protocol.PickerCursor{}, false | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | // pickerTabRowKey is the stable, opaque key of one tab row: the session | |||
| 361 | + | // identity plus the tab's stable ID, or its ordinal and raw name when the | |||
| 362 | + | // catalogue carries no ID. | |||
| 363 | + | func pickerTabRowKey(ref pickerSelectionRef) string { | |||
| 364 | + | tab := string(ref.tab.id) | |||
| 365 | + | if tab == "" { | |||
| 366 | + | tab = fmt.Sprintf("#%d:%s", ref.tab.index, ref.tab.name) | |||
| 367 | + | } | |||
| 368 | + | lifecycle := append(append([]byte(nil), ref.lifecycle[:]...), 0) | |||
| 369 | + | lifecycle = append(lifecycle, tab...) | |||
| 370 | + | return pickerRowKey("t", ref.local, ref.endpoint, ref.registration.Incarnation[:], ref.registration.Generation, lifecycle) | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | // attachmentTab is the client-owned tab selection an attachment Hello | |||
| 374 | + | // carries: a stable tab to prefer on a live or stopped-local session, or an | |||
| 375 | + | // exact stopped-remote selector. Its zero value attaches at session level. | |||
| 376 | + | type attachmentTab struct { | |||
| 377 | + | preferred domain.TabStableID | |||
| 378 | + | stopped *protocol.SessionAttachTarget | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | // pickerResolveTab turns a tab ref into the attachment tab it names, checked | |||
| 382 | + | // against the latest session metadata. A tab that left the session is gone. | |||
| 383 | + | func pickerResolveTab(ref pickerSelectionRef, session catalogue.RemoteCatalogSession) (attachmentTab, error) { | |||
| 384 | + | if !ref.tab.present { | |||
| 385 | + | return attachmentTab{}, nil | |||
| 386 | + | } | |||
| 387 | + | gone := pickerCatalogueError{Code: pickerCatalogueGone, Text: "tab is no longer in the catalogue"} | |||
| 388 | + | stopped := session.State == catalogue.RemoteCatalogSessionDown | |||
| 389 | + | if ref.tab.id != "" { | |||
| 390 | + | found := false | |||
| 391 | + | for _, tab := range session.Tabs { | |||
| 392 | + | if domain.TabStableID(tab.ID) == ref.tab.id { | |||
| 393 | + | found = true | |||
| 394 | + | break | |||
| 395 | + | } | |||
| 396 | + | } | |||
| 397 | + | if !found { | |||
| 398 | + | return attachmentTab{}, gone | |||
| 399 | + | } | |||
| 400 | + | if stopped && !ref.local { | |||
| 401 | + | target := protocol.SessionAttachTarget{LifecycleID: session.LifecycleID, SessionName: session.Name, TabID: ref.tab.id, TabIndex: protocol.NoTabIndex, Stopped: true} | |||
| 402 | + | if target.Validate() != nil { | |||
| 403 | + | return attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "stopped tab cannot be addressed"} | |||
| 404 | + | } | |||
| 405 | + | return attachmentTab{stopped: &target}, nil | |||
| 406 | + | } | |||
| 407 | + | return attachmentTab{preferred: ref.tab.id}, nil | |||
| 408 | + | } | |||
| 409 | + | if ref.tab.index >= len(session.Tabs) || session.Tabs[ref.tab.index].Name != ref.tab.name || len(session.Tabs) != ref.tab.count { | |||
| 410 | + | return attachmentTab{}, gone | |||
| 411 | + | } | |||
| 412 | + | if !stopped { | |||
| 413 | + | return attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "tab has no stable identity"} | |||
| 414 | + | } | |||
| 415 | + | if ref.local || ref.tab.count > int(^uint16(0)) { | |||
| 416 | + | // A local restore without a stable tab falls back to the session. | |||
| 417 | + | return attachmentTab{}, nil | |||
| 418 | + | } | |||
| 419 | + | target := protocol.SessionAttachTarget{ | |||
| 420 | + | LifecycleID: session.LifecycleID, SessionName: session.Name, Stopped: true, | |||
| 421 | + | TabIndex: int32(ref.tab.index), TabRawName: ref.tab.name, TabExpectedCount: uint16(ref.tab.count), | |||
| 422 | + | } | |||
| 423 | + | if target.Validate() != nil { | |||
| 424 | + | return attachmentTab{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "stopped tab cannot be addressed"} | |||
| 425 | + | } | |||
| 426 | + | return attachmentTab{stopped: &target}, nil | |||
| 427 | + | } | |||
| 428 | + | ||||
| 429 | + | // pickerObservationFailing reports a host the broker observed failing: its | |||
| 430 | + | // last probe could not reach it, authenticate, or read its catalogue. | |||
| 431 | + | func pickerObservationFailing(observation ports.BrokerDaemonObservation) bool { | |||
| 432 | + | switch observation.Availability { | |||
| 433 | + | case domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityAuthFailed, domain.RemoteAvailabilityInvalidResponse: | |||
| 434 | + | return true | |||
| 435 | + | default: | |||
| 436 | + | return false | |||
| 437 | + | } | |||
| 438 | + | } | |||
| 439 | + | ||||
| 440 | + | // pickerUnavailableNotice is main's remote-unavailable notice: the session | |||
| 441 | + | // and its origin, then the reason in words. | |||
| 442 | + | func pickerUnavailableNotice(ref pickerSelectionRef, observation ports.BrokerDaemonObservation, reason string) string { | |||
| 443 | + | message := "Remote session unavailable" | |||
| 444 | + | origin := pickerOriginLabel(observation) | |||
| 445 | + | if ref.name != "" { | |||
| 446 | + | message += ": " + ref.name + "@" + origin | |||
| 447 | + | } else { | |||
| 448 | + | message += ": " + origin | |||
| 449 | + | } | |||
| 450 | + | return message + " — " + pickerReasonText(reason) | |||
| 451 | + | } | |||
| 452 | + | ||||
| 453 | + | // pickerReasonText renders one domain.RemoteReason in words (main's | |||
| 454 | + | // remotePickerReasonText). | |||
| 455 | + | func pickerReasonText(reason string) string { | |||
| 456 | + | switch reason { | |||
| 457 | + | case domain.RemoteReasonCatalogStale: | |||
| 458 | + | return "catalog stale" | |||
| 459 | + | case domain.RemoteReasonHostUnreachable: | |||
| 460 | + | return "host unreachable" | |||
| 461 | + | case domain.RemoteReasonVersionMismatch: | |||
| 462 | + | return "version mismatch" | |||
| 463 | + | case domain.RemoteReasonSessionStopped: | |||
| 464 | + | return "session stopped" | |||
| 465 | + | case domain.RemoteReasonSessionBroken: | |||
| 466 | + | return "session broken" | |||
| 467 | + | case domain.RemoteReasonMalformed: | |||
| 468 | + | return "catalog malformed" | |||
| 469 | + | case domain.RemoteReasonAuthFailure: | |||
| 470 | + | return "authentication failed" | |||
| 471 | + | case domain.RemoteReasonRefreshing: | |||
| 472 | + | return "refreshing" | |||
| 473 | + | case domain.RemoteReasonIdentityChanged: | |||
| 474 | + | return "session identity changed" | |||
| 475 | + | default: | |||
| 476 | + | return "unavailable" | |||
| 477 | + | } | |||
| 478 | + | } | |||
| 479 | + | ||||
| 480 | + | // pickerKillTarget is one resolved kill: the exact local control route and | |||
| 481 | + | // the session name the daemon kills (live) or deletes from history | |||
| 482 | + | // (stopped). | |||
| 483 | + | type pickerKillTarget struct { | |||
| 484 | + | route BrokerOperationRoute | |||
| 485 | + | name string | |||
| 486 | + | stopped bool | |||
| 487 | + | } | |||
| 488 | + | ||||
| 489 | + | // ResolveKill revalidates one row key for destruction against the latest | |||
| 490 | + | // publication. Only local sessions are killable, exactly as on main; the row | |||
| 491 | + | // must still name the same lifecycle and name. | |||
| 492 | + | func (c *pickerCatalogue) ResolveKill(key string) (pickerKillTarget, error) { | |||
| 493 | + | if c == nil { | |||
| 494 | + | return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"} | |||
| 495 | + | } | |||
| 496 | + | c.mu.Lock() | |||
| 497 | + | defer c.mu.Unlock() | |||
| 498 | + | ref, ok := c.refs[key] | |||
| 499 | + | if !ok { | |||
| 500 | + | ref, ok = c.retired[key] | |||
| 501 | + | } | |||
| 502 | + | if !ok || ref.kind != pickerSelectionExact { | |||
| 503 | + | return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"} | |||
| 504 | + | } | |||
| 505 | + | if !ref.local { | |||
| 506 | + | return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "remote sessions cannot be killed from the picker"} | |||
| 507 | + | } | |||
| 508 | + | epoch := c.snapshot.Epoch | |||
| 509 | + | if epoch == 0 { | |||
| 510 | + | return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "broker catalogue is unavailable"} | |||
| 511 | + | } | |||
| 512 | + | if ref.epoch != 0 && ref.epoch != epoch { | |||
| 513 | + | return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueEpochStale, Text: "broker epoch changed"} | |||
| 514 | + | } | |||
| 515 | + | authority := c.authorityForRefLocked(ref) | |||
| 516 | + | if !authority.found { | |||
| 517 | + | return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "host is no longer in the catalogue"} | |||
| 518 | + | } | |||
| 519 | + | session, found := pickerFindSession(authority.observation.Sessions, ref.lifecycle) | |||
| 520 | + | if !found { | |||
| 521 | + | if pickerSessionReplaced(authority.observation.Sessions, ref) { | |||
| 522 | + | return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"} | |||
| 523 | + | } | |||
| 524 | + | return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "session is no longer in the catalogue"} | |||
| 525 | + | } | |||
| 526 | + | if session.Name != ref.name { | |||
| 527 | + | return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"} | |||
| 528 | + | } | |||
| 529 | + | if domain.ValidateSessionName(session.Name) != nil { | |||
| 530 | + | return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueInvalidName, Text: "session name is not valid"} | |||
| 531 | + | } | |||
| 532 | + | return pickerKillTarget{ | |||
| 533 | + | route: BrokerOperationRoute{Epoch: epoch, Destination: ports.BrokerEndpointFence{Local: true}}, | |||
| 534 | + | name: session.Name, | |||
| 535 | + | stopped: session.State != catalogue.RemoteCatalogSessionUp, | |||
| 536 | + | }, nil | |||
| 537 | + | } | |||
| 538 | + | ||||
| 539 | + | // pickerHostFailure is one remote host failure episode worth one toast. | |||
| 540 | + | type pickerHostFailure struct { | |||
| 541 | + | key string | |||
| 542 | + | episode uint64 | |||
| 543 | + | message string | |||
| 544 | + | } | |||
| 545 | + | ||||
| 546 | + | // failures reports every remote host currently in a failure episode. The | |||
| 547 | + | // controller toasts each (host, episode) once, as main's | |||
| 548 | + | // notifyNewRemoteFailures did. | |||
| 549 | + | func (c *pickerCatalogue) failures() []pickerHostFailure { | |||
| 550 | + | if c == nil { | |||
| 551 | + | return nil | |||
| 552 | + | } | |||
| 553 | + | c.mu.Lock() | |||
| 554 | + | defer c.mu.Unlock() | |||
| 555 | + | failures := make([]pickerHostFailure, 0) | |||
| 556 | + | for _, key := range c.order { | |||
| 557 | + | host, ok := c.hosts[key] | |||
| 558 | + | if !ok || host.observation.Local { | |||
| 559 | + | continue | |||
| 560 | + | } | |||
| 561 | + | observation := host.observation | |||
| 562 | + | if !pickerObservationFailing(observation) && observation.Availability != domain.RemoteAvailabilityIncompatible && !pickerObservationVersionMismatch(observation) { | |||
| 563 | + | continue | |||
| 564 | + | } | |||
| 565 | + | failures = append(failures, pickerHostFailure{ | |||
| 566 | + | key: key, | |||
| 567 | + | episode: observation.FailureEpisode, | |||
| 568 | + | message: pickerHostFailureMessage(observation), | |||
| 569 | + | }) | |||
| 570 | + | } | |||
| 571 | + | return failures | |||
| 572 | + | } | |||
| 573 | + | ||||
| 574 | + | // pickerHostFailureMessage is main's remoteFailureNoticeMessage, named by the | |||
| 575 | + | // host's display origin rather than a raw endpoint. | |||
| 576 | + | func pickerHostFailureMessage(observation ports.BrokerDaemonObservation) string { | |||
| 577 | + | prefix := "Remote check failed: " + pickerOriginLabel(observation) + " — " | |||
| 578 | + | if observation.Availability == domain.RemoteAvailabilityIncompatible || pickerObservationVersionMismatch(observation) { | |||
| 579 | + | return prefix + "remote vev version is incompatible" | |||
| 580 | + | } | |||
| 581 | + | switch observation.LastFailure.Kind { | |||
| 582 | + | case domain.RemoteFailureAuthentication: | |||
| 583 | + | return prefix + "SSH authentication failed; verify non-interactive SSH access" | |||
| 584 | + | case domain.RemoteFailureTrust: | |||
| 585 | + | return prefix + "SSH host verification failed; verify the host key policy" | |||
| 586 | + | case domain.RemoteFailureIncompatible: | |||
| 587 | + | return prefix + "remote vev version is incompatible" | |||
| 588 | + | case domain.RemoteFailureInvalidResponse: | |||
| 589 | + | return prefix + "remote catalog response is invalid" | |||
| 590 | + | case domain.RemoteFailureTimeout: | |||
| 591 | + | return prefix + "SSH timed out" | |||
| 592 | + | } | |||
| 593 | + | switch observation.Availability { | |||
| 594 | + | case domain.RemoteAvailabilityAuthFailed: | |||
| 595 | + | return prefix + "SSH authentication failed; verify non-interactive SSH access" | |||
| 596 | + | case domain.RemoteAvailabilityInvalidResponse: | |||
| 597 | + | return prefix + "remote catalog response is invalid" | |||
| 598 | + | default: | |||
| 599 | + | return prefix + "SSH connection failed; verify SSH access" | |||
| 600 | + | } | |||
| 601 | + | } | |||
M internal/usecase/client/picker_catalogue_test.go +81 -59
| @@ -2,7 +2,6 @@ package client | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | - | "strings" | |
| 6 | 5 | "sync" | |
| 7 | 6 | "testing" | |
| 8 | 7 | "time" | |
| @@ -170,36 +169,39 @@ func TestPickerCatalogueEmptySnapshot(t *testing.T) { | |||
| 170 | 169 | func TestPickerCatalogueProjectionCases(t *testing.T) { | |
| 171 | 170 | clock := newSupervisorTestClock() | |
| 172 | 171 | now := clock.Now() | |
| 172 | + | navigateKill := protocol.PickerCanNavigate | protocol.PickerCanKill | |
| 173 | 173 | ||
| 174 | 174 | tests := []struct { | |
| 175 | - | name string | |
| 176 | - | daemons []ports.BrokerDaemonObservation | |
| 177 | - | wantSections []string | |
| 178 | - | wantSessions []string | |
| 179 | - | wantSessionKeys map[string]bool // label -> action available | |
| 180 | - | wantStatus map[string]protocol.PickerLineStatus | |
| 181 | - | wantDim map[string]bool | |
| 175 | + | name string | |
| 176 | + | daemons []ports.BrokerDaemonObservation | |
| 177 | + | wantSections []string | |
| 178 | + | wantSessions []string | |
| 179 | + | wantActions map[string]protocol.PickerLineActions | |
| 180 | + | wantStatus map[string]protocol.PickerLineStatus | |
| 181 | + | wantDim map[string]bool | |
| 182 | + | wantHostRow bool | |
| 182 | 183 | }{ | |
| 183 | 184 | { | |
| 184 | - | name: "local only", | |
| 185 | - | daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up), pickerTestSession("beta", 2, catalogue_Up))}, | |
| 186 | - | wantSections: []string{"local"}, | |
| 187 | - | wantSessions: []string{"alpha", "beta"}, | |
| 188 | - | wantSessionKeys: map[string]bool{"alpha": true, "beta": true}, | |
| 185 | + | name: "local only", | |
| 186 | + | daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up), pickerTestSession("beta", 2, catalogue_Up))}, | |
| 187 | + | wantSections: []string{"local"}, | |
| 188 | + | wantSessions: []string{"alpha", "beta"}, | |
| 189 | + | wantActions: map[string]protocol.PickerLineActions{"alpha": navigateKill, "beta": navigateKill}, | |
| 189 | 190 | }, | |
| 190 | 191 | { | |
| 191 | - | name: "unobserved local daemon still projects", | |
| 192 | + | name: "unobserved local daemon with no session shows its status", | |
| 192 | 193 | daemons: []ports.BrokerDaemonObservation{pickerTestUnobservedObservation(now)}, | |
| 193 | 194 | wantSections: []string{"local"}, | |
| 194 | 195 | wantSessions: []string{}, | |
| 195 | 196 | wantStatus: map[string]protocol.PickerLineStatus{"local": protocol.PickerLineStatusStale}, | |
| 197 | + | wantHostRow: true, | |
| 196 | 198 | }, | |
| 197 | 199 | { | |
| 198 | - | name: "no local daemon entry", | |
| 199 | - | daemons: []ports.BrokerDaemonObservation{pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up))}, | |
| 200 | - | wantSections: []string{"user@arch"}, | |
| 201 | - | wantSessions: []string{"remote-a"}, | |
| 202 | - | wantSessionKeys: map[string]bool{"remote-a": true}, | |
| 200 | + | name: "no local daemon entry", | |
| 201 | + | daemons: []ports.BrokerDaemonObservation{pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up))}, | |
| 202 | + | wantSections: []string{"user@arch"}, | |
| 203 | + | wantSessions: []string{"remote-a"}, | |
| 204 | + | wantActions: map[string]protocol.PickerLineActions{"remote-a": protocol.PickerCanNavigate}, | |
| 203 | 205 | }, | |
| 204 | 206 | { | |
| 205 | 207 | name: "stale observation stays explicitly selectable", | |
| @@ -210,14 +212,14 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||
| 210 | 212 | return observation | |
| 211 | 213 | }(), | |
| 212 | 214 | }, | |
| 213 | - | wantSections: []string{"local"}, | |
| 214 | - | wantSessions: []string{"alpha"}, | |
| 215 | - | wantSessionKeys: map[string]bool{"alpha": true}, | |
| 216 | - | wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusUp, "local": protocol.PickerLineStatusStale}, | |
| 217 | - | wantDim: map[string]bool{"alpha": true}, | |
| 215 | + | wantSections: []string{"local"}, | |
| 216 | + | wantSessions: []string{"alpha"}, | |
| 217 | + | wantActions: map[string]protocol.PickerLineActions{"alpha": navigateKill}, | |
| 218 | + | wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusUp}, | |
| 219 | + | wantDim: map[string]bool{"alpha": true}, | |
| 218 | 220 | }, | |
| 219 | 221 | { | |
| 220 | - | name: "unavailable host stays explicitly selectable", | |
| 222 | + | name: "unavailable host keeps its rows and hides the redundant status row", | |
| 221 | 223 | daemons: []ports.BrokerDaemonObservation{ | |
| 222 | 224 | func() ports.BrokerDaemonObservation { | |
| 223 | 225 | observation := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up)) | |
| @@ -225,11 +227,25 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||
| 225 | 227 | return observation | |
| 226 | 228 | }(), | |
| 227 | 229 | }, | |
| 228 | - | wantSections: []string{"user@arch"}, | |
| 229 | - | wantSessions: []string{"remote-a"}, | |
| 230 | - | wantSessionKeys: map[string]bool{"remote-a": true}, | |
| 231 | - | wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp, "user@arch": protocol.PickerLineStatusDown}, | |
| 232 | - | wantDim: map[string]bool{"remote-a": true}, | |
| 230 | + | wantSections: []string{"user@arch"}, | |
| 231 | + | wantSessions: []string{"remote-a"}, | |
| 232 | + | wantActions: map[string]protocol.PickerLineActions{"remote-a": protocol.PickerCanNavigate}, | |
| 233 | + | wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp}, | |
| 234 | + | wantDim: map[string]bool{"remote-a": true}, | |
| 235 | + | }, | |
| 236 | + | { | |
| 237 | + | name: "unavailable host without sessions shows its status row", | |
| 238 | + | daemons: []ports.BrokerDaemonObservation{ | |
| 239 | + | func() ports.BrokerDaemonObservation { | |
| 240 | + | observation := pickerTestRemoteObservation("user@arch", 1, 1, now) | |
| 241 | + | observation.Availability = domain.RemoteAvailabilityUnreachable | |
| 242 | + | return observation | |
| 243 | + | }(), | |
| 244 | + | }, | |
| 245 | + | wantSections: []string{"user@arch"}, | |
| 246 | + | wantSessions: []string{}, | |
| 247 | + | wantStatus: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusDown}, | |
| 248 | + | wantHostRow: true, | |
| 233 | 249 | }, | |
| 234 | 250 | { | |
| 235 | 251 | name: "incompatible host", | |
| @@ -240,20 +256,20 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||
| 240 | 256 | return observation | |
| 241 | 257 | }(), | |
| 242 | 258 | }, | |
| 243 | - | wantSections: []string{"user@arch"}, | |
| 244 | - | wantSessions: []string{"remote-a"}, | |
| 245 | - | wantSessionKeys: map[string]bool{"remote-a": false}, | |
| 246 | - | wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp, "user@arch": protocol.PickerLineStatusVersion}, | |
| 247 | - | wantDim: map[string]bool{"remote-a": true}, | |
| 259 | + | wantSections: []string{"user@arch"}, | |
| 260 | + | wantSessions: []string{"remote-a"}, | |
| 261 | + | wantActions: map[string]protocol.PickerLineActions{"remote-a": 0}, | |
| 262 | + | wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp}, | |
| 263 | + | wantDim: map[string]bool{"remote-a": true}, | |
| 248 | 264 | }, | |
| 249 | 265 | { | |
| 250 | - | name: "broken session", | |
| 251 | - | daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Broken))}, | |
| 252 | - | wantSections: []string{"local"}, | |
| 253 | - | wantSessions: []string{"alpha"}, | |
| 254 | - | wantSessionKeys: map[string]bool{"alpha": false}, | |
| 255 | - | wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusError}, | |
| 256 | - | wantDim: map[string]bool{"alpha": true}, | |
| 266 | + | name: "broken session is only killable", | |
| 267 | + | daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Broken))}, | |
| 268 | + | wantSections: []string{"local"}, | |
| 269 | + | wantSessions: []string{"alpha"}, | |
| 270 | + | wantActions: map[string]protocol.PickerLineActions{"alpha": protocol.PickerCanKill}, | |
| 271 | + | wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusError}, | |
| 272 | + | wantDim: map[string]bool{"alpha": true}, | |
| 257 | 273 | }, | |
| 258 | 274 | } | |
| 259 | 275 | ||
| @@ -264,21 +280,16 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||
| 264 | 280 | lines := catalogue.Lines() | |
| 265 | 281 | require.Equal(t, tt.wantSections, pickerSectionLabels(lines)) | |
| 266 | 282 | require.Equal(t, tt.wantSessions, pickerSessionLabels(lines)) | |
| 267 | - | for label, actionable := range tt.wantSessionKeys { | |
| 283 | + | _, hasHost := pickerHostLine(lines) | |
| 284 | + | require.Equal(t, tt.wantHostRow, hasHost, "a host status row is published only when it adds information") | |
| 285 | + | for label, actions := range tt.wantActions { | |
| 268 | 286 | line, ok := pickerLineByLabel(lines, label) | |
| 269 | 287 | require.True(t, ok, "session %q must be projected", label) | |
| 270 | 288 | require.True(t, line.Focusable, "a projected row is always a cursor destination") | |
| 271 | - | if actionable { | |
| 272 | - | require.Equal(t, protocol.PickerCanNavigate, line.Actions, "an eligible row admits navigation") | |
| 273 | - | } else { | |
| 274 | - | require.Zero(t, line.Actions, "a refused row is inspectable but never committable") | |
| 275 | - | } | |
| 289 | + | require.Equal(t, actions, line.Actions, "row %q actions", label) | |
| 276 | 290 | } | |
| 277 | 291 | for label, status := range tt.wantStatus { | |
| 278 | 292 | line, ok := pickerLineByLabel(lines, label) | |
| 279 | - | if !ok && (label == "local" || strings.Contains(label, "@")) { | |
| 280 | - | line, ok = pickerHostLine(lines) | |
| 281 | - | } | |
| 282 | 293 | require.True(t, ok, "row %q must be projected", label) | |
| 283 | 294 | require.Equal(t, status, line.Status, "row %q badge", label) | |
| 284 | 295 | } | |
| @@ -322,6 +333,7 @@ func TestPickerCatalogueMonotonicUpdates(t *testing.T) { | |||
| 322 | 333 | // catalogue state into every literal. | |
| 323 | 334 | const ( | |
| 324 | 335 | catalogue_Up = catalogue.RemoteCatalogSessionState("up") | |
| 336 | + | catalogue_Down = catalogue.RemoteCatalogSessionState("down") | |
| 325 | 337 | catalogue_Broken = catalogue.RemoteCatalogSessionState("broken") | |
| 326 | 338 | ) | |
| 327 | 339 | ||
| @@ -510,7 +522,9 @@ func TestPickerCatalogueSelectionResolution(t *testing.T) { | |||
| 510 | 522 | wantErr: pickerCatalogueIncompatible, | |
| 511 | 523 | }, | |
| 512 | 524 | { | |
| 513 | - | name: "unavailable host is still explicitly selectable", | |
| 525 | + | // Plan 003 B4: a remote host the broker observed failing refuses | |
| 526 | + | // instantly instead of a slow attempt that fails the same way. | |
| 527 | + | name: "unreachable remote host refuses instantly", | |
| 514 | 528 | build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string { | |
| 515 | 529 | unavailable := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up)) | |
| 516 | 530 | unavailable.Availability = domain.RemoteAvailabilityUnreachable | |
| @@ -519,6 +533,17 @@ func TestPickerCatalogueSelectionResolution(t *testing.T) { | |||
| 519 | 533 | require.True(t, ok) | |
| 520 | 534 | return line.Key | |
| 521 | 535 | }, | |
| 536 | + | wantErr: pickerCatalogueUnavailable, | |
| 537 | + | }, | |
| 538 | + | { | |
| 539 | + | name: "stale reachable remote host stays attemptable", | |
| 540 | + | build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string { | |
| 541 | + | stale := pickerTestRemoteObservation("user@arch", 1, 1, now.Add(-24*time.Hour), pickerTestSession("remote-a", 2, catalogue_Up)) | |
| 542 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{stale}})) | |
| 543 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "remote-a") | |
| 544 | + | require.True(t, ok) | |
| 545 | + | return line.Key | |
| 546 | + | }, | |
| 522 | 547 | wantAdmission: ports.BrokerAdmissionExact, | |
| 523 | 548 | }, | |
| 524 | 549 | { | |
| @@ -891,17 +916,14 @@ func TestPickerCatalogueUnobservedDaemonProjects(t *testing.T) { | |||
| 891 | 916 | lines := catalogue.Lines() | |
| 892 | 917 | require.Equal(t, []string{"local"}, pickerSectionLabels(lines)) | |
| 893 | 918 | require.Equal(t, []string{"alpha"}, pickerSessionLabels(lines)) | |
| 894 | - | host, ok := pickerHostLine(lines) | |
| 895 | - | require.True(t, ok) | |
| 896 | - | require.Equal(t, "status", host.Label) | |
| 897 | - | require.False(t, host.Focusable, "host status is informational, not a duplicate destination") | |
| 898 | - | require.Equal(t, protocol.PickerLineStatusStale, host.Status) | |
| 899 | - | require.Equal(t, domain.RemoteReasonRefreshing, host.StatusDetail, "an unobserved daemon refreshes, it is not a version mismatch") | |
| 919 | + | _, hasHost := pickerHostLine(lines) | |
| 920 | + | require.False(t, hasHost, "the session rows already carry the host state") | |
| 900 | 921 | session, ok := pickerLineByLabel(lines, "alpha") | |
| 901 | 922 | require.True(t, ok) | |
| 902 | 923 | require.True(t, session.Focusable) | |
| 903 | - | require.Equal(t, protocol.PickerCanNavigate, session.Actions, "an unobserved daemon may still be attempted explicitly") | |
| 924 | + | require.Equal(t, protocol.PickerCanNavigate|protocol.PickerCanKill, session.Actions, "an unobserved daemon may still be attempted explicitly") | |
| 904 | 925 | require.True(t, session.Dim) | |
| 926 | + | require.Equal(t, domain.RemoteReasonRefreshing, session.StatusDetail, "an unobserved daemon refreshes, it is not a version mismatch") | |
| 905 | 927 | } | |
| 906 | 928 | ||
| 907 | 929 | // TestPickerCatalogueOriginLabelNeverShowsRawEndpoint proves display labels | |
| @@ -2,7 +2,6 @@ package client | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | - | "strings" | |||
| 6 | "sync" | 5 | "sync" | ||
| 7 | "testing" | 6 | "testing" | ||
| 8 | "time" | 7 | "time" | ||
| @@ -170,36 +169,39 @@ func TestPickerCatalogueEmptySnapshot(t *testing.T) { | |||||
| 170 | func TestPickerCatalogueProjectionCases(t *testing.T) { | 169 | func TestPickerCatalogueProjectionCases(t *testing.T) { | ||
| 171 | clock := newSupervisorTestClock() | 170 | clock := newSupervisorTestClock() | ||
| 172 | now := clock.Now() | 171 | now := clock.Now() | ||
| 172 | + | navigateKill := protocol.PickerCanNavigate | protocol.PickerCanKill | |||
| 173 | 173 | ||||
| 174 | tests := []struct { | 174 | tests := []struct { | ||
| 175 | - | name string | 175 | + | name string |
| 176 | - | daemons []ports.BrokerDaemonObservation | 176 | + | daemons []ports.BrokerDaemonObservation |
| 177 | - | wantSections []string | 177 | + | wantSections []string |
| 178 | - | wantSessions []string | 178 | + | wantSessions []string |
| 179 | - | wantSessionKeys map[string]bool // label -> action available | 179 | + | wantActions map[string]protocol.PickerLineActions |
| 180 | - | wantStatus map[string]protocol.PickerLineStatus | 180 | + | wantStatus map[string]protocol.PickerLineStatus |
| 181 | - | wantDim map[string]bool | 181 | + | wantDim map[string]bool |
| 182 | + | wantHostRow bool | |||
| 182 | }{ | 183 | }{ | ||
| 183 | { | 184 | { | ||
| 184 | - | name: "local only", | 185 | + | name: "local only", |
| 185 | - | daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up), pickerTestSession("beta", 2, catalogue_Up))}, | 186 | + | daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up), pickerTestSession("beta", 2, catalogue_Up))}, |
| 186 | - | wantSections: []string{"local"}, | 187 | + | wantSections: []string{"local"}, |
| 187 | - | wantSessions: []string{"alpha", "beta"}, | 188 | + | wantSessions: []string{"alpha", "beta"}, |
| 188 | - | wantSessionKeys: map[string]bool{"alpha": true, "beta": true}, | 189 | + | wantActions: map[string]protocol.PickerLineActions{"alpha": navigateKill, "beta": navigateKill}, |
| 189 | }, | 190 | }, | ||
| 190 | { | 191 | { | ||
| 191 | - | name: "unobserved local daemon still projects", | 192 | + | name: "unobserved local daemon with no session shows its status", |
| 192 | daemons: []ports.BrokerDaemonObservation{pickerTestUnobservedObservation(now)}, | 193 | daemons: []ports.BrokerDaemonObservation{pickerTestUnobservedObservation(now)}, | ||
| 193 | wantSections: []string{"local"}, | 194 | wantSections: []string{"local"}, | ||
| 194 | wantSessions: []string{}, | 195 | wantSessions: []string{}, | ||
| 195 | wantStatus: map[string]protocol.PickerLineStatus{"local": protocol.PickerLineStatusStale}, | 196 | wantStatus: map[string]protocol.PickerLineStatus{"local": protocol.PickerLineStatusStale}, | ||
| 197 | + | wantHostRow: true, | |||
| 196 | }, | 198 | }, | ||
| 197 | { | 199 | { | ||
| 198 | - | name: "no local daemon entry", | 200 | + | name: "no local daemon entry", |
| 199 | - | daemons: []ports.BrokerDaemonObservation{pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up))}, | 201 | + | daemons: []ports.BrokerDaemonObservation{pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up))}, |
| 200 | - | wantSections: []string{"user@arch"}, | 202 | + | wantSections: []string{"user@arch"}, |
| 201 | - | wantSessions: []string{"remote-a"}, | 203 | + | wantSessions: []string{"remote-a"}, |
| 202 | - | wantSessionKeys: map[string]bool{"remote-a": true}, | 204 | + | wantActions: map[string]protocol.PickerLineActions{"remote-a": protocol.PickerCanNavigate}, |
| 203 | }, | 205 | }, | ||
| 204 | { | 206 | { | ||
| 205 | name: "stale observation stays explicitly selectable", | 207 | name: "stale observation stays explicitly selectable", | ||
| @@ -210,14 +212,14 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||||
| 210 | return observation | 212 | return observation | ||
| 211 | }(), | 213 | }(), | ||
| 212 | }, | 214 | }, | ||
| 213 | - | wantSections: []string{"local"}, | 215 | + | wantSections: []string{"local"}, |
| 214 | - | wantSessions: []string{"alpha"}, | 216 | + | wantSessions: []string{"alpha"}, |
| 215 | - | wantSessionKeys: map[string]bool{"alpha": true}, | 217 | + | wantActions: map[string]protocol.PickerLineActions{"alpha": navigateKill}, |
| 216 | - | wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusUp, "local": protocol.PickerLineStatusStale}, | 218 | + | wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusUp}, |
| 217 | - | wantDim: map[string]bool{"alpha": true}, | 219 | + | wantDim: map[string]bool{"alpha": true}, |
| 218 | }, | 220 | }, | ||
| 219 | { | 221 | { | ||
| 220 | - | name: "unavailable host stays explicitly selectable", | 222 | + | name: "unavailable host keeps its rows and hides the redundant status row", |
| 221 | daemons: []ports.BrokerDaemonObservation{ | 223 | daemons: []ports.BrokerDaemonObservation{ | ||
| 222 | func() ports.BrokerDaemonObservation { | 224 | func() ports.BrokerDaemonObservation { | ||
| 223 | observation := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up)) | 225 | observation := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up)) | ||
| @@ -225,11 +227,25 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||||
| 225 | return observation | 227 | return observation | ||
| 226 | }(), | 228 | }(), | ||
| 227 | }, | 229 | }, | ||
| 228 | - | wantSections: []string{"user@arch"}, | 230 | + | wantSections: []string{"user@arch"}, |
| 229 | - | wantSessions: []string{"remote-a"}, | 231 | + | wantSessions: []string{"remote-a"}, |
| 230 | - | wantSessionKeys: map[string]bool{"remote-a": true}, | 232 | + | wantActions: map[string]protocol.PickerLineActions{"remote-a": protocol.PickerCanNavigate}, |
| 231 | - | wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp, "user@arch": protocol.PickerLineStatusDown}, | 233 | + | wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp}, |
| 232 | - | wantDim: map[string]bool{"remote-a": true}, | 234 | + | wantDim: map[string]bool{"remote-a": true}, |
| 235 | + | }, | |||
| 236 | + | { | |||
| 237 | + | name: "unavailable host without sessions shows its status row", | |||
| 238 | + | daemons: []ports.BrokerDaemonObservation{ | |||
| 239 | + | func() ports.BrokerDaemonObservation { | |||
| 240 | + | observation := pickerTestRemoteObservation("user@arch", 1, 1, now) | |||
| 241 | + | observation.Availability = domain.RemoteAvailabilityUnreachable | |||
| 242 | + | return observation | |||
| 243 | + | }(), | |||
| 244 | + | }, | |||
| 245 | + | wantSections: []string{"user@arch"}, | |||
| 246 | + | wantSessions: []string{}, | |||
| 247 | + | wantStatus: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusDown}, | |||
| 248 | + | wantHostRow: true, | |||
| 233 | }, | 249 | }, | ||
| 234 | { | 250 | { | ||
| 235 | name: "incompatible host", | 251 | name: "incompatible host", | ||
| @@ -240,20 +256,20 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||||
| 240 | return observation | 256 | return observation | ||
| 241 | }(), | 257 | }(), | ||
| 242 | }, | 258 | }, | ||
| 243 | - | wantSections: []string{"user@arch"}, | 259 | + | wantSections: []string{"user@arch"}, |
| 244 | - | wantSessions: []string{"remote-a"}, | 260 | + | wantSessions: []string{"remote-a"}, |
| 245 | - | wantSessionKeys: map[string]bool{"remote-a": false}, | 261 | + | wantActions: map[string]protocol.PickerLineActions{"remote-a": 0}, |
| 246 | - | wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp, "user@arch": protocol.PickerLineStatusVersion}, | 262 | + | wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp}, |
| 247 | - | wantDim: map[string]bool{"remote-a": true}, | 263 | + | wantDim: map[string]bool{"remote-a": true}, |
| 248 | }, | 264 | }, | ||
| 249 | { | 265 | { | ||
| 250 | - | name: "broken session", | 266 | + | name: "broken session is only killable", |
| 251 | - | daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Broken))}, | 267 | + | daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Broken))}, |
| 252 | - | wantSections: []string{"local"}, | 268 | + | wantSections: []string{"local"}, |
| 253 | - | wantSessions: []string{"alpha"}, | 269 | + | wantSessions: []string{"alpha"}, |
| 254 | - | wantSessionKeys: map[string]bool{"alpha": false}, | 270 | + | wantActions: map[string]protocol.PickerLineActions{"alpha": protocol.PickerCanKill}, |
| 255 | - | wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusError}, | 271 | + | wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusError}, |
| 256 | - | wantDim: map[string]bool{"alpha": true}, | 272 | + | wantDim: map[string]bool{"alpha": true}, |
| 257 | }, | 273 | }, | ||
| 258 | } | 274 | } | ||
| 259 | 275 | ||||
| @@ -264,21 +280,16 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||||
| 264 | lines := catalogue.Lines() | 280 | lines := catalogue.Lines() | ||
| 265 | require.Equal(t, tt.wantSections, pickerSectionLabels(lines)) | 281 | require.Equal(t, tt.wantSections, pickerSectionLabels(lines)) | ||
| 266 | require.Equal(t, tt.wantSessions, pickerSessionLabels(lines)) | 282 | require.Equal(t, tt.wantSessions, pickerSessionLabels(lines)) | ||
| 267 | - | for label, actionable := range tt.wantSessionKeys { | 283 | + | _, hasHost := pickerHostLine(lines) |
| 284 | + | require.Equal(t, tt.wantHostRow, hasHost, "a host status row is published only when it adds information") | |||
| 285 | + | for label, actions := range tt.wantActions { | |||
| 268 | line, ok := pickerLineByLabel(lines, label) | 286 | line, ok := pickerLineByLabel(lines, label) | ||
| 269 | require.True(t, ok, "session %q must be projected", label) | 287 | require.True(t, ok, "session %q must be projected", label) | ||
| 270 | require.True(t, line.Focusable, "a projected row is always a cursor destination") | 288 | require.True(t, line.Focusable, "a projected row is always a cursor destination") | ||
| 271 | - | if actionable { | 289 | + | require.Equal(t, actions, line.Actions, "row %q actions", label) |
| 272 | - | require.Equal(t, protocol.PickerCanNavigate, line.Actions, "an eligible row admits navigation") | |||
| 273 | - | } else { | |||
| 274 | - | require.Zero(t, line.Actions, "a refused row is inspectable but never committable") | |||
| 275 | - | } | |||
| 276 | } | 290 | } | ||
| 277 | for label, status := range tt.wantStatus { | 291 | for label, status := range tt.wantStatus { | ||
| 278 | line, ok := pickerLineByLabel(lines, label) | 292 | line, ok := pickerLineByLabel(lines, label) | ||
| 279 | - | if !ok && (label == "local" || strings.Contains(label, "@")) { | |||
| 280 | - | line, ok = pickerHostLine(lines) | |||
| 281 | - | } | |||
| 282 | require.True(t, ok, "row %q must be projected", label) | 293 | require.True(t, ok, "row %q must be projected", label) | ||
| 283 | require.Equal(t, status, line.Status, "row %q badge", label) | 294 | require.Equal(t, status, line.Status, "row %q badge", label) | ||
| 284 | } | 295 | } | ||
| @@ -322,6 +333,7 @@ func TestPickerCatalogueMonotonicUpdates(t *testing.T) { | |||||
| 322 | // catalogue state into every literal. | 333 | // catalogue state into every literal. | ||
| 323 | const ( | 334 | const ( | ||
| 324 | catalogue_Up = catalogue.RemoteCatalogSessionState("up") | 335 | catalogue_Up = catalogue.RemoteCatalogSessionState("up") | ||
| 336 | + | catalogue_Down = catalogue.RemoteCatalogSessionState("down") | |||
| 325 | catalogue_Broken = catalogue.RemoteCatalogSessionState("broken") | 337 | catalogue_Broken = catalogue.RemoteCatalogSessionState("broken") | ||
| 326 | ) | 338 | ) | ||
| 327 | 339 | ||||
| @@ -510,7 +522,9 @@ func TestPickerCatalogueSelectionResolution(t *testing.T) { | |||||
| 510 | wantErr: pickerCatalogueIncompatible, | 522 | wantErr: pickerCatalogueIncompatible, | ||
| 511 | }, | 523 | }, | ||
| 512 | { | 524 | { | ||
| 513 | - | name: "unavailable host is still explicitly selectable", | 525 | + | // Plan 003 B4: a remote host the broker observed failing refuses |
| 526 | + | // instantly instead of a slow attempt that fails the same way. | |||
| 527 | + | name: "unreachable remote host refuses instantly", | |||
| 514 | build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string { | 528 | build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string { | ||
| 515 | unavailable := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up)) | 529 | unavailable := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up)) | ||
| 516 | unavailable.Availability = domain.RemoteAvailabilityUnreachable | 530 | unavailable.Availability = domain.RemoteAvailabilityUnreachable | ||
| @@ -519,6 +533,17 @@ func TestPickerCatalogueSelectionResolution(t *testing.T) { | |||||
| 519 | require.True(t, ok) | 533 | require.True(t, ok) | ||
| 520 | return line.Key | 534 | return line.Key | ||
| 521 | }, | 535 | }, | ||
| 536 | + | wantErr: pickerCatalogueUnavailable, | |||
| 537 | + | }, | |||
| 538 | + | { | |||
| 539 | + | name: "stale reachable remote host stays attemptable", | |||
| 540 | + | build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string { | |||
| 541 | + | stale := pickerTestRemoteObservation("user@arch", 1, 1, now.Add(-24*time.Hour), pickerTestSession("remote-a", 2, catalogue_Up)) | |||
| 542 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{stale}})) | |||
| 543 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "remote-a") | |||
| 544 | + | require.True(t, ok) | |||
| 545 | + | return line.Key | |||
| 546 | + | }, | |||
| 522 | wantAdmission: ports.BrokerAdmissionExact, | 547 | wantAdmission: ports.BrokerAdmissionExact, | ||
| 523 | }, | 548 | }, | ||
| 524 | { | 549 | { | ||
| @@ -891,17 +916,14 @@ func TestPickerCatalogueUnobservedDaemonProjects(t *testing.T) { | |||||
| 891 | lines := catalogue.Lines() | 916 | lines := catalogue.Lines() | ||
| 892 | require.Equal(t, []string{"local"}, pickerSectionLabels(lines)) | 917 | require.Equal(t, []string{"local"}, pickerSectionLabels(lines)) | ||
| 893 | require.Equal(t, []string{"alpha"}, pickerSessionLabels(lines)) | 918 | require.Equal(t, []string{"alpha"}, pickerSessionLabels(lines)) | ||
| 894 | - | host, ok := pickerHostLine(lines) | 919 | + | _, hasHost := pickerHostLine(lines) |
| 895 | - | require.True(t, ok) | 920 | + | require.False(t, hasHost, "the session rows already carry the host state") |
| 896 | - | require.Equal(t, "status", host.Label) | |||
| 897 | - | require.False(t, host.Focusable, "host status is informational, not a duplicate destination") | |||
| 898 | - | require.Equal(t, protocol.PickerLineStatusStale, host.Status) | |||
| 899 | - | require.Equal(t, domain.RemoteReasonRefreshing, host.StatusDetail, "an unobserved daemon refreshes, it is not a version mismatch") | |||
| 900 | session, ok := pickerLineByLabel(lines, "alpha") | 921 | session, ok := pickerLineByLabel(lines, "alpha") | ||
| 901 | require.True(t, ok) | 922 | require.True(t, ok) | ||
| 902 | require.True(t, session.Focusable) | 923 | require.True(t, session.Focusable) | ||
| 903 | - | require.Equal(t, protocol.PickerCanNavigate, session.Actions, "an unobserved daemon may still be attempted explicitly") | 924 | + | require.Equal(t, protocol.PickerCanNavigate|protocol.PickerCanKill, session.Actions, "an unobserved daemon may still be attempted explicitly") |
| 904 | require.True(t, session.Dim) | 925 | require.True(t, session.Dim) | ||
| 926 | + | require.Equal(t, domain.RemoteReasonRefreshing, session.StatusDetail, "an unobserved daemon refreshes, it is not a version mismatch") | |||
| 905 | } | 927 | } | ||
| 906 | 928 | ||||
| 907 | // TestPickerCatalogueOriginLabelNeverShowsRawEndpoint proves display labels | 929 | // TestPickerCatalogueOriginLabelNeverShowsRawEndpoint proves display labels | ||
M internal/usecase/client/picker_composition.go +23 -0
| @@ -79,6 +79,29 @@ func (p *Picker) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpe | |||
| 79 | 79 | return p.controller.ResolveKey(key, base) | |
| 80 | 80 | } | |
| 81 | 81 | ||
| 82 | + | // ResolveKeyTarget resolves a committed key plus the exact tab it names. | |
| 83 | + | func (p *Picker) ResolveKeyTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) { | |
| 84 | + | if p == nil || p.controller == nil { | |
| 85 | + | return (*pickerController)(nil).ResolveKeyTarget(key, base) | |
| 86 | + | } | |
| 87 | + | return p.controller.ResolveKeyTarget(key, base) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // ResolveKill revalidates one kill key for the supervisor. | |
| 91 | + | func (p *Picker) ResolveKill(key string) (pickerKillTarget, error) { | |
| 92 | + | if p == nil || p.controller == nil { | |
| 93 | + | return (*pickerController)(nil).ResolveKill(key) | |
| 94 | + | } | |
| 95 | + | return p.controller.ResolveKill(key) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // SetCurrent names the attachment the picker is presented over. | |
| 99 | + | func (p *Picker) SetCurrent(current pickerCurrent) { | |
| 100 | + | if p != nil && p.controller != nil { | |
| 101 | + | p.controller.SetCurrent(current) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 82 | 105 | // SetOwnsInput updates picker input ownership at an attachment boundary. | |
| 83 | 106 | func (p *Picker) PreviewRequest(connection ports.BrokerConnectionID, stream ports.BrokerStreamID, size domain.Size) (ports.BrokerOpenStreamRequest, protocol.RemotePreviewRequest, bool) { | |
| 84 | 107 | if p == nil || p.controller == nil { | |
| @@ -79,6 +79,29 @@ func (p *Picker) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpe | |||||
| 79 | return p.controller.ResolveKey(key, base) | 79 | return p.controller.ResolveKey(key, base) | ||
| 80 | } | 80 | } | ||
| 81 | 81 | ||||
| 82 | + | // ResolveKeyTarget resolves a committed key plus the exact tab it names. | |||
| 83 | + | func (p *Picker) ResolveKeyTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) { | |||
| 84 | + | if p == nil || p.controller == nil { | |||
| 85 | + | return (*pickerController)(nil).ResolveKeyTarget(key, base) | |||
| 86 | + | } | |||
| 87 | + | return p.controller.ResolveKeyTarget(key, base) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // ResolveKill revalidates one kill key for the supervisor. | |||
| 91 | + | func (p *Picker) ResolveKill(key string) (pickerKillTarget, error) { | |||
| 92 | + | if p == nil || p.controller == nil { | |||
| 93 | + | return (*pickerController)(nil).ResolveKill(key) | |||
| 94 | + | } | |||
| 95 | + | return p.controller.ResolveKill(key) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // SetCurrent names the attachment the picker is presented over. | |||
| 99 | + | func (p *Picker) SetCurrent(current pickerCurrent) { | |||
| 100 | + | if p != nil && p.controller != nil { | |||
| 101 | + | p.controller.SetCurrent(current) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 82 | // SetOwnsInput updates picker input ownership at an attachment boundary. | 105 | // SetOwnsInput updates picker input ownership at an attachment boundary. | ||
| 83 | func (p *Picker) PreviewRequest(connection ports.BrokerConnectionID, stream ports.BrokerStreamID, size domain.Size) (ports.BrokerOpenStreamRequest, protocol.RemotePreviewRequest, bool) { | 106 | func (p *Picker) PreviewRequest(connection ports.BrokerConnectionID, stream ports.BrokerStreamID, size domain.Size) (ports.BrokerOpenStreamRequest, protocol.RemotePreviewRequest, bool) { | ||
| 84 | if p == nil || p.controller == nil { | 107 | if p == nil || p.controller == nil { | ||
M internal/usecase/client/picker_controller.go +131 -29
| @@ -2,12 +2,14 @@ package client | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "errors" | |
| 5 | 6 | "sync" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | "github.com/bnema/vev/internal/ports" | |
| 10 | 11 | "github.com/bnema/vev/internal/protocol" | |
| 12 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 11 | 13 | pickerusecase "github.com/bnema/vev/internal/usecase/picker" | |
| 12 | 14 | "github.com/bnema/vev/internal/usecase/ui" | |
| 13 | 15 | ) | |
| @@ -102,7 +104,12 @@ type pickerController struct { | |||
| 102 | 104 | // lastCommitKey is the catalogue key captured with the pending commit | |
| 103 | 105 | // decision, so TakeOp can hand a driver the exact committed row. | |
| 104 | 106 | lastCommitKey string | |
| 105 | - | preview pickerusecase.Preview | |
| 107 | + | // lastKillKey is the catalogue key captured with a pending kill decision. | |
| 108 | + | lastKillKey string | |
| 109 | + | // noticedFailures records the failure episode already toasted per remote | |
| 110 | + | // host, so one outage toasts once (main's notifyNewRemoteFailures). | |
| 111 | + | noticedFailures map[string]uint64 | |
| 112 | + | preview pickerusecase.Preview | |
| 106 | 113 | // flushStop cancels the armed lone-escape flush. It is non-nil exactly | |
| 107 | 114 | // while one flush is armed for the currently withheld prefix. | |
| 108 | 115 | flushStop chan struct{} | |
| @@ -195,8 +202,11 @@ func (p *pickerController) recordOpLocked(op pickerOp) { | |||
| 195 | 202 | if op.commit { | |
| 196 | 203 | p.lastCommitKey = p.commitKeyLocked() | |
| 197 | 204 | } | |
| 205 | + | if op.kill { | |
| 206 | + | p.lastKillKey = p.killKeyLocked() | |
| 207 | + | } | |
| 198 | 208 | p.lastOp = mergePickerOps(p.lastOp, op) | |
| 199 | - | if p.lastOp.commit || p.lastOp.close { | |
| 209 | + | if p.lastOp.commit || p.lastOp.close || p.lastOp.kill { | |
| 200 | 210 | // Coalesce exactly one wakeup: a driver that has not yet run TakeOp | |
| 201 | 211 | // re-reads the accumulated decision, so a dropped signal is harmless. | |
| 202 | 212 | select { | |
| @@ -229,6 +239,19 @@ func (p *pickerController) commitKeyLocked() string { | |||
| 229 | 239 | return selection.Key | |
| 230 | 240 | } | |
| 231 | 241 | ||
| 242 | + | // killKeyLocked returns the opaque catalogue key of the row under the cursor | |
| 243 | + | // when it authorises destruction, or the empty string. | |
| 244 | + | func (p *pickerController) killKeyLocked() string { | |
| 245 | + | if p.loop == nil { | |
| 246 | + | return "" | |
| 247 | + | } | |
| 248 | + | selection, ok := killSelection(p.loop, 0) | |
| 249 | + | if !ok { | |
| 250 | + | return "" | |
| 251 | + | } | |
| 252 | + | return selection.Key | |
| 253 | + | } | |
| 254 | + | ||
| 232 | 255 | // TakeOp returns and clears the accumulated presentation decision since the | |
| 233 | 256 | // last call, together with the catalogue key of the row the commit decision | |
| 234 | 257 | // selected at the moment it was recorded. Resolving that key with ResolveKey | |
| @@ -243,8 +266,14 @@ func (p *pickerController) TakeOp() (pickerOp, string) { | |||
| 243 | 266 | defer p.mu.Unlock() | |
| 244 | 267 | op := p.lastOp | |
| 245 | 268 | key := p.lastCommitKey | |
| 269 | + | if !op.commit { | |
| 270 | + | // A kill carries its own captured row; a commit in the same read wins | |
| 271 | + | // because it leaves the picker anyway. | |
| 272 | + | key = p.lastKillKey | |
| 273 | + | } | |
| 246 | 274 | p.lastOp = pickerOp{} | |
| 247 | 275 | p.lastCommitKey = "" | |
| 276 | + | p.lastKillKey = "" | |
| 248 | 277 | return op, key | |
| 249 | 278 | } | |
| 250 | 279 | ||
| @@ -392,7 +421,15 @@ func (p *pickerController) PreviewRequest(connection ports.BrokerConnectionID, s | |||
| 392 | 421 | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |
| 393 | 422 | } | |
| 394 | 423 | session, ok := pickerFindSession(authority.observation.Sessions, ref.lifecycle) | |
| 395 | - | if !ok || session.State == "down" || session.ActiveTabID == "" { | |
| 424 | + | if !ok || session.State != catalogue.RemoteCatalogSessionUp { | |
| 425 | + | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |
| 426 | + | } | |
| 427 | + | // A tab row previews its own tab; a session row previews the active one. | |
| 428 | + | previewTab := domain.TabStableID(session.ActiveTabID) | |
| 429 | + | if ref.tab.present { | |
| 430 | + | previewTab = ref.tab.id | |
| 431 | + | } | |
| 432 | + | if previewTab == "" { | |
| 396 | 433 | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |
| 397 | 434 | } | |
| 398 | 435 | viewport := pickerPreviewSize(size) | |
| @@ -402,7 +439,7 @@ func (p *pickerController) PreviewRequest(connection ports.BrokerConnectionID, s | |||
| 402 | 439 | // local daemon; "local" is the broker's canonical local authority. | |
| 403 | 440 | endpoint = "local" | |
| 404 | 441 | } | |
| 405 | - | target := domain.RemoteSessionTarget{Endpoint: endpoint, DisplayOrigin: authority.observation.DisplayOrigin, LifecycleID: session.LifecycleID, SessionName: session.Name, LiveTabID: domain.TabStableID(session.ActiveTabID)} | |
| 442 | + | target := domain.RemoteSessionTarget{Endpoint: endpoint, DisplayOrigin: authority.observation.DisplayOrigin, LifecycleID: session.LifecycleID, SessionName: session.Name, LiveTabID: previewTab} | |
| 406 | 443 | preview := protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: clampPreviewDimension(viewport.Cols, protocol.RemotePreviewMaxWidth), Height: clampPreviewDimension(viewport.Rows, protocol.RemotePreviewMaxHeight)} | |
| 407 | 444 | route.Purpose = ports.BrokerStreamObservation | |
| 408 | 445 | route.Admission = 0 | |
| @@ -510,12 +547,66 @@ func (p *pickerController) ResolveKey(key string, base pickerResolveBase) (ports | |||
| 510 | 547 | } | |
| 511 | 548 | return ports.BrokerOpenStreamRequest{}, err | |
| 512 | 549 | } | |
| 513 | - | request, err := p.catalogue.Resolve(key, base) | |
| 550 | + | request, _, err := p.ResolveKeyTarget(key, base) | |
| 551 | + | return request, err | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | // ResolveKeyTarget is ResolveKey plus the exact tab the committed row names. | |
| 555 | + | func (p *pickerController) ResolveKeyTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) { | |
| 556 | + | if p == nil || p.catalogue == nil { | |
| 557 | + | err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"} | |
| 558 | + | if p != nil { | |
| 559 | + | p.offerNotice("picker-refusal", err.Error()) | |
| 560 | + | } | |
| 561 | + | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err | |
| 562 | + | } | |
| 563 | + | request, tab, err := p.catalogue.ResolveTarget(key, base) | |
| 514 | 564 | if err != nil { | |
| 515 | - | p.offerNotice("picker-refusal", err.Error()) | |
| 516 | - | return ports.BrokerOpenStreamRequest{}, err | |
| 565 | + | p.offerNotice("picker-refusal", pickerRefusalNotice(err)) | |
| 566 | + | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err | |
| 517 | 567 | } | |
| 518 | - | return request, nil | |
| 568 | + | return request, tab, nil | |
| 569 | + | } | |
| 570 | + | ||
| 571 | + | // ResolveKill revalidates one kill key; a refusal is surfaced as a notice. | |
| 572 | + | func (p *pickerController) ResolveKill(key string) (pickerKillTarget, error) { | |
| 573 | + | if p == nil || p.catalogue == nil { | |
| 574 | + | return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"} | |
| 575 | + | } | |
| 576 | + | target, err := p.catalogue.ResolveKill(key) | |
| 577 | + | if err != nil { | |
| 578 | + | p.offerNotice("picker-refusal", pickerRefusalNotice(err)) | |
| 579 | + | return pickerKillTarget{}, err | |
| 580 | + | } | |
| 581 | + | return target, nil | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | // SetCurrent names the attachment the picker is about to be presented over | |
| 585 | + | // (or none) and restarts the presentation, so a fresh picker opens with the | |
| 586 | + | // cursor on that session's tab and no leftover search, exactly as each daemon | |
| 587 | + | // interaction did on main. | |
| 588 | + | func (p *pickerController) SetCurrent(current pickerCurrent) { | |
| 589 | + | if p == nil || p.catalogue == nil { | |
| 590 | + | return | |
| 591 | + | } | |
| 592 | + | p.catalogue.SetCurrent(current) | |
| 593 | + | p.mu.Lock() | |
| 594 | + | defer p.mu.Unlock() | |
| 595 | + | if p.catalogue.Epoch() == 0 { | |
| 596 | + | return | |
| 597 | + | } | |
| 598 | + | p.loop = nil | |
| 599 | + | p.rebuildLocked() | |
| 600 | + | p.renderer.invalidate() | |
| 601 | + | } | |
| 602 | + | ||
| 603 | + | // pickerRefusalNotice is the toast text for one resolution refusal. | |
| 604 | + | func pickerRefusalNotice(err error) string { | |
| 605 | + | var typed pickerCatalogueError | |
| 606 | + | if errors.As(err, &typed) { | |
| 607 | + | return typed.noticeText() | |
| 608 | + | } | |
| 609 | + | return err.Error() | |
| 519 | 610 | } | |
| 520 | 611 | ||
| 521 | 612 | // displayedKey names the row under the cursor, whether or not it admits an | |
| @@ -577,43 +668,54 @@ func (p *pickerController) handleRead(data []byte) bool { | |||
| 577 | 668 | } | |
| 578 | 669 | ||
| 579 | 670 | func (p *pickerController) rebuildLocked() { | |
| 580 | - | lines, cursor := p.catalogue.projection() | |
| 671 | + | recent, grouped := p.catalogue.projections() | |
| 581 | 672 | revision := uint64(p.catalogue.Revision()) | |
| 582 | - | if p.loop == nil { | |
| 583 | - | p.interaction++ | |
| 584 | - | p.loop = pickerLoopFromSnapshot(protocol.PickerSnapshot{ | |
| 673 | + | snapshot := func() protocol.PickerSnapshot { | |
| 674 | + | return protocol.PickerSnapshot{ | |
| 585 | 675 | InteractionID: p.interaction, | |
| 586 | 676 | SourceID: pickerCatalogueSourceID, | |
| 587 | 677 | SourceRevision: revision, | |
| 588 | - | Lines: lines, | |
| 589 | - | Cursor: cursor, | |
| 590 | - | }, protocol.PickerIntentNavigation, defaultPickerSort()) | |
| 678 | + | Lines: grouped.Lines, | |
| 679 | + | Cursor: grouped.Cursor, | |
| 680 | + | Recent: recent, | |
| 681 | + | Grouped: grouped, | |
| 682 | + | } | |
| 683 | + | } | |
| 684 | + | if p.loop == nil { | |
| 685 | + | p.interaction++ | |
| 686 | + | p.loop = pickerLoopFromSnapshot(snapshot(), protocol.PickerIntentNavigation, defaultPickerSort()) | |
| 591 | 687 | return | |
| 592 | 688 | } | |
| 593 | - | p.loop.replaceLines(protocol.PickerSnapshot{ | |
| 594 | - | InteractionID: p.interaction, | |
| 595 | - | SourceID: pickerCatalogueSourceID, | |
| 596 | - | SourceRevision: revision, | |
| 597 | - | Lines: lines, | |
| 598 | - | Cursor: cursor, | |
| 599 | - | }) | |
| 689 | + | p.loop.replaceLines(snapshot()) | |
| 600 | 690 | } | |
| 601 | 691 | ||
| 602 | - | // offerDiagnosticsLocked surfaces every host that is not a clean, fresh, | |
| 603 | - | // compatible, reachable daemon as a bounded toast. The toast manager keeps one | |
| 604 | - | // entry per host key, so the notices stay bounded regardless of update rate. | |
| 692 | + | // offerDiagnosticsLocked toasts each remote host failure once per failure | |
| 693 | + | // episode (main's notifyNewRemoteFailures). Ordinary observation progress and | |
| 694 | + | // catalogue aging stay on the rows; a recovered host forgets its episode so a | |
| 695 | + | // later outage toasts again. | |
| 605 | 696 | func (p *pickerController) offerDiagnosticsLocked() { | |
| 606 | - | for _, diagnostic := range p.catalogue.diagnostics() { | |
| 607 | - | if diagnostic.Message == "" { | |
| 697 | + | failing := make(map[string]struct{}) | |
| 698 | + | for _, failure := range p.catalogue.failures() { | |
| 699 | + | failing[failure.key] = struct{}{} | |
| 700 | + | if noticed, ok := p.noticedFailures[failure.key]; ok && noticed == failure.episode { | |
| 608 | 701 | continue | |
| 609 | 702 | } | |
| 703 | + | if p.noticedFailures == nil { | |
| 704 | + | p.noticedFailures = make(map[string]uint64) | |
| 705 | + | } | |
| 706 | + | p.noticedFailures[failure.key] = failure.episode | |
| 610 | 707 | p.notices.Show(p.clock.Now(), ui.Toast{ | |
| 611 | - | ID: "picker-host:" + diagnostic.Key, | |
| 612 | - | Message: diagnostic.Origin + ": " + diagnostic.Message, | |
| 708 | + | ID: "picker-host:" + failure.key, | |
| 709 | + | Message: failure.message, | |
| 613 | 710 | Anchor: domain.AnchorCenter, | |
| 614 | 711 | Duration: pickerNoticeLifetime, | |
| 615 | 712 | }) | |
| 616 | 713 | } | |
| 714 | + | for key := range p.noticedFailures { | |
| 715 | + | if _, ok := failing[key]; !ok { | |
| 716 | + | delete(p.noticedFailures, key) | |
| 717 | + | } | |
| 718 | + | } | |
| 617 | 719 | } | |
| 618 | 720 | ||
| 619 | 721 | func (p *pickerController) offerNotice(id, message string) { | |
| @@ -2,12 +2,14 @@ package client | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | + | "errors" | |||
| 5 | "sync" | 6 | "sync" | ||
| 6 | "time" | 7 | "time" | ||
| 7 | 8 | ||||
| 8 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 9 | "github.com/bnema/vev/internal/ports" | 10 | "github.com/bnema/vev/internal/ports" | ||
| 10 | "github.com/bnema/vev/internal/protocol" | 11 | "github.com/bnema/vev/internal/protocol" | ||
| 12 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |||
| 11 | pickerusecase "github.com/bnema/vev/internal/usecase/picker" | 13 | pickerusecase "github.com/bnema/vev/internal/usecase/picker" | ||
| 12 | "github.com/bnema/vev/internal/usecase/ui" | 14 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 13 | ) | 15 | ) | ||
| @@ -102,7 +104,12 @@ type pickerController struct { | |||||
| 102 | // lastCommitKey is the catalogue key captured with the pending commit | 104 | // lastCommitKey is the catalogue key captured with the pending commit | ||
| 103 | // decision, so TakeOp can hand a driver the exact committed row. | 105 | // decision, so TakeOp can hand a driver the exact committed row. | ||
| 104 | lastCommitKey string | 106 | lastCommitKey string | ||
| 105 | - | preview pickerusecase.Preview | 107 | + | // lastKillKey is the catalogue key captured with a pending kill decision. |
| 108 | + | lastKillKey string | |||
| 109 | + | // noticedFailures records the failure episode already toasted per remote | |||
| 110 | + | // host, so one outage toasts once (main's notifyNewRemoteFailures). | |||
| 111 | + | noticedFailures map[string]uint64 | |||
| 112 | + | preview pickerusecase.Preview | |||
| 106 | // flushStop cancels the armed lone-escape flush. It is non-nil exactly | 113 | // flushStop cancels the armed lone-escape flush. It is non-nil exactly | ||
| 107 | // while one flush is armed for the currently withheld prefix. | 114 | // while one flush is armed for the currently withheld prefix. | ||
| 108 | flushStop chan struct{} | 115 | flushStop chan struct{} | ||
| @@ -195,8 +202,11 @@ func (p *pickerController) recordOpLocked(op pickerOp) { | |||||
| 195 | if op.commit { | 202 | if op.commit { | ||
| 196 | p.lastCommitKey = p.commitKeyLocked() | 203 | p.lastCommitKey = p.commitKeyLocked() | ||
| 197 | } | 204 | } | ||
| 205 | + | if op.kill { | |||
| 206 | + | p.lastKillKey = p.killKeyLocked() | |||
| 207 | + | } | |||
| 198 | p.lastOp = mergePickerOps(p.lastOp, op) | 208 | p.lastOp = mergePickerOps(p.lastOp, op) | ||
| 199 | - | if p.lastOp.commit || p.lastOp.close { | 209 | + | if p.lastOp.commit || p.lastOp.close || p.lastOp.kill { |
| 200 | // Coalesce exactly one wakeup: a driver that has not yet run TakeOp | 210 | // Coalesce exactly one wakeup: a driver that has not yet run TakeOp | ||
| 201 | // re-reads the accumulated decision, so a dropped signal is harmless. | 211 | // re-reads the accumulated decision, so a dropped signal is harmless. | ||
| 202 | select { | 212 | select { | ||
| @@ -229,6 +239,19 @@ func (p *pickerController) commitKeyLocked() string { | |||||
| 229 | return selection.Key | 239 | return selection.Key | ||
| 230 | } | 240 | } | ||
| 231 | 241 | ||||
| 242 | + | // killKeyLocked returns the opaque catalogue key of the row under the cursor | |||
| 243 | + | // when it authorises destruction, or the empty string. | |||
| 244 | + | func (p *pickerController) killKeyLocked() string { | |||
| 245 | + | if p.loop == nil { | |||
| 246 | + | return "" | |||
| 247 | + | } | |||
| 248 | + | selection, ok := killSelection(p.loop, 0) | |||
| 249 | + | if !ok { | |||
| 250 | + | return "" | |||
| 251 | + | } | |||
| 252 | + | return selection.Key | |||
| 253 | + | } | |||
| 254 | + | ||||
| 232 | // TakeOp returns and clears the accumulated presentation decision since the | 255 | // TakeOp returns and clears the accumulated presentation decision since the | ||
| 233 | // last call, together with the catalogue key of the row the commit decision | 256 | // last call, together with the catalogue key of the row the commit decision | ||
| 234 | // selected at the moment it was recorded. Resolving that key with ResolveKey | 257 | // selected at the moment it was recorded. Resolving that key with ResolveKey | ||
| @@ -243,8 +266,14 @@ func (p *pickerController) TakeOp() (pickerOp, string) { | |||||
| 243 | defer p.mu.Unlock() | 266 | defer p.mu.Unlock() | ||
| 244 | op := p.lastOp | 267 | op := p.lastOp | ||
| 245 | key := p.lastCommitKey | 268 | key := p.lastCommitKey | ||
| 269 | + | if !op.commit { | |||
| 270 | + | // A kill carries its own captured row; a commit in the same read wins | |||
| 271 | + | // because it leaves the picker anyway. | |||
| 272 | + | key = p.lastKillKey | |||
| 273 | + | } | |||
| 246 | p.lastOp = pickerOp{} | 274 | p.lastOp = pickerOp{} | ||
| 247 | p.lastCommitKey = "" | 275 | p.lastCommitKey = "" | ||
| 276 | + | p.lastKillKey = "" | |||
| 248 | return op, key | 277 | return op, key | ||
| 249 | } | 278 | } | ||
| 250 | 279 | ||||
| @@ -392,7 +421,15 @@ func (p *pickerController) PreviewRequest(connection ports.BrokerConnectionID, s | |||||
| 392 | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | 421 | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | ||
| 393 | } | 422 | } | ||
| 394 | session, ok := pickerFindSession(authority.observation.Sessions, ref.lifecycle) | 423 | session, ok := pickerFindSession(authority.observation.Sessions, ref.lifecycle) | ||
| 395 | - | if !ok || session.State == "down" || session.ActiveTabID == "" { | 424 | + | if !ok || session.State != catalogue.RemoteCatalogSessionUp { |
| 425 | + | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | |||
| 426 | + | } | |||
| 427 | + | // A tab row previews its own tab; a session row previews the active one. | |||
| 428 | + | previewTab := domain.TabStableID(session.ActiveTabID) | |||
| 429 | + | if ref.tab.present { | |||
| 430 | + | previewTab = ref.tab.id | |||
| 431 | + | } | |||
| 432 | + | if previewTab == "" { | |||
| 396 | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | 433 | return ports.BrokerOpenStreamRequest{}, protocol.RemotePreviewRequest{}, false | ||
| 397 | } | 434 | } | ||
| 398 | viewport := pickerPreviewSize(size) | 435 | viewport := pickerPreviewSize(size) | ||
| @@ -402,7 +439,7 @@ func (p *pickerController) PreviewRequest(connection ports.BrokerConnectionID, s | |||||
| 402 | // local daemon; "local" is the broker's canonical local authority. | 439 | // local daemon; "local" is the broker's canonical local authority. | ||
| 403 | endpoint = "local" | 440 | endpoint = "local" | ||
| 404 | } | 441 | } | ||
| 405 | - | target := domain.RemoteSessionTarget{Endpoint: endpoint, DisplayOrigin: authority.observation.DisplayOrigin, LifecycleID: session.LifecycleID, SessionName: session.Name, LiveTabID: domain.TabStableID(session.ActiveTabID)} | 442 | + | target := domain.RemoteSessionTarget{Endpoint: endpoint, DisplayOrigin: authority.observation.DisplayOrigin, LifecycleID: session.LifecycleID, SessionName: session.Name, LiveTabID: previewTab} |
| 406 | preview := protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: clampPreviewDimension(viewport.Cols, protocol.RemotePreviewMaxWidth), Height: clampPreviewDimension(viewport.Rows, protocol.RemotePreviewMaxHeight)} | 443 | preview := protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: clampPreviewDimension(viewport.Cols, protocol.RemotePreviewMaxWidth), Height: clampPreviewDimension(viewport.Rows, protocol.RemotePreviewMaxHeight)} | ||
| 407 | route.Purpose = ports.BrokerStreamObservation | 444 | route.Purpose = ports.BrokerStreamObservation | ||
| 408 | route.Admission = 0 | 445 | route.Admission = 0 | ||
| @@ -510,12 +547,66 @@ func (p *pickerController) ResolveKey(key string, base pickerResolveBase) (ports | |||||
| 510 | } | 547 | } | ||
| 511 | return ports.BrokerOpenStreamRequest{}, err | 548 | return ports.BrokerOpenStreamRequest{}, err | ||
| 512 | } | 549 | } | ||
| 513 | - | request, err := p.catalogue.Resolve(key, base) | 550 | + | request, _, err := p.ResolveKeyTarget(key, base) |
| 551 | + | return request, err | |||
| 552 | + | } | |||
| 553 | + | ||||
| 554 | + | // ResolveKeyTarget is ResolveKey plus the exact tab the committed row names. | |||
| 555 | + | func (p *pickerController) ResolveKeyTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) { | |||
| 556 | + | if p == nil || p.catalogue == nil { | |||
| 557 | + | err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"} | |||
| 558 | + | if p != nil { | |||
| 559 | + | p.offerNotice("picker-refusal", err.Error()) | |||
| 560 | + | } | |||
| 561 | + | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err | |||
| 562 | + | } | |||
| 563 | + | request, tab, err := p.catalogue.ResolveTarget(key, base) | |||
| 514 | if err != nil { | 564 | if err != nil { | ||
| 515 | - | p.offerNotice("picker-refusal", err.Error()) | 565 | + | p.offerNotice("picker-refusal", pickerRefusalNotice(err)) |
| 516 | - | return ports.BrokerOpenStreamRequest{}, err | 566 | + | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err |
| 517 | } | 567 | } | ||
| 518 | - | return request, nil | 568 | + | return request, tab, nil |
| 569 | + | } | |||
| 570 | + | ||||
| 571 | + | // ResolveKill revalidates one kill key; a refusal is surfaced as a notice. | |||
| 572 | + | func (p *pickerController) ResolveKill(key string) (pickerKillTarget, error) { | |||
| 573 | + | if p == nil || p.catalogue == nil { | |||
| 574 | + | return pickerKillTarget{}, pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"} | |||
| 575 | + | } | |||
| 576 | + | target, err := p.catalogue.ResolveKill(key) | |||
| 577 | + | if err != nil { | |||
| 578 | + | p.offerNotice("picker-refusal", pickerRefusalNotice(err)) | |||
| 579 | + | return pickerKillTarget{}, err | |||
| 580 | + | } | |||
| 581 | + | return target, nil | |||
| 582 | + | } | |||
| 583 | + | ||||
| 584 | + | // SetCurrent names the attachment the picker is about to be presented over | |||
| 585 | + | // (or none) and restarts the presentation, so a fresh picker opens with the | |||
| 586 | + | // cursor on that session's tab and no leftover search, exactly as each daemon | |||
| 587 | + | // interaction did on main. | |||
| 588 | + | func (p *pickerController) SetCurrent(current pickerCurrent) { | |||
| 589 | + | if p == nil || p.catalogue == nil { | |||
| 590 | + | return | |||
| 591 | + | } | |||
| 592 | + | p.catalogue.SetCurrent(current) | |||
| 593 | + | p.mu.Lock() | |||
| 594 | + | defer p.mu.Unlock() | |||
| 595 | + | if p.catalogue.Epoch() == 0 { | |||
| 596 | + | return | |||
| 597 | + | } | |||
| 598 | + | p.loop = nil | |||
| 599 | + | p.rebuildLocked() | |||
| 600 | + | p.renderer.invalidate() | |||
| 601 | + | } | |||
| 602 | + | ||||
| 603 | + | // pickerRefusalNotice is the toast text for one resolution refusal. | |||
| 604 | + | func pickerRefusalNotice(err error) string { | |||
| 605 | + | var typed pickerCatalogueError | |||
| 606 | + | if errors.As(err, &typed) { | |||
| 607 | + | return typed.noticeText() | |||
| 608 | + | } | |||
| 609 | + | return err.Error() | |||
| 519 | } | 610 | } | ||
| 520 | 611 | ||||
| 521 | // displayedKey names the row under the cursor, whether or not it admits an | 612 | // displayedKey names the row under the cursor, whether or not it admits an | ||
| @@ -577,43 +668,54 @@ func (p *pickerController) handleRead(data []byte) bool { | |||||
| 577 | } | 668 | } | ||
| 578 | 669 | ||||
| 579 | func (p *pickerController) rebuildLocked() { | 670 | func (p *pickerController) rebuildLocked() { | ||
| 580 | - | lines, cursor := p.catalogue.projection() | 671 | + | recent, grouped := p.catalogue.projections() |
| 581 | revision := uint64(p.catalogue.Revision()) | 672 | revision := uint64(p.catalogue.Revision()) | ||
| 582 | - | if p.loop == nil { | 673 | + | snapshot := func() protocol.PickerSnapshot { |
| 583 | - | p.interaction++ | 674 | + | return protocol.PickerSnapshot{ |
| 584 | - | p.loop = pickerLoopFromSnapshot(protocol.PickerSnapshot{ | |||
| 585 | InteractionID: p.interaction, | 675 | InteractionID: p.interaction, | ||
| 586 | SourceID: pickerCatalogueSourceID, | 676 | SourceID: pickerCatalogueSourceID, | ||
| 587 | SourceRevision: revision, | 677 | SourceRevision: revision, | ||
| 588 | - | Lines: lines, | 678 | + | Lines: grouped.Lines, |
| 589 | - | Cursor: cursor, | 679 | + | Cursor: grouped.Cursor, |
| 590 | - | }, protocol.PickerIntentNavigation, defaultPickerSort()) | 680 | + | Recent: recent, |
| 681 | + | Grouped: grouped, | |||
| 682 | + | } | |||
| 683 | + | } | |||
| 684 | + | if p.loop == nil { | |||
| 685 | + | p.interaction++ | |||
| 686 | + | p.loop = pickerLoopFromSnapshot(snapshot(), protocol.PickerIntentNavigation, defaultPickerSort()) | |||
| 591 | return | 687 | return | ||
| 592 | } | 688 | } | ||
| 593 | - | p.loop.replaceLines(protocol.PickerSnapshot{ | 689 | + | p.loop.replaceLines(snapshot()) |
| 594 | - | InteractionID: p.interaction, | |||
| 595 | - | SourceID: pickerCatalogueSourceID, | |||
| 596 | - | SourceRevision: revision, | |||
| 597 | - | Lines: lines, | |||
| 598 | - | Cursor: cursor, | |||
| 599 | - | }) | |||
| 600 | } | 690 | } | ||
| 601 | 691 | ||||
| 602 | - | // offerDiagnosticsLocked surfaces every host that is not a clean, fresh, | 692 | + | // offerDiagnosticsLocked toasts each remote host failure once per failure |
| 603 | - | // compatible, reachable daemon as a bounded toast. The toast manager keeps one | 693 | + | // episode (main's notifyNewRemoteFailures). Ordinary observation progress and |
| 604 | - | // entry per host key, so the notices stay bounded regardless of update rate. | 694 | + | // catalogue aging stay on the rows; a recovered host forgets its episode so a |
| 695 | + | // later outage toasts again. | |||
| 605 | func (p *pickerController) offerDiagnosticsLocked() { | 696 | func (p *pickerController) offerDiagnosticsLocked() { | ||
| 606 | - | for _, diagnostic := range p.catalogue.diagnostics() { | 697 | + | failing := make(map[string]struct{}) |
| 607 | - | if diagnostic.Message == "" { | 698 | + | for _, failure := range p.catalogue.failures() { |
| 699 | + | failing[failure.key] = struct{}{} | |||
| 700 | + | if noticed, ok := p.noticedFailures[failure.key]; ok && noticed == failure.episode { | |||
| 608 | continue | 701 | continue | ||
| 609 | } | 702 | } | ||
| 703 | + | if p.noticedFailures == nil { | |||
| 704 | + | p.noticedFailures = make(map[string]uint64) | |||
| 705 | + | } | |||
| 706 | + | p.noticedFailures[failure.key] = failure.episode | |||
| 610 | p.notices.Show(p.clock.Now(), ui.Toast{ | 707 | p.notices.Show(p.clock.Now(), ui.Toast{ | ||
| 611 | - | ID: "picker-host:" + diagnostic.Key, | 708 | + | ID: "picker-host:" + failure.key, |
| 612 | - | Message: diagnostic.Origin + ": " + diagnostic.Message, | 709 | + | Message: failure.message, |
| 613 | Anchor: domain.AnchorCenter, | 710 | Anchor: domain.AnchorCenter, | ||
| 614 | Duration: pickerNoticeLifetime, | 711 | Duration: pickerNoticeLifetime, | ||
| 615 | }) | 712 | }) | ||
| 616 | } | 713 | } | ||
| 714 | + | for key := range p.noticedFailures { | |||
| 715 | + | if _, ok := failing[key]; !ok { | |||
| 716 | + | delete(p.noticedFailures, key) | |||
| 717 | + | } | |||
| 718 | + | } | |||
| 617 | } | 719 | } | ||
| 618 | 720 | ||||
| 619 | func (p *pickerController) offerNotice(id, message string) { | 721 | func (p *pickerController) offerNotice(id, message string) { | ||
M internal/usecase/client/picker_controller_test.go +5 -4
| @@ -301,13 +301,14 @@ func TestPickerControllerNotOwningInputLeavesReadUnconsumed(t *testing.T) { | |||
| 301 | 301 | ||
| 302 | 302 | func TestPickerControllerKeepsProgressInRowsAndSurfacesFailuresAsNotices(t *testing.T) { | |
| 303 | 303 | controller, clock := pickerTestController(t) | |
| 304 | - | checking := pickerTestLocalObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up)) | |
| 304 | + | checking := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now(), pickerTestSession("alpha", 1, catalogue_Up)) | |
| 305 | 305 | checking.Checking = true | |
| 306 | 306 | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{checking}}) | |
| 307 | - | require.Empty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "ordinary refreshing progress stays in the host row") | |
| 307 | + | require.Empty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "ordinary refreshing progress stays in the rows") | |
| 308 | 308 | ||
| 309 | - | unreachable := pickerTestLocalObservation(clock.Now()) | |
| 309 | + | unreachable := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now()) | |
| 310 | 310 | unreachable.Availability = domain.RemoteAvailabilityUnreachable | |
| 311 | + | unreachable.FailureEpisode = 1 | |
| 311 | 312 | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{unreachable}}) | |
| 312 | 313 | require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "an unavailable host is surfaced as a failure") | |
| 313 | 314 | } | |
| @@ -589,7 +590,7 @@ func TestPickerControllerApplySnapshotRejectsInvalidSnapshot(t *testing.T) { | |||
| 589 | 590 | // row and surfaces no failure toast. | |
| 590 | 591 | func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testing.T) { | |
| 591 | 592 | controller, clock := pickerTestController(t) | |
| 592 | - | unobserved := pickerTestUnobservedObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up)) | |
| 593 | + | unobserved := pickerTestUnobservedObservation(clock.Now()) | |
| 593 | 594 | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unobserved}}) | |
| 594 | 595 | ||
| 595 | 596 | controller.mu.Lock() | |
| @@ -301,13 +301,14 @@ func TestPickerControllerNotOwningInputLeavesReadUnconsumed(t *testing.T) { | |||||
| 301 | 301 | ||||
| 302 | func TestPickerControllerKeepsProgressInRowsAndSurfacesFailuresAsNotices(t *testing.T) { | 302 | func TestPickerControllerKeepsProgressInRowsAndSurfacesFailuresAsNotices(t *testing.T) { | ||
| 303 | controller, clock := pickerTestController(t) | 303 | controller, clock := pickerTestController(t) | ||
| 304 | - | checking := pickerTestLocalObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up)) | 304 | + | checking := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now(), pickerTestSession("alpha", 1, catalogue_Up)) |
| 305 | checking.Checking = true | 305 | checking.Checking = true | ||
| 306 | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{checking}}) | 306 | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{checking}}) | ||
| 307 | - | require.Empty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "ordinary refreshing progress stays in the host row") | 307 | + | require.Empty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "ordinary refreshing progress stays in the rows") |
| 308 | 308 | ||||
| 309 | - | unreachable := pickerTestLocalObservation(clock.Now()) | 309 | + | unreachable := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now()) |
| 310 | unreachable.Availability = domain.RemoteAvailabilityUnreachable | 310 | unreachable.Availability = domain.RemoteAvailabilityUnreachable | ||
| 311 | + | unreachable.FailureEpisode = 1 | |||
| 311 | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{unreachable}}) | 312 | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{unreachable}}) | ||
| 312 | require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "an unavailable host is surfaced as a failure") | 313 | require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "an unavailable host is surfaced as a failure") | ||
| 313 | } | 314 | } | ||
| @@ -589,7 +590,7 @@ func TestPickerControllerApplySnapshotRejectsInvalidSnapshot(t *testing.T) { | |||||
| 589 | // row and surfaces no failure toast. | 590 | // row and surfaces no failure toast. | ||
| 590 | func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testing.T) { | 591 | func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testing.T) { | ||
| 591 | controller, clock := pickerTestController(t) | 592 | controller, clock := pickerTestController(t) | ||
| 592 | - | unobserved := pickerTestUnobservedObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up)) | 593 | + | unobserved := pickerTestUnobservedObservation(clock.Now()) |
| 593 | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unobserved}}) | 594 | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unobserved}}) | ||
| 594 | 595 | ||||
| 595 | controller.mu.Lock() | 596 | controller.mu.Lock() | ||
A internal/usecase/client/picker_parity_test.go +615 -0
| @@ -0,0 +1,615 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | "github.com/bnema/vev/internal/protocol" | |
| 13 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 14 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // Picker parity with main (Plan 003 B1-B4, C1). These port the behaviors the | |
| 18 | + | // deleted daemon picker tests pinned on main (picker_interaction_test.go, | |
| 19 | + | // remote_picker_test.go, picker_lines.go) onto the client-owned catalogue. | |
| 20 | + | ||
| 21 | + | func pickerTestTab(id, name, detail string, attention bool) catalogue.RemoteCatalogTab { | |
| 22 | + | return catalogue.RemoteCatalogTab{ID: id, Name: name, Detail: detail, Attention: attention} | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func pickerTestTabbed(name string, seed byte, state catalogue.RemoteCatalogSessionState, lastUsed uint64, active string, tabs ...catalogue.RemoteCatalogTab) catalogue.RemoteCatalogSession { | |
| 26 | + | session := pickerTestSession(name, seed, state) | |
| 27 | + | session.LastUsedSeq = lastUsed | |
| 28 | + | session.ActiveTabID = active | |
| 29 | + | for i := range tabs { | |
| 30 | + | tabs[i].Index = uint16(i) | |
| 31 | + | } | |
| 32 | + | session.Tabs = tabs | |
| 33 | + | return session | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func pickerLinesOfKind(lines []protocol.PickerLine, kind protocol.PickerLineKind) []protocol.PickerLine { | |
| 37 | + | out := make([]protocol.PickerLine, 0) | |
| 38 | + | for _, line := range lines { | |
| 39 | + | if line.Kind == kind { | |
| 40 | + | out = append(out, line) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | return out | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // pickerTabKey returns the key of the tab row labelled label that follows the | |
| 47 | + | // session header labelled session. | |
| 48 | + | func pickerTabKey(t *testing.T, lines []protocol.PickerLine, session, label string) string { | |
| 49 | + | t.Helper() | |
| 50 | + | inSession := false | |
| 51 | + | for _, line := range lines { | |
| 52 | + | switch line.Kind { | |
| 53 | + | case protocol.PickerLineSession, protocol.PickerLineHost, protocol.PickerLineSection: | |
| 54 | + | inSession = line.Kind == protocol.PickerLineSession && line.Label == session | |
| 55 | + | case protocol.PickerLineTab: | |
| 56 | + | if inSession && line.Label == label { | |
| 57 | + | return line.Key | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | t.Fatalf("no tab %q under session %q", label, session) | |
| 62 | + | return "" | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // TestPickerCatalogueTabRows ports main's picker_lines.go tab rows: a session | |
| 66 | + | // with tabs is a non-focusable header naming its tabs; each tab row is a | |
| 67 | + | // destination with its pane-title detail and its own bell, and the header | |
| 68 | + | // rings when any tab does. | |
| 69 | + | func TestPickerCatalogueTabRows(t *testing.T) { | |
| 70 | + | now := time.Unix(1000, 0) | |
| 71 | + | navigateKill := protocol.PickerCanNavigate | protocol.PickerCanKill | |
| 72 | + | tests := []struct { | |
| 73 | + | name string | |
| 74 | + | daemon ports.BrokerDaemonObservation | |
| 75 | + | session string | |
| 76 | + | wantHeaderBel bool | |
| 77 | + | wantTabs []protocol.PickerLine // Label, Detail, Attention, Actions, Stopped | |
| 78 | + | }{ | |
| 79 | + | { | |
| 80 | + | name: "live local session", | |
| 81 | + | daemon: pickerTestLocalObservation(now, pickerTestTabbed("work", 1, catalogue_Up, 3, "t2", | |
| 82 | + | pickerTestTab("t1", "shell", "(zsh)", false), | |
| 83 | + | pickerTestTab("t2", "build", "(make)", true), | |
| 84 | + | pickerTestTab("t3", "", "", false), | |
| 85 | + | )), | |
| 86 | + | session: "work", | |
| 87 | + | wantHeaderBel: true, | |
| 88 | + | wantTabs: []protocol.PickerLine{ | |
| 89 | + | {Label: "shell", Detail: "(zsh)", Actions: navigateKill}, | |
| 90 | + | {Label: "build", Detail: "(make)", Attention: true, Actions: navigateKill}, | |
| 91 | + | {Label: "3", Actions: navigateKill}, | |
| 92 | + | }, | |
| 93 | + | }, | |
| 94 | + | { | |
| 95 | + | name: "stopped local session keeps its tabs", | |
| 96 | + | daemon: pickerTestLocalObservation(now, pickerTestTabbed("old", 2, catalogue_Down, 1, "", | |
| 97 | + | pickerTestTab("t1", "notes", "", false), | |
| 98 | + | )), | |
| 99 | + | session: "old", | |
| 100 | + | wantTabs: []protocol.PickerLine{ | |
| 101 | + | {Label: "notes", Actions: navigateKill, Stopped: true}, | |
| 102 | + | }, | |
| 103 | + | }, | |
| 104 | + | { | |
| 105 | + | name: "remote tabs navigate but never kill", | |
| 106 | + | daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 3, catalogue_Up, 0, "r1", | |
| 107 | + | pickerTestTab("r1", "logs", "(tail)", false), | |
| 108 | + | pickerTestTab("", "anon", "", false), | |
| 109 | + | )), | |
| 110 | + | session: "remote-a", | |
| 111 | + | wantTabs: []protocol.PickerLine{ | |
| 112 | + | {Label: "logs", Detail: "(tail)", Actions: protocol.PickerCanNavigate}, | |
| 113 | + | // A live tab without a stable ID cannot be targeted exactly. | |
| 114 | + | {Label: "anon"}, | |
| 115 | + | }, | |
| 116 | + | }, | |
| 117 | + | } | |
| 118 | + | for _, tt := range tests { | |
| 119 | + | t.Run(tt.name, func(t *testing.T) { | |
| 120 | + | catalogue, _ := pickerTestCatalogue(t) | |
| 121 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}})) | |
| 122 | + | for _, lines := range [][]protocol.PickerLine{catalogue.Lines(), catalogue.RecentLines()} { | |
| 123 | + | header, ok := pickerLineByLabel(lines, tt.session) | |
| 124 | + | require.True(t, ok) | |
| 125 | + | require.Equal(t, protocol.PickerLineSession, header.Kind) | |
| 126 | + | require.False(t, header.Focusable, "a tabbed session header is not a destination") | |
| 127 | + | require.Zero(t, header.Actions) | |
| 128 | + | require.Equal(t, tt.wantHeaderBel, header.Attention, "the header rings when any tab does") | |
| 129 | + | ||
| 130 | + | tabs := pickerLinesOfKind(lines, protocol.PickerLineTab) | |
| 131 | + | require.Len(t, tabs, len(tt.wantTabs)) | |
| 132 | + | for i, want := range tt.wantTabs { | |
| 133 | + | got := tabs[i] | |
| 134 | + | require.Equal(t, want.Label, got.Label) | |
| 135 | + | require.Equal(t, want.Detail, got.Detail) | |
| 136 | + | require.Equal(t, want.Attention, got.Attention) | |
| 137 | + | require.Equal(t, want.Actions, got.Actions, "tab %q actions", got.Label) | |
| 138 | + | require.Equal(t, want.Stopped, got.Stopped) | |
| 139 | + | require.True(t, got.Focusable, "every tab row is reachable") | |
| 140 | + | require.NotEqual(t, header.Key, got.Key) | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | }) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // TestPickerCatalogueResolvesTabTargets pins exact tab targets: live and | |
| 148 | + | // stopped-local tabs prefer their stable tab, a stopped remote tab restores | |
| 149 | + | // through its exact selector (stable ID, else ordinal), and a tab that left | |
| 150 | + | // the session is gone. | |
| 151 | + | func TestPickerCatalogueResolvesTabTargets(t *testing.T) { | |
| 152 | + | now := time.Unix(1000, 0) | |
| 153 | + | lifecycle := pickerTestLifecycle(3) | |
| 154 | + | tests := []struct { | |
| 155 | + | name string | |
| 156 | + | daemon ports.BrokerDaemonObservation | |
| 157 | + | session string | |
| 158 | + | tab string | |
| 159 | + | after *ports.BrokerDaemonObservation | |
| 160 | + | want attachmentTab | |
| 161 | + | wantErr pickerCatalogueErrorCode | |
| 162 | + | }{ | |
| 163 | + | { | |
| 164 | + | name: "live local tab", | |
| 165 | + | daemon: pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", false), pickerTestTab("t2", "build", "", false))), | |
| 166 | + | session: "work", tab: "build", | |
| 167 | + | want: attachmentTab{preferred: "t2"}, | |
| 168 | + | }, | |
| 169 | + | { | |
| 170 | + | name: "stopped local tab", | |
| 171 | + | daemon: pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Down, 1, "", pickerTestTab("t1", "shell", "", false), pickerTestTab("t2", "build", "", false))), | |
| 172 | + | session: "work", tab: "build", | |
| 173 | + | want: attachmentTab{preferred: "t2"}, | |
| 174 | + | }, | |
| 175 | + | { | |
| 176 | + | name: "live remote tab", | |
| 177 | + | daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 3, catalogue_Up, 0, "r1", pickerTestTab("r1", "logs", "", false), pickerTestTab("r2", "edit", "", false))), | |
| 178 | + | session: "remote-a", tab: "edit", | |
| 179 | + | want: attachmentTab{preferred: "r2"}, | |
| 180 | + | }, | |
| 181 | + | { | |
| 182 | + | name: "stopped remote tab by stable id", | |
| 183 | + | daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 3, catalogue_Down, 0, "", pickerTestTab("r1", "logs", "", false), pickerTestTab("r2", "edit", "", false))), | |
| 184 | + | session: "remote-a", tab: "edit", | |
| 185 | + | want: attachmentTab{stopped: &protocol.SessionAttachTarget{LifecycleID: lifecycle, SessionName: "remote-a", TabID: "r2", TabIndex: protocol.NoTabIndex, Stopped: true}}, | |
| 186 | + | }, | |
| 187 | + | { | |
| 188 | + | name: "stopped remote tab by ordinal", | |
| 189 | + | daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 3, catalogue_Down, 0, "", pickerTestTab("", "logs", "", false), pickerTestTab("", "edit", "", false))), | |
| 190 | + | session: "remote-a", tab: "edit", | |
| 191 | + | want: attachmentTab{stopped: &protocol.SessionAttachTarget{LifecycleID: lifecycle, SessionName: "remote-a", TabIndex: 1, TabRawName: "edit", TabExpectedCount: 2, Stopped: true}}, | |
| 192 | + | }, | |
| 193 | + | { | |
| 194 | + | name: "tab closed after projection", | |
| 195 | + | daemon: pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", false), pickerTestTab("t2", "build", "", false))), | |
| 196 | + | session: "work", tab: "build", | |
| 197 | + | after: func() *ports.BrokerDaemonObservation { | |
| 198 | + | o := pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", false))) | |
| 199 | + | return &o | |
| 200 | + | }(), | |
| 201 | + | wantErr: pickerCatalogueGone, | |
| 202 | + | }, | |
| 203 | + | } | |
| 204 | + | for _, tt := range tests { | |
| 205 | + | t.Run(tt.name, func(t *testing.T) { | |
| 206 | + | catalogue, _ := pickerTestCatalogue(t) | |
| 207 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}})) | |
| 208 | + | key := pickerTabKey(t, catalogue.Lines(), tt.session, tt.tab) | |
| 209 | + | if tt.after != nil { | |
| 210 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{*tt.after}})) | |
| 211 | + | } | |
| 212 | + | request, tab, err := catalogue.ResolveTarget(key, pickerTestBase()) | |
| 213 | + | if tt.wantErr != 0 { | |
| 214 | + | require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err) | |
| 215 | + | return | |
| 216 | + | } | |
| 217 | + | require.NoError(t, err) | |
| 218 | + | require.Equal(t, ports.BrokerAdmissionExact, request.Admission) | |
| 219 | + | require.Equal(t, lifecycle, request.Target.LifecycleID) | |
| 220 | + | require.Equal(t, tt.want, tab) | |
| 221 | + | }) | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | // TestPickerCatalogueRecentAndGroupedProjections ports main's | |
| 226 | + | // TestPickerSnapshotPublishesDistinctRecentAndGroupedProjections plus the MRU | |
| 227 | + | // order of picker.go: Recent is flat (live by recency, then remote, then | |
| 228 | + | // stopped by recency); Grouped is sectioned per daemon; both carry the same | |
| 229 | + | // destinations and actions. | |
| 230 | + | func TestPickerCatalogueRecentAndGroupedProjections(t *testing.T) { | |
| 231 | + | now := time.Unix(1000, 0) | |
| 232 | + | local := pickerTestLocalObservation(now, | |
| 233 | + | pickerTestTabbed("alpha", 1, catalogue_Up, 5, "", pickerTestTab("a1", "sh", "", false)), | |
| 234 | + | pickerTestTabbed("beta", 2, catalogue_Up, 9, "", pickerTestTab("b1", "sh", "", false)), | |
| 235 | + | pickerTestTabbed("gamma", 3, catalogue_Down, 7, ""), | |
| 236 | + | pickerTestTabbed("delta", 4, catalogue_Down, 8, ""), | |
| 237 | + | ) | |
| 238 | + | remote := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 5, catalogue_Up, 99, "", pickerTestTab("r1", "sh", "", false))) | |
| 239 | + | catalogue, _ := pickerTestCatalogue(t) | |
| 240 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{local, remote}})) | |
| 241 | + | recent, grouped := catalogue.projections() | |
| 242 | + | ||
| 243 | + | tests := []struct { | |
| 244 | + | name string | |
| 245 | + | lines []protocol.PickerLine | |
| 246 | + | wantSessions []string | |
| 247 | + | wantSections []string | |
| 248 | + | }{ | |
| 249 | + | {name: "recent", lines: recent.Lines, wantSessions: []string{"beta", "alpha", "remote-a", "delta", "gamma"}, wantSections: []string{}}, | |
| 250 | + | {name: "grouped", lines: grouped.Lines, wantSessions: []string{"beta", "alpha", "delta", "gamma", "remote-a"}, wantSections: []string{"local", "user@arch"}}, | |
| 251 | + | } | |
| 252 | + | for _, tt := range tests { | |
| 253 | + | t.Run(tt.name, func(t *testing.T) { | |
| 254 | + | require.Equal(t, tt.wantSessions, pickerSessionLabels(tt.lines)) | |
| 255 | + | require.Equal(t, tt.wantSections, pickerSectionLabels(tt.lines)) | |
| 256 | + | }) | |
| 257 | + | } | |
| 258 | + | actions := func(lines []protocol.PickerLine) map[string]protocol.PickerLineActions { | |
| 259 | + | out := make(map[string]protocol.PickerLineActions) | |
| 260 | + | for _, line := range lines { | |
| 261 | + | if line.Key != "" { | |
| 262 | + | out[line.Key] = line.Actions | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | return out | |
| 266 | + | } | |
| 267 | + | require.Equal(t, actions(recent.Lines), actions(grouped.Lines), "projections disagree on destination actions") | |
| 268 | + | require.NotEqual(t, recent.Lines, grouped.Lines, "both projections carry the same sort input") | |
| 269 | + | remoteRecent, ok := pickerLineByLabel(recent.Lines, "remote-a") | |
| 270 | + | require.True(t, ok) | |
| 271 | + | require.Contains(t, remoteRecent.Detail, "@user@arch", "a flat remote row names its origin") | |
| 272 | + | require.NoError(t, protocol.ValidatePickerSnapshot(protocol.PickerSnapshot{InteractionID: 1, SourceID: pickerCatalogueSourceID, SourceRevision: 1, Status: protocol.PickerSourceOK, Recent: recent, Grouped: grouped})) | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // TestPickerCatalogueCursorStartsOnCurrent ports main's pickerSelectionMatches | |
| 276 | + | // and default cursor: the attached tab, else the attached session's active | |
| 277 | + | // tab, else the most recent session's active tab. | |
| 278 | + | func TestPickerCatalogueCursorStartsOnCurrent(t *testing.T) { | |
| 279 | + | now := time.Unix(1000, 0) | |
| 280 | + | local := pickerTestLocalObservation(now, | |
| 281 | + | pickerTestTabbed("alpha", 1, catalogue_Up, 5, "a2", pickerTestTab("a1", "one", "", false), pickerTestTab("a2", "two", "", false)), | |
| 282 | + | pickerTestTabbed("beta", 2, catalogue_Up, 9, "b2", pickerTestTab("b1", "one", "", false), pickerTestTab("b2", "two", "", false)), | |
| 283 | + | ) | |
| 284 | + | remote := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 5, catalogue_Up, 0, "r1", pickerTestTab("r1", "one", "", false), pickerTestTab("r2", "two", "", false))) | |
| 285 | + | tests := []struct { | |
| 286 | + | name string | |
| 287 | + | current pickerCurrent | |
| 288 | + | wantSession string | |
| 289 | + | wantTab string | |
| 290 | + | }{ | |
| 291 | + | {name: "no attachment: most recent active tab", wantSession: "beta", wantTab: "two"}, | |
| 292 | + | {name: "attached tab", current: pickerCurrent{known: true, local: true, lifecycle: pickerTestLifecycle(1), tab: "a1"}, wantSession: "alpha", wantTab: "one"}, | |
| 293 | + | {name: "attached session, unknown tab", current: pickerCurrent{known: true, local: true, lifecycle: pickerTestLifecycle(1)}, wantSession: "alpha", wantTab: "two"}, | |
| 294 | + | {name: "attached remote tab", current: pickerCurrent{known: true, endpoint: "user@arch", lifecycle: pickerTestLifecycle(5), tab: "r2"}, wantSession: "remote-a", wantTab: "two"}, | |
| 295 | + | {name: "other endpoint never matches", current: pickerCurrent{known: true, endpoint: "user@other", lifecycle: pickerTestLifecycle(5), tab: "r2"}, wantSession: "beta", wantTab: "two"}, | |
| 296 | + | } | |
| 297 | + | for _, tt := range tests { | |
| 298 | + | t.Run(tt.name, func(t *testing.T) { | |
| 299 | + | catalogue, _ := pickerTestCatalogue(t) | |
| 300 | + | catalogue.SetCurrent(tt.current) | |
| 301 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{local, remote}})) | |
| 302 | + | recent, grouped := catalogue.projections() | |
| 303 | + | for _, projection := range []protocol.PickerProjection{recent, grouped} { | |
| 304 | + | want := pickerTabKey(t, projection.Lines, tt.wantSession, tt.wantTab) | |
| 305 | + | require.Equal(t, want, projection.Cursor.Key) | |
| 306 | + | require.Equal(t, want, projection.Lines[projection.Cursor.Index].Key) | |
| 307 | + | } | |
| 308 | + | }) | |
| 309 | + | } | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // TestPickerControllerOverlayOpensOnCurrentTab pins that SetCurrent restarts | |
| 313 | + | // the presentation on the attached tab and drops a leftover search. | |
| 314 | + | func TestPickerControllerOverlayOpensOnCurrentTab(t *testing.T) { | |
| 315 | + | controller, clock := pickerTestController(t) | |
| 316 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(clock.Now(), | |
| 317 | + | pickerTestTabbed("alpha", 1, catalogue_Up, 1, "a1", pickerTestTab("a1", "one", "", false), pickerTestTab("a2", "two", "", false)), | |
| 318 | + | pickerTestTabbed("beta", 2, catalogue_Up, 9, "b1", pickerTestTab("b1", "one", "", false)), | |
| 319 | + | )}}) | |
| 320 | + | require.True(t, controller.ConsumeTerminalRead([]byte("/on"))) | |
| 321 | + | require.True(t, controller.modelSearchActive(t)) | |
| 322 | + | ||
| 323 | + | controller.SetCurrent(pickerCurrent{known: true, local: true, lifecycle: pickerTestLifecycle(1), tab: "a2"}) | |
| 324 | + | require.False(t, controller.modelSearchActive(t), "a fresh overlay starts without the previous search") | |
| 325 | + | require.Equal(t, pickerTabKey(t, controller.Catalogue().RecentLines(), "alpha", "two"), mustCursorKey(t, controller)) | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | // TestPickerControllerSearchesTabNamesAndPaneTitles pins that tab rows are | |
| 329 | + | // searchable by their tab name and their pane-title detail. | |
| 330 | + | func TestPickerControllerSearchesTabNamesAndPaneTitles(t *testing.T) { | |
| 331 | + | tests := []struct { | |
| 332 | + | name string | |
| 333 | + | query string | |
| 334 | + | want string | |
| 335 | + | }{ | |
| 336 | + | {name: "tab name", query: "deploy", want: "deploy"}, | |
| 337 | + | {name: "pane title", query: "htop", want: "monitor"}, | |
| 338 | + | } | |
| 339 | + | for _, tt := range tests { | |
| 340 | + | t.Run(tt.name, func(t *testing.T) { | |
| 341 | + | controller, clock := pickerTestController(t) | |
| 342 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(clock.Now(), | |
| 343 | + | pickerTestTabbed("work", 1, catalogue_Up, 1, "w1", | |
| 344 | + | pickerTestTab("w1", "shell", "(zsh)", false), | |
| 345 | + | pickerTestTab("w2", "deploy", "(ansible)", false), | |
| 346 | + | pickerTestTab("w3", "monitor", "(htop)", false), | |
| 347 | + | ), | |
| 348 | + | )}}) | |
| 349 | + | require.True(t, controller.ConsumeTerminalRead([]byte("/"+tt.query))) | |
| 350 | + | require.Equal(t, pickerTabKey(t, controller.Catalogue().RecentLines(), "work", tt.want), mustCursorKey(t, controller)) | |
| 351 | + | }) | |
| 352 | + | } | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | // TestPickerControllerSortToggleSwitchesProjection pins that `s` switches | |
| 356 | + | // between the distinct Recent and Grouped projections. | |
| 357 | + | func TestPickerControllerSortToggleSwitchesProjection(t *testing.T) { | |
| 358 | + | controller, clock := pickerTestController(t) | |
| 359 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |
| 360 | + | pickerTestLocalObservation(clock.Now(), pickerTestTabbed("alpha", 1, catalogue_Up, 5, ""), pickerTestTabbed("gamma", 3, catalogue_Down, 1, "")), | |
| 361 | + | pickerTestRemoteObservation("user@arch", 1, 1, clock.Now(), pickerTestTabbed("remote-a", 5, catalogue_Up, 0, "")), | |
| 362 | + | }}) | |
| 363 | + | order := func() []string { | |
| 364 | + | frame := string(controller.Render(domain.Size{Cols: 100, Rows: 30})) | |
| 365 | + | positions := map[string]int{} | |
| 366 | + | for _, label := range []string{"alpha", "gamma", "remote-a"} { | |
| 367 | + | positions[label] = strings.Index(frame, label) | |
| 368 | + | require.GreaterOrEqual(t, positions[label], 0, "%q is rendered", label) | |
| 369 | + | } | |
| 370 | + | labels := []string{"alpha", "gamma", "remote-a"} | |
| 371 | + | for i := range labels { | |
| 372 | + | for j := i + 1; j < len(labels); j++ { | |
| 373 | + | if positions[labels[j]] < positions[labels[i]] { | |
| 374 | + | labels[i], labels[j] = labels[j], labels[i] | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | } | |
| 378 | + | return labels | |
| 379 | + | } | |
| 380 | + | require.Equal(t, []string{"alpha", "remote-a", "gamma"}, order(), "recent: live, remote, stopped") | |
| 381 | + | require.True(t, controller.ConsumeTerminalRead([]byte("s"))) | |
| 382 | + | require.Equal(t, []string{"alpha", "gamma", "remote-a"}, order(), "grouped: local section, then remote") | |
| 383 | + | require.True(t, controller.ConsumeTerminalRead([]byte("s"))) | |
| 384 | + | require.Equal(t, []string{"alpha", "remote-a", "gamma"}, order()) | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | // TestPickerControllerRendersBellsAndRepaintsOnPublication ports main's | |
| 388 | + | // TestPickerSnapshotCarriesAttentionBellsToTheClient and | |
| 389 | + | // TestAttentionPulseRepublishesTheOpenPickerSource: a bell raised by a later | |
| 390 | + | // publication reaches the rendered picker. | |
| 391 | + | func TestPickerControllerRendersBellsAndRepaintsOnPublication(t *testing.T) { | |
| 392 | + | controller, clock := pickerTestController(t) | |
| 393 | + | quiet := pickerTestLocalObservation(clock.Now(), pickerTestTabbed("work", 1, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", false))) | |
| 394 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{quiet}}) | |
| 395 | + | bell := string(ui.AttentionGlyph) | |
| 396 | + | require.NotContains(t, string(controller.Render(domain.Size{Cols: 100, Rows: 30})), bell) | |
| 397 | + | ||
| 398 | + | ringing := pickerTestLocalObservation(clock.Now(), pickerTestTabbed("work", 1, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", true))) | |
| 399 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{ringing}}) | |
| 400 | + | controller.invalidatePresentation() | |
| 401 | + | frame := string(controller.Render(domain.Size{Cols: 100, Rows: 30})) | |
| 402 | + | require.Equal(t, 2, strings.Count(frame, bell), "the header and its tab row both ring") | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | // TestPickerCatalogueResolveKill pins which rows `x` may destroy: local live | |
| 406 | + | // and stopped sessions (by exact lifecycle and name), never a remote row, and | |
| 407 | + | // never a lifecycle that left or was replaced. | |
| 408 | + | func TestPickerCatalogueResolveKill(t *testing.T) { | |
| 409 | + | now := time.Unix(1000, 0) | |
| 410 | + | tests := []struct { | |
| 411 | + | name string | |
| 412 | + | daemon ports.BrokerDaemonObservation | |
| 413 | + | label string | |
| 414 | + | after *ports.BrokerDaemonObservation | |
| 415 | + | wantStopped bool | |
| 416 | + | wantErr pickerCatalogueErrorCode | |
| 417 | + | }{ | |
| 418 | + | {name: "live local", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), label: "alpha"}, | |
| 419 | + | {name: "stopped local deletes history", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Down)), label: "alpha", wantStopped: true}, | |
| 420 | + | {name: "broken local deletes history", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Broken)), label: "alpha", wantStopped: true}, | |
| 421 | + | {name: "remote is refused", daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("alpha", 1, catalogue_Up)), label: "alpha", wantErr: pickerCatalogueUnavailable}, | |
| 422 | + | { | |
| 423 | + | name: "gone", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), label: "alpha", | |
| 424 | + | after: func() *ports.BrokerDaemonObservation { o := pickerTestLocalObservation(now); return &o }(), | |
| 425 | + | wantErr: pickerCatalogueGone, | |
| 426 | + | }, | |
| 427 | + | { | |
| 428 | + | name: "replaced by the same name", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), label: "alpha", | |
| 429 | + | after: func() *ports.BrokerDaemonObservation { | |
| 430 | + | o := pickerTestLocalObservation(now, pickerTestSession("alpha", 2, catalogue_Up)) | |
| 431 | + | return &o | |
| 432 | + | }(), | |
| 433 | + | wantErr: pickerCatalogueReplaced, | |
| 434 | + | }, | |
| 435 | + | } | |
| 436 | + | for _, tt := range tests { | |
| 437 | + | t.Run(tt.name, func(t *testing.T) { | |
| 438 | + | catalogue, _ := pickerTestCatalogue(t) | |
| 439 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}})) | |
| 440 | + | line, ok := pickerLineByLabel(catalogue.Lines(), tt.label) | |
| 441 | + | require.True(t, ok) | |
| 442 | + | if tt.after != nil { | |
| 443 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{*tt.after}})) | |
| 444 | + | } | |
| 445 | + | target, err := catalogue.ResolveKill(line.Key) | |
| 446 | + | if tt.wantErr != 0 { | |
| 447 | + | require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err) | |
| 448 | + | return | |
| 449 | + | } | |
| 450 | + | require.NoError(t, err) | |
| 451 | + | require.Equal(t, BrokerOperationRoute{Epoch: 3, Destination: ports.BrokerEndpointFence{Local: true}}, target.route) | |
| 452 | + | require.NoError(t, target.route.Validate()) | |
| 453 | + | require.Equal(t, tt.label, target.name) | |
| 454 | + | require.Equal(t, tt.wantStopped, target.stopped) | |
| 455 | + | }) | |
| 456 | + | } | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | // TestPickerControllerKillKeyWakesSupervisor pins that `x` wakes the | |
| 460 | + | // supervisor with the killed row's key, only on a row that authorises it, and | |
| 461 | + | // types into an active search instead. | |
| 462 | + | func TestPickerControllerKillKeyWakesSupervisor(t *testing.T) { | |
| 463 | + | tests := []struct { | |
| 464 | + | name string | |
| 465 | + | daemon func(time.Time) ports.BrokerDaemonObservation | |
| 466 | + | input string | |
| 467 | + | wantKill bool | |
| 468 | + | wantKey bool | |
| 469 | + | }{ | |
| 470 | + | {name: "local row", daemon: func(now time.Time) ports.BrokerDaemonObservation { | |
| 471 | + | return pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)) | |
| 472 | + | }, input: "x", wantKill: true, wantKey: true}, | |
| 473 | + | {name: "remote row authorises no kill", daemon: func(now time.Time) ports.BrokerDaemonObservation { | |
| 474 | + | return pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("alpha", 1, catalogue_Up)) | |
| 475 | + | }, input: "x", wantKill: true}, | |
| 476 | + | {name: "search types x", daemon: func(now time.Time) ports.BrokerDaemonObservation { | |
| 477 | + | return pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)) | |
| 478 | + | }, input: "/x"}, | |
| 479 | + | } | |
| 480 | + | for _, tt := range tests { | |
| 481 | + | t.Run(tt.name, func(t *testing.T) { | |
| 482 | + | controller, clock := pickerTestController(t) | |
| 483 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon(clock.Now())}}) | |
| 484 | + | require.True(t, controller.ConsumeTerminalRead([]byte(tt.input))) | |
| 485 | + | if tt.wantKill { | |
| 486 | + | select { | |
| 487 | + | case <-controller.OpsReady(): | |
| 488 | + | default: | |
| 489 | + | t.Fatal("a kill decision must wake the supervisor") | |
| 490 | + | } | |
| 491 | + | } | |
| 492 | + | op, key := controller.TakeOp() | |
| 493 | + | require.Equal(t, tt.wantKill, op.kill) | |
| 494 | + | if tt.wantKey { | |
| 495 | + | require.Equal(t, pickerRowKeyByLabel(t, controller, "alpha"), key) | |
| 496 | + | } else { | |
| 497 | + | require.Empty(t, key) | |
| 498 | + | } | |
| 499 | + | }) | |
| 500 | + | } | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | // TestPickerControllerHostFailureToastOncePerEpisode ports main's | |
| 504 | + | // TestRemoteFailureNoticeEmittedOncePerFailureEpisode and | |
| 505 | + | // TestRemoteFailureNoticesKeepEndpointsDistinct. | |
| 506 | + | func TestPickerControllerHostFailureToastOncePerEpisode(t *testing.T) { | |
| 507 | + | controller, clock := pickerTestController(t) | |
| 508 | + | failing := func(endpoint string, seed byte, episode uint64, kind domain.RemoteFailureKind) ports.BrokerDaemonObservation { | |
| 509 | + | o := pickerTestRemoteObservation(endpoint, seed, seed, clock.Now()) | |
| 510 | + | o.Availability = domain.RemoteAvailabilityUnreachable | |
| 511 | + | o.ConsecutiveFailures = 1 | |
| 512 | + | o.FailureEpisode = episode | |
| 513 | + | o.LastFailure = domain.RemoteFailure{Kind: kind} | |
| 514 | + | return o | |
| 515 | + | } | |
| 516 | + | revision := ports.BrokerRevision(0) | |
| 517 | + | apply := func(daemons ...ports.BrokerDaemonObservation) []ui.ActiveToast { | |
| 518 | + | revision++ | |
| 519 | + | controller.mu.Lock() | |
| 520 | + | controller.notices.Clear() | |
| 521 | + | controller.mu.Unlock() | |
| 522 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: revision, Daemons: daemons}) | |
| 523 | + | controller.mu.Lock() | |
| 524 | + | defer controller.mu.Unlock() | |
| 525 | + | return controller.notices.Active(clock.Now()) | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | toasts := apply(failing("user@arch", 1, 1, domain.RemoteFailureTrust)) | |
| 529 | + | require.Len(t, toasts, 1) | |
| 530 | + | require.Contains(t, toasts[0].Message, "SSH host verification failed") | |
| 531 | + | require.Contains(t, toasts[0].Message, "user@arch") | |
| 532 | + | require.Empty(t, apply(failing("user@arch", 1, 1, domain.RemoteFailureTrust)), "the same episode toasts once") | |
| 533 | + | require.Len(t, apply(failing("user@arch", 1, 2, domain.RemoteFailureTrust)), 1, "a new episode toasts again") | |
| 534 | + | ||
| 535 | + | recovered := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(time.Second)) | |
| 536 | + | recovered.FailureEpisode = 2 | |
| 537 | + | require.Empty(t, apply(recovered)) | |
| 538 | + | require.Len(t, apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout)), 1, "an outage after recovery toasts again") | |
| 539 | + | ||
| 540 | + | toasts = apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout), failing("user@mule", 2, 1, domain.RemoteFailureAuthentication)) | |
| 541 | + | require.Len(t, toasts, 1, "only the newly failing endpoint toasts") | |
| 542 | + | require.Contains(t, toasts[0].Message, "user@mule") | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | // TestPickerControllerRefusesFailingRemoteInstantly pins B4: a commit on a | |
| 546 | + | // remote host the broker observed failing refuses at once with main's notice | |
| 547 | + | // instead of dialing, while a stale-but-reachable host stays attemptable. | |
| 548 | + | func TestPickerControllerRefusesFailingRemoteInstantly(t *testing.T) { | |
| 549 | + | tests := []struct { | |
| 550 | + | name string | |
| 551 | + | availability domain.RemoteAvailability | |
| 552 | + | wantNotice string | |
| 553 | + | }{ | |
| 554 | + | {name: "unreachable", availability: domain.RemoteAvailabilityUnreachable, wantNotice: "Remote session unavailable: remote-a@user@arch — host unreachable"}, | |
| 555 | + | {name: "authentication", availability: domain.RemoteAvailabilityAuthFailed, wantNotice: "Remote session unavailable: remote-a@user@arch — authentication failed"}, | |
| 556 | + | {name: "reachable", availability: domain.RemoteAvailabilityReachable}, | |
| 557 | + | } | |
| 558 | + | for _, tt := range tests { | |
| 559 | + | t.Run(tt.name, func(t *testing.T) { | |
| 560 | + | controller, clock := pickerTestController(t) | |
| 561 | + | o := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(-time.Hour), pickerTestSession("remote-a", 3, catalogue_Up)) | |
| 562 | + | o.Availability = tt.availability | |
| 563 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{o}}) | |
| 564 | + | controller.mu.Lock() | |
| 565 | + | controller.notices.Clear() | |
| 566 | + | controller.mu.Unlock() | |
| 567 | + | ||
| 568 | + | _, _, err := controller.ResolveKeyTarget(pickerRowKeyByLabel(t, controller, "remote-a"), pickerTestBase()) | |
| 569 | + | if tt.wantNotice == "" { | |
| 570 | + | require.NoError(t, err) | |
| 571 | + | return | |
| 572 | + | } | |
| 573 | + | require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueUnavailable)) | |
| 574 | + | controller.mu.Lock() | |
| 575 | + | active := controller.notices.Active(clock.Now()) | |
| 576 | + | controller.mu.Unlock() | |
| 577 | + | require.Len(t, active, 1) | |
| 578 | + | require.Equal(t, tt.wantNotice, active[0].Message) | |
| 579 | + | }) | |
| 580 | + | } | |
| 581 | + | } | |
| 582 | + | ||
| 583 | + | // TestSessionAttachmentHelloCarriesTab pins that a committed tab reaches the | |
| 584 | + | // daemon: a stable tab is preferred, a stopped remote tab is restored through | |
| 585 | + | // its exact selector, and a session-level commit carries neither. | |
| 586 | + | func TestSessionAttachmentHelloCarriesTab(t *testing.T) { | |
| 587 | + | stopped := protocol.SessionAttachTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha", TabIndex: 1, TabRawName: "edit", TabExpectedCount: 2, Stopped: true} | |
| 588 | + | tests := []struct { | |
| 589 | + | name string | |
| 590 | + | local bool | |
| 591 | + | tab attachmentTab | |
| 592 | + | wantPreferred domain.TabStableID | |
| 593 | + | wantTarget *protocol.SessionAttachTarget | |
| 594 | + | }{ | |
| 595 | + | {name: "session level", local: true}, | |
| 596 | + | {name: "local tab", local: true, tab: attachmentTab{preferred: "t_two"}, wantPreferred: "t_two"}, | |
| 597 | + | {name: "remote live tab", tab: attachmentTab{preferred: "t_two"}, wantPreferred: "t_two"}, | |
| 598 | + | {name: "remote stopped tab", tab: attachmentTab{stopped: &stopped}, wantTarget: &stopped}, | |
| 599 | + | } | |
| 600 | + | for _, tt := range tests { | |
| 601 | + | t.Run(tt.name, func(t *testing.T) { | |
| 602 | + | request := sessionTestRequest(tt.local) | |
| 603 | + | provenance := SessionEnvironmentRemote | |
| 604 | + | if tt.local { | |
| 605 | + | provenance = SessionEnvironmentLocalPicker | |
| 606 | + | } | |
| 607 | + | worker, err := newSessionAttachmentWorker(sessionAttachmentConfig{Request: request, Geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, SessionEnvironment: SessionEnvironment{Provenance: provenance}, Tab: tt.tab}) | |
| 608 | + | require.NoError(t, err) | |
| 609 | + | hello := worker.hello(newSessionTestStream()) | |
| 610 | + | require.Equal(t, tt.wantPreferred, hello.PreferredTabID) | |
| 611 | + | require.Equal(t, tt.wantTarget, hello.SessionTarget) | |
| 612 | + | require.NoError(t, protocol.ValidateHello(hello)) | |
| 613 | + | }) | |
| 614 | + | } | |
| 615 | + | } | |
| @@ -0,0 +1,615 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/domain" | |||
| 11 | + | "github.com/bnema/vev/internal/ports" | |||
| 12 | + | "github.com/bnema/vev/internal/protocol" | |||
| 13 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |||
| 14 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // Picker parity with main (Plan 003 B1-B4, C1). These port the behaviors the | |||
| 18 | + | // deleted daemon picker tests pinned on main (picker_interaction_test.go, | |||
| 19 | + | // remote_picker_test.go, picker_lines.go) onto the client-owned catalogue. | |||
| 20 | + | ||||
| 21 | + | func pickerTestTab(id, name, detail string, attention bool) catalogue.RemoteCatalogTab { | |||
| 22 | + | return catalogue.RemoteCatalogTab{ID: id, Name: name, Detail: detail, Attention: attention} | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func pickerTestTabbed(name string, seed byte, state catalogue.RemoteCatalogSessionState, lastUsed uint64, active string, tabs ...catalogue.RemoteCatalogTab) catalogue.RemoteCatalogSession { | |||
| 26 | + | session := pickerTestSession(name, seed, state) | |||
| 27 | + | session.LastUsedSeq = lastUsed | |||
| 28 | + | session.ActiveTabID = active | |||
| 29 | + | for i := range tabs { | |||
| 30 | + | tabs[i].Index = uint16(i) | |||
| 31 | + | } | |||
| 32 | + | session.Tabs = tabs | |||
| 33 | + | return session | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func pickerLinesOfKind(lines []protocol.PickerLine, kind protocol.PickerLineKind) []protocol.PickerLine { | |||
| 37 | + | out := make([]protocol.PickerLine, 0) | |||
| 38 | + | for _, line := range lines { | |||
| 39 | + | if line.Kind == kind { | |||
| 40 | + | out = append(out, line) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | return out | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // pickerTabKey returns the key of the tab row labelled label that follows the | |||
| 47 | + | // session header labelled session. | |||
| 48 | + | func pickerTabKey(t *testing.T, lines []protocol.PickerLine, session, label string) string { | |||
| 49 | + | t.Helper() | |||
| 50 | + | inSession := false | |||
| 51 | + | for _, line := range lines { | |||
| 52 | + | switch line.Kind { | |||
| 53 | + | case protocol.PickerLineSession, protocol.PickerLineHost, protocol.PickerLineSection: | |||
| 54 | + | inSession = line.Kind == protocol.PickerLineSession && line.Label == session | |||
| 55 | + | case protocol.PickerLineTab: | |||
| 56 | + | if inSession && line.Label == label { | |||
| 57 | + | return line.Key | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | t.Fatalf("no tab %q under session %q", label, session) | |||
| 62 | + | return "" | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // TestPickerCatalogueTabRows ports main's picker_lines.go tab rows: a session | |||
| 66 | + | // with tabs is a non-focusable header naming its tabs; each tab row is a | |||
| 67 | + | // destination with its pane-title detail and its own bell, and the header | |||
| 68 | + | // rings when any tab does. | |||
| 69 | + | func TestPickerCatalogueTabRows(t *testing.T) { | |||
| 70 | + | now := time.Unix(1000, 0) | |||
| 71 | + | navigateKill := protocol.PickerCanNavigate | protocol.PickerCanKill | |||
| 72 | + | tests := []struct { | |||
| 73 | + | name string | |||
| 74 | + | daemon ports.BrokerDaemonObservation | |||
| 75 | + | session string | |||
| 76 | + | wantHeaderBel bool | |||
| 77 | + | wantTabs []protocol.PickerLine // Label, Detail, Attention, Actions, Stopped | |||
| 78 | + | }{ | |||
| 79 | + | { | |||
| 80 | + | name: "live local session", | |||
| 81 | + | daemon: pickerTestLocalObservation(now, pickerTestTabbed("work", 1, catalogue_Up, 3, "t2", | |||
| 82 | + | pickerTestTab("t1", "shell", "(zsh)", false), | |||
| 83 | + | pickerTestTab("t2", "build", "(make)", true), | |||
| 84 | + | pickerTestTab("t3", "", "", false), | |||
| 85 | + | )), | |||
| 86 | + | session: "work", | |||
| 87 | + | wantHeaderBel: true, | |||
| 88 | + | wantTabs: []protocol.PickerLine{ | |||
| 89 | + | {Label: "shell", Detail: "(zsh)", Actions: navigateKill}, | |||
| 90 | + | {Label: "build", Detail: "(make)", Attention: true, Actions: navigateKill}, | |||
| 91 | + | {Label: "3", Actions: navigateKill}, | |||
| 92 | + | }, | |||
| 93 | + | }, | |||
| 94 | + | { | |||
| 95 | + | name: "stopped local session keeps its tabs", | |||
| 96 | + | daemon: pickerTestLocalObservation(now, pickerTestTabbed("old", 2, catalogue_Down, 1, "", | |||
| 97 | + | pickerTestTab("t1", "notes", "", false), | |||
| 98 | + | )), | |||
| 99 | + | session: "old", | |||
| 100 | + | wantTabs: []protocol.PickerLine{ | |||
| 101 | + | {Label: "notes", Actions: navigateKill, Stopped: true}, | |||
| 102 | + | }, | |||
| 103 | + | }, | |||
| 104 | + | { | |||
| 105 | + | name: "remote tabs navigate but never kill", | |||
| 106 | + | daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 3, catalogue_Up, 0, "r1", | |||
| 107 | + | pickerTestTab("r1", "logs", "(tail)", false), | |||
| 108 | + | pickerTestTab("", "anon", "", false), | |||
| 109 | + | )), | |||
| 110 | + | session: "remote-a", | |||
| 111 | + | wantTabs: []protocol.PickerLine{ | |||
| 112 | + | {Label: "logs", Detail: "(tail)", Actions: protocol.PickerCanNavigate}, | |||
| 113 | + | // A live tab without a stable ID cannot be targeted exactly. | |||
| 114 | + | {Label: "anon"}, | |||
| 115 | + | }, | |||
| 116 | + | }, | |||
| 117 | + | } | |||
| 118 | + | for _, tt := range tests { | |||
| 119 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 120 | + | catalogue, _ := pickerTestCatalogue(t) | |||
| 121 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}})) | |||
| 122 | + | for _, lines := range [][]protocol.PickerLine{catalogue.Lines(), catalogue.RecentLines()} { | |||
| 123 | + | header, ok := pickerLineByLabel(lines, tt.session) | |||
| 124 | + | require.True(t, ok) | |||
| 125 | + | require.Equal(t, protocol.PickerLineSession, header.Kind) | |||
| 126 | + | require.False(t, header.Focusable, "a tabbed session header is not a destination") | |||
| 127 | + | require.Zero(t, header.Actions) | |||
| 128 | + | require.Equal(t, tt.wantHeaderBel, header.Attention, "the header rings when any tab does") | |||
| 129 | + | ||||
| 130 | + | tabs := pickerLinesOfKind(lines, protocol.PickerLineTab) | |||
| 131 | + | require.Len(t, tabs, len(tt.wantTabs)) | |||
| 132 | + | for i, want := range tt.wantTabs { | |||
| 133 | + | got := tabs[i] | |||
| 134 | + | require.Equal(t, want.Label, got.Label) | |||
| 135 | + | require.Equal(t, want.Detail, got.Detail) | |||
| 136 | + | require.Equal(t, want.Attention, got.Attention) | |||
| 137 | + | require.Equal(t, want.Actions, got.Actions, "tab %q actions", got.Label) | |||
| 138 | + | require.Equal(t, want.Stopped, got.Stopped) | |||
| 139 | + | require.True(t, got.Focusable, "every tab row is reachable") | |||
| 140 | + | require.NotEqual(t, header.Key, got.Key) | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | }) | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | // TestPickerCatalogueResolvesTabTargets pins exact tab targets: live and | |||
| 148 | + | // stopped-local tabs prefer their stable tab, a stopped remote tab restores | |||
| 149 | + | // through its exact selector (stable ID, else ordinal), and a tab that left | |||
| 150 | + | // the session is gone. | |||
| 151 | + | func TestPickerCatalogueResolvesTabTargets(t *testing.T) { | |||
| 152 | + | now := time.Unix(1000, 0) | |||
| 153 | + | lifecycle := pickerTestLifecycle(3) | |||
| 154 | + | tests := []struct { | |||
| 155 | + | name string | |||
| 156 | + | daemon ports.BrokerDaemonObservation | |||
| 157 | + | session string | |||
| 158 | + | tab string | |||
| 159 | + | after *ports.BrokerDaemonObservation | |||
| 160 | + | want attachmentTab | |||
| 161 | + | wantErr pickerCatalogueErrorCode | |||
| 162 | + | }{ | |||
| 163 | + | { | |||
| 164 | + | name: "live local tab", | |||
| 165 | + | daemon: pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", false), pickerTestTab("t2", "build", "", false))), | |||
| 166 | + | session: "work", tab: "build", | |||
| 167 | + | want: attachmentTab{preferred: "t2"}, | |||
| 168 | + | }, | |||
| 169 | + | { | |||
| 170 | + | name: "stopped local tab", | |||
| 171 | + | daemon: pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Down, 1, "", pickerTestTab("t1", "shell", "", false), pickerTestTab("t2", "build", "", false))), | |||
| 172 | + | session: "work", tab: "build", | |||
| 173 | + | want: attachmentTab{preferred: "t2"}, | |||
| 174 | + | }, | |||
| 175 | + | { | |||
| 176 | + | name: "live remote tab", | |||
| 177 | + | daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 3, catalogue_Up, 0, "r1", pickerTestTab("r1", "logs", "", false), pickerTestTab("r2", "edit", "", false))), | |||
| 178 | + | session: "remote-a", tab: "edit", | |||
| 179 | + | want: attachmentTab{preferred: "r2"}, | |||
| 180 | + | }, | |||
| 181 | + | { | |||
| 182 | + | name: "stopped remote tab by stable id", | |||
| 183 | + | daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 3, catalogue_Down, 0, "", pickerTestTab("r1", "logs", "", false), pickerTestTab("r2", "edit", "", false))), | |||
| 184 | + | session: "remote-a", tab: "edit", | |||
| 185 | + | want: attachmentTab{stopped: &protocol.SessionAttachTarget{LifecycleID: lifecycle, SessionName: "remote-a", TabID: "r2", TabIndex: protocol.NoTabIndex, Stopped: true}}, | |||
| 186 | + | }, | |||
| 187 | + | { | |||
| 188 | + | name: "stopped remote tab by ordinal", | |||
| 189 | + | daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 3, catalogue_Down, 0, "", pickerTestTab("", "logs", "", false), pickerTestTab("", "edit", "", false))), | |||
| 190 | + | session: "remote-a", tab: "edit", | |||
| 191 | + | want: attachmentTab{stopped: &protocol.SessionAttachTarget{LifecycleID: lifecycle, SessionName: "remote-a", TabIndex: 1, TabRawName: "edit", TabExpectedCount: 2, Stopped: true}}, | |||
| 192 | + | }, | |||
| 193 | + | { | |||
| 194 | + | name: "tab closed after projection", | |||
| 195 | + | daemon: pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", false), pickerTestTab("t2", "build", "", false))), | |||
| 196 | + | session: "work", tab: "build", | |||
| 197 | + | after: func() *ports.BrokerDaemonObservation { | |||
| 198 | + | o := pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", false))) | |||
| 199 | + | return &o | |||
| 200 | + | }(), | |||
| 201 | + | wantErr: pickerCatalogueGone, | |||
| 202 | + | }, | |||
| 203 | + | } | |||
| 204 | + | for _, tt := range tests { | |||
| 205 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 206 | + | catalogue, _ := pickerTestCatalogue(t) | |||
| 207 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}})) | |||
| 208 | + | key := pickerTabKey(t, catalogue.Lines(), tt.session, tt.tab) | |||
| 209 | + | if tt.after != nil { | |||
| 210 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{*tt.after}})) | |||
| 211 | + | } | |||
| 212 | + | request, tab, err := catalogue.ResolveTarget(key, pickerTestBase()) | |||
| 213 | + | if tt.wantErr != 0 { | |||
| 214 | + | require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err) | |||
| 215 | + | return | |||
| 216 | + | } | |||
| 217 | + | require.NoError(t, err) | |||
| 218 | + | require.Equal(t, ports.BrokerAdmissionExact, request.Admission) | |||
| 219 | + | require.Equal(t, lifecycle, request.Target.LifecycleID) | |||
| 220 | + | require.Equal(t, tt.want, tab) | |||
| 221 | + | }) | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | // TestPickerCatalogueRecentAndGroupedProjections ports main's | |||
| 226 | + | // TestPickerSnapshotPublishesDistinctRecentAndGroupedProjections plus the MRU | |||
| 227 | + | // order of picker.go: Recent is flat (live by recency, then remote, then | |||
| 228 | + | // stopped by recency); Grouped is sectioned per daemon; both carry the same | |||
| 229 | + | // destinations and actions. | |||
| 230 | + | func TestPickerCatalogueRecentAndGroupedProjections(t *testing.T) { | |||
| 231 | + | now := time.Unix(1000, 0) | |||
| 232 | + | local := pickerTestLocalObservation(now, | |||
| 233 | + | pickerTestTabbed("alpha", 1, catalogue_Up, 5, "", pickerTestTab("a1", "sh", "", false)), | |||
| 234 | + | pickerTestTabbed("beta", 2, catalogue_Up, 9, "", pickerTestTab("b1", "sh", "", false)), | |||
| 235 | + | pickerTestTabbed("gamma", 3, catalogue_Down, 7, ""), | |||
| 236 | + | pickerTestTabbed("delta", 4, catalogue_Down, 8, ""), | |||
| 237 | + | ) | |||
| 238 | + | remote := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 5, catalogue_Up, 99, "", pickerTestTab("r1", "sh", "", false))) | |||
| 239 | + | catalogue, _ := pickerTestCatalogue(t) | |||
| 240 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{local, remote}})) | |||
| 241 | + | recent, grouped := catalogue.projections() | |||
| 242 | + | ||||
| 243 | + | tests := []struct { | |||
| 244 | + | name string | |||
| 245 | + | lines []protocol.PickerLine | |||
| 246 | + | wantSessions []string | |||
| 247 | + | wantSections []string | |||
| 248 | + | }{ | |||
| 249 | + | {name: "recent", lines: recent.Lines, wantSessions: []string{"beta", "alpha", "remote-a", "delta", "gamma"}, wantSections: []string{}}, | |||
| 250 | + | {name: "grouped", lines: grouped.Lines, wantSessions: []string{"beta", "alpha", "delta", "gamma", "remote-a"}, wantSections: []string{"local", "user@arch"}}, | |||
| 251 | + | } | |||
| 252 | + | for _, tt := range tests { | |||
| 253 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 254 | + | require.Equal(t, tt.wantSessions, pickerSessionLabels(tt.lines)) | |||
| 255 | + | require.Equal(t, tt.wantSections, pickerSectionLabels(tt.lines)) | |||
| 256 | + | }) | |||
| 257 | + | } | |||
| 258 | + | actions := func(lines []protocol.PickerLine) map[string]protocol.PickerLineActions { | |||
| 259 | + | out := make(map[string]protocol.PickerLineActions) | |||
| 260 | + | for _, line := range lines { | |||
| 261 | + | if line.Key != "" { | |||
| 262 | + | out[line.Key] = line.Actions | |||
| 263 | + | } | |||
| 264 | + | } | |||
| 265 | + | return out | |||
| 266 | + | } | |||
| 267 | + | require.Equal(t, actions(recent.Lines), actions(grouped.Lines), "projections disagree on destination actions") | |||
| 268 | + | require.NotEqual(t, recent.Lines, grouped.Lines, "both projections carry the same sort input") | |||
| 269 | + | remoteRecent, ok := pickerLineByLabel(recent.Lines, "remote-a") | |||
| 270 | + | require.True(t, ok) | |||
| 271 | + | require.Contains(t, remoteRecent.Detail, "@user@arch", "a flat remote row names its origin") | |||
| 272 | + | require.NoError(t, protocol.ValidatePickerSnapshot(protocol.PickerSnapshot{InteractionID: 1, SourceID: pickerCatalogueSourceID, SourceRevision: 1, Status: protocol.PickerSourceOK, Recent: recent, Grouped: grouped})) | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // TestPickerCatalogueCursorStartsOnCurrent ports main's pickerSelectionMatches | |||
| 276 | + | // and default cursor: the attached tab, else the attached session's active | |||
| 277 | + | // tab, else the most recent session's active tab. | |||
| 278 | + | func TestPickerCatalogueCursorStartsOnCurrent(t *testing.T) { | |||
| 279 | + | now := time.Unix(1000, 0) | |||
| 280 | + | local := pickerTestLocalObservation(now, | |||
| 281 | + | pickerTestTabbed("alpha", 1, catalogue_Up, 5, "a2", pickerTestTab("a1", "one", "", false), pickerTestTab("a2", "two", "", false)), | |||
| 282 | + | pickerTestTabbed("beta", 2, catalogue_Up, 9, "b2", pickerTestTab("b1", "one", "", false), pickerTestTab("b2", "two", "", false)), | |||
| 283 | + | ) | |||
| 284 | + | remote := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 5, catalogue_Up, 0, "r1", pickerTestTab("r1", "one", "", false), pickerTestTab("r2", "two", "", false))) | |||
| 285 | + | tests := []struct { | |||
| 286 | + | name string | |||
| 287 | + | current pickerCurrent | |||
| 288 | + | wantSession string | |||
| 289 | + | wantTab string | |||
| 290 | + | }{ | |||
| 291 | + | {name: "no attachment: most recent active tab", wantSession: "beta", wantTab: "two"}, | |||
| 292 | + | {name: "attached tab", current: pickerCurrent{known: true, local: true, lifecycle: pickerTestLifecycle(1), tab: "a1"}, wantSession: "alpha", wantTab: "one"}, | |||
| 293 | + | {name: "attached session, unknown tab", current: pickerCurrent{known: true, local: true, lifecycle: pickerTestLifecycle(1)}, wantSession: "alpha", wantTab: "two"}, | |||
| 294 | + | {name: "attached remote tab", current: pickerCurrent{known: true, endpoint: "user@arch", lifecycle: pickerTestLifecycle(5), tab: "r2"}, wantSession: "remote-a", wantTab: "two"}, | |||
| 295 | + | {name: "other endpoint never matches", current: pickerCurrent{known: true, endpoint: "user@other", lifecycle: pickerTestLifecycle(5), tab: "r2"}, wantSession: "beta", wantTab: "two"}, | |||
| 296 | + | } | |||
| 297 | + | for _, tt := range tests { | |||
| 298 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 299 | + | catalogue, _ := pickerTestCatalogue(t) | |||
| 300 | + | catalogue.SetCurrent(tt.current) | |||
| 301 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{local, remote}})) | |||
| 302 | + | recent, grouped := catalogue.projections() | |||
| 303 | + | for _, projection := range []protocol.PickerProjection{recent, grouped} { | |||
| 304 | + | want := pickerTabKey(t, projection.Lines, tt.wantSession, tt.wantTab) | |||
| 305 | + | require.Equal(t, want, projection.Cursor.Key) | |||
| 306 | + | require.Equal(t, want, projection.Lines[projection.Cursor.Index].Key) | |||
| 307 | + | } | |||
| 308 | + | }) | |||
| 309 | + | } | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | // TestPickerControllerOverlayOpensOnCurrentTab pins that SetCurrent restarts | |||
| 313 | + | // the presentation on the attached tab and drops a leftover search. | |||
| 314 | + | func TestPickerControllerOverlayOpensOnCurrentTab(t *testing.T) { | |||
| 315 | + | controller, clock := pickerTestController(t) | |||
| 316 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(clock.Now(), | |||
| 317 | + | pickerTestTabbed("alpha", 1, catalogue_Up, 1, "a1", pickerTestTab("a1", "one", "", false), pickerTestTab("a2", "two", "", false)), | |||
| 318 | + | pickerTestTabbed("beta", 2, catalogue_Up, 9, "b1", pickerTestTab("b1", "one", "", false)), | |||
| 319 | + | )}}) | |||
| 320 | + | require.True(t, controller.ConsumeTerminalRead([]byte("/on"))) | |||
| 321 | + | require.True(t, controller.modelSearchActive(t)) | |||
| 322 | + | ||||
| 323 | + | controller.SetCurrent(pickerCurrent{known: true, local: true, lifecycle: pickerTestLifecycle(1), tab: "a2"}) | |||
| 324 | + | require.False(t, controller.modelSearchActive(t), "a fresh overlay starts without the previous search") | |||
| 325 | + | require.Equal(t, pickerTabKey(t, controller.Catalogue().RecentLines(), "alpha", "two"), mustCursorKey(t, controller)) | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | // TestPickerControllerSearchesTabNamesAndPaneTitles pins that tab rows are | |||
| 329 | + | // searchable by their tab name and their pane-title detail. | |||
| 330 | + | func TestPickerControllerSearchesTabNamesAndPaneTitles(t *testing.T) { | |||
| 331 | + | tests := []struct { | |||
| 332 | + | name string | |||
| 333 | + | query string | |||
| 334 | + | want string | |||
| 335 | + | }{ | |||
| 336 | + | {name: "tab name", query: "deploy", want: "deploy"}, | |||
| 337 | + | {name: "pane title", query: "htop", want: "monitor"}, | |||
| 338 | + | } | |||
| 339 | + | for _, tt := range tests { | |||
| 340 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 341 | + | controller, clock := pickerTestController(t) | |||
| 342 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(clock.Now(), | |||
| 343 | + | pickerTestTabbed("work", 1, catalogue_Up, 1, "w1", | |||
| 344 | + | pickerTestTab("w1", "shell", "(zsh)", false), | |||
| 345 | + | pickerTestTab("w2", "deploy", "(ansible)", false), | |||
| 346 | + | pickerTestTab("w3", "monitor", "(htop)", false), | |||
| 347 | + | ), | |||
| 348 | + | )}}) | |||
| 349 | + | require.True(t, controller.ConsumeTerminalRead([]byte("/"+tt.query))) | |||
| 350 | + | require.Equal(t, pickerTabKey(t, controller.Catalogue().RecentLines(), "work", tt.want), mustCursorKey(t, controller)) | |||
| 351 | + | }) | |||
| 352 | + | } | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | // TestPickerControllerSortToggleSwitchesProjection pins that `s` switches | |||
| 356 | + | // between the distinct Recent and Grouped projections. | |||
| 357 | + | func TestPickerControllerSortToggleSwitchesProjection(t *testing.T) { | |||
| 358 | + | controller, clock := pickerTestController(t) | |||
| 359 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |||
| 360 | + | pickerTestLocalObservation(clock.Now(), pickerTestTabbed("alpha", 1, catalogue_Up, 5, ""), pickerTestTabbed("gamma", 3, catalogue_Down, 1, "")), | |||
| 361 | + | pickerTestRemoteObservation("user@arch", 1, 1, clock.Now(), pickerTestTabbed("remote-a", 5, catalogue_Up, 0, "")), | |||
| 362 | + | }}) | |||
| 363 | + | order := func() []string { | |||
| 364 | + | frame := string(controller.Render(domain.Size{Cols: 100, Rows: 30})) | |||
| 365 | + | positions := map[string]int{} | |||
| 366 | + | for _, label := range []string{"alpha", "gamma", "remote-a"} { | |||
| 367 | + | positions[label] = strings.Index(frame, label) | |||
| 368 | + | require.GreaterOrEqual(t, positions[label], 0, "%q is rendered", label) | |||
| 369 | + | } | |||
| 370 | + | labels := []string{"alpha", "gamma", "remote-a"} | |||
| 371 | + | for i := range labels { | |||
| 372 | + | for j := i + 1; j < len(labels); j++ { | |||
| 373 | + | if positions[labels[j]] < positions[labels[i]] { | |||
| 374 | + | labels[i], labels[j] = labels[j], labels[i] | |||
| 375 | + | } | |||
| 376 | + | } | |||
| 377 | + | } | |||
| 378 | + | return labels | |||
| 379 | + | } | |||
| 380 | + | require.Equal(t, []string{"alpha", "remote-a", "gamma"}, order(), "recent: live, remote, stopped") | |||
| 381 | + | require.True(t, controller.ConsumeTerminalRead([]byte("s"))) | |||
| 382 | + | require.Equal(t, []string{"alpha", "gamma", "remote-a"}, order(), "grouped: local section, then remote") | |||
| 383 | + | require.True(t, controller.ConsumeTerminalRead([]byte("s"))) | |||
| 384 | + | require.Equal(t, []string{"alpha", "remote-a", "gamma"}, order()) | |||
| 385 | + | } | |||
| 386 | + | ||||
| 387 | + | // TestPickerControllerRendersBellsAndRepaintsOnPublication ports main's | |||
| 388 | + | // TestPickerSnapshotCarriesAttentionBellsToTheClient and | |||
| 389 | + | // TestAttentionPulseRepublishesTheOpenPickerSource: a bell raised by a later | |||
| 390 | + | // publication reaches the rendered picker. | |||
| 391 | + | func TestPickerControllerRendersBellsAndRepaintsOnPublication(t *testing.T) { | |||
| 392 | + | controller, clock := pickerTestController(t) | |||
| 393 | + | quiet := pickerTestLocalObservation(clock.Now(), pickerTestTabbed("work", 1, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", false))) | |||
| 394 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{quiet}}) | |||
| 395 | + | bell := string(ui.AttentionGlyph) | |||
| 396 | + | require.NotContains(t, string(controller.Render(domain.Size{Cols: 100, Rows: 30})), bell) | |||
| 397 | + | ||||
| 398 | + | ringing := pickerTestLocalObservation(clock.Now(), pickerTestTabbed("work", 1, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", true))) | |||
| 399 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{ringing}}) | |||
| 400 | + | controller.invalidatePresentation() | |||
| 401 | + | frame := string(controller.Render(domain.Size{Cols: 100, Rows: 30})) | |||
| 402 | + | require.Equal(t, 2, strings.Count(frame, bell), "the header and its tab row both ring") | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | // TestPickerCatalogueResolveKill pins which rows `x` may destroy: local live | |||
| 406 | + | // and stopped sessions (by exact lifecycle and name), never a remote row, and | |||
| 407 | + | // never a lifecycle that left or was replaced. | |||
| 408 | + | func TestPickerCatalogueResolveKill(t *testing.T) { | |||
| 409 | + | now := time.Unix(1000, 0) | |||
| 410 | + | tests := []struct { | |||
| 411 | + | name string | |||
| 412 | + | daemon ports.BrokerDaemonObservation | |||
| 413 | + | label string | |||
| 414 | + | after *ports.BrokerDaemonObservation | |||
| 415 | + | wantStopped bool | |||
| 416 | + | wantErr pickerCatalogueErrorCode | |||
| 417 | + | }{ | |||
| 418 | + | {name: "live local", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), label: "alpha"}, | |||
| 419 | + | {name: "stopped local deletes history", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Down)), label: "alpha", wantStopped: true}, | |||
| 420 | + | {name: "broken local deletes history", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Broken)), label: "alpha", wantStopped: true}, | |||
| 421 | + | {name: "remote is refused", daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("alpha", 1, catalogue_Up)), label: "alpha", wantErr: pickerCatalogueUnavailable}, | |||
| 422 | + | { | |||
| 423 | + | name: "gone", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), label: "alpha", | |||
| 424 | + | after: func() *ports.BrokerDaemonObservation { o := pickerTestLocalObservation(now); return &o }(), | |||
| 425 | + | wantErr: pickerCatalogueGone, | |||
| 426 | + | }, | |||
| 427 | + | { | |||
| 428 | + | name: "replaced by the same name", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), label: "alpha", | |||
| 429 | + | after: func() *ports.BrokerDaemonObservation { | |||
| 430 | + | o := pickerTestLocalObservation(now, pickerTestSession("alpha", 2, catalogue_Up)) | |||
| 431 | + | return &o | |||
| 432 | + | }(), | |||
| 433 | + | wantErr: pickerCatalogueReplaced, | |||
| 434 | + | }, | |||
| 435 | + | } | |||
| 436 | + | for _, tt := range tests { | |||
| 437 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 438 | + | catalogue, _ := pickerTestCatalogue(t) | |||
| 439 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}})) | |||
| 440 | + | line, ok := pickerLineByLabel(catalogue.Lines(), tt.label) | |||
| 441 | + | require.True(t, ok) | |||
| 442 | + | if tt.after != nil { | |||
| 443 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{*tt.after}})) | |||
| 444 | + | } | |||
| 445 | + | target, err := catalogue.ResolveKill(line.Key) | |||
| 446 | + | if tt.wantErr != 0 { | |||
| 447 | + | require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err) | |||
| 448 | + | return | |||
| 449 | + | } | |||
| 450 | + | require.NoError(t, err) | |||
| 451 | + | require.Equal(t, BrokerOperationRoute{Epoch: 3, Destination: ports.BrokerEndpointFence{Local: true}}, target.route) | |||
| 452 | + | require.NoError(t, target.route.Validate()) | |||
| 453 | + | require.Equal(t, tt.label, target.name) | |||
| 454 | + | require.Equal(t, tt.wantStopped, target.stopped) | |||
| 455 | + | }) | |||
| 456 | + | } | |||
| 457 | + | } | |||
| 458 | + | ||||
| 459 | + | // TestPickerControllerKillKeyWakesSupervisor pins that `x` wakes the | |||
| 460 | + | // supervisor with the killed row's key, only on a row that authorises it, and | |||
| 461 | + | // types into an active search instead. | |||
| 462 | + | func TestPickerControllerKillKeyWakesSupervisor(t *testing.T) { | |||
| 463 | + | tests := []struct { | |||
| 464 | + | name string | |||
| 465 | + | daemon func(time.Time) ports.BrokerDaemonObservation | |||
| 466 | + | input string | |||
| 467 | + | wantKill bool | |||
| 468 | + | wantKey bool | |||
| 469 | + | }{ | |||
| 470 | + | {name: "local row", daemon: func(now time.Time) ports.BrokerDaemonObservation { | |||
| 471 | + | return pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)) | |||
| 472 | + | }, input: "x", wantKill: true, wantKey: true}, | |||
| 473 | + | {name: "remote row authorises no kill", daemon: func(now time.Time) ports.BrokerDaemonObservation { | |||
| 474 | + | return pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("alpha", 1, catalogue_Up)) | |||
| 475 | + | }, input: "x", wantKill: true}, | |||
| 476 | + | {name: "search types x", daemon: func(now time.Time) ports.BrokerDaemonObservation { | |||
| 477 | + | return pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)) | |||
| 478 | + | }, input: "/x"}, | |||
| 479 | + | } | |||
| 480 | + | for _, tt := range tests { | |||
| 481 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 482 | + | controller, clock := pickerTestController(t) | |||
| 483 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon(clock.Now())}}) | |||
| 484 | + | require.True(t, controller.ConsumeTerminalRead([]byte(tt.input))) | |||
| 485 | + | if tt.wantKill { | |||
| 486 | + | select { | |||
| 487 | + | case <-controller.OpsReady(): | |||
| 488 | + | default: | |||
| 489 | + | t.Fatal("a kill decision must wake the supervisor") | |||
| 490 | + | } | |||
| 491 | + | } | |||
| 492 | + | op, key := controller.TakeOp() | |||
| 493 | + | require.Equal(t, tt.wantKill, op.kill) | |||
| 494 | + | if tt.wantKey { | |||
| 495 | + | require.Equal(t, pickerRowKeyByLabel(t, controller, "alpha"), key) | |||
| 496 | + | } else { | |||
| 497 | + | require.Empty(t, key) | |||
| 498 | + | } | |||
| 499 | + | }) | |||
| 500 | + | } | |||
| 501 | + | } | |||
| 502 | + | ||||
| 503 | + | // TestPickerControllerHostFailureToastOncePerEpisode ports main's | |||
| 504 | + | // TestRemoteFailureNoticeEmittedOncePerFailureEpisode and | |||
| 505 | + | // TestRemoteFailureNoticesKeepEndpointsDistinct. | |||
| 506 | + | func TestPickerControllerHostFailureToastOncePerEpisode(t *testing.T) { | |||
| 507 | + | controller, clock := pickerTestController(t) | |||
| 508 | + | failing := func(endpoint string, seed byte, episode uint64, kind domain.RemoteFailureKind) ports.BrokerDaemonObservation { | |||
| 509 | + | o := pickerTestRemoteObservation(endpoint, seed, seed, clock.Now()) | |||
| 510 | + | o.Availability = domain.RemoteAvailabilityUnreachable | |||
| 511 | + | o.ConsecutiveFailures = 1 | |||
| 512 | + | o.FailureEpisode = episode | |||
| 513 | + | o.LastFailure = domain.RemoteFailure{Kind: kind} | |||
| 514 | + | return o | |||
| 515 | + | } | |||
| 516 | + | revision := ports.BrokerRevision(0) | |||
| 517 | + | apply := func(daemons ...ports.BrokerDaemonObservation) []ui.ActiveToast { | |||
| 518 | + | revision++ | |||
| 519 | + | controller.mu.Lock() | |||
| 520 | + | controller.notices.Clear() | |||
| 521 | + | controller.mu.Unlock() | |||
| 522 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: revision, Daemons: daemons}) | |||
| 523 | + | controller.mu.Lock() | |||
| 524 | + | defer controller.mu.Unlock() | |||
| 525 | + | return controller.notices.Active(clock.Now()) | |||
| 526 | + | } | |||
| 527 | + | ||||
| 528 | + | toasts := apply(failing("user@arch", 1, 1, domain.RemoteFailureTrust)) | |||
| 529 | + | require.Len(t, toasts, 1) | |||
| 530 | + | require.Contains(t, toasts[0].Message, "SSH host verification failed") | |||
| 531 | + | require.Contains(t, toasts[0].Message, "user@arch") | |||
| 532 | + | require.Empty(t, apply(failing("user@arch", 1, 1, domain.RemoteFailureTrust)), "the same episode toasts once") | |||
| 533 | + | require.Len(t, apply(failing("user@arch", 1, 2, domain.RemoteFailureTrust)), 1, "a new episode toasts again") | |||
| 534 | + | ||||
| 535 | + | recovered := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(time.Second)) | |||
| 536 | + | recovered.FailureEpisode = 2 | |||
| 537 | + | require.Empty(t, apply(recovered)) | |||
| 538 | + | require.Len(t, apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout)), 1, "an outage after recovery toasts again") | |||
| 539 | + | ||||
| 540 | + | toasts = apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout), failing("user@mule", 2, 1, domain.RemoteFailureAuthentication)) | |||
| 541 | + | require.Len(t, toasts, 1, "only the newly failing endpoint toasts") | |||
| 542 | + | require.Contains(t, toasts[0].Message, "user@mule") | |||
| 543 | + | } | |||
| 544 | + | ||||
| 545 | + | // TestPickerControllerRefusesFailingRemoteInstantly pins B4: a commit on a | |||
| 546 | + | // remote host the broker observed failing refuses at once with main's notice | |||
| 547 | + | // instead of dialing, while a stale-but-reachable host stays attemptable. | |||
| 548 | + | func TestPickerControllerRefusesFailingRemoteInstantly(t *testing.T) { | |||
| 549 | + | tests := []struct { | |||
| 550 | + | name string | |||
| 551 | + | availability domain.RemoteAvailability | |||
| 552 | + | wantNotice string | |||
| 553 | + | }{ | |||
| 554 | + | {name: "unreachable", availability: domain.RemoteAvailabilityUnreachable, wantNotice: "Remote session unavailable: remote-a@user@arch — host unreachable"}, | |||
| 555 | + | {name: "authentication", availability: domain.RemoteAvailabilityAuthFailed, wantNotice: "Remote session unavailable: remote-a@user@arch — authentication failed"}, | |||
| 556 | + | {name: "reachable", availability: domain.RemoteAvailabilityReachable}, | |||
| 557 | + | } | |||
| 558 | + | for _, tt := range tests { | |||
| 559 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 560 | + | controller, clock := pickerTestController(t) | |||
| 561 | + | o := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(-time.Hour), pickerTestSession("remote-a", 3, catalogue_Up)) | |||
| 562 | + | o.Availability = tt.availability | |||
| 563 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{o}}) | |||
| 564 | + | controller.mu.Lock() | |||
| 565 | + | controller.notices.Clear() | |||
| 566 | + | controller.mu.Unlock() | |||
| 567 | + | ||||
| 568 | + | _, _, err := controller.ResolveKeyTarget(pickerRowKeyByLabel(t, controller, "remote-a"), pickerTestBase()) | |||
| 569 | + | if tt.wantNotice == "" { | |||
| 570 | + | require.NoError(t, err) | |||
| 571 | + | return | |||
| 572 | + | } | |||
| 573 | + | require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueUnavailable)) | |||
| 574 | + | controller.mu.Lock() | |||
| 575 | + | active := controller.notices.Active(clock.Now()) | |||
| 576 | + | controller.mu.Unlock() | |||
| 577 | + | require.Len(t, active, 1) | |||
| 578 | + | require.Equal(t, tt.wantNotice, active[0].Message) | |||
| 579 | + | }) | |||
| 580 | + | } | |||
| 581 | + | } | |||
| 582 | + | ||||
| 583 | + | // TestSessionAttachmentHelloCarriesTab pins that a committed tab reaches the | |||
| 584 | + | // daemon: a stable tab is preferred, a stopped remote tab is restored through | |||
| 585 | + | // its exact selector, and a session-level commit carries neither. | |||
| 586 | + | func TestSessionAttachmentHelloCarriesTab(t *testing.T) { | |||
| 587 | + | stopped := protocol.SessionAttachTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha", TabIndex: 1, TabRawName: "edit", TabExpectedCount: 2, Stopped: true} | |||
| 588 | + | tests := []struct { | |||
| 589 | + | name string | |||
| 590 | + | local bool | |||
| 591 | + | tab attachmentTab | |||
| 592 | + | wantPreferred domain.TabStableID | |||
| 593 | + | wantTarget *protocol.SessionAttachTarget | |||
| 594 | + | }{ | |||
| 595 | + | {name: "session level", local: true}, | |||
| 596 | + | {name: "local tab", local: true, tab: attachmentTab{preferred: "t_two"}, wantPreferred: "t_two"}, | |||
| 597 | + | {name: "remote live tab", tab: attachmentTab{preferred: "t_two"}, wantPreferred: "t_two"}, | |||
| 598 | + | {name: "remote stopped tab", tab: attachmentTab{stopped: &stopped}, wantTarget: &stopped}, | |||
| 599 | + | } | |||
| 600 | + | for _, tt := range tests { | |||
| 601 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 602 | + | request := sessionTestRequest(tt.local) | |||
| 603 | + | provenance := SessionEnvironmentRemote | |||
| 604 | + | if tt.local { | |||
| 605 | + | provenance = SessionEnvironmentLocalPicker | |||
| 606 | + | } | |||
| 607 | + | worker, err := newSessionAttachmentWorker(sessionAttachmentConfig{Request: request, Geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, SessionEnvironment: SessionEnvironment{Provenance: provenance}, Tab: tt.tab}) | |||
| 608 | + | require.NoError(t, err) | |||
| 609 | + | hello := worker.hello(newSessionTestStream()) | |||
| 610 | + | require.Equal(t, tt.wantPreferred, hello.PreferredTabID) | |||
| 611 | + | require.Equal(t, tt.wantTarget, hello.SessionTarget) | |||
| 612 | + | require.NoError(t, protocol.ValidateHello(hello)) | |||
| 613 | + | }) | |||
| 614 | + | } | |||
| 615 | + | } | |||
M internal/usecase/client/supervisor.go +16 -1
| @@ -409,7 +409,9 @@ type Supervisor struct { | |||
| 409 | 409 | // pendingSwap is the request the picker overlay committed to another | |
| 410 | 410 | // target while an attachment was live. It is only touched from the run | |
| 411 | 411 | // goroutine and consumed by runResolvedAttachment. | |
| 412 | - | pendingSwap *ports.BrokerOpenStreamRequest | |
| 412 | + | pendingSwap *pickerAttachmentTarget | |
| 413 | + | // kills runs the picker's `x` operations off the run goroutine. | |
| 414 | + | kills pickerKills | |
| 413 | 415 | } | |
| 414 | 416 | ||
| 415 | 417 | // NewSupervisor validates the required dependencies and returns a supervisor | |
| @@ -508,6 +510,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||
| 508 | 510 | sub ports.BrokerSubscription | |
| 509 | 511 | ) | |
| 510 | 512 | retire := func() { | |
| 513 | + | s.retirePickerKill() | |
| 511 | 514 | s.preview.close(s.cfg.Picker) | |
| 512 | 515 | s.readySub = nil | |
| 513 | 516 | if sub != nil { | |
| @@ -825,8 +828,15 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim | |||
| 825 | 828 | if s.preview.publish(s.cfg.Picker) { | |
| 826 | 829 | s.renderCurrent() | |
| 827 | 830 | } | |
| 831 | + | case outcome := <-s.kills.results(): | |
| 832 | + | s.finishPickerKill(service, outcome) | |
| 828 | 833 | case <-ops: | |
| 829 | 834 | op, key := s.cfg.Picker.TakeOp() | |
| 835 | + | if op.kill && !op.commit && !op.close { | |
| 836 | + | s.startPickerKill(service, key) | |
| 837 | + | s.renderCurrent() | |
| 838 | + | continue | |
| 839 | + | } | |
| 830 | 840 | if !op.close && !op.commit { | |
| 831 | 841 | s.refreshPreview(service) | |
| 832 | 842 | s.renderCurrent() | |
| @@ -892,6 +902,11 @@ func (s *Supervisor) takePickerClose() bool { | |||
| 892 | 902 | s.offerPickerNotice("picker-exit-hint", "no session attached: press Ctrl+C to quit") | |
| 893 | 903 | s.renderCurrent() | |
| 894 | 904 | } | |
| 905 | + | if op.kill && !op.commit && !op.close && key != "" { | |
| 906 | + | // A kill needs the broker's control stream; it is refused, not queued. | |
| 907 | + | s.offerPickerNotice("picker-kill", "couldn't kill: broker unavailable") | |
| 908 | + | s.renderCurrent() | |
| 909 | + | } | |
| 895 | 910 | return op.close && op.exit | |
| 896 | 911 | } | |
| 897 | 912 | ||
| @@ -409,7 +409,9 @@ type Supervisor struct { | |||||
| 409 | // pendingSwap is the request the picker overlay committed to another | 409 | // pendingSwap is the request the picker overlay committed to another | ||
| 410 | // target while an attachment was live. It is only touched from the run | 410 | // target while an attachment was live. It is only touched from the run | ||
| 411 | // goroutine and consumed by runResolvedAttachment. | 411 | // goroutine and consumed by runResolvedAttachment. | ||
| 412 | - | pendingSwap *ports.BrokerOpenStreamRequest | 412 | + | pendingSwap *pickerAttachmentTarget |
| 413 | + | // kills runs the picker's `x` operations off the run goroutine. | |||
| 414 | + | kills pickerKills | |||
| 413 | } | 415 | } | ||
| 414 | 416 | ||||
| 415 | // NewSupervisor validates the required dependencies and returns a supervisor | 417 | // NewSupervisor validates the required dependencies and returns a supervisor | ||
| @@ -508,6 +510,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||||
| 508 | sub ports.BrokerSubscription | 510 | sub ports.BrokerSubscription | ||
| 509 | ) | 511 | ) | ||
| 510 | retire := func() { | 512 | retire := func() { | ||
| 513 | + | s.retirePickerKill() | |||
| 511 | s.preview.close(s.cfg.Picker) | 514 | s.preview.close(s.cfg.Picker) | ||
| 512 | s.readySub = nil | 515 | s.readySub = nil | ||
| 513 | if sub != nil { | 516 | if sub != nil { | ||
| @@ -825,8 +828,15 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim | |||||
| 825 | if s.preview.publish(s.cfg.Picker) { | 828 | if s.preview.publish(s.cfg.Picker) { | ||
| 826 | s.renderCurrent() | 829 | s.renderCurrent() | ||
| 827 | } | 830 | } | ||
| 831 | + | case outcome := <-s.kills.results(): | |||
| 832 | + | s.finishPickerKill(service, outcome) | |||
| 828 | case <-ops: | 833 | case <-ops: | ||
| 829 | op, key := s.cfg.Picker.TakeOp() | 834 | op, key := s.cfg.Picker.TakeOp() | ||
| 835 | + | if op.kill && !op.commit && !op.close { | |||
| 836 | + | s.startPickerKill(service, key) | |||
| 837 | + | s.renderCurrent() | |||
| 838 | + | continue | |||
| 839 | + | } | |||
| 830 | if !op.close && !op.commit { | 840 | if !op.close && !op.commit { | ||
| 831 | s.refreshPreview(service) | 841 | s.refreshPreview(service) | ||
| 832 | s.renderCurrent() | 842 | s.renderCurrent() | ||
| @@ -892,6 +902,11 @@ func (s *Supervisor) takePickerClose() bool { | |||||
| 892 | s.offerPickerNotice("picker-exit-hint", "no session attached: press Ctrl+C to quit") | 902 | s.offerPickerNotice("picker-exit-hint", "no session attached: press Ctrl+C to quit") | ||
| 893 | s.renderCurrent() | 903 | s.renderCurrent() | ||
| 894 | } | 904 | } | ||
| 905 | + | if op.kill && !op.commit && !op.close && key != "" { | |||
| 906 | + | // A kill needs the broker's control stream; it is refused, not queued. | |||
| 907 | + | s.offerPickerNotice("picker-kill", "couldn't kill: broker unavailable") | |||
| 908 | + | s.renderCurrent() | |||
| 909 | + | } | |||
| 895 | return op.close && op.exit | 910 | return op.close && op.exit | ||
| 896 | } | 911 | } | ||
| 897 | 912 | ||||
M internal/usecase/client/supervisor_attach.go +38 -15
| @@ -272,22 +272,39 @@ func classifyInitialNavigationFailure(err error) error { | |||
| 272 | 272 | return pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "initial navigation could not be resolved"} | |
| 273 | 273 | } | |
| 274 | 274 | ||
| 275 | - | func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerService, key string) (ports.BrokerOpenStreamRequest, error) { | |
| 275 | + | func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerService, key string) (ports.BrokerOpenStreamRequest, attachmentTab, error) { | |
| 276 | 276 | stream, err := service.NextStreamID() | |
| 277 | 277 | if err != nil { | |
| 278 | - | return ports.BrokerOpenStreamRequest{}, err | |
| 278 | + | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err | |
| 279 | 279 | } | |
| 280 | 280 | base := pickerResolveBase{ | |
| 281 | 281 | Connection: service.ConnectionID(), | |
| 282 | 282 | Stream: stream, | |
| 283 | 283 | } | |
| 284 | - | return s.cfg.Picker.ResolveKey(key, base) | |
| 284 | + | if resolver, ok := s.cfg.Picker.(pickerTargetResolver); ok { | |
| 285 | + | return resolver.ResolveKeyTarget(key, base) | |
| 286 | + | } | |
| 287 | + | request, err := s.cfg.Picker.ResolveKey(key, base) | |
| 288 | + | return request, attachmentTab{}, err | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // pickerTargetResolver is the optional tab-aware resolution of the real | |
| 292 | + | // picker; a scripted picker may resolve sessions only. | |
| 293 | + | type pickerTargetResolver interface { | |
| 294 | + | ResolveKeyTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | // pickerAttachmentTarget is one resolved attachment: the exact broker stream | |
| 298 | + | // request and the tab its Hello selects. | |
| 299 | + | type pickerAttachmentTarget struct { | |
| 300 | + | request ports.BrokerOpenStreamRequest | |
| 301 | + | tab attachmentTab | |
| 285 | 302 | } | |
| 286 | 303 | ||
| 287 | 304 | // newAttachmentWorker builds the real typed-session worker for one admitted | |
| 288 | 305 | // request. The worker receives no endpoint, route, or raw-mode authority: it | |
| 289 | 306 | // only drives the typed session protocol on the stream the supervisor opened. | |
| 290 | - | func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, beforeAttached func() error, sessionEnv SessionEnvironment) (AttachmentWorker, error) { | |
| 307 | + | func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, tab attachmentTab, beforeAttached func() error, sessionEnv SessionEnvironment) (AttachmentWorker, error) { | |
| 291 | 308 | geometry, ok := s.attachments.latestGeometry() | |
| 292 | 309 | if !ok { | |
| 293 | 310 | var err error | |
| @@ -308,6 +325,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, | |||
| 308 | 325 | BeforeAttached: beforeAttached, | |
| 309 | 326 | Clock: s.cfg.Clock, | |
| 310 | 327 | Theme: &s.theme, | |
| 328 | + | Tab: tab, | |
| 311 | 329 | }) | |
| 312 | 330 | if err != nil { | |
| 313 | 331 | return nil, err | |
| @@ -388,24 +406,24 @@ func (s *Supervisor) runInitialNavigation(ctx context.Context, input *terminalIn | |||
| 388 | 406 | s.reportAttachmentFailure(err) | |
| 389 | 407 | return false, nil | |
| 390 | 408 | } | |
| 391 | - | return s.runResolvedAttachment(ctx, input, service, request, SessionEnvironmentLocalCLI) | |
| 409 | + | return s.runResolvedAttachment(ctx, input, service, pickerAttachmentTarget{request: request}, SessionEnvironmentLocalCLI) | |
| 392 | 410 | } | |
| 393 | 411 | ||
| 394 | 412 | func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, key string) (bool, error) { | |
| 395 | 413 | if s.cfg.Picker == nil { | |
| 396 | 414 | return false, nil | |
| 397 | 415 | } | |
| 398 | - | request, err := s.resolveCommittedStreamRequest(service, key) | |
| 416 | + | request, tab, err := s.resolveCommittedStreamRequest(service, key) | |
| 399 | 417 | if err != nil { | |
| 400 | 418 | // A refused selection is a typed presentation notice; it never opens a | |
| 401 | 419 | // stream or becomes a session write. | |
| 402 | 420 | s.reportAttachmentFailure(err) | |
| 403 | 421 | return false, nil | |
| 404 | 422 | } | |
| 405 | - | return s.runResolvedAttachment(ctx, input, service, request, SessionEnvironmentLocalPicker) | |
| 423 | + | return s.runResolvedAttachment(ctx, input, service, pickerAttachmentTarget{request: request, tab: tab}, SessionEnvironmentLocalPicker) | |
| 406 | 424 | } | |
| 407 | 425 | ||
| 408 | - | func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, request ports.BrokerOpenStreamRequest, localProvenance SessionEnvironmentProvenance) (bool, error) { | |
| 426 | + | func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, target pickerAttachmentTarget, localProvenance SessionEnvironmentProvenance) (bool, error) { | |
| 409 | 427 | picker := s.cfg.Picker | |
| 410 | 428 | if picker == nil { | |
| 411 | 429 | return false, nil | |
| @@ -416,25 +434,27 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI | |||
| 416 | 434 | // Whoever owned the terminal since the last picker frame, the next | |
| 417 | 435 | // frame must draw the whole box again. | |
| 418 | 436 | s.invalidatePickerPresentation() | |
| 437 | + | // Back on the plain picker there is no attachment to start on. | |
| 438 | + | s.setPickerCurrent(pickerCurrent{}) | |
| 419 | 439 | picker.SetOwnsInput(true) | |
| 420 | 440 | input.acquirePicker() | |
| 421 | 441 | }() | |
| 422 | 442 | for { | |
| 423 | - | terminated, termErr, swap := s.attachOnce(ctx, input, service, request, localProvenance) | |
| 443 | + | terminated, termErr, swap := s.attachOnce(ctx, input, service, target, localProvenance) | |
| 424 | 444 | if terminated || swap == nil { | |
| 425 | 445 | return terminated, termErr | |
| 426 | 446 | } | |
| 427 | 447 | // A commit from the picker overlay to another target: the previous | |
| 428 | 448 | // attachment detached cleanly, and the swap goes through Connecting | |
| 429 | 449 | // exactly like any committed selection. | |
| 430 | - | request, localProvenance = *swap, SessionEnvironmentLocalPicker | |
| 450 | + | target, localProvenance = *swap, SessionEnvironmentLocalPicker | |
| 431 | 451 | } | |
| 432 | 452 | } | |
| 433 | 453 | ||
| 434 | 454 | // attachOnce runs one resolved attachment end to end. A non-nil swap names the | |
| 435 | - | // next request the picker overlay committed while this attachment was live. | |
| 436 | - | func (s *Supervisor) attachOnce(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, request ports.BrokerOpenStreamRequest, localProvenance SessionEnvironmentProvenance) (bool, error, *ports.BrokerOpenStreamRequest) { | |
| 437 | - | terminated, termErr := s.attachResolved(ctx, input, service, request, localProvenance) | |
| 455 | + | // next target the picker overlay committed while this attachment was live. | |
| 456 | + | func (s *Supervisor) attachOnce(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, target pickerAttachmentTarget, localProvenance SessionEnvironmentProvenance) (bool, error, *pickerAttachmentTarget) { | |
| 457 | + | terminated, termErr := s.attachResolved(ctx, input, service, target, localProvenance) | |
| 438 | 458 | swap := s.pendingSwap | |
| 439 | 459 | s.pendingSwap = nil | |
| 440 | 460 | if terminated { | |
| @@ -443,7 +463,8 @@ func (s *Supervisor) attachOnce(ctx context.Context, input *terminalInputLifetim | |||
| 443 | 463 | return false, nil, swap | |
| 444 | 464 | } | |
| 445 | 465 | ||
| 446 | - | func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, request ports.BrokerOpenStreamRequest, localProvenance SessionEnvironmentProvenance) (bool, error) { | |
| 466 | + | func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, target pickerAttachmentTarget, localProvenance SessionEnvironmentProvenance) (bool, error) { | |
| 467 | + | request := target.request | |
| 447 | 468 | s.transition(supervisorEvent{kind: supervisorAttachBegin}) | |
| 448 | 469 | deadline := startAttachmentDeadline(ctx, s.cfg.Clock) | |
| 449 | 470 | defer deadline.finish() | |
| @@ -479,7 +500,7 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif | |||
| 479 | 500 | return false, nil | |
| 480 | 501 | } | |
| 481 | 502 | ||
| 482 | - | worker, err := s.newAttachmentWorker(request, func() error { | |
| 503 | + | worker, err := s.newAttachmentWorker(request, target.tab, func() error { | |
| 483 | 504 | deadline.disarm() | |
| 484 | 505 | if deadline.expired.Load() { | |
| 485 | 506 | return errAttachmentDeadline | |
| @@ -542,6 +563,8 @@ settlement: | |||
| 542 | 563 | overlay.takeOp() | |
| 543 | 564 | case <-overlay.invalidation(): | |
| 544 | 565 | overlay.resize() | |
| 566 | + | case outcome := <-s.kills.results(): | |
| 567 | + | s.finishPickerKill(service, outcome) | |
| 545 | 568 | case <-service.Done(): | |
| 546 | 569 | brokerLost = true | |
| 547 | 570 | // The broker connection is gone; the supervisor cancels the run and | |
| @@ -272,22 +272,39 @@ func classifyInitialNavigationFailure(err error) error { | |||||
| 272 | return pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "initial navigation could not be resolved"} | 272 | return pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "initial navigation could not be resolved"} | ||
| 273 | } | 273 | } | ||
| 274 | 274 | ||||
| 275 | - | func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerService, key string) (ports.BrokerOpenStreamRequest, error) { | 275 | + | func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerService, key string) (ports.BrokerOpenStreamRequest, attachmentTab, error) { |
| 276 | stream, err := service.NextStreamID() | 276 | stream, err := service.NextStreamID() | ||
| 277 | if err != nil { | 277 | if err != nil { | ||
| 278 | - | return ports.BrokerOpenStreamRequest{}, err | 278 | + | return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err |
| 279 | } | 279 | } | ||
| 280 | base := pickerResolveBase{ | 280 | base := pickerResolveBase{ | ||
| 281 | Connection: service.ConnectionID(), | 281 | Connection: service.ConnectionID(), | ||
| 282 | Stream: stream, | 282 | Stream: stream, | ||
| 283 | } | 283 | } | ||
| 284 | - | return s.cfg.Picker.ResolveKey(key, base) | 284 | + | if resolver, ok := s.cfg.Picker.(pickerTargetResolver); ok { |
| 285 | + | return resolver.ResolveKeyTarget(key, base) | |||
| 286 | + | } | |||
| 287 | + | request, err := s.cfg.Picker.ResolveKey(key, base) | |||
| 288 | + | return request, attachmentTab{}, err | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | // pickerTargetResolver is the optional tab-aware resolution of the real | |||
| 292 | + | // picker; a scripted picker may resolve sessions only. | |||
| 293 | + | type pickerTargetResolver interface { | |||
| 294 | + | ResolveKeyTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | // pickerAttachmentTarget is one resolved attachment: the exact broker stream | |||
| 298 | + | // request and the tab its Hello selects. | |||
| 299 | + | type pickerAttachmentTarget struct { | |||
| 300 | + | request ports.BrokerOpenStreamRequest | |||
| 301 | + | tab attachmentTab | |||
| 285 | } | 302 | } | ||
| 286 | 303 | ||||
| 287 | // newAttachmentWorker builds the real typed-session worker for one admitted | 304 | // newAttachmentWorker builds the real typed-session worker for one admitted | ||
| 288 | // request. The worker receives no endpoint, route, or raw-mode authority: it | 305 | // request. The worker receives no endpoint, route, or raw-mode authority: it | ||
| 289 | // only drives the typed session protocol on the stream the supervisor opened. | 306 | // only drives the typed session protocol on the stream the supervisor opened. | ||
| 290 | - | func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, beforeAttached func() error, sessionEnv SessionEnvironment) (AttachmentWorker, error) { | 307 | + | func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, tab attachmentTab, beforeAttached func() error, sessionEnv SessionEnvironment) (AttachmentWorker, error) { |
| 291 | geometry, ok := s.attachments.latestGeometry() | 308 | geometry, ok := s.attachments.latestGeometry() | ||
| 292 | if !ok { | 309 | if !ok { | ||
| 293 | var err error | 310 | var err error | ||
| @@ -308,6 +325,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, | |||||
| 308 | BeforeAttached: beforeAttached, | 325 | BeforeAttached: beforeAttached, | ||
| 309 | Clock: s.cfg.Clock, | 326 | Clock: s.cfg.Clock, | ||
| 310 | Theme: &s.theme, | 327 | Theme: &s.theme, | ||
| 328 | + | Tab: tab, | |||
| 311 | }) | 329 | }) | ||
| 312 | if err != nil { | 330 | if err != nil { | ||
| 313 | return nil, err | 331 | return nil, err | ||
| @@ -388,24 +406,24 @@ func (s *Supervisor) runInitialNavigation(ctx context.Context, input *terminalIn | |||||
| 388 | s.reportAttachmentFailure(err) | 406 | s.reportAttachmentFailure(err) | ||
| 389 | return false, nil | 407 | return false, nil | ||
| 390 | } | 408 | } | ||
| 391 | - | return s.runResolvedAttachment(ctx, input, service, request, SessionEnvironmentLocalCLI) | 409 | + | return s.runResolvedAttachment(ctx, input, service, pickerAttachmentTarget{request: request}, SessionEnvironmentLocalCLI) |
| 392 | } | 410 | } | ||
| 393 | 411 | ||||
| 394 | func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, key string) (bool, error) { | 412 | func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, key string) (bool, error) { | ||
| 395 | if s.cfg.Picker == nil { | 413 | if s.cfg.Picker == nil { | ||
| 396 | return false, nil | 414 | return false, nil | ||
| 397 | } | 415 | } | ||
| 398 | - | request, err := s.resolveCommittedStreamRequest(service, key) | 416 | + | request, tab, err := s.resolveCommittedStreamRequest(service, key) |
| 399 | if err != nil { | 417 | if err != nil { | ||
| 400 | // A refused selection is a typed presentation notice; it never opens a | 418 | // A refused selection is a typed presentation notice; it never opens a | ||
| 401 | // stream or becomes a session write. | 419 | // stream or becomes a session write. | ||
| 402 | s.reportAttachmentFailure(err) | 420 | s.reportAttachmentFailure(err) | ||
| 403 | return false, nil | 421 | return false, nil | ||
| 404 | } | 422 | } | ||
| 405 | - | return s.runResolvedAttachment(ctx, input, service, request, SessionEnvironmentLocalPicker) | 423 | + | return s.runResolvedAttachment(ctx, input, service, pickerAttachmentTarget{request: request, tab: tab}, SessionEnvironmentLocalPicker) |
| 406 | } | 424 | } | ||
| 407 | 425 | ||||
| 408 | - | func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, request ports.BrokerOpenStreamRequest, localProvenance SessionEnvironmentProvenance) (bool, error) { | 426 | + | func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, target pickerAttachmentTarget, localProvenance SessionEnvironmentProvenance) (bool, error) { |
| 409 | picker := s.cfg.Picker | 427 | picker := s.cfg.Picker | ||
| 410 | if picker == nil { | 428 | if picker == nil { | ||
| 411 | return false, nil | 429 | return false, nil | ||
| @@ -416,25 +434,27 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI | |||||
| 416 | // Whoever owned the terminal since the last picker frame, the next | 434 | // Whoever owned the terminal since the last picker frame, the next | ||
| 417 | // frame must draw the whole box again. | 435 | // frame must draw the whole box again. | ||
| 418 | s.invalidatePickerPresentation() | 436 | s.invalidatePickerPresentation() | ||
| 437 | + | // Back on the plain picker there is no attachment to start on. | |||
| 438 | + | s.setPickerCurrent(pickerCurrent{}) | |||
| 419 | picker.SetOwnsInput(true) | 439 | picker.SetOwnsInput(true) | ||
| 420 | input.acquirePicker() | 440 | input.acquirePicker() | ||
| 421 | }() | 441 | }() | ||
| 422 | for { | 442 | for { | ||
| 423 | - | terminated, termErr, swap := s.attachOnce(ctx, input, service, request, localProvenance) | 443 | + | terminated, termErr, swap := s.attachOnce(ctx, input, service, target, localProvenance) |
| 424 | if terminated || swap == nil { | 444 | if terminated || swap == nil { | ||
| 425 | return terminated, termErr | 445 | return terminated, termErr | ||
| 426 | } | 446 | } | ||
| 427 | // A commit from the picker overlay to another target: the previous | 447 | // A commit from the picker overlay to another target: the previous | ||
| 428 | // attachment detached cleanly, and the swap goes through Connecting | 448 | // attachment detached cleanly, and the swap goes through Connecting | ||
| 429 | // exactly like any committed selection. | 449 | // exactly like any committed selection. | ||
| 430 | - | request, localProvenance = *swap, SessionEnvironmentLocalPicker | 450 | + | target, localProvenance = *swap, SessionEnvironmentLocalPicker |
| 431 | } | 451 | } | ||
| 432 | } | 452 | } | ||
| 433 | 453 | ||||
| 434 | // attachOnce runs one resolved attachment end to end. A non-nil swap names the | 454 | // attachOnce runs one resolved attachment end to end. A non-nil swap names the | ||
| 435 | - | // next request the picker overlay committed while this attachment was live. | 455 | + | // next target the picker overlay committed while this attachment was live. |
| 436 | - | func (s *Supervisor) attachOnce(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, request ports.BrokerOpenStreamRequest, localProvenance SessionEnvironmentProvenance) (bool, error, *ports.BrokerOpenStreamRequest) { | 456 | + | func (s *Supervisor) attachOnce(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, target pickerAttachmentTarget, localProvenance SessionEnvironmentProvenance) (bool, error, *pickerAttachmentTarget) { |
| 437 | - | terminated, termErr := s.attachResolved(ctx, input, service, request, localProvenance) | 457 | + | terminated, termErr := s.attachResolved(ctx, input, service, target, localProvenance) |
| 438 | swap := s.pendingSwap | 458 | swap := s.pendingSwap | ||
| 439 | s.pendingSwap = nil | 459 | s.pendingSwap = nil | ||
| 440 | if terminated { | 460 | if terminated { | ||
| @@ -443,7 +463,8 @@ func (s *Supervisor) attachOnce(ctx context.Context, input *terminalInputLifetim | |||||
| 443 | return false, nil, swap | 463 | return false, nil, swap | ||
| 444 | } | 464 | } | ||
| 445 | 465 | ||||
| 446 | - | func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, request ports.BrokerOpenStreamRequest, localProvenance SessionEnvironmentProvenance) (bool, error) { | 466 | + | func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, target pickerAttachmentTarget, localProvenance SessionEnvironmentProvenance) (bool, error) { |
| 467 | + | request := target.request | |||
| 447 | s.transition(supervisorEvent{kind: supervisorAttachBegin}) | 468 | s.transition(supervisorEvent{kind: supervisorAttachBegin}) | ||
| 448 | deadline := startAttachmentDeadline(ctx, s.cfg.Clock) | 469 | deadline := startAttachmentDeadline(ctx, s.cfg.Clock) | ||
| 449 | defer deadline.finish() | 470 | defer deadline.finish() | ||
| @@ -479,7 +500,7 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif | |||||
| 479 | return false, nil | 500 | return false, nil | ||
| 480 | } | 501 | } | ||
| 481 | 502 | ||||
| 482 | - | worker, err := s.newAttachmentWorker(request, func() error { | 503 | + | worker, err := s.newAttachmentWorker(request, target.tab, func() error { |
| 483 | deadline.disarm() | 504 | deadline.disarm() | ||
| 484 | if deadline.expired.Load() { | 505 | if deadline.expired.Load() { | ||
| 485 | return errAttachmentDeadline | 506 | return errAttachmentDeadline | ||
| @@ -542,6 +563,8 @@ settlement: | |||||
| 542 | overlay.takeOp() | 563 | overlay.takeOp() | ||
| 543 | case <-overlay.invalidation(): | 564 | case <-overlay.invalidation(): | ||
| 544 | overlay.resize() | 565 | overlay.resize() | ||
| 566 | + | case outcome := <-s.kills.results(): | |||
| 567 | + | s.finishPickerKill(service, outcome) | |||
| 545 | case <-service.Done(): | 568 | case <-service.Done(): | ||
| 546 | brokerLost = true | 569 | brokerLost = true | ||
| 547 | // The broker connection is gone; the supervisor cancels the run and | 570 | // The broker connection is gone; the supervisor cancels the run and | ||
M internal/usecase/client/supervisor_overlay.go +31 -3
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package client | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 4 | 5 | "github.com/bnema/vev/internal/ports" | |
| 5 | 6 | "github.com/bnema/vev/internal/protocol" | |
| 6 | 7 | ) | |
| @@ -78,6 +79,8 @@ func (o *attachmentPickerOverlay) enter() { | |||
| 78 | 79 | return | |
| 79 | 80 | } | |
| 80 | 81 | picker.ApplySnapshot(o.service.Snapshot()) | |
| 82 | + | // A fresh overlay starts on the attached session's tab. | |
| 83 | + | s.setPickerCurrent(o.current()) | |
| 81 | 84 | // A decision recorded before the overlay existed never applies to it. | |
| 82 | 85 | picker.TakeOp() | |
| 83 | 86 | picker.SetOwnsInput(true) | |
| @@ -143,27 +146,52 @@ func (o *attachmentPickerOverlay) takeOp() { | |||
| 143 | 146 | // attachment to return to, so nothing here ends the process. | |
| 144 | 147 | o.exit() | |
| 145 | 148 | case op.commit && key != "": | |
| 146 | - | request, err := s.resolveCommittedStreamRequest(o.service, key) | |
| 149 | + | request, tab, err := s.resolveCommittedStreamRequest(o.service, key) | |
| 147 | 150 | if err != nil { | |
| 148 | 151 | // The picker already shows the bounded refusal; the attachment | |
| 149 | 152 | // stays live under the overlay. | |
| 150 | 153 | s.renderCurrent() | |
| 151 | 154 | return | |
| 152 | 155 | } | |
| 153 | - | if o.sameTarget(request) { | |
| 156 | + | if o.sameTarget(request) && tab.stopped == nil { | |
| 157 | + | // Another tab of the attached session switches in place; the | |
| 158 | + | // attachment is never reconnected for it. | |
| 159 | + | _, current, _ := s.attachments.committedView() | |
| 160 | + | if tab.preferred != "" && tab.preferred != current { | |
| 161 | + | s.attachments.requestTabSelection(o.run.token, tab.preferred) | |
| 162 | + | } | |
| 154 | 163 | o.exit() | |
| 155 | 164 | return | |
| 156 | 165 | } | |
| 157 | - | s.pendingSwap = &request | |
| 166 | + | s.pendingSwap = &pickerAttachmentTarget{request: request, tab: tab} | |
| 158 | 167 | o.swapping = true | |
| 159 | 168 | s.preview.close(o.picker()) | |
| 160 | 169 | s.attachments.requestDetach(o.run.token) | |
| 170 | + | case op.kill && key != "": | |
| 171 | + | s.startPickerKill(o.service, key) | |
| 172 | + | s.renderCurrent() | |
| 161 | 173 | default: | |
| 162 | 174 | s.refreshPreview(o.service) | |
| 163 | 175 | s.renderCurrent() | |
| 164 | 176 | } | |
| 165 | 177 | } | |
| 166 | 178 | ||
| 179 | + | // current names the attachment this overlay is presented over, so the picker | |
| 180 | + | // opens on its session and tab. | |
| 181 | + | func (o *attachmentPickerOverlay) current() pickerCurrent { | |
| 182 | + | target, tab, known := o.sup.attachments.committedView() | |
| 183 | + | if !known { | |
| 184 | + | if o.request.Admission != ports.BrokerAdmissionExact { | |
| 185 | + | return pickerCurrent{} | |
| 186 | + | } | |
| 187 | + | target = o.request.Target | |
| 188 | + | } | |
| 189 | + | if target.LifecycleID == (domain.SessionLifecycleID{}) { | |
| 190 | + | return pickerCurrent{} | |
| 191 | + | } | |
| 192 | + | return pickerCurrent{known: true, local: o.request.Local, endpoint: o.request.Endpoint, lifecycle: target.LifecycleID, tab: tab} | |
| 193 | + | } | |
| 194 | + | ||
| 167 | 195 | // sameTarget reports whether request names the session this attachment is | |
| 168 | 196 | // already showing, in which case committing it is a close. | |
| 169 | 197 | func (o *attachmentPickerOverlay) sameTarget(request ports.BrokerOpenStreamRequest) bool { | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package client | 1 | package client | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 4 | "github.com/bnema/vev/internal/ports" | 5 | "github.com/bnema/vev/internal/ports" | ||
| 5 | "github.com/bnema/vev/internal/protocol" | 6 | "github.com/bnema/vev/internal/protocol" | ||
| 6 | ) | 7 | ) | ||
| @@ -78,6 +79,8 @@ func (o *attachmentPickerOverlay) enter() { | |||||
| 78 | return | 79 | return | ||
| 79 | } | 80 | } | ||
| 80 | picker.ApplySnapshot(o.service.Snapshot()) | 81 | picker.ApplySnapshot(o.service.Snapshot()) | ||
| 82 | + | // A fresh overlay starts on the attached session's tab. | |||
| 83 | + | s.setPickerCurrent(o.current()) | |||
| 81 | // A decision recorded before the overlay existed never applies to it. | 84 | // A decision recorded before the overlay existed never applies to it. | ||
| 82 | picker.TakeOp() | 85 | picker.TakeOp() | ||
| 83 | picker.SetOwnsInput(true) | 86 | picker.SetOwnsInput(true) | ||
| @@ -143,27 +146,52 @@ func (o *attachmentPickerOverlay) takeOp() { | |||||
| 143 | // attachment to return to, so nothing here ends the process. | 146 | // attachment to return to, so nothing here ends the process. | ||
| 144 | o.exit() | 147 | o.exit() | ||
| 145 | case op.commit && key != "": | 148 | case op.commit && key != "": | ||
| 146 | - | request, err := s.resolveCommittedStreamRequest(o.service, key) | 149 | + | request, tab, err := s.resolveCommittedStreamRequest(o.service, key) |
| 147 | if err != nil { | 150 | if err != nil { | ||
| 148 | // The picker already shows the bounded refusal; the attachment | 151 | // The picker already shows the bounded refusal; the attachment | ||
| 149 | // stays live under the overlay. | 152 | // stays live under the overlay. | ||
| 150 | s.renderCurrent() | 153 | s.renderCurrent() | ||
| 151 | return | 154 | return | ||
| 152 | } | 155 | } | ||
| 153 | - | if o.sameTarget(request) { | 156 | + | if o.sameTarget(request) && tab.stopped == nil { |
| 157 | + | // Another tab of the attached session switches in place; the | |||
| 158 | + | // attachment is never reconnected for it. | |||
| 159 | + | _, current, _ := s.attachments.committedView() | |||
| 160 | + | if tab.preferred != "" && tab.preferred != current { | |||
| 161 | + | s.attachments.requestTabSelection(o.run.token, tab.preferred) | |||
| 162 | + | } | |||
| 154 | o.exit() | 163 | o.exit() | ||
| 155 | return | 164 | return | ||
| 156 | } | 165 | } | ||
| 157 | - | s.pendingSwap = &request | 166 | + | s.pendingSwap = &pickerAttachmentTarget{request: request, tab: tab} |
| 158 | o.swapping = true | 167 | o.swapping = true | ||
| 159 | s.preview.close(o.picker()) | 168 | s.preview.close(o.picker()) | ||
| 160 | s.attachments.requestDetach(o.run.token) | 169 | s.attachments.requestDetach(o.run.token) | ||
| 170 | + | case op.kill && key != "": | |||
| 171 | + | s.startPickerKill(o.service, key) | |||
| 172 | + | s.renderCurrent() | |||
| 161 | default: | 173 | default: | ||
| 162 | s.refreshPreview(o.service) | 174 | s.refreshPreview(o.service) | ||
| 163 | s.renderCurrent() | 175 | s.renderCurrent() | ||
| 164 | } | 176 | } | ||
| 165 | } | 177 | } | ||
| 166 | 178 | ||||
| 179 | + | // current names the attachment this overlay is presented over, so the picker | |||
| 180 | + | // opens on its session and tab. | |||
| 181 | + | func (o *attachmentPickerOverlay) current() pickerCurrent { | |||
| 182 | + | target, tab, known := o.sup.attachments.committedView() | |||
| 183 | + | if !known { | |||
| 184 | + | if o.request.Admission != ports.BrokerAdmissionExact { | |||
| 185 | + | return pickerCurrent{} | |||
| 186 | + | } | |||
| 187 | + | target = o.request.Target | |||
| 188 | + | } | |||
| 189 | + | if target.LifecycleID == (domain.SessionLifecycleID{}) { | |||
| 190 | + | return pickerCurrent{} | |||
| 191 | + | } | |||
| 192 | + | return pickerCurrent{known: true, local: o.request.Local, endpoint: o.request.Endpoint, lifecycle: target.LifecycleID, tab: tab} | |||
| 193 | + | } | |||
| 194 | + | ||||
| 167 | // sameTarget reports whether request names the session this attachment is | 195 | // sameTarget reports whether request names the session this attachment is | ||
| 168 | // already showing, in which case committing it is a close. | 196 | // already showing, in which case committing it is a close. | ||
| 169 | func (o *attachmentPickerOverlay) sameTarget(request ports.BrokerOpenStreamRequest) bool { | 197 | func (o *attachmentPickerOverlay) sameTarget(request ports.BrokerOpenStreamRequest) bool { | ||
A internal/usecase/client/supervisor_picker_kill.go +155 -0
| @@ -0,0 +1,155 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "sync" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | "github.com/bnema/vev/internal/protocol" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // Picker kill (Plan 003 B1). | |
| 13 | + | // | |
| 14 | + | // `x` on a local session row kills it through the broker operations seam: one | |
| 15 | + | // independent, bounded control stream to the owning daemon, never the | |
| 16 | + | // attachment stream. A live session is torn down; a stopped session's history | |
| 17 | + | // record is deleted by the same typed Kill, which the daemon routes to its | |
| 18 | + | // stopped-session purge. The operation runs off the supervisor's run goroutine | |
| 19 | + | // so the picker keeps rendering; its typed outcome comes back as one notice and | |
| 20 | + | // a catalogue reconcile. An unknown outcome is reported and never replayed. | |
| 21 | + | ||
| 22 | + | // pickerKillResolver is the optional kill resolution of the real picker. | |
| 23 | + | type pickerKillResolver interface { | |
| 24 | + | ResolveKill(key string) (pickerKillTarget, error) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // pickerCurrentHost is the optional cursor seam of the real picker. | |
| 28 | + | type pickerCurrentHost interface { | |
| 29 | + | SetCurrent(pickerCurrent) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // pickerKillOutcome is one settled kill. | |
| 33 | + | type pickerKillOutcome struct { | |
| 34 | + | target pickerKillTarget | |
| 35 | + | result protocol.KillResult | |
| 36 | + | err error | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // pickerKills owns at most one in-flight picker kill. It is only touched from | |
| 40 | + | // the supervisor's run goroutine; the worker goroutine only sends on done. | |
| 41 | + | type pickerKills struct { | |
| 42 | + | running bool | |
| 43 | + | done chan pickerKillOutcome | |
| 44 | + | cancel context.CancelFunc | |
| 45 | + | wg sync.WaitGroup | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // results is the settled-kill wake, nil while no kill runs. | |
| 49 | + | func (k *pickerKills) results() <-chan pickerKillOutcome { | |
| 50 | + | if k == nil || !k.running { | |
| 51 | + | return nil | |
| 52 | + | } | |
| 53 | + | return k.done | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // setPickerCurrent names the attachment the picker is presented over. | |
| 57 | + | func (s *Supervisor) setPickerCurrent(current pickerCurrent) { | |
| 58 | + | if host, ok := s.cfg.Picker.(pickerCurrentHost); ok { | |
| 59 | + | host.SetCurrent(current) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // startPickerKill resolves one kill key against the latest catalogue and runs | |
| 64 | + | // the kill. A refusal is already a picker notice; a second `x` while one kill | |
| 65 | + | // runs is refused rather than queued. | |
| 66 | + | func (s *Supervisor) startPickerKill(service ports.BrokerService, key string) { | |
| 67 | + | resolver, ok := s.cfg.Picker.(pickerKillResolver) | |
| 68 | + | if !ok || key == "" || supervisorNil(service) { | |
| 69 | + | return | |
| 70 | + | } | |
| 71 | + | if s.kills.running { | |
| 72 | + | s.offerPickerNotice("picker-kill", "a kill is already in progress") | |
| 73 | + | return | |
| 74 | + | } | |
| 75 | + | target, err := resolver.ResolveKill(key) | |
| 76 | + | if err != nil { | |
| 77 | + | return | |
| 78 | + | } | |
| 79 | + | operations, err := NewBrokerOperations(service, s.cfg.Clock) | |
| 80 | + | if err != nil { | |
| 81 | + | s.offerPickerNotice("picker-kill", "couldn't kill "+target.name+": broker unavailable") | |
| 82 | + | return | |
| 83 | + | } | |
| 84 | + | if s.kills.done == nil { | |
| 85 | + | s.kills.done = make(chan pickerKillOutcome, 1) | |
| 86 | + | } | |
| 87 | + | killCtx, cancel := context.WithCancel(context.Background()) | |
| 88 | + | s.kills.running = true | |
| 89 | + | s.kills.cancel = cancel | |
| 90 | + | s.kills.wg.Add(1) | |
| 91 | + | go func() { | |
| 92 | + | defer s.kills.wg.Done() | |
| 93 | + | result, err := operations.Kill(killCtx, target.route, target.name) | |
| 94 | + | s.kills.done <- pickerKillOutcome{target: target, result: result, err: err} | |
| 95 | + | }() | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // finishPickerKill reports one settled kill and asks the broker to re-observe | |
| 99 | + | // the local daemon, so the killed row leaves the catalogue promptly. | |
| 100 | + | func (s *Supervisor) finishPickerKill(service ports.BrokerService, outcome pickerKillOutcome) { | |
| 101 | + | s.settlePickerKill() | |
| 102 | + | s.offerPickerNotice("picker-kill", pickerKillNotice(outcome)) | |
| 103 | + | if !supervisorNil(service) { | |
| 104 | + | service.RequestReconcile("") | |
| 105 | + | } | |
| 106 | + | s.renderCurrent() | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // retirePickerKill cancels and joins an in-flight kill before its borrowed | |
| 110 | + | // service closes. Its outcome is still reported, never replayed. | |
| 111 | + | func (s *Supervisor) retirePickerKill() { | |
| 112 | + | if !s.kills.running { | |
| 113 | + | return | |
| 114 | + | } | |
| 115 | + | s.kills.cancel() | |
| 116 | + | s.kills.wg.Wait() | |
| 117 | + | outcome := <-s.kills.done | |
| 118 | + | s.settlePickerKill() | |
| 119 | + | s.offerPickerNotice("picker-kill", pickerKillNotice(outcome)) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func (s *Supervisor) settlePickerKill() { | |
| 123 | + | if s.kills.cancel != nil { | |
| 124 | + | s.kills.cancel() | |
| 125 | + | } | |
| 126 | + | s.kills.running = false | |
| 127 | + | s.kills.cancel = nil | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // pickerKillNotice renders one typed kill outcome. | |
| 131 | + | func pickerKillNotice(outcome pickerKillOutcome) string { | |
| 132 | + | name := outcome.target.name | |
| 133 | + | verb, done := "kill", "killed session " | |
| 134 | + | if outcome.target.stopped { | |
| 135 | + | verb, done = "delete stopped session", "deleted stopped session " | |
| 136 | + | } | |
| 137 | + | if outcome.err != nil { | |
| 138 | + | if errors.Is(outcome.err, ErrBrokerOperationNotSent) { | |
| 139 | + | return "couldn't " + verb + " " + name + ": not sent" | |
| 140 | + | } | |
| 141 | + | return "couldn't " + verb + " " + name | |
| 142 | + | } | |
| 143 | + | switch outcome.result.Outcome { | |
| 144 | + | case protocol.KillSucceeded: | |
| 145 | + | return done + name | |
| 146 | + | case protocol.KillFailed: | |
| 147 | + | if outcome.result.Text != "" { | |
| 148 | + | return "couldn't " + verb + " " + name + ": " + outcome.result.Text | |
| 149 | + | } | |
| 150 | + | return "couldn't " + verb + " " + name | |
| 151 | + | default: | |
| 152 | + | // The request may have reached the daemon: never retried. | |
| 153 | + | return verb + " " + name + ": outcome unknown" | |
| 154 | + | } | |
| 155 | + | } | |
| @@ -0,0 +1,155 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "sync" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | + | "github.com/bnema/vev/internal/protocol" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // Picker kill (Plan 003 B1). | |||
| 13 | + | // | |||
| 14 | + | // `x` on a local session row kills it through the broker operations seam: one | |||
| 15 | + | // independent, bounded control stream to the owning daemon, never the | |||
| 16 | + | // attachment stream. A live session is torn down; a stopped session's history | |||
| 17 | + | // record is deleted by the same typed Kill, which the daemon routes to its | |||
| 18 | + | // stopped-session purge. The operation runs off the supervisor's run goroutine | |||
| 19 | + | // so the picker keeps rendering; its typed outcome comes back as one notice and | |||
| 20 | + | // a catalogue reconcile. An unknown outcome is reported and never replayed. | |||
| 21 | + | ||||
| 22 | + | // pickerKillResolver is the optional kill resolution of the real picker. | |||
| 23 | + | type pickerKillResolver interface { | |||
| 24 | + | ResolveKill(key string) (pickerKillTarget, error) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // pickerCurrentHost is the optional cursor seam of the real picker. | |||
| 28 | + | type pickerCurrentHost interface { | |||
| 29 | + | SetCurrent(pickerCurrent) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // pickerKillOutcome is one settled kill. | |||
| 33 | + | type pickerKillOutcome struct { | |||
| 34 | + | target pickerKillTarget | |||
| 35 | + | result protocol.KillResult | |||
| 36 | + | err error | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // pickerKills owns at most one in-flight picker kill. It is only touched from | |||
| 40 | + | // the supervisor's run goroutine; the worker goroutine only sends on done. | |||
| 41 | + | type pickerKills struct { | |||
| 42 | + | running bool | |||
| 43 | + | done chan pickerKillOutcome | |||
| 44 | + | cancel context.CancelFunc | |||
| 45 | + | wg sync.WaitGroup | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // results is the settled-kill wake, nil while no kill runs. | |||
| 49 | + | func (k *pickerKills) results() <-chan pickerKillOutcome { | |||
| 50 | + | if k == nil || !k.running { | |||
| 51 | + | return nil | |||
| 52 | + | } | |||
| 53 | + | return k.done | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // setPickerCurrent names the attachment the picker is presented over. | |||
| 57 | + | func (s *Supervisor) setPickerCurrent(current pickerCurrent) { | |||
| 58 | + | if host, ok := s.cfg.Picker.(pickerCurrentHost); ok { | |||
| 59 | + | host.SetCurrent(current) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // startPickerKill resolves one kill key against the latest catalogue and runs | |||
| 64 | + | // the kill. A refusal is already a picker notice; a second `x` while one kill | |||
| 65 | + | // runs is refused rather than queued. | |||
| 66 | + | func (s *Supervisor) startPickerKill(service ports.BrokerService, key string) { | |||
| 67 | + | resolver, ok := s.cfg.Picker.(pickerKillResolver) | |||
| 68 | + | if !ok || key == "" || supervisorNil(service) { | |||
| 69 | + | return | |||
| 70 | + | } | |||
| 71 | + | if s.kills.running { | |||
| 72 | + | s.offerPickerNotice("picker-kill", "a kill is already in progress") | |||
| 73 | + | return | |||
| 74 | + | } | |||
| 75 | + | target, err := resolver.ResolveKill(key) | |||
| 76 | + | if err != nil { | |||
| 77 | + | return | |||
| 78 | + | } | |||
| 79 | + | operations, err := NewBrokerOperations(service, s.cfg.Clock) | |||
| 80 | + | if err != nil { | |||
| 81 | + | s.offerPickerNotice("picker-kill", "couldn't kill "+target.name+": broker unavailable") | |||
| 82 | + | return | |||
| 83 | + | } | |||
| 84 | + | if s.kills.done == nil { | |||
| 85 | + | s.kills.done = make(chan pickerKillOutcome, 1) | |||
| 86 | + | } | |||
| 87 | + | killCtx, cancel := context.WithCancel(context.Background()) | |||
| 88 | + | s.kills.running = true | |||
| 89 | + | s.kills.cancel = cancel | |||
| 90 | + | s.kills.wg.Add(1) | |||
| 91 | + | go func() { | |||
| 92 | + | defer s.kills.wg.Done() | |||
| 93 | + | result, err := operations.Kill(killCtx, target.route, target.name) | |||
| 94 | + | s.kills.done <- pickerKillOutcome{target: target, result: result, err: err} | |||
| 95 | + | }() | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // finishPickerKill reports one settled kill and asks the broker to re-observe | |||
| 99 | + | // the local daemon, so the killed row leaves the catalogue promptly. | |||
| 100 | + | func (s *Supervisor) finishPickerKill(service ports.BrokerService, outcome pickerKillOutcome) { | |||
| 101 | + | s.settlePickerKill() | |||
| 102 | + | s.offerPickerNotice("picker-kill", pickerKillNotice(outcome)) | |||
| 103 | + | if !supervisorNil(service) { | |||
| 104 | + | service.RequestReconcile("") | |||
| 105 | + | } | |||
| 106 | + | s.renderCurrent() | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // retirePickerKill cancels and joins an in-flight kill before its borrowed | |||
| 110 | + | // service closes. Its outcome is still reported, never replayed. | |||
| 111 | + | func (s *Supervisor) retirePickerKill() { | |||
| 112 | + | if !s.kills.running { | |||
| 113 | + | return | |||
| 114 | + | } | |||
| 115 | + | s.kills.cancel() | |||
| 116 | + | s.kills.wg.Wait() | |||
| 117 | + | outcome := <-s.kills.done | |||
| 118 | + | s.settlePickerKill() | |||
| 119 | + | s.offerPickerNotice("picker-kill", pickerKillNotice(outcome)) | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func (s *Supervisor) settlePickerKill() { | |||
| 123 | + | if s.kills.cancel != nil { | |||
| 124 | + | s.kills.cancel() | |||
| 125 | + | } | |||
| 126 | + | s.kills.running = false | |||
| 127 | + | s.kills.cancel = nil | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // pickerKillNotice renders one typed kill outcome. | |||
| 131 | + | func pickerKillNotice(outcome pickerKillOutcome) string { | |||
| 132 | + | name := outcome.target.name | |||
| 133 | + | verb, done := "kill", "killed session " | |||
| 134 | + | if outcome.target.stopped { | |||
| 135 | + | verb, done = "delete stopped session", "deleted stopped session " | |||
| 136 | + | } | |||
| 137 | + | if outcome.err != nil { | |||
| 138 | + | if errors.Is(outcome.err, ErrBrokerOperationNotSent) { | |||
| 139 | + | return "couldn't " + verb + " " + name + ": not sent" | |||
| 140 | + | } | |||
| 141 | + | return "couldn't " + verb + " " + name | |||
| 142 | + | } | |||
| 143 | + | switch outcome.result.Outcome { | |||
| 144 | + | case protocol.KillSucceeded: | |||
| 145 | + | return done + name | |||
| 146 | + | case protocol.KillFailed: | |||
| 147 | + | if outcome.result.Text != "" { | |||
| 148 | + | return "couldn't " + verb + " " + name + ": " + outcome.result.Text | |||
| 149 | + | } | |||
| 150 | + | return "couldn't " + verb + " " + name | |||
| 151 | + | default: | |||
| 152 | + | // The request may have reached the daemon: never retried. | |||
| 153 | + | return verb + " " + name + ": outcome unknown" | |||
| 154 | + | } | |||
| 155 | + | } | |||
A internal/usecase/client/supervisor_picker_parity_test.go +218 -0
| @@ -0,0 +1,218 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "strings" | |
| 7 | + | "sync" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | "github.com/bnema/vev/internal/protocol" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // Supervisor halves of picker parity (Plan 003 B1/B2): the in-place tab switch | |
| 18 | + | // over a live attachment and the `x` kill through the broker operations seam. | |
| 19 | + | // They drive the real supervisor, host, foreground, and worker with bounded | |
| 20 | + | // waits and no sleeps. | |
| 21 | + | ||
| 22 | + | // parityTestPicker adds the optional tab, kill, and notice seams of the real | |
| 23 | + | // picker to the scripted picker. The fields are guarded by attachTestPicker.mu. | |
| 24 | + | type parityTestPicker struct { | |
| 25 | + | *attachTestPicker | |
| 26 | + | tab attachmentTab | |
| 27 | + | kill pickerKillTarget | |
| 28 | + | notices []string | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func newParityTestPicker() *parityTestPicker { | |
| 32 | + | return &parityTestPicker{attachTestPicker: newAttachTestPicker()} | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (p *parityTestPicker) ResolveKeyTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) { | |
| 36 | + | request, err := p.ResolveKey(key, base) | |
| 37 | + | p.mu.Lock() | |
| 38 | + | defer p.mu.Unlock() | |
| 39 | + | return request, p.tab, err | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func (p *parityTestPicker) ResolveKill(string) (pickerKillTarget, error) { | |
| 43 | + | p.mu.Lock() | |
| 44 | + | defer p.mu.Unlock() | |
| 45 | + | return p.kill, nil | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (p *parityTestPicker) offerNotice(_ string, message string) { | |
| 49 | + | p.mu.Lock() | |
| 50 | + | p.notices = append(p.notices, message) | |
| 51 | + | p.mu.Unlock() | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func (p *parityTestPicker) invalidatePresentation() {} | |
| 55 | + | ||
| 56 | + | func (p *parityTestPicker) noticeList() []string { | |
| 57 | + | p.mu.Lock() | |
| 58 | + | defer p.mu.Unlock() | |
| 59 | + | return append([]string(nil), p.notices...) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // TestPickerOverlayCommitAnotherTabSwitchesInPlace pins the tab-aware same | |
| 63 | + | // target: committing another tab of the attached session switches the live | |
| 64 | + | // attachment's view with SelectTab and never reconnects; the attached tab or | |
| 65 | + | // the session itself just closes the overlay. | |
| 66 | + | func TestPickerOverlayCommitAnotherTabSwitchesInPlace(t *testing.T) { | |
| 67 | + | tests := []struct { | |
| 68 | + | name string | |
| 69 | + | tab attachmentTab | |
| 70 | + | inPlace bool | |
| 71 | + | }{ | |
| 72 | + | {name: "another tab", tab: attachmentTab{preferred: "t_other"}, inPlace: true}, | |
| 73 | + | {name: "the attached tab", tab: attachmentTab{preferred: "t_abc123"}}, | |
| 74 | + | {name: "the session row", tab: attachmentTab{}}, | |
| 75 | + | } | |
| 76 | + | for _, tt := range tests { | |
| 77 | + | t.Run(tt.name, func(t *testing.T) { | |
| 78 | + | picker := newParityTestPicker() | |
| 79 | + | harness := startAttachHarness(t, picker) | |
| 80 | + | stream := attachLiveSession(t, harness, picker.attachTestPicker) | |
| 81 | + | foreground := harness.sup.attachments.authority.foreground() | |
| 82 | + | stream.deliver(navigationOffer(1)) | |
| 83 | + | awaitPresentation(t, harness.sup, PresentAttachedPicker) | |
| 84 | + | ||
| 85 | + | picker.mu.Lock() | |
| 86 | + | picker.tab = tt.tab | |
| 87 | + | picker.mu.Unlock() | |
| 88 | + | picker.recordOp(pickerOp{commit: true}, "row", sessionTestRequest(true)) | |
| 89 | + | ||
| 90 | + | awaitPresentation(t, harness.sup, PresentAttached) | |
| 91 | + | awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest]) | |
| 92 | + | if tt.inPlace { | |
| 93 | + | selected := awaitSent(t, stream, "SelectTab", isSent[protocol.SelectTab]).(protocol.SelectTab) | |
| 94 | + | require.Equal(t, tt.tab.preferred, selected.TabID) | |
| 95 | + | } else { | |
| 96 | + | require.Zero(t, countSent[protocol.SelectTab](stream)) | |
| 97 | + | } | |
| 98 | + | require.Same(t, foreground, harness.sup.attachments.authority.foreground(), "the attachment is never replaced") | |
| 99 | + | require.Len(t, harness.service.openedRequests(), 1, "a tab switch never reconnects") | |
| 100 | + | require.Zero(t, countSent[protocol.Detach](stream)) | |
| 101 | + | require.False(t, stream.closedNow()) | |
| 102 | + | }) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // TestSupervisorPickerKill pins B1 end to end: `x` resolves the row, sends one | |
| 107 | + | // typed Kill on its own local control stream, reports the typed outcome as a | |
| 108 | + | // notice, and asks the broker to re-observe the local daemon. An unknown | |
| 109 | + | // outcome is reported once and never replayed. | |
| 110 | + | func TestSupervisorPickerKill(t *testing.T) { | |
| 111 | + | tests := []struct { | |
| 112 | + | name string | |
| 113 | + | stopped bool | |
| 114 | + | overlay bool | |
| 115 | + | reply func(*brokerOpsTestStream) | |
| 116 | + | wantNotice string | |
| 117 | + | }{ | |
| 118 | + | {name: "live session", reply: func(s *brokerOpsTestStream) { | |
| 119 | + | s.deliver(protocol.KillResult{RequestID: brokerOperationRequestID, Outcome: protocol.KillSucceeded}) | |
| 120 | + | }, wantNotice: "killed session alpha"}, | |
| 121 | + | {name: "stopped session history", stopped: true, reply: func(s *brokerOpsTestStream) { | |
| 122 | + | s.deliver(protocol.KillResult{RequestID: brokerOperationRequestID, Outcome: protocol.KillSucceeded}) | |
| 123 | + | }, wantNotice: "deleted stopped session alpha"}, | |
| 124 | + | {name: "daemon refusal", reply: func(s *brokerOpsTestStream) { | |
| 125 | + | s.deliver(protocol.KillResult{RequestID: brokerOperationRequestID, Outcome: protocol.KillFailed, Code: protocol.ErrNoSuchSession, Text: "no such session: alpha"}) | |
| 126 | + | }, wantNotice: "couldn't kill alpha: no such session: alpha"}, | |
| 127 | + | {name: "outcome unknown is never replayed", reply: func(s *brokerOpsTestStream) { | |
| 128 | + | s.failReceive(errors.New("carriage lost")) | |
| 129 | + | _ = s.Close() | |
| 130 | + | }, wantNotice: "kill alpha: outcome unknown"}, | |
| 131 | + | {name: "from the overlay", overlay: true, reply: func(s *brokerOpsTestStream) { | |
| 132 | + | s.deliver(protocol.KillResult{RequestID: brokerOperationRequestID, Outcome: protocol.KillSucceeded}) | |
| 133 | + | }, wantNotice: "killed session alpha"}, | |
| 134 | + | } | |
| 135 | + | for _, tt := range tests { | |
| 136 | + | t.Run(tt.name, func(t *testing.T) { | |
| 137 | + | picker := newParityTestPicker() | |
| 138 | + | harness := startAttachHarness(t, picker) | |
| 139 | + | harness.service.publishSnapshot(localDaemonSnapshot(3, 2)) | |
| 140 | + | ||
| 141 | + | var mu sync.Mutex | |
| 142 | + | controls := make([]*brokerOpsTestStream, 0, 1) | |
| 143 | + | session := newSessionTestStream() | |
| 144 | + | harness.service.setOpenStream(func(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 145 | + | if request.Purpose != ports.BrokerStreamControl { | |
| 146 | + | return session, nil | |
| 147 | + | } | |
| 148 | + | control := newBrokerOpsTestStream() | |
| 149 | + | mu.Lock() | |
| 150 | + | controls = append(controls, control) | |
| 151 | + | mu.Unlock() | |
| 152 | + | return control, nil | |
| 153 | + | }) | |
| 154 | + | if tt.overlay { | |
| 155 | + | picker.commit(sessionTestRequest(true)) | |
| 156 | + | awaitStreamHello(t, &sync.Mutex{}, &session) | |
| 157 | + | deliverReadyStream(t, session) | |
| 158 | + | awaitAttachedState(t, harness.sup) | |
| 159 | + | session.deliver(navigationOffer(1)) | |
| 160 | + | awaitPresentation(t, harness.sup, PresentAttachedPicker) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | picker.mu.Lock() | |
| 164 | + | picker.kill = pickerKillTarget{route: BrokerOperationRoute{Epoch: 3, Destination: ports.BrokerEndpointFence{Local: true}}, name: "alpha", stopped: tt.stopped} | |
| 165 | + | picker.mu.Unlock() | |
| 166 | + | picker.recordOp(pickerOp{kill: true}, "row", ports.BrokerOpenStreamRequest{}) | |
| 167 | + | ||
| 168 | + | var control *brokerOpsTestStream | |
| 169 | + | require.Eventually(t, func() bool { | |
| 170 | + | mu.Lock() | |
| 171 | + | defer mu.Unlock() | |
| 172 | + | if len(controls) == 0 { | |
| 173 | + | return false | |
| 174 | + | } | |
| 175 | + | control = controls[0] | |
| 176 | + | return true | |
| 177 | + | }, 5*time.Second, time.Millisecond, "the kill never opened a control stream") | |
| 178 | + | control.awaitDispatch(t) | |
| 179 | + | tt.reply(control) | |
| 180 | + | ||
| 181 | + | require.Eventually(t, func() bool { | |
| 182 | + | for _, notice := range picker.noticeList() { | |
| 183 | + | if notice == tt.wantNotice { | |
| 184 | + | return true | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | return false | |
| 188 | + | }, 5*time.Second, time.Millisecond, "notice %q never shown (got %v)", tt.wantNotice, picker.noticeList()) | |
| 189 | + | require.Eventually(t, func() bool { return len(harness.service.reconcileHints()) > 0 }, 5*time.Second, time.Millisecond) | |
| 190 | + | for _, hint := range harness.service.reconcileHints() { | |
| 191 | + | require.Empty(t, hint, "the local daemon is re-observed") | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | mu.Lock() | |
| 195 | + | require.Len(t, controls, 1, "a kill is never replayed") | |
| 196 | + | mu.Unlock() | |
| 197 | + | sent := control.messages() | |
| 198 | + | require.Len(t, sent, 1) | |
| 199 | + | kill, ok := sent[0].(protocol.Kill) | |
| 200 | + | require.True(t, ok) | |
| 201 | + | require.Equal(t, protocol.Kill{RequestID: brokerOperationRequestID, Name: "alpha", Scope: protocol.KillSession}, kill) | |
| 202 | + | var controlOpens int | |
| 203 | + | for _, request := range harness.service.openedRequests() { | |
| 204 | + | if request.Purpose == ports.BrokerStreamControl { | |
| 205 | + | controlOpens++ | |
| 206 | + | require.True(t, request.Local) | |
| 207 | + | require.Equal(t, ports.BrokerEpoch(3), request.Epoch) | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | require.Equal(t, 1, controlOpens) | |
| 211 | + | if tt.overlay { | |
| 212 | + | require.Equal(t, PresentAttachedPicker, harness.sup.State().Presentation, "a kill keeps the overlay open") | |
| 213 | + | require.False(t, session.closedNow()) | |
| 214 | + | } | |
| 215 | + | require.False(t, strings.Contains(strings.Join(picker.noticeList(), "\n"), "already in progress")) | |
| 216 | + | }) | |
| 217 | + | } | |
| 218 | + | } | |
| @@ -0,0 +1,218 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "strings" | |||
| 7 | + | "sync" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/vev/internal/ports" | |||
| 14 | + | "github.com/bnema/vev/internal/protocol" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // Supervisor halves of picker parity (Plan 003 B1/B2): the in-place tab switch | |||
| 18 | + | // over a live attachment and the `x` kill through the broker operations seam. | |||
| 19 | + | // They drive the real supervisor, host, foreground, and worker with bounded | |||
| 20 | + | // waits and no sleeps. | |||
| 21 | + | ||||
| 22 | + | // parityTestPicker adds the optional tab, kill, and notice seams of the real | |||
| 23 | + | // picker to the scripted picker. The fields are guarded by attachTestPicker.mu. | |||
| 24 | + | type parityTestPicker struct { | |||
| 25 | + | *attachTestPicker | |||
| 26 | + | tab attachmentTab | |||
| 27 | + | kill pickerKillTarget | |||
| 28 | + | notices []string | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func newParityTestPicker() *parityTestPicker { | |||
| 32 | + | return &parityTestPicker{attachTestPicker: newAttachTestPicker()} | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (p *parityTestPicker) ResolveKeyTarget(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, attachmentTab, error) { | |||
| 36 | + | request, err := p.ResolveKey(key, base) | |||
| 37 | + | p.mu.Lock() | |||
| 38 | + | defer p.mu.Unlock() | |||
| 39 | + | return request, p.tab, err | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func (p *parityTestPicker) ResolveKill(string) (pickerKillTarget, error) { | |||
| 43 | + | p.mu.Lock() | |||
| 44 | + | defer p.mu.Unlock() | |||
| 45 | + | return p.kill, nil | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (p *parityTestPicker) offerNotice(_ string, message string) { | |||
| 49 | + | p.mu.Lock() | |||
| 50 | + | p.notices = append(p.notices, message) | |||
| 51 | + | p.mu.Unlock() | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func (p *parityTestPicker) invalidatePresentation() {} | |||
| 55 | + | ||||
| 56 | + | func (p *parityTestPicker) noticeList() []string { | |||
| 57 | + | p.mu.Lock() | |||
| 58 | + | defer p.mu.Unlock() | |||
| 59 | + | return append([]string(nil), p.notices...) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // TestPickerOverlayCommitAnotherTabSwitchesInPlace pins the tab-aware same | |||
| 63 | + | // target: committing another tab of the attached session switches the live | |||
| 64 | + | // attachment's view with SelectTab and never reconnects; the attached tab or | |||
| 65 | + | // the session itself just closes the overlay. | |||
| 66 | + | func TestPickerOverlayCommitAnotherTabSwitchesInPlace(t *testing.T) { | |||
| 67 | + | tests := []struct { | |||
| 68 | + | name string | |||
| 69 | + | tab attachmentTab | |||
| 70 | + | inPlace bool | |||
| 71 | + | }{ | |||
| 72 | + | {name: "another tab", tab: attachmentTab{preferred: "t_other"}, inPlace: true}, | |||
| 73 | + | {name: "the attached tab", tab: attachmentTab{preferred: "t_abc123"}}, | |||
| 74 | + | {name: "the session row", tab: attachmentTab{}}, | |||
| 75 | + | } | |||
| 76 | + | for _, tt := range tests { | |||
| 77 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 78 | + | picker := newParityTestPicker() | |||
| 79 | + | harness := startAttachHarness(t, picker) | |||
| 80 | + | stream := attachLiveSession(t, harness, picker.attachTestPicker) | |||
| 81 | + | foreground := harness.sup.attachments.authority.foreground() | |||
| 82 | + | stream.deliver(navigationOffer(1)) | |||
| 83 | + | awaitPresentation(t, harness.sup, PresentAttachedPicker) | |||
| 84 | + | ||||
| 85 | + | picker.mu.Lock() | |||
| 86 | + | picker.tab = tt.tab | |||
| 87 | + | picker.mu.Unlock() | |||
| 88 | + | picker.recordOp(pickerOp{commit: true}, "row", sessionTestRequest(true)) | |||
| 89 | + | ||||
| 90 | + | awaitPresentation(t, harness.sup, PresentAttached) | |||
| 91 | + | awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest]) | |||
| 92 | + | if tt.inPlace { | |||
| 93 | + | selected := awaitSent(t, stream, "SelectTab", isSent[protocol.SelectTab]).(protocol.SelectTab) | |||
| 94 | + | require.Equal(t, tt.tab.preferred, selected.TabID) | |||
| 95 | + | } else { | |||
| 96 | + | require.Zero(t, countSent[protocol.SelectTab](stream)) | |||
| 97 | + | } | |||
| 98 | + | require.Same(t, foreground, harness.sup.attachments.authority.foreground(), "the attachment is never replaced") | |||
| 99 | + | require.Len(t, harness.service.openedRequests(), 1, "a tab switch never reconnects") | |||
| 100 | + | require.Zero(t, countSent[protocol.Detach](stream)) | |||
| 101 | + | require.False(t, stream.closedNow()) | |||
| 102 | + | }) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // TestSupervisorPickerKill pins B1 end to end: `x` resolves the row, sends one | |||
| 107 | + | // typed Kill on its own local control stream, reports the typed outcome as a | |||
| 108 | + | // notice, and asks the broker to re-observe the local daemon. An unknown | |||
| 109 | + | // outcome is reported once and never replayed. | |||
| 110 | + | func TestSupervisorPickerKill(t *testing.T) { | |||
| 111 | + | tests := []struct { | |||
| 112 | + | name string | |||
| 113 | + | stopped bool | |||
| 114 | + | overlay bool | |||
| 115 | + | reply func(*brokerOpsTestStream) | |||
| 116 | + | wantNotice string | |||
| 117 | + | }{ | |||
| 118 | + | {name: "live session", reply: func(s *brokerOpsTestStream) { | |||
| 119 | + | s.deliver(protocol.KillResult{RequestID: brokerOperationRequestID, Outcome: protocol.KillSucceeded}) | |||
| 120 | + | }, wantNotice: "killed session alpha"}, | |||
| 121 | + | {name: "stopped session history", stopped: true, reply: func(s *brokerOpsTestStream) { | |||
| 122 | + | s.deliver(protocol.KillResult{RequestID: brokerOperationRequestID, Outcome: protocol.KillSucceeded}) | |||
| 123 | + | }, wantNotice: "deleted stopped session alpha"}, | |||
| 124 | + | {name: "daemon refusal", reply: func(s *brokerOpsTestStream) { | |||
| 125 | + | s.deliver(protocol.KillResult{RequestID: brokerOperationRequestID, Outcome: protocol.KillFailed, Code: protocol.ErrNoSuchSession, Text: "no such session: alpha"}) | |||
| 126 | + | }, wantNotice: "couldn't kill alpha: no such session: alpha"}, | |||
| 127 | + | {name: "outcome unknown is never replayed", reply: func(s *brokerOpsTestStream) { | |||
| 128 | + | s.failReceive(errors.New("carriage lost")) | |||
| 129 | + | _ = s.Close() | |||
| 130 | + | }, wantNotice: "kill alpha: outcome unknown"}, | |||
| 131 | + | {name: "from the overlay", overlay: true, reply: func(s *brokerOpsTestStream) { | |||
| 132 | + | s.deliver(protocol.KillResult{RequestID: brokerOperationRequestID, Outcome: protocol.KillSucceeded}) | |||
| 133 | + | }, wantNotice: "killed session alpha"}, | |||
| 134 | + | } | |||
| 135 | + | for _, tt := range tests { | |||
| 136 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 137 | + | picker := newParityTestPicker() | |||
| 138 | + | harness := startAttachHarness(t, picker) | |||
| 139 | + | harness.service.publishSnapshot(localDaemonSnapshot(3, 2)) | |||
| 140 | + | ||||
| 141 | + | var mu sync.Mutex | |||
| 142 | + | controls := make([]*brokerOpsTestStream, 0, 1) | |||
| 143 | + | session := newSessionTestStream() | |||
| 144 | + | harness.service.setOpenStream(func(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 145 | + | if request.Purpose != ports.BrokerStreamControl { | |||
| 146 | + | return session, nil | |||
| 147 | + | } | |||
| 148 | + | control := newBrokerOpsTestStream() | |||
| 149 | + | mu.Lock() | |||
| 150 | + | controls = append(controls, control) | |||
| 151 | + | mu.Unlock() | |||
| 152 | + | return control, nil | |||
| 153 | + | }) | |||
| 154 | + | if tt.overlay { | |||
| 155 | + | picker.commit(sessionTestRequest(true)) | |||
| 156 | + | awaitStreamHello(t, &sync.Mutex{}, &session) | |||
| 157 | + | deliverReadyStream(t, session) | |||
| 158 | + | awaitAttachedState(t, harness.sup) | |||
| 159 | + | session.deliver(navigationOffer(1)) | |||
| 160 | + | awaitPresentation(t, harness.sup, PresentAttachedPicker) | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | picker.mu.Lock() | |||
| 164 | + | picker.kill = pickerKillTarget{route: BrokerOperationRoute{Epoch: 3, Destination: ports.BrokerEndpointFence{Local: true}}, name: "alpha", stopped: tt.stopped} | |||
| 165 | + | picker.mu.Unlock() | |||
| 166 | + | picker.recordOp(pickerOp{kill: true}, "row", ports.BrokerOpenStreamRequest{}) | |||
| 167 | + | ||||
| 168 | + | var control *brokerOpsTestStream | |||
| 169 | + | require.Eventually(t, func() bool { | |||
| 170 | + | mu.Lock() | |||
| 171 | + | defer mu.Unlock() | |||
| 172 | + | if len(controls) == 0 { | |||
| 173 | + | return false | |||
| 174 | + | } | |||
| 175 | + | control = controls[0] | |||
| 176 | + | return true | |||
| 177 | + | }, 5*time.Second, time.Millisecond, "the kill never opened a control stream") | |||
| 178 | + | control.awaitDispatch(t) | |||
| 179 | + | tt.reply(control) | |||
| 180 | + | ||||
| 181 | + | require.Eventually(t, func() bool { | |||
| 182 | + | for _, notice := range picker.noticeList() { | |||
| 183 | + | if notice == tt.wantNotice { | |||
| 184 | + | return true | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | return false | |||
| 188 | + | }, 5*time.Second, time.Millisecond, "notice %q never shown (got %v)", tt.wantNotice, picker.noticeList()) | |||
| 189 | + | require.Eventually(t, func() bool { return len(harness.service.reconcileHints()) > 0 }, 5*time.Second, time.Millisecond) | |||
| 190 | + | for _, hint := range harness.service.reconcileHints() { | |||
| 191 | + | require.Empty(t, hint, "the local daemon is re-observed") | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | mu.Lock() | |||
| 195 | + | require.Len(t, controls, 1, "a kill is never replayed") | |||
| 196 | + | mu.Unlock() | |||
| 197 | + | sent := control.messages() | |||
| 198 | + | require.Len(t, sent, 1) | |||
| 199 | + | kill, ok := sent[0].(protocol.Kill) | |||
| 200 | + | require.True(t, ok) | |||
| 201 | + | require.Equal(t, protocol.Kill{RequestID: brokerOperationRequestID, Name: "alpha", Scope: protocol.KillSession}, kill) | |||
| 202 | + | var controlOpens int | |||
| 203 | + | for _, request := range harness.service.openedRequests() { | |||
| 204 | + | if request.Purpose == ports.BrokerStreamControl { | |||
| 205 | + | controlOpens++ | |||
| 206 | + | require.True(t, request.Local) | |||
| 207 | + | require.Equal(t, ports.BrokerEpoch(3), request.Epoch) | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | require.Equal(t, 1, controlOpens) | |||
| 211 | + | if tt.overlay { | |||
| 212 | + | require.Equal(t, PresentAttachedPicker, harness.sup.State().Presentation, "a kill keeps the overlay open") | |||
| 213 | + | require.False(t, session.closedNow()) | |||
| 214 | + | } | |||
| 215 | + | require.False(t, strings.Contains(strings.Join(picker.noticeList(), "\n"), "already in progress")) | |||
| 216 | + | }) | |||
| 217 | + | } | |||
| 218 | + | } | |||
M internal/usecase/client/supervisor_test.go +13 -1
| @@ -329,6 +329,7 @@ type supervisorTestService struct { | |||
| 329 | 329 | openStream func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) | |
| 330 | 330 | openCalls []ports.BrokerOpenStreamRequest | |
| 331 | 331 | nextStream ports.BrokerStreamID | |
| 332 | + | reconciles []string | |
| 332 | 333 | } | |
| 333 | 334 | ||
| 334 | 335 | func newSupervisorTestService(id ports.BrokerConnectionID) *supervisorTestService { | |
| @@ -416,7 +417,18 @@ func (s *supervisorTestService) UpdateHostPolicy(context.Context, domain.RemoteR | |||
| 416 | 417 | return domain.RemoteRegistration{}, nil | |
| 417 | 418 | } | |
| 418 | 419 | ||
| 419 | - | func (s *supervisorTestService) RequestReconcile(string) {} | |
| 420 | + | func (s *supervisorTestService) RequestReconcile(endpoint string) { | |
| 421 | + | s.mu.Lock() | |
| 422 | + | s.reconciles = append(s.reconciles, endpoint) | |
| 423 | + | s.mu.Unlock() | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | // reconcileHints returns every re-observation hint the supervisor asked for. | |
| 427 | + | func (s *supervisorTestService) reconcileHints() []string { | |
| 428 | + | s.mu.Lock() | |
| 429 | + | defer s.mu.Unlock() | |
| 430 | + | return append([]string(nil), s.reconciles...) | |
| 431 | + | } | |
| 420 | 432 | ||
| 421 | 433 | func (s *supervisorTestService) Close() error { | |
| 422 | 434 | s.closeOnce.Do(func() { | |
| @@ -329,6 +329,7 @@ type supervisorTestService struct { | |||||
| 329 | openStream func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) | 329 | openStream func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) | ||
| 330 | openCalls []ports.BrokerOpenStreamRequest | 330 | openCalls []ports.BrokerOpenStreamRequest | ||
| 331 | nextStream ports.BrokerStreamID | 331 | nextStream ports.BrokerStreamID | ||
| 332 | + | reconciles []string | |||
| 332 | } | 333 | } | ||
| 333 | 334 | ||||
| 334 | func newSupervisorTestService(id ports.BrokerConnectionID) *supervisorTestService { | 335 | func newSupervisorTestService(id ports.BrokerConnectionID) *supervisorTestService { | ||
| @@ -416,7 +417,18 @@ func (s *supervisorTestService) UpdateHostPolicy(context.Context, domain.RemoteR | |||||
| 416 | return domain.RemoteRegistration{}, nil | 417 | return domain.RemoteRegistration{}, nil | ||
| 417 | } | 418 | } | ||
| 418 | 419 | ||||
| 419 | - | func (s *supervisorTestService) RequestReconcile(string) {} | 420 | + | func (s *supervisorTestService) RequestReconcile(endpoint string) { |
| 421 | + | s.mu.Lock() | |||
| 422 | + | s.reconciles = append(s.reconciles, endpoint) | |||
| 423 | + | s.mu.Unlock() | |||
| 424 | + | } | |||
| 425 | + | ||||
| 426 | + | // reconcileHints returns every re-observation hint the supervisor asked for. | |||
| 427 | + | func (s *supervisorTestService) reconcileHints() []string { | |||
| 428 | + | s.mu.Lock() | |||
| 429 | + | defer s.mu.Unlock() | |||
| 430 | + | return append([]string(nil), s.reconciles...) | |||
| 431 | + | } | |||
| 420 | 432 | ||||
| 421 | func (s *supervisorTestService) Close() error { | 433 | func (s *supervisorTestService) Close() error { | ||
| 422 | s.closeOnce.Do(func() { | 434 | s.closeOnce.Do(func() { | ||
M internal/usecase/picker/model.go +4 -0
| @@ -535,6 +535,10 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||
| 535 | 535 | continue | |
| 536 | 536 | } | |
| 537 | 537 | if r.rendersAsHeader() { | |
| 538 | + | if r.line.Attention && x < contentClipX { | |
| 539 | + | // A session header rings when any of its tabs does. | |
| 540 | + | x = ui.DrawText(frame, x, rect.Y+y, contentClipX, " "+string(ui.AttentionGlyph), base) | |
| 541 | + | } | |
| 538 | 542 | if r.line.Detail != "" && x < contentClipX { | |
| 539 | 543 | x = ui.DrawText(frame, x, rect.Y+y, contentClipX, " ", detailStyle) | |
| 540 | 544 | } | |
| @@ -535,6 +535,10 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||||
| 535 | continue | 535 | continue | ||
| 536 | } | 536 | } | ||
| 537 | if r.rendersAsHeader() { | 537 | if r.rendersAsHeader() { | ||
| 538 | + | if r.line.Attention && x < contentClipX { | |||
| 539 | + | // A session header rings when any of its tabs does. | |||
| 540 | + | x = ui.DrawText(frame, x, rect.Y+y, contentClipX, " "+string(ui.AttentionGlyph), base) | |||
| 541 | + | } | |||
| 538 | if r.line.Detail != "" && x < contentClipX { | 542 | if r.line.Detail != "" && x < contentClipX { | ||
| 539 | x = ui.DrawText(frame, x, rect.Y+y, contentClipX, " ", detailStyle) | 543 | x = ui.DrawText(frame, x, rect.Y+y, contentClipX, " ", detailStyle) | ||
| 540 | } | 544 | } | ||
M internal/usecase/picker/model_test.go +26 -0
| @@ -1,11 +1,13 @@ | |||
| 1 | 1 | package picker | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "strings" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | renderer "github.com/bnema/vev-vt" | |
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| 8 | 9 | "github.com/bnema/vev/internal/protocol" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 9 | 11 | "github.com/stretchr/testify/require" | |
| 10 | 12 | ) | |
| 11 | 13 | ||
| @@ -321,6 +323,30 @@ func TestRenderAttentionMarkerFollowsTheTabName(t *testing.T) { | |||
| 321 | 323 | require.NotEqual(t, rowText(frame.Row(0)), rowText(frame.Row(1))) | |
| 322 | 324 | } | |
| 323 | 325 | ||
| 326 | + | // TestRenderAttentionBellOnHeadersAndTabs pins Plan 003 C1: a session header | |
| 327 | + | // and a tab row both draw the bell right after their name when they ring, and | |
| 328 | + | // neither draws it otherwise. | |
| 329 | + | func TestRenderAttentionBellOnHeadersAndTabs(t *testing.T) { | |
| 330 | + | bell := string(ui.AttentionGlyph) | |
| 331 | + | tests := []struct { | |
| 332 | + | name string | |
| 333 | + | line protocol.PickerLine | |
| 334 | + | want bool | |
| 335 | + | }{ | |
| 336 | + | {name: "ringing header", line: protocol.PickerLine{Key: "s1", Kind: protocol.PickerLineSession, Label: "work", Detail: "attached", Attention: true}, want: true}, | |
| 337 | + | {name: "quiet header", line: protocol.PickerLine{Key: "s1", Kind: protocol.PickerLineSession, Label: "work", Detail: "attached"}}, | |
| 338 | + | {name: "ringing tab", line: protocol.PickerLine{Key: "t1", Kind: protocol.PickerLineTab, Label: "build", Attention: true, Focusable: true, Actions: protocol.PickerCanNavigate}, want: true}, | |
| 339 | + | {name: "quiet tab", line: protocol.PickerLine{Key: "t1", Kind: protocol.PickerLineTab, Label: "build", Focusable: true, Actions: protocol.PickerCanNavigate}}, | |
| 340 | + | } | |
| 341 | + | for _, tt := range tests { | |
| 342 | + | t.Run(tt.name, func(t *testing.T) { | |
| 343 | + | m := New([]protocol.PickerLine{tt.line}, Config{Intent: protocol.PickerIntentNavigation}) | |
| 344 | + | text := rowText(m.Render(domain.Size{Cols: 60, Rows: 6}, Preview{}).Row(0)) | |
| 345 | + | require.Equal(t, tt.want, strings.Contains(text, tt.line.Label+" "+bell), "row %q", text) | |
| 346 | + | }) | |
| 347 | + | } | |
| 348 | + | } | |
| 349 | + | ||
| 324 | 350 | func TestSearchMatchesLabelsAndDetails(t *testing.T) { | |
| 325 | 351 | m := New([]protocol.PickerLine{ | |
| 326 | 352 | navLine("a/one", "one"), | |
| @@ -1,11 +1,13 @@ | |||||
| 1 | package picker | 1 | package picker | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "strings" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | 6 | ||||
| 6 | renderer "github.com/bnema/vev-vt" | 7 | renderer "github.com/bnema/vev-vt" | ||
| 7 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 8 | "github.com/bnema/vev/internal/protocol" | 9 | "github.com/bnema/vev/internal/protocol" | ||
| 10 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 9 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 10 | ) | 12 | ) | ||
| 11 | 13 | ||||
| @@ -321,6 +323,30 @@ func TestRenderAttentionMarkerFollowsTheTabName(t *testing.T) { | |||||
| 321 | require.NotEqual(t, rowText(frame.Row(0)), rowText(frame.Row(1))) | 323 | require.NotEqual(t, rowText(frame.Row(0)), rowText(frame.Row(1))) | ||
| 322 | } | 324 | } | ||
| 323 | 325 | ||||
| 326 | + | // TestRenderAttentionBellOnHeadersAndTabs pins Plan 003 C1: a session header | |||
| 327 | + | // and a tab row both draw the bell right after their name when they ring, and | |||
| 328 | + | // neither draws it otherwise. | |||
| 329 | + | func TestRenderAttentionBellOnHeadersAndTabs(t *testing.T) { | |||
| 330 | + | bell := string(ui.AttentionGlyph) | |||
| 331 | + | tests := []struct { | |||
| 332 | + | name string | |||
| 333 | + | line protocol.PickerLine | |||
| 334 | + | want bool | |||
| 335 | + | }{ | |||
| 336 | + | {name: "ringing header", line: protocol.PickerLine{Key: "s1", Kind: protocol.PickerLineSession, Label: "work", Detail: "attached", Attention: true}, want: true}, | |||
| 337 | + | {name: "quiet header", line: protocol.PickerLine{Key: "s1", Kind: protocol.PickerLineSession, Label: "work", Detail: "attached"}}, | |||
| 338 | + | {name: "ringing tab", line: protocol.PickerLine{Key: "t1", Kind: protocol.PickerLineTab, Label: "build", Attention: true, Focusable: true, Actions: protocol.PickerCanNavigate}, want: true}, | |||
| 339 | + | {name: "quiet tab", line: protocol.PickerLine{Key: "t1", Kind: protocol.PickerLineTab, Label: "build", Focusable: true, Actions: protocol.PickerCanNavigate}}, | |||
| 340 | + | } | |||
| 341 | + | for _, tt := range tests { | |||
| 342 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 343 | + | m := New([]protocol.PickerLine{tt.line}, Config{Intent: protocol.PickerIntentNavigation}) | |||
| 344 | + | text := rowText(m.Render(domain.Size{Cols: 60, Rows: 6}, Preview{}).Row(0)) | |||
| 345 | + | require.Equal(t, tt.want, strings.Contains(text, tt.line.Label+" "+bell), "row %q", text) | |||
| 346 | + | }) | |||
| 347 | + | } | |||
| 348 | + | } | |||
| 349 | + | ||||
| 324 | func TestSearchMatchesLabelsAndDetails(t *testing.T) { | 350 | func TestSearchMatchesLabelsAndDetails(t *testing.T) { | ||
| 325 | m := New([]protocol.PickerLine{ | 351 | m := New([]protocol.PickerLine{ | ||
| 326 | navLine("a/one", "one"), | 352 | navLine("a/one", "one"), | ||