feat(client): keep attachment live under the picker overlay
SSP no longer detaches the attachment. The daemon sends a navigation PickerOffer and opens no interaction; the attached worker asks the supervisor for its client-owned picker, which enters the new Attached+Picker presentation (ADR 001 Amendment 1): - the foreground overlay slot, set under the output lease, suppresses attachment output (frames are still applied and acknowledged) and diverts every authorized input delivery to the picker, so the single terminal reader is kept and no picker byte reaches the session; - broker publications, previews, and resizes repaint the overlay; - Escape, q, and Ctrl+C, and a commit to the attached session, release the slot and the worker requests an authoritative repaint (OutputResetRequest); the attachment is never reconnected; - a commit to another target sends a clean Detach and swaps through Connecting without passing through the plain picker. The worker also presents the daemon's move-destination offers (MFP/MAT) through the same overlay slot with the existing typed PickerSelection and PickerClose replies, and declines a move offer it cannot present so the daemon never keeps swallowing input. Decision for a picker with no attachment (initial, or after loss): only the explicit exit key ends the process. Ctrl+C is the explicit exit; Escape and q are cancels, and with nothing to cancel back to they keep the picker and show a "press Ctrl+C to quit" notice. Over a live attachment all three cancel back to it. A lone Escape keypress now resolves on its disambiguation window through ports.Clock instead of waiting for another byte.
- commit
- f65f2e3773db5bc33f7632c2b3e5b48faacfbdde
- author
- brice · · 2026-09-22 19:44 UTC
- parent
- fff93b8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
14 files · +1660 -26
M internal/usecase/client/attachment_session.go +82 -3
| @@ -68,6 +68,9 @@ type sessionAttachmentConfig struct { | |||
| 68 | 68 | // BeforeAttached runs after the initial frame commits but before attached | |
| 69 | 69 | // presentation becomes observable. A failure keeps the run unattached. | |
| 70 | 70 | BeforeAttached func() error | |
| 71 | + | // Clock bounds the move picker's lone-escape window. A nil clock uses the | |
| 72 | + | // system clock. | |
| 73 | + | Clock ports.Clock | |
| 71 | 74 | } | |
| 72 | 75 | ||
| 73 | 76 | // sessionAttachmentWorker implements AttachmentWorker for one broker logical | |
| @@ -87,6 +90,9 @@ func newSessionAttachmentWorker(cfg sessionAttachmentConfig) (*sessionAttachment | |||
| 87 | 90 | } | |
| 88 | 91 | cfg.Request.Env = append([]string(nil), cfg.Request.Env...) | |
| 89 | 92 | cfg.SessionEnvironment = cfg.SessionEnvironment.Clone() | |
| 93 | + | if supervisorNil(cfg.Clock) { | |
| 94 | + | cfg.Clock = systemClock{} | |
| 95 | + | } | |
| 90 | 96 | return &sessionAttachmentWorker{cfg: cfg}, nil | |
| 91 | 97 | } | |
| 92 | 98 | ||
| @@ -188,6 +194,7 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f | |||
| 188 | 194 | return state, &event | |
| 189 | 195 | } | |
| 190 | 196 | state = next | |
| 197 | + | attachmentNoteCommitted(fg, state.context.Route.Target) | |
| 191 | 198 | // The frame transaction is the pre-attach publication: it commits the first | |
| 192 | 199 | // session bytes with the Connecting presentation and, through the foreground | |
| 193 | 200 | // shape rule, a public generation of zero. Attached is published only after | |
| @@ -246,6 +253,18 @@ type attachmentClientEvent struct { | |||
| 246 | 253 | action AttachmentLifecycleActionKind | |
| 247 | 254 | } | |
| 248 | 255 | ||
| 256 | + | // attachmentOverlay returns the optional overlay seam of fg, or nil. | |
| 257 | + | func attachmentOverlay(fg AttachmentForeground) attachmentOverlayForeground { | |
| 258 | + | overlay, _ := fg.(attachmentOverlayForeground) | |
| 259 | + | return overlay | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func attachmentNoteCommitted(fg AttachmentForeground, target protocol.ExactSessionTarget) { | |
| 263 | + | if overlay := attachmentOverlay(fg); overlay != nil { | |
| 264 | + | overlay.noteCommitted(target) | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | ||
| 249 | 268 | var ( | |
| 250 | 269 | errDetachToPicker = errors.New("client: detach to picker") | |
| 251 | 270 | errDetachAndExit = errors.New("client: detach and exit") | |
| @@ -261,8 +280,30 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 261 | 280 | go pumpAttachmentGeometry(pumpCtx, fg, outgoing) | |
| 262 | 281 | go pumpAttachmentLifecycle(pumpCtx, fg, outgoing) | |
| 263 | 282 | outputResetRequested := false | |
| 283 | + | overlay := attachmentOverlay(fg) | |
| 284 | + | var repaint <-chan struct{} | |
| 285 | + | if overlay != nil { | |
| 286 | + | repaint = overlay.overlayRepaint() | |
| 287 | + | } | |
| 288 | + | picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay()} | |
| 289 | + | defer picker.stopEscape() | |
| 290 | + | var inputSeq uint64 | |
| 264 | 291 | for { | |
| 265 | 292 | select { | |
| 293 | + | case <-repaint: | |
| 294 | + | // An overlay released the terminal: the picker box is still on screen | |
| 295 | + | // and suppressed frames were never written, so ask the daemon for an | |
| 296 | + | // authoritative full repaint. | |
| 297 | + | if err := w.send(ctx, fg, stream, protocol.OutputResetRequest{}); err != nil { | |
| 298 | + | return w.settle(ctx, fg, stream, token, err) | |
| 299 | + | } | |
| 300 | + | outputResetRequested = true | |
| 301 | + | case <-picker.escape(): | |
| 302 | + | picker.escapeFired() | |
| 303 | + | op, changed := picker.move.flush() | |
| 304 | + | if err := picker.applyOp(ctx, state, op, changed); err != nil { | |
| 305 | + | return w.settle(ctx, fg, stream, token, err) | |
| 306 | + | } | |
| 266 | 307 | case result := <-incoming: | |
| 267 | 308 | if result.err != nil { | |
| 268 | 309 | return w.settle(ctx, fg, stream, token, result.err) | |
| @@ -275,6 +316,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 275 | 316 | } | |
| 276 | 317 | state = next | |
| 277 | 318 | outputResetRequested = false | |
| 319 | + | attachmentNoteCommitted(fg, state.context.Route.Target) | |
| 278 | 320 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil { | |
| 279 | 321 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)} | |
| 280 | 322 | } | |
| @@ -298,6 +340,16 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 298 | 340 | } | |
| 299 | 341 | case protocol.UIReceipt: | |
| 300 | 342 | attachmentUIReceipt(fg, typed) | |
| 343 | + | case protocol.PickerOffer: | |
| 344 | + | if err := picker.offer(ctx, typed); err != nil { | |
| 345 | + | return w.settle(ctx, fg, stream, token, err) | |
| 346 | + | } | |
| 347 | + | case protocol.PickerSnapshot: | |
| 348 | + | if err := picker.snapshot(ctx, state, typed); err != nil { | |
| 349 | + | return w.settle(ctx, fg, stream, token, err) | |
| 350 | + | } | |
| 351 | + | case protocol.PickerClosed: | |
| 352 | + | picker.closed(typed.InteractionID) | |
| 301 | 353 | case protocol.ErrorMsg: | |
| 302 | 354 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}} | |
| 303 | 355 | case protocol.Detached: | |
| @@ -311,6 +363,32 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 311 | 363 | fg.PreserveInput(event.input.Data) | |
| 312 | 364 | return w.settle(ctx, fg, stream, token, event.input.Err) | |
| 313 | 365 | } | |
| 366 | + | if event.input != nil { | |
| 367 | + | consumed, err := picker.consumeInput(ctx, state, *event.input) | |
| 368 | + | if err != nil { | |
| 369 | + | fg.PreserveInput(event.input.Data) | |
| 370 | + | return w.settle(ctx, fg, stream, token, err) | |
| 371 | + | } | |
| 372 | + | if consumed { | |
| 373 | + | // The overlay owned these bytes: nothing reaches the session. | |
| 374 | + | // A driver action still fences through the daemon so it | |
| 375 | + | // completes after the overlay applied it. | |
| 376 | + | fg.AckInput() | |
| 377 | + | if event.input.actionID != 0 { | |
| 378 | + | if err := w.send(ctx, fg, stream, protocol.UIFence{ActionID: event.input.actionID}); err != nil { | |
| 379 | + | return w.settle(ctx, fg, stream, token, err) | |
| 380 | + | } | |
| 381 | + | } | |
| 382 | + | continue | |
| 383 | + | } | |
| 384 | + | inputSeq++ | |
| 385 | + | event.message = protocol.Input{InputSeq: inputSeq, ActionID: event.input.actionID, Data: append([]byte(nil), event.input.Data...)} | |
| 386 | + | } | |
| 387 | + | if resize, ok := event.message.(protocol.Resize); ok { | |
| 388 | + | if err := picker.resize(ctx, state, resize.Size); err != nil { | |
| 389 | + | return w.settle(ctx, fg, stream, token, err) | |
| 390 | + | } | |
| 391 | + | } | |
| 314 | 392 | if err := w.send(ctx, fg, stream, event.message); err != nil { | |
| 315 | 393 | if event.input != nil { | |
| 316 | 394 | fg.PreserveInput(event.input.Data) | |
| @@ -364,8 +442,10 @@ func attachmentUIReceipt(fg AttachmentForeground, receipt protocol.UIReceipt) { | |||
| 364 | 442 | } | |
| 365 | 443 | } | |
| 366 | 444 | ||
| 445 | + | // pumpAttachmentInput forwards each authorized delivery to the attached loop. | |
| 446 | + | // The loop decides whether an overlay owns it or it becomes session Input, and | |
| 447 | + | // sequences only the deliveries that actually reach the session. | |
| 367 | 448 | func pumpAttachmentInput(ctx context.Context, fg AttachmentForeground, out chan<- attachmentClientEvent) { | |
| 368 | - | var sequence uint64 | |
| 369 | 449 | for { | |
| 370 | 450 | event, ok := fg.Input(ctx) | |
| 371 | 451 | if !ok { | |
| @@ -379,10 +459,9 @@ func pumpAttachmentInput(ctx context.Context, fg AttachmentForeground, out chan< | |||
| 379 | 459 | } | |
| 380 | 460 | return | |
| 381 | 461 | } | |
| 382 | - | sequence++ | |
| 383 | 462 | copyEvent := event | |
| 384 | 463 | select { | |
| 385 | - | case out <- attachmentClientEvent{message: protocol.Input{InputSeq: sequence, ActionID: event.actionID, Data: append([]byte(nil), event.Data...)}, input: ©Event}: | |
| 464 | + | case out <- attachmentClientEvent{input: ©Event}: | |
| 386 | 465 | case <-ctx.Done(): | |
| 387 | 466 | fg.PreserveInput(event.Data) | |
| 388 | 467 | return | |
| @@ -68,6 +68,9 @@ type sessionAttachmentConfig struct { | |||||
| 68 | // BeforeAttached runs after the initial frame commits but before attached | 68 | // BeforeAttached runs after the initial frame commits but before attached | ||
| 69 | // presentation becomes observable. A failure keeps the run unattached. | 69 | // presentation becomes observable. A failure keeps the run unattached. | ||
| 70 | BeforeAttached func() error | 70 | BeforeAttached func() error | ||
| 71 | + | // Clock bounds the move picker's lone-escape window. A nil clock uses the | |||
| 72 | + | // system clock. | |||
| 73 | + | Clock ports.Clock | |||
| 71 | } | 74 | } | ||
| 72 | 75 | ||||
| 73 | // sessionAttachmentWorker implements AttachmentWorker for one broker logical | 76 | // sessionAttachmentWorker implements AttachmentWorker for one broker logical | ||
| @@ -87,6 +90,9 @@ func newSessionAttachmentWorker(cfg sessionAttachmentConfig) (*sessionAttachment | |||||
| 87 | } | 90 | } | ||
| 88 | cfg.Request.Env = append([]string(nil), cfg.Request.Env...) | 91 | cfg.Request.Env = append([]string(nil), cfg.Request.Env...) | ||
| 89 | cfg.SessionEnvironment = cfg.SessionEnvironment.Clone() | 92 | cfg.SessionEnvironment = cfg.SessionEnvironment.Clone() | ||
| 93 | + | if supervisorNil(cfg.Clock) { | |||
| 94 | + | cfg.Clock = systemClock{} | |||
| 95 | + | } | |||
| 90 | return &sessionAttachmentWorker{cfg: cfg}, nil | 96 | return &sessionAttachmentWorker{cfg: cfg}, nil | ||
| 91 | } | 97 | } | ||
| 92 | 98 | ||||
| @@ -188,6 +194,7 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f | |||||
| 188 | return state, &event | 194 | return state, &event | ||
| 189 | } | 195 | } | ||
| 190 | state = next | 196 | state = next | ||
| 197 | + | attachmentNoteCommitted(fg, state.context.Route.Target) | |||
| 191 | // The frame transaction is the pre-attach publication: it commits the first | 198 | // The frame transaction is the pre-attach publication: it commits the first | ||
| 192 | // session bytes with the Connecting presentation and, through the foreground | 199 | // session bytes with the Connecting presentation and, through the foreground | ||
| 193 | // shape rule, a public generation of zero. Attached is published only after | 200 | // shape rule, a public generation of zero. Attached is published only after | ||
| @@ -246,6 +253,18 @@ type attachmentClientEvent struct { | |||||
| 246 | action AttachmentLifecycleActionKind | 253 | action AttachmentLifecycleActionKind | ||
| 247 | } | 254 | } | ||
| 248 | 255 | ||||
| 256 | + | // attachmentOverlay returns the optional overlay seam of fg, or nil. | |||
| 257 | + | func attachmentOverlay(fg AttachmentForeground) attachmentOverlayForeground { | |||
| 258 | + | overlay, _ := fg.(attachmentOverlayForeground) | |||
| 259 | + | return overlay | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func attachmentNoteCommitted(fg AttachmentForeground, target protocol.ExactSessionTarget) { | |||
| 263 | + | if overlay := attachmentOverlay(fg); overlay != nil { | |||
| 264 | + | overlay.noteCommitted(target) | |||
| 265 | + | } | |||
| 266 | + | } | |||
| 267 | + | ||||
| 249 | var ( | 268 | var ( | ||
| 250 | errDetachToPicker = errors.New("client: detach to picker") | 269 | errDetachToPicker = errors.New("client: detach to picker") | ||
| 251 | errDetachAndExit = errors.New("client: detach and exit") | 270 | errDetachAndExit = errors.New("client: detach and exit") | ||
| @@ -261,8 +280,30 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 261 | go pumpAttachmentGeometry(pumpCtx, fg, outgoing) | 280 | go pumpAttachmentGeometry(pumpCtx, fg, outgoing) | ||
| 262 | go pumpAttachmentLifecycle(pumpCtx, fg, outgoing) | 281 | go pumpAttachmentLifecycle(pumpCtx, fg, outgoing) | ||
| 263 | outputResetRequested := false | 282 | outputResetRequested := false | ||
| 283 | + | overlay := attachmentOverlay(fg) | |||
| 284 | + | var repaint <-chan struct{} | |||
| 285 | + | if overlay != nil { | |||
| 286 | + | repaint = overlay.overlayRepaint() | |||
| 287 | + | } | |||
| 288 | + | picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay()} | |||
| 289 | + | defer picker.stopEscape() | |||
| 290 | + | var inputSeq uint64 | |||
| 264 | for { | 291 | for { | ||
| 265 | select { | 292 | select { | ||
| 293 | + | case <-repaint: | |||
| 294 | + | // An overlay released the terminal: the picker box is still on screen | |||
| 295 | + | // and suppressed frames were never written, so ask the daemon for an | |||
| 296 | + | // authoritative full repaint. | |||
| 297 | + | if err := w.send(ctx, fg, stream, protocol.OutputResetRequest{}); err != nil { | |||
| 298 | + | return w.settle(ctx, fg, stream, token, err) | |||
| 299 | + | } | |||
| 300 | + | outputResetRequested = true | |||
| 301 | + | case <-picker.escape(): | |||
| 302 | + | picker.escapeFired() | |||
| 303 | + | op, changed := picker.move.flush() | |||
| 304 | + | if err := picker.applyOp(ctx, state, op, changed); err != nil { | |||
| 305 | + | return w.settle(ctx, fg, stream, token, err) | |||
| 306 | + | } | |||
| 266 | case result := <-incoming: | 307 | case result := <-incoming: | ||
| 267 | if result.err != nil { | 308 | if result.err != nil { | ||
| 268 | return w.settle(ctx, fg, stream, token, result.err) | 309 | return w.settle(ctx, fg, stream, token, result.err) | ||
| @@ -275,6 +316,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 275 | } | 316 | } | ||
| 276 | state = next | 317 | state = next | ||
| 277 | outputResetRequested = false | 318 | outputResetRequested = false | ||
| 319 | + | attachmentNoteCommitted(fg, state.context.Route.Target) | |||
| 278 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil { | 320 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil { | ||
| 279 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)} | 321 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)} | ||
| 280 | } | 322 | } | ||
| @@ -298,6 +340,16 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 298 | } | 340 | } | ||
| 299 | case protocol.UIReceipt: | 341 | case protocol.UIReceipt: | ||
| 300 | attachmentUIReceipt(fg, typed) | 342 | attachmentUIReceipt(fg, typed) | ||
| 343 | + | case protocol.PickerOffer: | |||
| 344 | + | if err := picker.offer(ctx, typed); err != nil { | |||
| 345 | + | return w.settle(ctx, fg, stream, token, err) | |||
| 346 | + | } | |||
| 347 | + | case protocol.PickerSnapshot: | |||
| 348 | + | if err := picker.snapshot(ctx, state, typed); err != nil { | |||
| 349 | + | return w.settle(ctx, fg, stream, token, err) | |||
| 350 | + | } | |||
| 351 | + | case protocol.PickerClosed: | |||
| 352 | + | picker.closed(typed.InteractionID) | |||
| 301 | case protocol.ErrorMsg: | 353 | case protocol.ErrorMsg: | ||
| 302 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}} | 354 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}} | ||
| 303 | case protocol.Detached: | 355 | case protocol.Detached: | ||
| @@ -311,6 +363,32 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 311 | fg.PreserveInput(event.input.Data) | 363 | fg.PreserveInput(event.input.Data) | ||
| 312 | return w.settle(ctx, fg, stream, token, event.input.Err) | 364 | return w.settle(ctx, fg, stream, token, event.input.Err) | ||
| 313 | } | 365 | } | ||
| 366 | + | if event.input != nil { | |||
| 367 | + | consumed, err := picker.consumeInput(ctx, state, *event.input) | |||
| 368 | + | if err != nil { | |||
| 369 | + | fg.PreserveInput(event.input.Data) | |||
| 370 | + | return w.settle(ctx, fg, stream, token, err) | |||
| 371 | + | } | |||
| 372 | + | if consumed { | |||
| 373 | + | // The overlay owned these bytes: nothing reaches the session. | |||
| 374 | + | // A driver action still fences through the daemon so it | |||
| 375 | + | // completes after the overlay applied it. | |||
| 376 | + | fg.AckInput() | |||
| 377 | + | if event.input.actionID != 0 { | |||
| 378 | + | if err := w.send(ctx, fg, stream, protocol.UIFence{ActionID: event.input.actionID}); err != nil { | |||
| 379 | + | return w.settle(ctx, fg, stream, token, err) | |||
| 380 | + | } | |||
| 381 | + | } | |||
| 382 | + | continue | |||
| 383 | + | } | |||
| 384 | + | inputSeq++ | |||
| 385 | + | event.message = protocol.Input{InputSeq: inputSeq, ActionID: event.input.actionID, Data: append([]byte(nil), event.input.Data...)} | |||
| 386 | + | } | |||
| 387 | + | if resize, ok := event.message.(protocol.Resize); ok { | |||
| 388 | + | if err := picker.resize(ctx, state, resize.Size); err != nil { | |||
| 389 | + | return w.settle(ctx, fg, stream, token, err) | |||
| 390 | + | } | |||
| 391 | + | } | |||
| 314 | if err := w.send(ctx, fg, stream, event.message); err != nil { | 392 | if err := w.send(ctx, fg, stream, event.message); err != nil { | ||
| 315 | if event.input != nil { | 393 | if event.input != nil { | ||
| 316 | fg.PreserveInput(event.input.Data) | 394 | fg.PreserveInput(event.input.Data) | ||
| @@ -364,8 +442,10 @@ func attachmentUIReceipt(fg AttachmentForeground, receipt protocol.UIReceipt) { | |||||
| 364 | } | 442 | } | ||
| 365 | } | 443 | } | ||
| 366 | 444 | ||||
| 445 | + | // pumpAttachmentInput forwards each authorized delivery to the attached loop. | |||
| 446 | + | // The loop decides whether an overlay owns it or it becomes session Input, and | |||
| 447 | + | // sequences only the deliveries that actually reach the session. | |||
| 367 | func pumpAttachmentInput(ctx context.Context, fg AttachmentForeground, out chan<- attachmentClientEvent) { | 448 | func pumpAttachmentInput(ctx context.Context, fg AttachmentForeground, out chan<- attachmentClientEvent) { | ||
| 368 | - | var sequence uint64 | |||
| 369 | for { | 449 | for { | ||
| 370 | event, ok := fg.Input(ctx) | 450 | event, ok := fg.Input(ctx) | ||
| 371 | if !ok { | 451 | if !ok { | ||
| @@ -379,10 +459,9 @@ func pumpAttachmentInput(ctx context.Context, fg AttachmentForeground, out chan< | |||||
| 379 | } | 459 | } | ||
| 380 | return | 460 | return | ||
| 381 | } | 461 | } | ||
| 382 | - | sequence++ | |||
| 383 | copyEvent := event | 462 | copyEvent := event | ||
| 384 | select { | 463 | select { | ||
| 385 | - | case out <- attachmentClientEvent{message: protocol.Input{InputSeq: sequence, ActionID: event.actionID, Data: append([]byte(nil), event.Data...)}, input: ©Event}: | 464 | + | case out <- attachmentClientEvent{input: ©Event}: |
| 386 | case <-ctx.Done(): | 465 | case <-ctx.Done(): | ||
| 387 | fg.PreserveInput(event.Data) | 466 | fg.PreserveInput(event.Data) | ||
| 388 | return | 467 | return | ||
M internal/usecase/client/attachment_worker.go +284 -0
| @@ -392,6 +392,14 @@ type attachmentHost struct { | |||
| 392 | 392 | geometryCancel context.CancelFunc | |
| 393 | 393 | geometryDone chan struct{} | |
| 394 | 394 | ownerBoundary bool | |
| 395 | + | ||
| 396 | + | // navigationRequests carries a coalesced request from the attached worker | |
| 397 | + | // to compose the client's session picker over the live attachment (the | |
| 398 | + | // daemon's navigation PickerOffer). Only the supervisor consumes it. | |
| 399 | + | navigationRequests chan struct{} | |
| 400 | + | // internalActions carries supervisor-originated lifecycle decisions for the | |
| 401 | + | // current foreground, such as the detach that precedes an attachment swap. | |
| 402 | + | internalActions chan AttachmentLifecycleAction | |
| 395 | 403 | } | |
| 396 | 404 | ||
| 397 | 405 | // newAttachmentHost builds the reusable foreground host. A nil clock falls back | |
| @@ -429,6 +437,8 @@ func newAttachmentHost(cfg attachmentHostConfig) *attachmentHost { | |||
| 429 | 437 | geometryValid: initialGeometryValid, | |
| 430 | 438 | geometryUpdate: make(chan struct{}, 1), | |
| 431 | 439 | presentationUpdate: make(chan struct{}, 1), | |
| 440 | + | navigationRequests: make(chan struct{}, 1), | |
| 441 | + | internalActions: make(chan AttachmentLifecycleAction, 1), | |
| 432 | 442 | } | |
| 433 | 443 | } | |
| 434 | 444 | ||
| @@ -460,6 +470,9 @@ func (h *attachmentHost) Begin(ctx context.Context, token AttachmentToken, worke | |||
| 460 | 470 | cancel() | |
| 461 | 471 | return nil, false | |
| 462 | 472 | } | |
| 473 | + | // A request or action left behind by a previous foreground never applies | |
| 474 | + | // to this one. | |
| 475 | + | h.drainForegroundSignals() | |
| 463 | 476 | if h.actionUI != nil { | |
| 464 | 477 | fg.uiGeneration = h.actionUI.bindForeground(workerCtx, fg.input, fg.consumer) | |
| 465 | 478 | } | |
| @@ -595,7 +608,245 @@ func (h *attachmentHost) newForeground(token AttachmentToken, stream ports.Broke | |||
| 595 | 608 | done: make(chan struct{}), | |
| 596 | 609 | decided: true, | |
| 597 | 610 | onAttached: h.onAttach, | |
| 611 | + | repaint: make(chan struct{}, 1), | |
| 612 | + | } | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | // drainForegroundSignals drops any navigation request or internal lifecycle | |
| 616 | + | // action that belonged to an earlier foreground. | |
| 617 | + | func (h *attachmentHost) drainForegroundSignals() { | |
| 618 | + | for { | |
| 619 | + | select { | |
| 620 | + | case <-h.navigationRequests: | |
| 621 | + | case <-h.internalActions: | |
| 622 | + | default: | |
| 623 | + | return | |
| 624 | + | } | |
| 625 | + | } | |
| 626 | + | } | |
| 627 | + | ||
| 628 | + | // NavigationRequests wakes the supervisor when the attached worker asked for | |
| 629 | + | // the client picker over the live attachment. | |
| 630 | + | func (h *attachmentHost) NavigationRequests() <-chan struct{} { | |
| 631 | + | if h == nil { | |
| 632 | + | return nil | |
| 633 | + | } | |
| 634 | + | return h.navigationRequests | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | // beginNavigationOverlay composes the supervisor-owned picker over the current | |
| 638 | + | // foreground: from its return, attachment output is applied and acknowledged | |
| 639 | + | // but never written, and every authorized input delivery is handed to sink. It | |
| 640 | + | // reports false when no active foreground can host the overlay. | |
| 641 | + | func (h *attachmentHost) beginNavigationOverlay(sink pickerInputConsumer) bool { | |
| 642 | + | if h == nil || sink == nil { | |
| 643 | + | return false | |
| 644 | + | } | |
| 645 | + | fg := h.authority.foreground() | |
| 646 | + | if fg == nil { | |
| 647 | + | return false | |
| 648 | + | } | |
| 649 | + | return fg.setOverlay(attachmentOverlayNavigation, sink) | |
| 650 | + | } | |
| 651 | + | ||
| 652 | + | // endNavigationOverlay returns the terminal to the current foreground and asks | |
| 653 | + | // its worker for an authoritative repaint over the picker box. | |
| 654 | + | func (h *attachmentHost) endNavigationOverlay() { | |
| 655 | + | if h == nil { | |
| 656 | + | return | |
| 657 | + | } | |
| 658 | + | if fg := h.authority.foreground(); fg != nil { | |
| 659 | + | fg.clearOverlay(attachmentOverlayNavigation) | |
| 660 | + | } | |
| 661 | + | } | |
| 662 | + | ||
| 663 | + | // committedTarget reports the session the current foreground last committed | |
| 664 | + | // output for, so the supervisor can tell a commit to the attached session from | |
| 665 | + | // a commit elsewhere. | |
| 666 | + | func (h *attachmentHost) committedTarget() (protocol.ExactSessionTarget, bool) { | |
| 667 | + | if h == nil { | |
| 668 | + | return protocol.ExactSessionTarget{}, false | |
| 669 | + | } | |
| 670 | + | fg := h.authority.foreground() | |
| 671 | + | if fg == nil { | |
| 672 | + | return protocol.ExactSessionTarget{}, false | |
| 673 | + | } | |
| 674 | + | fg.overlayMu.Lock() | |
| 675 | + | defer fg.overlayMu.Unlock() | |
| 676 | + | return fg.committed, fg.committedKnown | |
| 677 | + | } | |
| 678 | + | ||
| 679 | + | // committedTargetOrZero is committedTarget with an unknown target as zero. | |
| 680 | + | func (h *attachmentHost) committedTargetOrZero() protocol.ExactSessionTarget { | |
| 681 | + | target, ok := h.committedTarget() | |
| 682 | + | if !ok { | |
| 683 | + | return protocol.ExactSessionTarget{} | |
| 684 | + | } | |
| 685 | + | return target | |
| 686 | + | } | |
| 687 | + | ||
| 688 | + | // requestDetach asks the current foreground's worker to detach explicitly, so | |
| 689 | + | // the daemon observes a clean Detach before the supervisor swaps attachments. | |
| 690 | + | func (h *attachmentHost) requestDetach(token AttachmentToken) { | |
| 691 | + | if h == nil { | |
| 692 | + | return | |
| 693 | + | } | |
| 694 | + | select { | |
| 695 | + | case h.internalActions <- AttachmentLifecycleAction{Token: token, Kind: AttachmentDetachToPicker}: | |
| 696 | + | default: | |
| 697 | + | } | |
| 698 | + | } | |
| 699 | + | ||
| 700 | + | // attachmentOverlayKind names the client-composed overlay that owns the | |
| 701 | + | // terminal over a live attachment. | |
| 702 | + | type attachmentOverlayKind uint8 | |
| 703 | + | ||
| 704 | + | const ( | |
| 705 | + | attachmentOverlayNone attachmentOverlayKind = iota | |
| 706 | + | // attachmentOverlayNavigation is the supervisor-owned session picker. | |
| 707 | + | attachmentOverlayNavigation | |
| 708 | + | // attachmentOverlayMove is the worker-owned move-destination picker the | |
| 709 | + | // serving daemon offered. | |
| 710 | + | attachmentOverlayMove | |
| 711 | + | ) | |
| 712 | + | ||
| 713 | + | // attachmentOverlayForeground is the optional overlay seam of the real | |
| 714 | + | // foreground. A worker reaches it by assertion so scripted foregrounds may | |
| 715 | + | // omit it; without it, overlays are simply never presented. | |
| 716 | + | type attachmentOverlayForeground interface { | |
| 717 | + | requestNavigationPicker() | |
| 718 | + | setOverlay(kind attachmentOverlayKind, sink pickerInputConsumer) bool | |
| 719 | + | clearOverlay(kind attachmentOverlayKind) | |
| 720 | + | overlayKind() attachmentOverlayKind | |
| 721 | + | divertInput(data []byte) bool | |
| 722 | + | overlayRepaint() <-chan struct{} | |
| 723 | + | overlayOutput(uiContext ports.UIContext, data []byte) error | |
| 724 | + | noteCommitted(target protocol.ExactSessionTarget) | |
| 725 | + | } | |
| 726 | + | ||
| 727 | + | var _ attachmentOverlayForeground = (*attachmentForeground)(nil) | |
| 728 | + | ||
| 729 | + | // requestNavigationPicker records the daemon's navigation offer for the | |
| 730 | + | // supervisor. The request is coalesced; the supervisor decides whether the | |
| 731 | + | // current presentation can host the overlay. | |
| 732 | + | func (f *attachmentForeground) requestNavigationPicker() { | |
| 733 | + | if f == nil || f.host == nil || !f.authority.actionAuthorized(f) { | |
| 734 | + | return | |
| 735 | + | } | |
| 736 | + | select { | |
| 737 | + | case f.host.navigationRequests <- struct{}{}: | |
| 738 | + | default: | |
| 739 | + | } | |
| 740 | + | } | |
| 741 | + | ||
| 742 | + | // setOverlay installs kind under the output lease, so no attachment frame is | |
| 743 | + | // mid-write when the overlay takes the terminal. An occupied slot refuses a | |
| 744 | + | // different overlay. | |
| 745 | + | func (f *attachmentForeground) setOverlay(kind attachmentOverlayKind, sink pickerInputConsumer) bool { | |
| 746 | + | if f == nil || kind == attachmentOverlayNone { | |
| 747 | + | return false | |
| 748 | + | } | |
| 749 | + | installed := false | |
| 750 | + | f.lease.send(func() bool { | |
| 751 | + | if !f.authority.actionAuthorized(f) { | |
| 752 | + | return false | |
| 753 | + | } | |
| 754 | + | f.overlayMu.Lock() | |
| 755 | + | defer f.overlayMu.Unlock() | |
| 756 | + | if f.overlay != attachmentOverlayNone && f.overlay != kind { | |
| 757 | + | return true | |
| 758 | + | } | |
| 759 | + | f.overlay = kind | |
| 760 | + | f.overlaySink = sink | |
| 761 | + | installed = true | |
| 762 | + | return true | |
| 763 | + | }) | |
| 764 | + | return installed | |
| 765 | + | } | |
| 766 | + | ||
| 767 | + | // clearOverlay releases kind under the output lease and asks the worker for an | |
| 768 | + | // authoritative repaint: the terminal still shows the picker box and any | |
| 769 | + | // attachment frames it suppressed were never written. | |
| 770 | + | func (f *attachmentForeground) clearOverlay(kind attachmentOverlayKind) { | |
| 771 | + | if f == nil { | |
| 772 | + | return | |
| 773 | + | } | |
| 774 | + | cleared := false | |
| 775 | + | f.lease.send(func() bool { | |
| 776 | + | f.overlayMu.Lock() | |
| 777 | + | defer f.overlayMu.Unlock() | |
| 778 | + | if f.overlay != kind { | |
| 779 | + | return true | |
| 780 | + | } | |
| 781 | + | f.overlay = attachmentOverlayNone | |
| 782 | + | f.overlaySink = nil | |
| 783 | + | cleared = true | |
| 784 | + | return true | |
| 785 | + | }) | |
| 786 | + | if !cleared { | |
| 787 | + | // The lease stops at finalization; the overlay dies with the grant. | |
| 788 | + | f.overlayMu.Lock() | |
| 789 | + | if f.overlay == kind { | |
| 790 | + | f.overlay = attachmentOverlayNone | |
| 791 | + | f.overlaySink = nil | |
| 792 | + | } | |
| 793 | + | f.overlayMu.Unlock() | |
| 794 | + | return | |
| 795 | + | } | |
| 796 | + | select { | |
| 797 | + | case f.repaint <- struct{}{}: | |
| 798 | + | default: | |
| 799 | + | } | |
| 800 | + | } | |
| 801 | + | ||
| 802 | + | func (f *attachmentForeground) overlayKind() attachmentOverlayKind { | |
| 803 | + | if f == nil { | |
| 804 | + | return attachmentOverlayNone | |
| 598 | 805 | } | |
| 806 | + | f.overlayMu.Lock() | |
| 807 | + | defer f.overlayMu.Unlock() | |
| 808 | + | return f.overlay | |
| 809 | + | } | |
| 810 | + | ||
| 811 | + | // divertInput hands one authorized delivery to the supervisor-owned overlay. | |
| 812 | + | // It reports false when no navigation overlay owns input, so the worker keeps | |
| 813 | + | // its session path. | |
| 814 | + | func (f *attachmentForeground) divertInput(data []byte) bool { | |
| 815 | + | if f == nil { | |
| 816 | + | return false | |
| 817 | + | } | |
| 818 | + | f.overlayMu.Lock() | |
| 819 | + | sink := f.overlaySink | |
| 820 | + | kind := f.overlay | |
| 821 | + | f.overlayMu.Unlock() | |
| 822 | + | if kind != attachmentOverlayNavigation || sink == nil { | |
| 823 | + | return false | |
| 824 | + | } | |
| 825 | + | sink.ConsumeTerminalRead(data) | |
| 826 | + | return true | |
| 827 | + | } | |
| 828 | + | ||
| 829 | + | func (f *attachmentForeground) overlayRepaint() <-chan struct{} { | |
| 830 | + | if f == nil { | |
| 831 | + | return nil | |
| 832 | + | } | |
| 833 | + | return f.repaint | |
| 834 | + | } | |
| 835 | + | ||
| 836 | + | func (f *attachmentForeground) noteCommitted(target protocol.ExactSessionTarget) { | |
| 837 | + | if f == nil { | |
| 838 | + | return | |
| 839 | + | } | |
| 840 | + | f.overlayMu.Lock() | |
| 841 | + | f.committed = target | |
| 842 | + | f.committedKnown = true | |
| 843 | + | f.overlayMu.Unlock() | |
| 844 | + | } | |
| 845 | + | ||
| 846 | + | // overlayOutput writes one overlay frame through the same lease and UI | |
| 847 | + | // transaction as attachment output, bypassing only the overlay suppression. | |
| 848 | + | func (f *attachmentForeground) overlayOutput(uiContext ports.UIContext, data []byte) error { | |
| 849 | + | return f.write(uiContext, data, true) | |
| 599 | 850 | } | |
| 600 | 851 | ||
| 601 | 852 | // attachmentRun is one in-flight worker generation owned by the supervisor. | |
| @@ -726,6 +977,18 @@ type attachmentForeground struct { | |||
| 726 | 977 | geometrySeq uint64 | |
| 727 | 978 | attached atomic.Bool | |
| 728 | 979 | onAttached func(AttachmentToken) | |
| 980 | + | ||
| 981 | + | // overlayMu guards the overlay slot and the committed target. The slot is | |
| 982 | + | // changed only inside the output lease, so an overlay never takes the | |
| 983 | + | // terminal while an attachment frame is mid-write. | |
| 984 | + | overlayMu sync.Mutex | |
| 985 | + | overlay attachmentOverlayKind | |
| 986 | + | overlaySink pickerInputConsumer | |
| 987 | + | committed protocol.ExactSessionTarget | |
| 988 | + | committedKnown bool | |
| 989 | + | // repaint wakes the worker to request an authoritative repaint once an | |
| 990 | + | // overlay released the terminal. | |
| 991 | + | repaint chan struct{} | |
| 729 | 992 | } | |
| 730 | 993 | ||
| 731 | 994 | // Token is the generation/attempt identity of this grant. | |
| @@ -904,6 +1167,10 @@ func (f *attachmentForeground) Lifecycle(ctx context.Context) (AttachmentLifecyc | |||
| 904 | 1167 | if !f.authority.actionAuthorized(f) { | |
| 905 | 1168 | return 0, false | |
| 906 | 1169 | } | |
| 1170 | + | var internal <-chan AttachmentLifecycleAction | |
| 1171 | + | if f.host != nil { | |
| 1172 | + | internal = f.host.internalActions | |
| 1173 | + | } | |
| 907 | 1174 | select { | |
| 908 | 1175 | case action, ok := <-f.actions: | |
| 909 | 1176 | if !ok || !f.authority.actionAuthorized(f) { | |
| @@ -913,6 +1180,11 @@ func (f *attachmentForeground) Lifecycle(ctx context.Context) (AttachmentLifecyc | |||
| 913 | 1180 | continue | |
| 914 | 1181 | } | |
| 915 | 1182 | return action.Kind, action.Kind == AttachmentDetachToPicker || action.Kind == AttachmentDetachAndExit | |
| 1183 | + | case action := <-internal: | |
| 1184 | + | if action.Token != f.token || !f.authority.actionAuthorized(f) { | |
| 1185 | + | continue | |
| 1186 | + | } | |
| 1187 | + | return action.Kind, action.Kind == AttachmentDetachToPicker || action.Kind == AttachmentDetachAndExit | |
| 916 | 1188 | case <-ctx.Done(): | |
| 917 | 1189 | return 0, false | |
| 918 | 1190 | case <-f.done: | |
| @@ -973,6 +1245,15 @@ func (f *attachmentForeground) Resize(ctx context.Context) (domain.Geometry, boo | |||
| 973 | 1245 | // flush, and EndOutput(true) commit the frame; any failed or short write or | |
| 974 | 1246 | // failed flush leaves EndOutput(false). | |
| 975 | 1247 | func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) error { | |
| 1248 | + | return f.write(uiContext, data, false) | |
| 1249 | + | } | |
| 1250 | + | ||
| 1251 | + | // write is the shared output transaction. While an overlay owns the terminal an | |
| 1252 | + | // attachment frame (overlay=false) is accepted without being written or | |
| 1253 | + | // published: the caller still applies and acknowledges it, and the overlay's | |
| 1254 | + | // release requests an authoritative repaint. Overlay frames bypass only that | |
| 1255 | + | // suppression. | |
| 1256 | + | func (f *attachmentForeground) write(uiContext ports.UIContext, data []byte, overlay bool) error { | |
| 976 | 1257 | if f == nil { | |
| 977 | 1258 | return errAttachmentForegroundRevoked | |
| 978 | 1259 | } | |
| @@ -981,6 +1262,9 @@ func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) er | |||
| 981 | 1262 | if !f.authority.actionAuthorized(f) { | |
| 982 | 1263 | return false | |
| 983 | 1264 | } | |
| 1265 | + | if !overlay && f.overlayKind() != attachmentOverlayNone { | |
| 1266 | + | return true | |
| 1267 | + | } | |
| 984 | 1268 | if supervisorNil(f.term) { | |
| 985 | 1269 | outputErr = ports.ErrUIUnavailable | |
| 986 | 1270 | return true | |
| @@ -392,6 +392,14 @@ type attachmentHost struct { | |||||
| 392 | geometryCancel context.CancelFunc | 392 | geometryCancel context.CancelFunc | ||
| 393 | geometryDone chan struct{} | 393 | geometryDone chan struct{} | ||
| 394 | ownerBoundary bool | 394 | ownerBoundary bool | ||
| 395 | + | ||||
| 396 | + | // navigationRequests carries a coalesced request from the attached worker | |||
| 397 | + | // to compose the client's session picker over the live attachment (the | |||
| 398 | + | // daemon's navigation PickerOffer). Only the supervisor consumes it. | |||
| 399 | + | navigationRequests chan struct{} | |||
| 400 | + | // internalActions carries supervisor-originated lifecycle decisions for the | |||
| 401 | + | // current foreground, such as the detach that precedes an attachment swap. | |||
| 402 | + | internalActions chan AttachmentLifecycleAction | |||
| 395 | } | 403 | } | ||
| 396 | 404 | ||||
| 397 | // newAttachmentHost builds the reusable foreground host. A nil clock falls back | 405 | // newAttachmentHost builds the reusable foreground host. A nil clock falls back | ||
| @@ -429,6 +437,8 @@ func newAttachmentHost(cfg attachmentHostConfig) *attachmentHost { | |||||
| 429 | geometryValid: initialGeometryValid, | 437 | geometryValid: initialGeometryValid, | ||
| 430 | geometryUpdate: make(chan struct{}, 1), | 438 | geometryUpdate: make(chan struct{}, 1), | ||
| 431 | presentationUpdate: make(chan struct{}, 1), | 439 | presentationUpdate: make(chan struct{}, 1), | ||
| 440 | + | navigationRequests: make(chan struct{}, 1), | |||
| 441 | + | internalActions: make(chan AttachmentLifecycleAction, 1), | |||
| 432 | } | 442 | } | ||
| 433 | } | 443 | } | ||
| 434 | 444 | ||||
| @@ -460,6 +470,9 @@ func (h *attachmentHost) Begin(ctx context.Context, token AttachmentToken, worke | |||||
| 460 | cancel() | 470 | cancel() | ||
| 461 | return nil, false | 471 | return nil, false | ||
| 462 | } | 472 | } | ||
| 473 | + | // A request or action left behind by a previous foreground never applies | |||
| 474 | + | // to this one. | |||
| 475 | + | h.drainForegroundSignals() | |||
| 463 | if h.actionUI != nil { | 476 | if h.actionUI != nil { | ||
| 464 | fg.uiGeneration = h.actionUI.bindForeground(workerCtx, fg.input, fg.consumer) | 477 | fg.uiGeneration = h.actionUI.bindForeground(workerCtx, fg.input, fg.consumer) | ||
| 465 | } | 478 | } | ||
| @@ -595,7 +608,245 @@ func (h *attachmentHost) newForeground(token AttachmentToken, stream ports.Broke | |||||
| 595 | done: make(chan struct{}), | 608 | done: make(chan struct{}), | ||
| 596 | decided: true, | 609 | decided: true, | ||
| 597 | onAttached: h.onAttach, | 610 | onAttached: h.onAttach, | ||
| 611 | + | repaint: make(chan struct{}, 1), | |||
| 612 | + | } | |||
| 613 | + | } | |||
| 614 | + | ||||
| 615 | + | // drainForegroundSignals drops any navigation request or internal lifecycle | |||
| 616 | + | // action that belonged to an earlier foreground. | |||
| 617 | + | func (h *attachmentHost) drainForegroundSignals() { | |||
| 618 | + | for { | |||
| 619 | + | select { | |||
| 620 | + | case <-h.navigationRequests: | |||
| 621 | + | case <-h.internalActions: | |||
| 622 | + | default: | |||
| 623 | + | return | |||
| 624 | + | } | |||
| 625 | + | } | |||
| 626 | + | } | |||
| 627 | + | ||||
| 628 | + | // NavigationRequests wakes the supervisor when the attached worker asked for | |||
| 629 | + | // the client picker over the live attachment. | |||
| 630 | + | func (h *attachmentHost) NavigationRequests() <-chan struct{} { | |||
| 631 | + | if h == nil { | |||
| 632 | + | return nil | |||
| 633 | + | } | |||
| 634 | + | return h.navigationRequests | |||
| 635 | + | } | |||
| 636 | + | ||||
| 637 | + | // beginNavigationOverlay composes the supervisor-owned picker over the current | |||
| 638 | + | // foreground: from its return, attachment output is applied and acknowledged | |||
| 639 | + | // but never written, and every authorized input delivery is handed to sink. It | |||
| 640 | + | // reports false when no active foreground can host the overlay. | |||
| 641 | + | func (h *attachmentHost) beginNavigationOverlay(sink pickerInputConsumer) bool { | |||
| 642 | + | if h == nil || sink == nil { | |||
| 643 | + | return false | |||
| 644 | + | } | |||
| 645 | + | fg := h.authority.foreground() | |||
| 646 | + | if fg == nil { | |||
| 647 | + | return false | |||
| 648 | + | } | |||
| 649 | + | return fg.setOverlay(attachmentOverlayNavigation, sink) | |||
| 650 | + | } | |||
| 651 | + | ||||
| 652 | + | // endNavigationOverlay returns the terminal to the current foreground and asks | |||
| 653 | + | // its worker for an authoritative repaint over the picker box. | |||
| 654 | + | func (h *attachmentHost) endNavigationOverlay() { | |||
| 655 | + | if h == nil { | |||
| 656 | + | return | |||
| 657 | + | } | |||
| 658 | + | if fg := h.authority.foreground(); fg != nil { | |||
| 659 | + | fg.clearOverlay(attachmentOverlayNavigation) | |||
| 660 | + | } | |||
| 661 | + | } | |||
| 662 | + | ||||
| 663 | + | // committedTarget reports the session the current foreground last committed | |||
| 664 | + | // output for, so the supervisor can tell a commit to the attached session from | |||
| 665 | + | // a commit elsewhere. | |||
| 666 | + | func (h *attachmentHost) committedTarget() (protocol.ExactSessionTarget, bool) { | |||
| 667 | + | if h == nil { | |||
| 668 | + | return protocol.ExactSessionTarget{}, false | |||
| 669 | + | } | |||
| 670 | + | fg := h.authority.foreground() | |||
| 671 | + | if fg == nil { | |||
| 672 | + | return protocol.ExactSessionTarget{}, false | |||
| 673 | + | } | |||
| 674 | + | fg.overlayMu.Lock() | |||
| 675 | + | defer fg.overlayMu.Unlock() | |||
| 676 | + | return fg.committed, fg.committedKnown | |||
| 677 | + | } | |||
| 678 | + | ||||
| 679 | + | // committedTargetOrZero is committedTarget with an unknown target as zero. | |||
| 680 | + | func (h *attachmentHost) committedTargetOrZero() protocol.ExactSessionTarget { | |||
| 681 | + | target, ok := h.committedTarget() | |||
| 682 | + | if !ok { | |||
| 683 | + | return protocol.ExactSessionTarget{} | |||
| 684 | + | } | |||
| 685 | + | return target | |||
| 686 | + | } | |||
| 687 | + | ||||
| 688 | + | // requestDetach asks the current foreground's worker to detach explicitly, so | |||
| 689 | + | // the daemon observes a clean Detach before the supervisor swaps attachments. | |||
| 690 | + | func (h *attachmentHost) requestDetach(token AttachmentToken) { | |||
| 691 | + | if h == nil { | |||
| 692 | + | return | |||
| 693 | + | } | |||
| 694 | + | select { | |||
| 695 | + | case h.internalActions <- AttachmentLifecycleAction{Token: token, Kind: AttachmentDetachToPicker}: | |||
| 696 | + | default: | |||
| 697 | + | } | |||
| 698 | + | } | |||
| 699 | + | ||||
| 700 | + | // attachmentOverlayKind names the client-composed overlay that owns the | |||
| 701 | + | // terminal over a live attachment. | |||
| 702 | + | type attachmentOverlayKind uint8 | |||
| 703 | + | ||||
| 704 | + | const ( | |||
| 705 | + | attachmentOverlayNone attachmentOverlayKind = iota | |||
| 706 | + | // attachmentOverlayNavigation is the supervisor-owned session picker. | |||
| 707 | + | attachmentOverlayNavigation | |||
| 708 | + | // attachmentOverlayMove is the worker-owned move-destination picker the | |||
| 709 | + | // serving daemon offered. | |||
| 710 | + | attachmentOverlayMove | |||
| 711 | + | ) | |||
| 712 | + | ||||
| 713 | + | // attachmentOverlayForeground is the optional overlay seam of the real | |||
| 714 | + | // foreground. A worker reaches it by assertion so scripted foregrounds may | |||
| 715 | + | // omit it; without it, overlays are simply never presented. | |||
| 716 | + | type attachmentOverlayForeground interface { | |||
| 717 | + | requestNavigationPicker() | |||
| 718 | + | setOverlay(kind attachmentOverlayKind, sink pickerInputConsumer) bool | |||
| 719 | + | clearOverlay(kind attachmentOverlayKind) | |||
| 720 | + | overlayKind() attachmentOverlayKind | |||
| 721 | + | divertInput(data []byte) bool | |||
| 722 | + | overlayRepaint() <-chan struct{} | |||
| 723 | + | overlayOutput(uiContext ports.UIContext, data []byte) error | |||
| 724 | + | noteCommitted(target protocol.ExactSessionTarget) | |||
| 725 | + | } | |||
| 726 | + | ||||
| 727 | + | var _ attachmentOverlayForeground = (*attachmentForeground)(nil) | |||
| 728 | + | ||||
| 729 | + | // requestNavigationPicker records the daemon's navigation offer for the | |||
| 730 | + | // supervisor. The request is coalesced; the supervisor decides whether the | |||
| 731 | + | // current presentation can host the overlay. | |||
| 732 | + | func (f *attachmentForeground) requestNavigationPicker() { | |||
| 733 | + | if f == nil || f.host == nil || !f.authority.actionAuthorized(f) { | |||
| 734 | + | return | |||
| 735 | + | } | |||
| 736 | + | select { | |||
| 737 | + | case f.host.navigationRequests <- struct{}{}: | |||
| 738 | + | default: | |||
| 739 | + | } | |||
| 740 | + | } | |||
| 741 | + | ||||
| 742 | + | // setOverlay installs kind under the output lease, so no attachment frame is | |||
| 743 | + | // mid-write when the overlay takes the terminal. An occupied slot refuses a | |||
| 744 | + | // different overlay. | |||
| 745 | + | func (f *attachmentForeground) setOverlay(kind attachmentOverlayKind, sink pickerInputConsumer) bool { | |||
| 746 | + | if f == nil || kind == attachmentOverlayNone { | |||
| 747 | + | return false | |||
| 748 | + | } | |||
| 749 | + | installed := false | |||
| 750 | + | f.lease.send(func() bool { | |||
| 751 | + | if !f.authority.actionAuthorized(f) { | |||
| 752 | + | return false | |||
| 753 | + | } | |||
| 754 | + | f.overlayMu.Lock() | |||
| 755 | + | defer f.overlayMu.Unlock() | |||
| 756 | + | if f.overlay != attachmentOverlayNone && f.overlay != kind { | |||
| 757 | + | return true | |||
| 758 | + | } | |||
| 759 | + | f.overlay = kind | |||
| 760 | + | f.overlaySink = sink | |||
| 761 | + | installed = true | |||
| 762 | + | return true | |||
| 763 | + | }) | |||
| 764 | + | return installed | |||
| 765 | + | } | |||
| 766 | + | ||||
| 767 | + | // clearOverlay releases kind under the output lease and asks the worker for an | |||
| 768 | + | // authoritative repaint: the terminal still shows the picker box and any | |||
| 769 | + | // attachment frames it suppressed were never written. | |||
| 770 | + | func (f *attachmentForeground) clearOverlay(kind attachmentOverlayKind) { | |||
| 771 | + | if f == nil { | |||
| 772 | + | return | |||
| 773 | + | } | |||
| 774 | + | cleared := false | |||
| 775 | + | f.lease.send(func() bool { | |||
| 776 | + | f.overlayMu.Lock() | |||
| 777 | + | defer f.overlayMu.Unlock() | |||
| 778 | + | if f.overlay != kind { | |||
| 779 | + | return true | |||
| 780 | + | } | |||
| 781 | + | f.overlay = attachmentOverlayNone | |||
| 782 | + | f.overlaySink = nil | |||
| 783 | + | cleared = true | |||
| 784 | + | return true | |||
| 785 | + | }) | |||
| 786 | + | if !cleared { | |||
| 787 | + | // The lease stops at finalization; the overlay dies with the grant. | |||
| 788 | + | f.overlayMu.Lock() | |||
| 789 | + | if f.overlay == kind { | |||
| 790 | + | f.overlay = attachmentOverlayNone | |||
| 791 | + | f.overlaySink = nil | |||
| 792 | + | } | |||
| 793 | + | f.overlayMu.Unlock() | |||
| 794 | + | return | |||
| 795 | + | } | |||
| 796 | + | select { | |||
| 797 | + | case f.repaint <- struct{}{}: | |||
| 798 | + | default: | |||
| 799 | + | } | |||
| 800 | + | } | |||
| 801 | + | ||||
| 802 | + | func (f *attachmentForeground) overlayKind() attachmentOverlayKind { | |||
| 803 | + | if f == nil { | |||
| 804 | + | return attachmentOverlayNone | |||
| 598 | } | 805 | } | ||
| 806 | + | f.overlayMu.Lock() | |||
| 807 | + | defer f.overlayMu.Unlock() | |||
| 808 | + | return f.overlay | |||
| 809 | + | } | |||
| 810 | + | ||||
| 811 | + | // divertInput hands one authorized delivery to the supervisor-owned overlay. | |||
| 812 | + | // It reports false when no navigation overlay owns input, so the worker keeps | |||
| 813 | + | // its session path. | |||
| 814 | + | func (f *attachmentForeground) divertInput(data []byte) bool { | |||
| 815 | + | if f == nil { | |||
| 816 | + | return false | |||
| 817 | + | } | |||
| 818 | + | f.overlayMu.Lock() | |||
| 819 | + | sink := f.overlaySink | |||
| 820 | + | kind := f.overlay | |||
| 821 | + | f.overlayMu.Unlock() | |||
| 822 | + | if kind != attachmentOverlayNavigation || sink == nil { | |||
| 823 | + | return false | |||
| 824 | + | } | |||
| 825 | + | sink.ConsumeTerminalRead(data) | |||
| 826 | + | return true | |||
| 827 | + | } | |||
| 828 | + | ||||
| 829 | + | func (f *attachmentForeground) overlayRepaint() <-chan struct{} { | |||
| 830 | + | if f == nil { | |||
| 831 | + | return nil | |||
| 832 | + | } | |||
| 833 | + | return f.repaint | |||
| 834 | + | } | |||
| 835 | + | ||||
| 836 | + | func (f *attachmentForeground) noteCommitted(target protocol.ExactSessionTarget) { | |||
| 837 | + | if f == nil { | |||
| 838 | + | return | |||
| 839 | + | } | |||
| 840 | + | f.overlayMu.Lock() | |||
| 841 | + | f.committed = target | |||
| 842 | + | f.committedKnown = true | |||
| 843 | + | f.overlayMu.Unlock() | |||
| 844 | + | } | |||
| 845 | + | ||||
| 846 | + | // overlayOutput writes one overlay frame through the same lease and UI | |||
| 847 | + | // transaction as attachment output, bypassing only the overlay suppression. | |||
| 848 | + | func (f *attachmentForeground) overlayOutput(uiContext ports.UIContext, data []byte) error { | |||
| 849 | + | return f.write(uiContext, data, true) | |||
| 599 | } | 850 | } | ||
| 600 | 851 | ||||
| 601 | // attachmentRun is one in-flight worker generation owned by the supervisor. | 852 | // attachmentRun is one in-flight worker generation owned by the supervisor. | ||
| @@ -726,6 +977,18 @@ type attachmentForeground struct { | |||||
| 726 | geometrySeq uint64 | 977 | geometrySeq uint64 | ||
| 727 | attached atomic.Bool | 978 | attached atomic.Bool | ||
| 728 | onAttached func(AttachmentToken) | 979 | onAttached func(AttachmentToken) | ||
| 980 | + | ||||
| 981 | + | // overlayMu guards the overlay slot and the committed target. The slot is | |||
| 982 | + | // changed only inside the output lease, so an overlay never takes the | |||
| 983 | + | // terminal while an attachment frame is mid-write. | |||
| 984 | + | overlayMu sync.Mutex | |||
| 985 | + | overlay attachmentOverlayKind | |||
| 986 | + | overlaySink pickerInputConsumer | |||
| 987 | + | committed protocol.ExactSessionTarget | |||
| 988 | + | committedKnown bool | |||
| 989 | + | // repaint wakes the worker to request an authoritative repaint once an | |||
| 990 | + | // overlay released the terminal. | |||
| 991 | + | repaint chan struct{} | |||
| 729 | } | 992 | } | ||
| 730 | 993 | ||||
| 731 | // Token is the generation/attempt identity of this grant. | 994 | // Token is the generation/attempt identity of this grant. | ||
| @@ -904,6 +1167,10 @@ func (f *attachmentForeground) Lifecycle(ctx context.Context) (AttachmentLifecyc | |||||
| 904 | if !f.authority.actionAuthorized(f) { | 1167 | if !f.authority.actionAuthorized(f) { | ||
| 905 | return 0, false | 1168 | return 0, false | ||
| 906 | } | 1169 | } | ||
| 1170 | + | var internal <-chan AttachmentLifecycleAction | |||
| 1171 | + | if f.host != nil { | |||
| 1172 | + | internal = f.host.internalActions | |||
| 1173 | + | } | |||
| 907 | select { | 1174 | select { | ||
| 908 | case action, ok := <-f.actions: | 1175 | case action, ok := <-f.actions: | ||
| 909 | if !ok || !f.authority.actionAuthorized(f) { | 1176 | if !ok || !f.authority.actionAuthorized(f) { | ||
| @@ -913,6 +1180,11 @@ func (f *attachmentForeground) Lifecycle(ctx context.Context) (AttachmentLifecyc | |||||
| 913 | continue | 1180 | continue | ||
| 914 | } | 1181 | } | ||
| 915 | return action.Kind, action.Kind == AttachmentDetachToPicker || action.Kind == AttachmentDetachAndExit | 1182 | return action.Kind, action.Kind == AttachmentDetachToPicker || action.Kind == AttachmentDetachAndExit | ||
| 1183 | + | case action := <-internal: | |||
| 1184 | + | if action.Token != f.token || !f.authority.actionAuthorized(f) { | |||
| 1185 | + | continue | |||
| 1186 | + | } | |||
| 1187 | + | return action.Kind, action.Kind == AttachmentDetachToPicker || action.Kind == AttachmentDetachAndExit | |||
| 916 | case <-ctx.Done(): | 1188 | case <-ctx.Done(): | ||
| 917 | return 0, false | 1189 | return 0, false | ||
| 918 | case <-f.done: | 1190 | case <-f.done: | ||
| @@ -973,6 +1245,15 @@ func (f *attachmentForeground) Resize(ctx context.Context) (domain.Geometry, boo | |||||
| 973 | // flush, and EndOutput(true) commit the frame; any failed or short write or | 1245 | // flush, and EndOutput(true) commit the frame; any failed or short write or | ||
| 974 | // failed flush leaves EndOutput(false). | 1246 | // failed flush leaves EndOutput(false). | ||
| 975 | func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) error { | 1247 | func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) error { | ||
| 1248 | + | return f.write(uiContext, data, false) | |||
| 1249 | + | } | |||
| 1250 | + | ||||
| 1251 | + | // write is the shared output transaction. While an overlay owns the terminal an | |||
| 1252 | + | // attachment frame (overlay=false) is accepted without being written or | |||
| 1253 | + | // published: the caller still applies and acknowledges it, and the overlay's | |||
| 1254 | + | // release requests an authoritative repaint. Overlay frames bypass only that | |||
| 1255 | + | // suppression. | |||
| 1256 | + | func (f *attachmentForeground) write(uiContext ports.UIContext, data []byte, overlay bool) error { | |||
| 976 | if f == nil { | 1257 | if f == nil { | ||
| 977 | return errAttachmentForegroundRevoked | 1258 | return errAttachmentForegroundRevoked | ||
| 978 | } | 1259 | } | ||
| @@ -981,6 +1262,9 @@ func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) er | |||||
| 981 | if !f.authority.actionAuthorized(f) { | 1262 | if !f.authority.actionAuthorized(f) { | ||
| 982 | return false | 1263 | return false | ||
| 983 | } | 1264 | } | ||
| 1265 | + | if !overlay && f.overlayKind() != attachmentOverlayNone { | |||
| 1266 | + | return true | |||
| 1267 | + | } | |||
| 984 | if supervisorNil(f.term) { | 1268 | if supervisorNil(f.term) { | ||
| 985 | outputErr = ports.ErrUIUnavailable | 1269 | outputErr = ports.ErrUIUnavailable | ||
| 986 | return true | 1270 | return true | ||
A internal/usecase/client/move_picker_overlay.go +318 -0
| @@ -0,0 +1,318 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | ansirenderer "github.com/bnema/vev-vt/ansi" | |
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | "github.com/bnema/vev/internal/protocol" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Move-destination picker over a live attachment (Plan 003 A2). | |
| 15 | + | // | |
| 16 | + | // The serving daemon still owns move semantics: it captures the source when | |
| 17 | + | // MFP/MAT runs, publishes the destination rows in a PickerSnapshot, resolves | |
| 18 | + | // the opaque key of a typed PickerSelection, and confirms the end of the | |
| 19 | + | // interaction with PickerClosed. The attachment worker only presents it: it | |
| 20 | + | // composes the box over the attachment through the foreground's overlay slot, | |
| 21 | + | // decodes the input the overlay owns, and answers with the existing typed | |
| 22 | + | // PickerSelection/PickerClose replies. Zero picker bytes reach the session. | |
| 23 | + | // | |
| 24 | + | // Every method runs on the worker goroutine, which is the overlay's only | |
| 25 | + | // owner, so it holds no lock of its own. | |
| 26 | + | ||
| 27 | + | // movePickerInputGeneration is the fixed input generation of the worker-owned | |
| 28 | + | // move overlay; the interaction ID alone names the namespace. | |
| 29 | + | const movePickerInputGeneration = uint64(1) | |
| 30 | + | ||
| 31 | + | type movePickerOverlay struct { | |
| 32 | + | namespace pickerInteraction | |
| 33 | + | intent protocol.PickerIntent | |
| 34 | + | loop *pickerLoop | |
| 35 | + | consumer pickerConsumer | |
| 36 | + | renderer *pickerRenderer | |
| 37 | + | // presenting reports that the foreground overlay slot is held for this | |
| 38 | + | // interaction. | |
| 39 | + | presenting bool | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func newMovePickerOverlay() *movePickerOverlay { | |
| 43 | + | return &movePickerOverlay{renderer: newPickerRenderer(ansirenderer.ColorProfileTrueColor)} | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // offer opens the namespace a move offer names. A superseding offer retires | |
| 47 | + | // the previous interaction, whose rows can no longer be committed; the caller | |
| 48 | + | // releases the overlay when superseded reports true. | |
| 49 | + | func (m *movePickerOverlay) offer(offer protocol.PickerOffer) (superseded bool) { | |
| 50 | + | superseded = m.presenting && m.namespace.interaction != offer.InteractionID | |
| 51 | + | if superseded { | |
| 52 | + | m.retire() | |
| 53 | + | } | |
| 54 | + | m.namespace.setOpen(true, offer.InteractionID) | |
| 55 | + | m.namespace.setIntent(offer.InteractionID, offer.Intent) | |
| 56 | + | m.intent = offer.Intent | |
| 57 | + | m.loop = nil | |
| 58 | + | return superseded | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // admit folds one daemon publication into the model. It reports whether the | |
| 62 | + | // snapshot belongs to the open interaction and is newer than what is shown. | |
| 63 | + | func (m *movePickerOverlay) admit(snapshot protocol.PickerSnapshot) bool { | |
| 64 | + | if !m.namespace.open || m.intent == protocol.PickerIntentNavigation || m.intent == 0 { | |
| 65 | + | return false | |
| 66 | + | } | |
| 67 | + | if !m.namespace.admitSnapshot(snapshot, true) { | |
| 68 | + | return false | |
| 69 | + | } | |
| 70 | + | if m.loop == nil { | |
| 71 | + | m.loop = pickerLoopFromSnapshot(snapshot, m.intent, defaultPickerSort()) | |
| 72 | + | m.consumer.setOwned(snapshot.InteractionID, movePickerInputGeneration) | |
| 73 | + | return true | |
| 74 | + | } | |
| 75 | + | m.loop.replaceLines(snapshot) | |
| 76 | + | return true | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // interaction names the open namespace, or zero. | |
| 80 | + | func (m *movePickerOverlay) interaction() uint64 { | |
| 81 | + | if !m.namespace.open { | |
| 82 | + | return 0 | |
| 83 | + | } | |
| 84 | + | return m.namespace.interaction | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // closedBy reports whether a daemon close retires the open interaction. | |
| 88 | + | func (m *movePickerOverlay) closedBy(interaction uint64) bool { | |
| 89 | + | return m.namespace.open && m.namespace.interaction == interaction | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // retire ends the interaction locally. A late snapshot for it is refused. | |
| 93 | + | func (m *movePickerOverlay) retire() { | |
| 94 | + | if m.namespace.open { | |
| 95 | + | m.namespace.setOpen(false, m.namespace.interaction) | |
| 96 | + | } | |
| 97 | + | m.loop = nil | |
| 98 | + | m.presenting = false | |
| 99 | + | m.consumer.clear() | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // input decodes one owned delivery and applies it to the model. | |
| 103 | + | func (m *movePickerOverlay) input(data []byte, actionID uint64) (pickerOp, bool) { | |
| 104 | + | outcome, consumed := m.consumer.consume(terminalReadResult{data: data, actionID: actionID}) | |
| 105 | + | return m.apply(outcome, consumed) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // flush resolves a withheld lone escape once its window elapsed. | |
| 109 | + | func (m *movePickerOverlay) flush() (pickerOp, bool) { | |
| 110 | + | outcome, consumed := m.consumer.flushPending() | |
| 111 | + | return m.apply(outcome, consumed) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func (m *movePickerOverlay) apply(outcome pickerConsumeOutcome, consumed bool) (pickerOp, bool) { | |
| 115 | + | if !consumed || m.loop == nil || !outcome.acceptOutcome(m.namespace.interaction, movePickerInputGeneration) { | |
| 116 | + | return pickerOp{}, false | |
| 117 | + | } | |
| 118 | + | return applyPickerBatch(m.loop, outcome.events) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // selection builds the typed move for the cursor row, refusing rows the daemon | |
| 122 | + | // did not authorise for a move. | |
| 123 | + | func (m *movePickerOverlay) selection(causeActionID uint64) (protocol.PickerSelection, bool) { | |
| 124 | + | action, ok := m.loop.selectedAction() | |
| 125 | + | if !ok || action != protocol.PickerActionMove { | |
| 126 | + | return protocol.PickerSelection{}, false | |
| 127 | + | } | |
| 128 | + | return commitSelection(m.loop, action, causeActionID) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // render composes the box for one terminal size. | |
| 132 | + | func (m *movePickerOverlay) render(size domain.Size) []byte { | |
| 133 | + | if m.loop == nil { | |
| 134 | + | return nil | |
| 135 | + | } | |
| 136 | + | return m.renderer.render(m.loop, size, emptyPickerPreview()) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // attachmentMovePicker binds the move overlay and the navigation request to | |
| 140 | + | // one attached worker loop: it owns the foreground overlay slot while a move | |
| 141 | + | // interaction is presented and answers the daemon on the worker's stream. | |
| 142 | + | type attachmentMovePicker struct { | |
| 143 | + | worker *sessionAttachmentWorker | |
| 144 | + | fg AttachmentForeground | |
| 145 | + | overlay attachmentOverlayForeground | |
| 146 | + | stream ports.BrokerLogicalConnection | |
| 147 | + | size domain.Size | |
| 148 | + | move *movePickerOverlay | |
| 149 | + | // escapeTimer bounds a withheld lone escape while the move overlay owns | |
| 150 | + | // input; nil while nothing is withheld. | |
| 151 | + | escapeTimer ports.Timer | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // offer handles one daemon PickerOffer. A navigation offer asks the | |
| 155 | + | // supervisor for the client's session picker over this attachment; a move | |
| 156 | + | // offer opens the namespace whose snapshot the overlay will present. | |
| 157 | + | func (p *attachmentMovePicker) offer(ctx context.Context, offer protocol.PickerOffer) error { | |
| 158 | + | if p.overlay == nil { | |
| 159 | + | // No overlay seam: never leave the daemon swallowing input for an | |
| 160 | + | // interaction nothing presents. | |
| 161 | + | return p.decline(ctx, offer) | |
| 162 | + | } | |
| 163 | + | if offer.Intent == protocol.PickerIntentNavigation { | |
| 164 | + | if p.move.presenting { | |
| 165 | + | return nil | |
| 166 | + | } | |
| 167 | + | p.overlay.requestNavigationPicker() | |
| 168 | + | return nil | |
| 169 | + | } | |
| 170 | + | if p.overlay.overlayKind() == attachmentOverlayNavigation { | |
| 171 | + | // The session picker owns the terminal; a move picker cannot be | |
| 172 | + | // presented under it, so release the daemon's interaction at once. | |
| 173 | + | return p.decline(ctx, offer) | |
| 174 | + | } | |
| 175 | + | if p.move.offer(offer) { | |
| 176 | + | p.stopEscape() | |
| 177 | + | p.overlay.clearOverlay(attachmentOverlayMove) | |
| 178 | + | } | |
| 179 | + | return nil | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // decline closes a move interaction the client will not present. | |
| 183 | + | func (p *attachmentMovePicker) decline(ctx context.Context, offer protocol.PickerOffer) error { | |
| 184 | + | if offer.Intent == protocol.PickerIntentNavigation { | |
| 185 | + | return nil | |
| 186 | + | } | |
| 187 | + | return p.worker.send(ctx, p.fg, p.stream, protocol.PickerClose{InteractionID: offer.InteractionID}) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // snapshot presents one daemon publication. The first admitted snapshot takes | |
| 191 | + | // the foreground overlay slot; a refused slot declines the interaction. | |
| 192 | + | func (p *attachmentMovePicker) snapshot(ctx context.Context, state outputApplyState, snapshot protocol.PickerSnapshot) error { | |
| 193 | + | if p.overlay == nil || !p.move.admit(snapshot) { | |
| 194 | + | return nil | |
| 195 | + | } | |
| 196 | + | if !p.move.presenting { | |
| 197 | + | if !p.overlay.setOverlay(attachmentOverlayMove, nil) { | |
| 198 | + | interaction := p.move.interaction() | |
| 199 | + | p.move.retire() | |
| 200 | + | return p.worker.send(ctx, p.fg, p.stream, protocol.PickerClose{InteractionID: interaction}) | |
| 201 | + | } | |
| 202 | + | p.move.presenting = true | |
| 203 | + | p.move.renderer.invalidate() | |
| 204 | + | } | |
| 205 | + | return p.paint(state) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // closed retires the interaction the daemon closed and returns the terminal to | |
| 209 | + | // the attachment. | |
| 210 | + | func (p *attachmentMovePicker) closed(interaction uint64) { | |
| 211 | + | if !p.move.closedBy(interaction) { | |
| 212 | + | return | |
| 213 | + | } | |
| 214 | + | p.release() | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func (p *attachmentMovePicker) release() { | |
| 218 | + | presenting := p.move.presenting | |
| 219 | + | p.move.retire() | |
| 220 | + | p.stopEscape() | |
| 221 | + | if presenting && p.overlay != nil { | |
| 222 | + | p.overlay.clearOverlay(attachmentOverlayMove) | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // consumeInput offers one authorized delivery to the overlay that owns input. | |
| 227 | + | // It reports true when the bytes were consumed and must not reach the session. | |
| 228 | + | func (p *attachmentMovePicker) consumeInput(ctx context.Context, state outputApplyState, input AttachmentInputEvent) (bool, error) { | |
| 229 | + | if p.move.presenting { | |
| 230 | + | p.stopEscape() | |
| 231 | + | op, changed := p.move.input(input.Data, input.actionID) | |
| 232 | + | if err := p.applyOp(ctx, state, op, changed); err != nil { | |
| 233 | + | return true, err | |
| 234 | + | } | |
| 235 | + | p.armEscape() | |
| 236 | + | return true, nil | |
| 237 | + | } | |
| 238 | + | if p.overlay != nil && p.overlay.divertInput(input.Data) { | |
| 239 | + | return true, nil | |
| 240 | + | } | |
| 241 | + | return false, nil | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // applyOp carries out one decoded decision: close sends the typed close and | |
| 245 | + | // releases the terminal, commit sends the typed move, and a changed model | |
| 246 | + | // repaints the box. | |
| 247 | + | func (p *attachmentMovePicker) applyOp(ctx context.Context, state outputApplyState, op pickerOp, changed bool) error { | |
| 248 | + | if !p.move.presenting { | |
| 249 | + | return nil | |
| 250 | + | } | |
| 251 | + | if op.close { | |
| 252 | + | interaction := p.move.interaction() | |
| 253 | + | p.release() | |
| 254 | + | return p.worker.send(ctx, p.fg, p.stream, protocol.PickerClose{InteractionID: interaction}) | |
| 255 | + | } | |
| 256 | + | if op.commit { | |
| 257 | + | if selection, ok := p.move.selection(0); ok { | |
| 258 | + | if err := p.worker.send(ctx, p.fg, p.stream, selection); err != nil { | |
| 259 | + | return err | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | if changed { | |
| 264 | + | return p.paint(state) | |
| 265 | + | } | |
| 266 | + | return nil | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | // resize repaints the presented box at the new terminal size. | |
| 270 | + | func (p *attachmentMovePicker) resize(_ context.Context, state outputApplyState, size domain.Size) error { | |
| 271 | + | p.size = size | |
| 272 | + | if !p.move.presenting { | |
| 273 | + | return nil | |
| 274 | + | } | |
| 275 | + | return p.paint(state) | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | func (p *attachmentMovePicker) paint(state outputApplyState) error { | |
| 279 | + | frame := p.move.render(p.size) | |
| 280 | + | if len(frame) == 0 { | |
| 281 | + | return nil | |
| 282 | + | } | |
| 283 | + | uiContext := state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(p.fg, p.fg.Token())}, ports.UIStatusAttached) | |
| 284 | + | if err := p.overlay.overlayOutput(uiContext, frame); err != nil { | |
| 285 | + | return fmt.Errorf("vev: publishing move picker: %w", err) | |
| 286 | + | } | |
| 287 | + | return nil | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | // escape is the armed lone-escape window, or nil. | |
| 291 | + | func (p *attachmentMovePicker) escape() <-chan time.Time { | |
| 292 | + | if p.escapeTimer == nil { | |
| 293 | + | return nil | |
| 294 | + | } | |
| 295 | + | return p.escapeTimer.C() | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | func (p *attachmentMovePicker) armEscape() { | |
| 299 | + | if p.escapeTimer != nil || !p.move.presenting || !p.move.consumer.hasPending() { | |
| 300 | + | return | |
| 301 | + | } | |
| 302 | + | timer := p.worker.cfg.Clock.NewTimer(pickerEscapeDeadline) | |
| 303 | + | if supervisorNil(timer) { | |
| 304 | + | return | |
| 305 | + | } | |
| 306 | + | p.escapeTimer = timer | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | // escapeFired forgets the timer whose window just elapsed. | |
| 310 | + | func (p *attachmentMovePicker) escapeFired() { p.escapeTimer = nil } | |
| 311 | + | ||
| 312 | + | func (p *attachmentMovePicker) stopEscape() { | |
| 313 | + | if p.escapeTimer == nil { | |
| 314 | + | return | |
| 315 | + | } | |
| 316 | + | stopSupervisorTimer(p.escapeTimer) | |
| 317 | + | p.escapeTimer = nil | |
| 318 | + | } | |
| @@ -0,0 +1,318 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | ansirenderer "github.com/bnema/vev-vt/ansi" | |||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | "github.com/bnema/vev/internal/protocol" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // Move-destination picker over a live attachment (Plan 003 A2). | |||
| 15 | + | // | |||
| 16 | + | // The serving daemon still owns move semantics: it captures the source when | |||
| 17 | + | // MFP/MAT runs, publishes the destination rows in a PickerSnapshot, resolves | |||
| 18 | + | // the opaque key of a typed PickerSelection, and confirms the end of the | |||
| 19 | + | // interaction with PickerClosed. The attachment worker only presents it: it | |||
| 20 | + | // composes the box over the attachment through the foreground's overlay slot, | |||
| 21 | + | // decodes the input the overlay owns, and answers with the existing typed | |||
| 22 | + | // PickerSelection/PickerClose replies. Zero picker bytes reach the session. | |||
| 23 | + | // | |||
| 24 | + | // Every method runs on the worker goroutine, which is the overlay's only | |||
| 25 | + | // owner, so it holds no lock of its own. | |||
| 26 | + | ||||
| 27 | + | // movePickerInputGeneration is the fixed input generation of the worker-owned | |||
| 28 | + | // move overlay; the interaction ID alone names the namespace. | |||
| 29 | + | const movePickerInputGeneration = uint64(1) | |||
| 30 | + | ||||
| 31 | + | type movePickerOverlay struct { | |||
| 32 | + | namespace pickerInteraction | |||
| 33 | + | intent protocol.PickerIntent | |||
| 34 | + | loop *pickerLoop | |||
| 35 | + | consumer pickerConsumer | |||
| 36 | + | renderer *pickerRenderer | |||
| 37 | + | // presenting reports that the foreground overlay slot is held for this | |||
| 38 | + | // interaction. | |||
| 39 | + | presenting bool | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func newMovePickerOverlay() *movePickerOverlay { | |||
| 43 | + | return &movePickerOverlay{renderer: newPickerRenderer(ansirenderer.ColorProfileTrueColor)} | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // offer opens the namespace a move offer names. A superseding offer retires | |||
| 47 | + | // the previous interaction, whose rows can no longer be committed; the caller | |||
| 48 | + | // releases the overlay when superseded reports true. | |||
| 49 | + | func (m *movePickerOverlay) offer(offer protocol.PickerOffer) (superseded bool) { | |||
| 50 | + | superseded = m.presenting && m.namespace.interaction != offer.InteractionID | |||
| 51 | + | if superseded { | |||
| 52 | + | m.retire() | |||
| 53 | + | } | |||
| 54 | + | m.namespace.setOpen(true, offer.InteractionID) | |||
| 55 | + | m.namespace.setIntent(offer.InteractionID, offer.Intent) | |||
| 56 | + | m.intent = offer.Intent | |||
| 57 | + | m.loop = nil | |||
| 58 | + | return superseded | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // admit folds one daemon publication into the model. It reports whether the | |||
| 62 | + | // snapshot belongs to the open interaction and is newer than what is shown. | |||
| 63 | + | func (m *movePickerOverlay) admit(snapshot protocol.PickerSnapshot) bool { | |||
| 64 | + | if !m.namespace.open || m.intent == protocol.PickerIntentNavigation || m.intent == 0 { | |||
| 65 | + | return false | |||
| 66 | + | } | |||
| 67 | + | if !m.namespace.admitSnapshot(snapshot, true) { | |||
| 68 | + | return false | |||
| 69 | + | } | |||
| 70 | + | if m.loop == nil { | |||
| 71 | + | m.loop = pickerLoopFromSnapshot(snapshot, m.intent, defaultPickerSort()) | |||
| 72 | + | m.consumer.setOwned(snapshot.InteractionID, movePickerInputGeneration) | |||
| 73 | + | return true | |||
| 74 | + | } | |||
| 75 | + | m.loop.replaceLines(snapshot) | |||
| 76 | + | return true | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // interaction names the open namespace, or zero. | |||
| 80 | + | func (m *movePickerOverlay) interaction() uint64 { | |||
| 81 | + | if !m.namespace.open { | |||
| 82 | + | return 0 | |||
| 83 | + | } | |||
| 84 | + | return m.namespace.interaction | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // closedBy reports whether a daemon close retires the open interaction. | |||
| 88 | + | func (m *movePickerOverlay) closedBy(interaction uint64) bool { | |||
| 89 | + | return m.namespace.open && m.namespace.interaction == interaction | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // retire ends the interaction locally. A late snapshot for it is refused. | |||
| 93 | + | func (m *movePickerOverlay) retire() { | |||
| 94 | + | if m.namespace.open { | |||
| 95 | + | m.namespace.setOpen(false, m.namespace.interaction) | |||
| 96 | + | } | |||
| 97 | + | m.loop = nil | |||
| 98 | + | m.presenting = false | |||
| 99 | + | m.consumer.clear() | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // input decodes one owned delivery and applies it to the model. | |||
| 103 | + | func (m *movePickerOverlay) input(data []byte, actionID uint64) (pickerOp, bool) { | |||
| 104 | + | outcome, consumed := m.consumer.consume(terminalReadResult{data: data, actionID: actionID}) | |||
| 105 | + | return m.apply(outcome, consumed) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // flush resolves a withheld lone escape once its window elapsed. | |||
| 109 | + | func (m *movePickerOverlay) flush() (pickerOp, bool) { | |||
| 110 | + | outcome, consumed := m.consumer.flushPending() | |||
| 111 | + | return m.apply(outcome, consumed) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func (m *movePickerOverlay) apply(outcome pickerConsumeOutcome, consumed bool) (pickerOp, bool) { | |||
| 115 | + | if !consumed || m.loop == nil || !outcome.acceptOutcome(m.namespace.interaction, movePickerInputGeneration) { | |||
| 116 | + | return pickerOp{}, false | |||
| 117 | + | } | |||
| 118 | + | return applyPickerBatch(m.loop, outcome.events) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // selection builds the typed move for the cursor row, refusing rows the daemon | |||
| 122 | + | // did not authorise for a move. | |||
| 123 | + | func (m *movePickerOverlay) selection(causeActionID uint64) (protocol.PickerSelection, bool) { | |||
| 124 | + | action, ok := m.loop.selectedAction() | |||
| 125 | + | if !ok || action != protocol.PickerActionMove { | |||
| 126 | + | return protocol.PickerSelection{}, false | |||
| 127 | + | } | |||
| 128 | + | return commitSelection(m.loop, action, causeActionID) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // render composes the box for one terminal size. | |||
| 132 | + | func (m *movePickerOverlay) render(size domain.Size) []byte { | |||
| 133 | + | if m.loop == nil { | |||
| 134 | + | return nil | |||
| 135 | + | } | |||
| 136 | + | return m.renderer.render(m.loop, size, emptyPickerPreview()) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // attachmentMovePicker binds the move overlay and the navigation request to | |||
| 140 | + | // one attached worker loop: it owns the foreground overlay slot while a move | |||
| 141 | + | // interaction is presented and answers the daemon on the worker's stream. | |||
| 142 | + | type attachmentMovePicker struct { | |||
| 143 | + | worker *sessionAttachmentWorker | |||
| 144 | + | fg AttachmentForeground | |||
| 145 | + | overlay attachmentOverlayForeground | |||
| 146 | + | stream ports.BrokerLogicalConnection | |||
| 147 | + | size domain.Size | |||
| 148 | + | move *movePickerOverlay | |||
| 149 | + | // escapeTimer bounds a withheld lone escape while the move overlay owns | |||
| 150 | + | // input; nil while nothing is withheld. | |||
| 151 | + | escapeTimer ports.Timer | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // offer handles one daemon PickerOffer. A navigation offer asks the | |||
| 155 | + | // supervisor for the client's session picker over this attachment; a move | |||
| 156 | + | // offer opens the namespace whose snapshot the overlay will present. | |||
| 157 | + | func (p *attachmentMovePicker) offer(ctx context.Context, offer protocol.PickerOffer) error { | |||
| 158 | + | if p.overlay == nil { | |||
| 159 | + | // No overlay seam: never leave the daemon swallowing input for an | |||
| 160 | + | // interaction nothing presents. | |||
| 161 | + | return p.decline(ctx, offer) | |||
| 162 | + | } | |||
| 163 | + | if offer.Intent == protocol.PickerIntentNavigation { | |||
| 164 | + | if p.move.presenting { | |||
| 165 | + | return nil | |||
| 166 | + | } | |||
| 167 | + | p.overlay.requestNavigationPicker() | |||
| 168 | + | return nil | |||
| 169 | + | } | |||
| 170 | + | if p.overlay.overlayKind() == attachmentOverlayNavigation { | |||
| 171 | + | // The session picker owns the terminal; a move picker cannot be | |||
| 172 | + | // presented under it, so release the daemon's interaction at once. | |||
| 173 | + | return p.decline(ctx, offer) | |||
| 174 | + | } | |||
| 175 | + | if p.move.offer(offer) { | |||
| 176 | + | p.stopEscape() | |||
| 177 | + | p.overlay.clearOverlay(attachmentOverlayMove) | |||
| 178 | + | } | |||
| 179 | + | return nil | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | // decline closes a move interaction the client will not present. | |||
| 183 | + | func (p *attachmentMovePicker) decline(ctx context.Context, offer protocol.PickerOffer) error { | |||
| 184 | + | if offer.Intent == protocol.PickerIntentNavigation { | |||
| 185 | + | return nil | |||
| 186 | + | } | |||
| 187 | + | return p.worker.send(ctx, p.fg, p.stream, protocol.PickerClose{InteractionID: offer.InteractionID}) | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | // snapshot presents one daemon publication. The first admitted snapshot takes | |||
| 191 | + | // the foreground overlay slot; a refused slot declines the interaction. | |||
| 192 | + | func (p *attachmentMovePicker) snapshot(ctx context.Context, state outputApplyState, snapshot protocol.PickerSnapshot) error { | |||
| 193 | + | if p.overlay == nil || !p.move.admit(snapshot) { | |||
| 194 | + | return nil | |||
| 195 | + | } | |||
| 196 | + | if !p.move.presenting { | |||
| 197 | + | if !p.overlay.setOverlay(attachmentOverlayMove, nil) { | |||
| 198 | + | interaction := p.move.interaction() | |||
| 199 | + | p.move.retire() | |||
| 200 | + | return p.worker.send(ctx, p.fg, p.stream, protocol.PickerClose{InteractionID: interaction}) | |||
| 201 | + | } | |||
| 202 | + | p.move.presenting = true | |||
| 203 | + | p.move.renderer.invalidate() | |||
| 204 | + | } | |||
| 205 | + | return p.paint(state) | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | // closed retires the interaction the daemon closed and returns the terminal to | |||
| 209 | + | // the attachment. | |||
| 210 | + | func (p *attachmentMovePicker) closed(interaction uint64) { | |||
| 211 | + | if !p.move.closedBy(interaction) { | |||
| 212 | + | return | |||
| 213 | + | } | |||
| 214 | + | p.release() | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func (p *attachmentMovePicker) release() { | |||
| 218 | + | presenting := p.move.presenting | |||
| 219 | + | p.move.retire() | |||
| 220 | + | p.stopEscape() | |||
| 221 | + | if presenting && p.overlay != nil { | |||
| 222 | + | p.overlay.clearOverlay(attachmentOverlayMove) | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | // consumeInput offers one authorized delivery to the overlay that owns input. | |||
| 227 | + | // It reports true when the bytes were consumed and must not reach the session. | |||
| 228 | + | func (p *attachmentMovePicker) consumeInput(ctx context.Context, state outputApplyState, input AttachmentInputEvent) (bool, error) { | |||
| 229 | + | if p.move.presenting { | |||
| 230 | + | p.stopEscape() | |||
| 231 | + | op, changed := p.move.input(input.Data, input.actionID) | |||
| 232 | + | if err := p.applyOp(ctx, state, op, changed); err != nil { | |||
| 233 | + | return true, err | |||
| 234 | + | } | |||
| 235 | + | p.armEscape() | |||
| 236 | + | return true, nil | |||
| 237 | + | } | |||
| 238 | + | if p.overlay != nil && p.overlay.divertInput(input.Data) { | |||
| 239 | + | return true, nil | |||
| 240 | + | } | |||
| 241 | + | return false, nil | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | // applyOp carries out one decoded decision: close sends the typed close and | |||
| 245 | + | // releases the terminal, commit sends the typed move, and a changed model | |||
| 246 | + | // repaints the box. | |||
| 247 | + | func (p *attachmentMovePicker) applyOp(ctx context.Context, state outputApplyState, op pickerOp, changed bool) error { | |||
| 248 | + | if !p.move.presenting { | |||
| 249 | + | return nil | |||
| 250 | + | } | |||
| 251 | + | if op.close { | |||
| 252 | + | interaction := p.move.interaction() | |||
| 253 | + | p.release() | |||
| 254 | + | return p.worker.send(ctx, p.fg, p.stream, protocol.PickerClose{InteractionID: interaction}) | |||
| 255 | + | } | |||
| 256 | + | if op.commit { | |||
| 257 | + | if selection, ok := p.move.selection(0); ok { | |||
| 258 | + | if err := p.worker.send(ctx, p.fg, p.stream, selection); err != nil { | |||
| 259 | + | return err | |||
| 260 | + | } | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | if changed { | |||
| 264 | + | return p.paint(state) | |||
| 265 | + | } | |||
| 266 | + | return nil | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | // resize repaints the presented box at the new terminal size. | |||
| 270 | + | func (p *attachmentMovePicker) resize(_ context.Context, state outputApplyState, size domain.Size) error { | |||
| 271 | + | p.size = size | |||
| 272 | + | if !p.move.presenting { | |||
| 273 | + | return nil | |||
| 274 | + | } | |||
| 275 | + | return p.paint(state) | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | func (p *attachmentMovePicker) paint(state outputApplyState) error { | |||
| 279 | + | frame := p.move.render(p.size) | |||
| 280 | + | if len(frame) == 0 { | |||
| 281 | + | return nil | |||
| 282 | + | } | |||
| 283 | + | uiContext := state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(p.fg, p.fg.Token())}, ports.UIStatusAttached) | |||
| 284 | + | if err := p.overlay.overlayOutput(uiContext, frame); err != nil { | |||
| 285 | + | return fmt.Errorf("vev: publishing move picker: %w", err) | |||
| 286 | + | } | |||
| 287 | + | return nil | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | // escape is the armed lone-escape window, or nil. | |||
| 291 | + | func (p *attachmentMovePicker) escape() <-chan time.Time { | |||
| 292 | + | if p.escapeTimer == nil { | |||
| 293 | + | return nil | |||
| 294 | + | } | |||
| 295 | + | return p.escapeTimer.C() | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func (p *attachmentMovePicker) armEscape() { | |||
| 299 | + | if p.escapeTimer != nil || !p.move.presenting || !p.move.consumer.hasPending() { | |||
| 300 | + | return | |||
| 301 | + | } | |||
| 302 | + | timer := p.worker.cfg.Clock.NewTimer(pickerEscapeDeadline) | |||
| 303 | + | if supervisorNil(timer) { | |||
| 304 | + | return | |||
| 305 | + | } | |||
| 306 | + | p.escapeTimer = timer | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | // escapeFired forgets the timer whose window just elapsed. | |||
| 310 | + | func (p *attachmentMovePicker) escapeFired() { p.escapeTimer = nil } | |||
| 311 | + | ||||
| 312 | + | func (p *attachmentMovePicker) stopEscape() { | |||
| 313 | + | if p.escapeTimer == nil { | |||
| 314 | + | return | |||
| 315 | + | } | |||
| 316 | + | stopSupervisorTimer(p.escapeTimer) | |||
| 317 | + | p.escapeTimer = nil | |||
| 318 | + | } | |||
M internal/usecase/client/picker_composition.go +18 -2
| @@ -22,7 +22,9 @@ import ( | |||
| 22 | 22 | // invariants or consume a pending user decision before the supervisor handles | |
| 23 | 23 | // it. | |
| 24 | 24 | // | |
| 25 | - | // Painting is allowed only while PickerPresentation reports true. In | |
| 25 | + | // Painting is allowed only while PickerPresentation reports true: the plain | |
| 26 | + | // picker, or the picker overlay over a live attachment whose output the | |
| 27 | + | // attachment foreground suppresses while the overlay is open. In | |
| 26 | 28 | // particular, PresentConnecting is not safe: once the attachment foreground is | |
| 27 | 29 | // admitted it owns the same terminal writer before MarkAttached or the initial | |
| 28 | 30 | // publication. Nothing serializes picker painting with that foreground. | |
| @@ -42,7 +44,7 @@ var _ pickerHost = (*Picker)(nil) | |||
| 42 | 44 | // admitted foreground can write while the public presentation is still | |
| 43 | 45 | // PresentConnecting. | |
| 44 | 46 | func PickerPresentation(state State) bool { | |
| 45 | - | return state.Presentation == PresentPicker | |
| 47 | + | return state.Presentation == PresentPicker || state.Presentation == PresentAttachedPicker | |
| 46 | 48 | } | |
| 47 | 49 | ||
| 48 | 50 | // NewPicker composes one client-owned picker. A nil clock uses the system | |
| @@ -102,6 +104,20 @@ func (p *Picker) SetOwnsInput(owns bool) { | |||
| 102 | 104 | p.controller.SetOwnsInput(owns) | |
| 103 | 105 | } | |
| 104 | 106 | ||
| 107 | + | // offerNotice shows one bounded client-local notice on the picker. | |
| 108 | + | func (p *Picker) offerNotice(id, message string) { | |
| 109 | + | if p != nil && p.controller != nil { | |
| 110 | + | p.controller.offerNotice(id, message) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // invalidatePresentation forces the next frame to redraw the whole box. | |
| 115 | + | func (p *Picker) invalidatePresentation() { | |
| 116 | + | if p != nil && p.controller != nil { | |
| 117 | + | p.controller.invalidatePresentation() | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 105 | 121 | // OpsReady returns the supervisor's coalescing operation wake channel. | |
| 106 | 122 | func (p *Picker) OpsReady() <-chan struct{} { | |
| 107 | 123 | if p == nil || p.controller == nil { | |
| @@ -22,7 +22,9 @@ import ( | |||||
| 22 | // invariants or consume a pending user decision before the supervisor handles | 22 | // invariants or consume a pending user decision before the supervisor handles | ||
| 23 | // it. | 23 | // it. | ||
| 24 | // | 24 | // | ||
| 25 | - | // Painting is allowed only while PickerPresentation reports true. In | 25 | + | // Painting is allowed only while PickerPresentation reports true: the plain |
| 26 | + | // picker, or the picker overlay over a live attachment whose output the | |||
| 27 | + | // attachment foreground suppresses while the overlay is open. In | |||
| 26 | // particular, PresentConnecting is not safe: once the attachment foreground is | 28 | // particular, PresentConnecting is not safe: once the attachment foreground is | ||
| 27 | // admitted it owns the same terminal writer before MarkAttached or the initial | 29 | // admitted it owns the same terminal writer before MarkAttached or the initial | ||
| 28 | // publication. Nothing serializes picker painting with that foreground. | 30 | // publication. Nothing serializes picker painting with that foreground. | ||
| @@ -42,7 +44,7 @@ var _ pickerHost = (*Picker)(nil) | |||||
| 42 | // admitted foreground can write while the public presentation is still | 44 | // admitted foreground can write while the public presentation is still | ||
| 43 | // PresentConnecting. | 45 | // PresentConnecting. | ||
| 44 | func PickerPresentation(state State) bool { | 46 | func PickerPresentation(state State) bool { | ||
| 45 | - | return state.Presentation == PresentPicker | 47 | + | return state.Presentation == PresentPicker || state.Presentation == PresentAttachedPicker |
| 46 | } | 48 | } | ||
| 47 | 49 | ||||
| 48 | // NewPicker composes one client-owned picker. A nil clock uses the system | 50 | // NewPicker composes one client-owned picker. A nil clock uses the system | ||
| @@ -102,6 +104,20 @@ func (p *Picker) SetOwnsInput(owns bool) { | |||||
| 102 | p.controller.SetOwnsInput(owns) | 104 | p.controller.SetOwnsInput(owns) | ||
| 103 | } | 105 | } | ||
| 104 | 106 | ||||
| 107 | + | // offerNotice shows one bounded client-local notice on the picker. | |||
| 108 | + | func (p *Picker) offerNotice(id, message string) { | |||
| 109 | + | if p != nil && p.controller != nil { | |||
| 110 | + | p.controller.offerNotice(id, message) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // invalidatePresentation forces the next frame to redraw the whole box. | |||
| 115 | + | func (p *Picker) invalidatePresentation() { | |||
| 116 | + | if p != nil && p.controller != nil { | |||
| 117 | + | p.controller.invalidatePresentation() | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | ||||
| 105 | // OpsReady returns the supervisor's coalescing operation wake channel. | 121 | // OpsReady returns the supervisor's coalescing operation wake channel. | ||
| 106 | func (p *Picker) OpsReady() <-chan struct{} { | 122 | func (p *Picker) OpsReady() <-chan struct{} { | ||
| 107 | if p == nil || p.controller == nil { | 123 | if p == nil || p.controller == nil { | ||
M internal/usecase/client/picker_controller.go +67 -2
| @@ -104,6 +104,9 @@ type pickerController struct { | |||
| 104 | 104 | // decision, so TakeOp can hand a driver the exact committed row. | |
| 105 | 105 | lastCommitKey string | |
| 106 | 106 | preview pickerusecase.Preview | |
| 107 | + | // flushStop cancels the armed lone-escape flush. It is non-nil exactly | |
| 108 | + | // while one flush is armed for the currently withheld prefix. | |
| 109 | + | flushStop chan struct{} | |
| 107 | 110 | } | |
| 108 | 111 | ||
| 109 | 112 | // newPickerController builds a picker over an empty catalogue. The picker owns | |
| @@ -265,8 +268,67 @@ func (p *pickerController) FlushPending() { | |||
| 265 | 268 | if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration { | |
| 266 | 269 | return | |
| 267 | 270 | } | |
| 268 | - | op, _ := applyPickerBatch(p.loop, outcome.events) | |
| 271 | + | op, changed := applyPickerBatch(p.loop, outcome.events) | |
| 269 | 272 | p.recordOpLocked(op) | |
| 273 | + | if changed { | |
| 274 | + | select { | |
| 275 | + | case p.opsReady <- struct{}{}: | |
| 276 | + | default: | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | // armFlushLocked bounds a withheld escape or UTF-8 prefix with the picker's | |
| 282 | + | // disambiguation window on the controller clock, so a lone Escape keypress | |
| 283 | + | // resolves without waiting for another key. A later read disarms it first. | |
| 284 | + | // Callers hold p.mu. | |
| 285 | + | func (p *pickerController) armFlushLocked() { | |
| 286 | + | if p.flushStop != nil || !p.consumer.hasPending() { | |
| 287 | + | return | |
| 288 | + | } | |
| 289 | + | timer := p.clock.NewTimer(pickerEscapeDeadline) | |
| 290 | + | if supervisorNil(timer) { | |
| 291 | + | return | |
| 292 | + | } | |
| 293 | + | stop := make(chan struct{}) | |
| 294 | + | p.flushStop = stop | |
| 295 | + | go func() { | |
| 296 | + | select { | |
| 297 | + | case <-timer.C(): | |
| 298 | + | case <-stop: | |
| 299 | + | stopSupervisorTimer(timer) | |
| 300 | + | return | |
| 301 | + | } | |
| 302 | + | p.mu.Lock() | |
| 303 | + | if p.flushStop != stop { | |
| 304 | + | p.mu.Unlock() | |
| 305 | + | return | |
| 306 | + | } | |
| 307 | + | p.flushStop = nil | |
| 308 | + | p.mu.Unlock() | |
| 309 | + | p.FlushPending() | |
| 310 | + | }() | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | // disarmFlushLocked cancels an armed flush: the next read resolves the | |
| 314 | + | // withheld prefix itself. Callers hold p.mu. | |
| 315 | + | func (p *pickerController) disarmFlushLocked() { | |
| 316 | + | if p.flushStop == nil { | |
| 317 | + | return | |
| 318 | + | } | |
| 319 | + | close(p.flushStop) | |
| 320 | + | p.flushStop = nil | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | // invalidatePresentation makes the next Render redraw the whole box, because | |
| 324 | + | // another owner wrote the terminal since the last picker frame. | |
| 325 | + | func (p *pickerController) invalidatePresentation() { | |
| 326 | + | if p == nil { | |
| 327 | + | return | |
| 328 | + | } | |
| 329 | + | p.mu.Lock() | |
| 330 | + | p.renderer.invalidate() | |
| 331 | + | p.mu.Unlock() | |
| 270 | 332 | } | |
| 271 | 333 | ||
| 272 | 334 | // OwnsInput reports whether the picker currently owns terminal input. | |
| @@ -485,10 +547,12 @@ func (p *pickerController) handleRead(data []byte) bool { | |||
| 485 | 547 | if len(data) != 0 { | |
| 486 | 548 | data = append([]byte(nil), data...) | |
| 487 | 549 | } | |
| 550 | + | p.disarmFlushLocked() | |
| 488 | 551 | outcome, consumed := p.consumer.consume(terminalReadResult{data: data}) | |
| 489 | 552 | if !consumed { | |
| 490 | 553 | return false | |
| 491 | 554 | } | |
| 555 | + | defer p.armFlushLocked() | |
| 492 | 556 | if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration { | |
| 493 | 557 | return true | |
| 494 | 558 | } | |
| @@ -497,7 +561,7 @@ func (p *pickerController) handleRead(data []byte) bool { | |||
| 497 | 561 | // Use decoded events so a pasted Ctrl-C is not mistaken for an exit. | |
| 498 | 562 | for _, event := range outcome.events { | |
| 499 | 563 | if event.kind == pickerEventKey && event.key == "Ctrl+C" { | |
| 500 | - | p.recordOpLocked(pickerOp{close: true}) | |
| 564 | + | p.recordOpLocked(pickerOp{close: true, exit: true}) | |
| 501 | 565 | } | |
| 502 | 566 | } | |
| 503 | 567 | return true | |
| @@ -569,5 +633,6 @@ func mergePickerOps(a, b pickerOp) pickerOp { | |||
| 569 | 633 | commit: a.commit || b.commit, | |
| 570 | 634 | kill: a.kill || b.kill, | |
| 571 | 635 | close: a.close || b.close, | |
| 636 | + | exit: a.exit || b.exit, | |
| 572 | 637 | } | |
| 573 | 638 | } | |
| @@ -104,6 +104,9 @@ type pickerController struct { | |||||
| 104 | // decision, so TakeOp can hand a driver the exact committed row. | 104 | // decision, so TakeOp can hand a driver the exact committed row. | ||
| 105 | lastCommitKey string | 105 | lastCommitKey string | ||
| 106 | preview pickerusecase.Preview | 106 | preview pickerusecase.Preview | ||
| 107 | + | // flushStop cancels the armed lone-escape flush. It is non-nil exactly | |||
| 108 | + | // while one flush is armed for the currently withheld prefix. | |||
| 109 | + | flushStop chan struct{} | |||
| 107 | } | 110 | } | ||
| 108 | 111 | ||||
| 109 | // newPickerController builds a picker over an empty catalogue. The picker owns | 112 | // newPickerController builds a picker over an empty catalogue. The picker owns | ||
| @@ -265,8 +268,67 @@ func (p *pickerController) FlushPending() { | |||||
| 265 | if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration { | 268 | if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration { | ||
| 266 | return | 269 | return | ||
| 267 | } | 270 | } | ||
| 268 | - | op, _ := applyPickerBatch(p.loop, outcome.events) | 271 | + | op, changed := applyPickerBatch(p.loop, outcome.events) |
| 269 | p.recordOpLocked(op) | 272 | p.recordOpLocked(op) | ||
| 273 | + | if changed { | |||
| 274 | + | select { | |||
| 275 | + | case p.opsReady <- struct{}{}: | |||
| 276 | + | default: | |||
| 277 | + | } | |||
| 278 | + | } | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | // armFlushLocked bounds a withheld escape or UTF-8 prefix with the picker's | |||
| 282 | + | // disambiguation window on the controller clock, so a lone Escape keypress | |||
| 283 | + | // resolves without waiting for another key. A later read disarms it first. | |||
| 284 | + | // Callers hold p.mu. | |||
| 285 | + | func (p *pickerController) armFlushLocked() { | |||
| 286 | + | if p.flushStop != nil || !p.consumer.hasPending() { | |||
| 287 | + | return | |||
| 288 | + | } | |||
| 289 | + | timer := p.clock.NewTimer(pickerEscapeDeadline) | |||
| 290 | + | if supervisorNil(timer) { | |||
| 291 | + | return | |||
| 292 | + | } | |||
| 293 | + | stop := make(chan struct{}) | |||
| 294 | + | p.flushStop = stop | |||
| 295 | + | go func() { | |||
| 296 | + | select { | |||
| 297 | + | case <-timer.C(): | |||
| 298 | + | case <-stop: | |||
| 299 | + | stopSupervisorTimer(timer) | |||
| 300 | + | return | |||
| 301 | + | } | |||
| 302 | + | p.mu.Lock() | |||
| 303 | + | if p.flushStop != stop { | |||
| 304 | + | p.mu.Unlock() | |||
| 305 | + | return | |||
| 306 | + | } | |||
| 307 | + | p.flushStop = nil | |||
| 308 | + | p.mu.Unlock() | |||
| 309 | + | p.FlushPending() | |||
| 310 | + | }() | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | // disarmFlushLocked cancels an armed flush: the next read resolves the | |||
| 314 | + | // withheld prefix itself. Callers hold p.mu. | |||
| 315 | + | func (p *pickerController) disarmFlushLocked() { | |||
| 316 | + | if p.flushStop == nil { | |||
| 317 | + | return | |||
| 318 | + | } | |||
| 319 | + | close(p.flushStop) | |||
| 320 | + | p.flushStop = nil | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | // invalidatePresentation makes the next Render redraw the whole box, because | |||
| 324 | + | // another owner wrote the terminal since the last picker frame. | |||
| 325 | + | func (p *pickerController) invalidatePresentation() { | |||
| 326 | + | if p == nil { | |||
| 327 | + | return | |||
| 328 | + | } | |||
| 329 | + | p.mu.Lock() | |||
| 330 | + | p.renderer.invalidate() | |||
| 331 | + | p.mu.Unlock() | |||
| 270 | } | 332 | } | ||
| 271 | 333 | ||||
| 272 | // OwnsInput reports whether the picker currently owns terminal input. | 334 | // OwnsInput reports whether the picker currently owns terminal input. | ||
| @@ -485,10 +547,12 @@ func (p *pickerController) handleRead(data []byte) bool { | |||||
| 485 | if len(data) != 0 { | 547 | if len(data) != 0 { | ||
| 486 | data = append([]byte(nil), data...) | 548 | data = append([]byte(nil), data...) | ||
| 487 | } | 549 | } | ||
| 550 | + | p.disarmFlushLocked() | |||
| 488 | outcome, consumed := p.consumer.consume(terminalReadResult{data: data}) | 551 | outcome, consumed := p.consumer.consume(terminalReadResult{data: data}) | ||
| 489 | if !consumed { | 552 | if !consumed { | ||
| 490 | return false | 553 | return false | ||
| 491 | } | 554 | } | ||
| 555 | + | defer p.armFlushLocked() | |||
| 492 | if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration { | 556 | if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration { | ||
| 493 | return true | 557 | return true | ||
| 494 | } | 558 | } | ||
| @@ -497,7 +561,7 @@ func (p *pickerController) handleRead(data []byte) bool { | |||||
| 497 | // Use decoded events so a pasted Ctrl-C is not mistaken for an exit. | 561 | // Use decoded events so a pasted Ctrl-C is not mistaken for an exit. | ||
| 498 | for _, event := range outcome.events { | 562 | for _, event := range outcome.events { | ||
| 499 | if event.kind == pickerEventKey && event.key == "Ctrl+C" { | 563 | if event.kind == pickerEventKey && event.key == "Ctrl+C" { | ||
| 500 | - | p.recordOpLocked(pickerOp{close: true}) | 564 | + | p.recordOpLocked(pickerOp{close: true, exit: true}) |
| 501 | } | 565 | } | ||
| 502 | } | 566 | } | ||
| 503 | return true | 567 | return true | ||
| @@ -569,5 +633,6 @@ func mergePickerOps(a, b pickerOp) pickerOp { | |||||
| 569 | commit: a.commit || b.commit, | 633 | commit: a.commit || b.commit, | ||
| 570 | kill: a.kill || b.kill, | 634 | kill: a.kill || b.kill, | ||
| 571 | close: a.close || b.close, | 635 | close: a.close || b.close, | ||
| 636 | + | exit: a.exit || b.exit, | |||
| 572 | } | 637 | } | ||
| 573 | } | 638 | } | ||
M internal/usecase/client/picker_interaction.go +21 -3
| @@ -499,6 +499,20 @@ func (r *pickerRenderer) reset() { | |||
| 499 | 499 | r.prevBounds = nil | |
| 500 | 500 | } | |
| 501 | 501 | ||
| 502 | + | // invalidate forgets everything the renderer believes is on screen. The next | |
| 503 | + | // render re-primes an empty shadow and writes the whole box again: someone | |
| 504 | + | // else (an attachment's output or its authoritative repaint) owned the | |
| 505 | + | // terminal since the last picker frame, so a diff against the old shadow would | |
| 506 | + | // leave parts of the box missing. | |
| 507 | + | func (r *pickerRenderer) invalidate() { | |
| 508 | + | if r == nil { | |
| 509 | + | return | |
| 510 | + | } | |
| 511 | + | r.renderer = nil | |
| 512 | + | r.primed = false | |
| 513 | + | r.prevBounds = nil | |
| 514 | + | } | |
| 515 | + | ||
| 502 | 516 | // damageRect is the damage one rectangle of a composed frame produces. | |
| 503 | 517 | func damageRect(rect domain.Rect) ansirenderer.Damage { | |
| 504 | 518 | return ansirenderer.Damage{ | |
| @@ -591,6 +605,10 @@ type pickerOp struct { | |||
| 591 | 605 | commit bool | |
| 592 | 606 | kill bool | |
| 593 | 607 | close bool | |
| 608 | + | // exit marks the explicit exit key (Ctrl+C). It always travels with close: | |
| 609 | + | // over a live attachment it cancels back to that attachment like any close, | |
| 610 | + | // and only a picker with no attachment to return to ends the process on it. | |
| 611 | + | exit bool | |
| 594 | 612 | } | |
| 595 | 613 | ||
| 596 | 614 | // pickerDriverOp is one ui-driver operation applied to an open loop. Keys | |
| @@ -668,8 +686,8 @@ func pickerDriverOp(loop *pickerLoop, keys []string, text string) (op pickerOp, | |||
| 668 | 686 | } | |
| 669 | 687 | case "Ctrl+C": | |
| 670 | 688 | // The overlay exits on Ctrl-C in both modes; the byte | |
| 671 | - | // itself never reaches the query. | |
| 672 | - | return pickerOp{close: true}, changed | |
| 689 | + | // itself never reaches the query. It is the explicit exit key. | |
| 690 | + | return pickerOp{close: true, exit: true}, changed | |
| 673 | 691 | default: | |
| 674 | 692 | if len(key) == 1 { | |
| 675 | 693 | insertLit(rune(key[0])) | |
| @@ -734,7 +752,7 @@ func applyPickerBatch(loop *pickerLoop, events []pickerEvent) (op pickerOp, chan | |||
| 734 | 752 | eventOp, eventChanged := pickerDriverOp(loop, []string{event.key}, "") | |
| 735 | 753 | changed = changed || eventChanged | |
| 736 | 754 | if eventOp.close { | |
| 737 | - | return pickerOp{close: true}, changed | |
| 755 | + | return pickerOp{close: true, exit: eventOp.exit}, changed | |
| 738 | 756 | } | |
| 739 | 757 | op.commit = op.commit || eventOp.commit | |
| 740 | 758 | op.kill = op.kill || eventOp.kill | |
| @@ -499,6 +499,20 @@ func (r *pickerRenderer) reset() { | |||||
| 499 | r.prevBounds = nil | 499 | r.prevBounds = nil | ||
| 500 | } | 500 | } | ||
| 501 | 501 | ||||
| 502 | + | // invalidate forgets everything the renderer believes is on screen. The next | |||
| 503 | + | // render re-primes an empty shadow and writes the whole box again: someone | |||
| 504 | + | // else (an attachment's output or its authoritative repaint) owned the | |||
| 505 | + | // terminal since the last picker frame, so a diff against the old shadow would | |||
| 506 | + | // leave parts of the box missing. | |||
| 507 | + | func (r *pickerRenderer) invalidate() { | |||
| 508 | + | if r == nil { | |||
| 509 | + | return | |||
| 510 | + | } | |||
| 511 | + | r.renderer = nil | |||
| 512 | + | r.primed = false | |||
| 513 | + | r.prevBounds = nil | |||
| 514 | + | } | |||
| 515 | + | ||||
| 502 | // damageRect is the damage one rectangle of a composed frame produces. | 516 | // damageRect is the damage one rectangle of a composed frame produces. | ||
| 503 | func damageRect(rect domain.Rect) ansirenderer.Damage { | 517 | func damageRect(rect domain.Rect) ansirenderer.Damage { | ||
| 504 | return ansirenderer.Damage{ | 518 | return ansirenderer.Damage{ | ||
| @@ -591,6 +605,10 @@ type pickerOp struct { | |||||
| 591 | commit bool | 605 | commit bool | ||
| 592 | kill bool | 606 | kill bool | ||
| 593 | close bool | 607 | close bool | ||
| 608 | + | // exit marks the explicit exit key (Ctrl+C). It always travels with close: | |||
| 609 | + | // over a live attachment it cancels back to that attachment like any close, | |||
| 610 | + | // and only a picker with no attachment to return to ends the process on it. | |||
| 611 | + | exit bool | |||
| 594 | } | 612 | } | ||
| 595 | 613 | ||||
| 596 | // pickerDriverOp is one ui-driver operation applied to an open loop. Keys | 614 | // pickerDriverOp is one ui-driver operation applied to an open loop. Keys | ||
| @@ -668,8 +686,8 @@ func pickerDriverOp(loop *pickerLoop, keys []string, text string) (op pickerOp, | |||||
| 668 | } | 686 | } | ||
| 669 | case "Ctrl+C": | 687 | case "Ctrl+C": | ||
| 670 | // The overlay exits on Ctrl-C in both modes; the byte | 688 | // The overlay exits on Ctrl-C in both modes; the byte | ||
| 671 | - | // itself never reaches the query. | 689 | + | // itself never reaches the query. It is the explicit exit key. |
| 672 | - | return pickerOp{close: true}, changed | 690 | + | return pickerOp{close: true, exit: true}, changed |
| 673 | default: | 691 | default: | ||
| 674 | if len(key) == 1 { | 692 | if len(key) == 1 { | ||
| 675 | insertLit(rune(key[0])) | 693 | insertLit(rune(key[0])) | ||
| @@ -734,7 +752,7 @@ func applyPickerBatch(loop *pickerLoop, events []pickerEvent) (op pickerOp, chan | |||||
| 734 | eventOp, eventChanged := pickerDriverOp(loop, []string{event.key}, "") | 752 | eventOp, eventChanged := pickerDriverOp(loop, []string{event.key}, "") | ||
| 735 | changed = changed || eventChanged | 753 | changed = changed || eventChanged | ||
| 736 | if eventOp.close { | 754 | if eventOp.close { | ||
| 737 | - | return pickerOp{close: true}, changed | 755 | + | return pickerOp{close: true, exit: eventOp.exit}, changed |
| 738 | } | 756 | } | ||
| 739 | op.commit = op.commit || eventOp.commit | 757 | op.commit = op.commit || eventOp.commit | ||
| 740 | op.kill = op.kill || eventOp.kill | 758 | op.kill = op.kill || eventOp.kill | ||
A internal/usecase/client/picker_overlay_test.go +460 -0
| @@ -0,0 +1,460 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | "github.com/bnema/vev/internal/protocol" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // Picker overlay and move picker tests (Plan 003 A1/A2). They drive the real | |
| 18 | + | // supervisor, host, foreground, and worker over the in-package broker fakes | |
| 19 | + | // with the scripted test clock: no sleeps, and every wait is bounded. | |
| 20 | + | ||
| 21 | + | // recordOp records one picker decision on the scripted picker, exactly like | |
| 22 | + | // the controller's coalesced wake. | |
| 23 | + | func (p *attachTestPicker) recordOp(op pickerOp, key string, request ports.BrokerOpenStreamRequest) { | |
| 24 | + | p.mu.Lock() | |
| 25 | + | p.op = op | |
| 26 | + | p.key = key | |
| 27 | + | p.request = request | |
| 28 | + | p.mu.Unlock() | |
| 29 | + | select { | |
| 30 | + | case p.opsReady <- struct{}{}: | |
| 31 | + | default: | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // opTaken reports whether the supervisor consumed the recorded decision. | |
| 36 | + | func (p *attachTestPicker) opTaken() bool { | |
| 37 | + | p.mu.Lock() | |
| 38 | + | defer p.mu.Unlock() | |
| 39 | + | return p.op == (pickerOp{}) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // attachLiveSession commits one attachment on the harness and returns its | |
| 43 | + | // stream once the attachment is presented. | |
| 44 | + | func attachLiveSession(t *testing.T, harness *attachTestHarness, picker *attachTestPicker) *sessionTestStream { | |
| 45 | + | t.Helper() | |
| 46 | + | stream := newSessionTestStream() | |
| 47 | + | harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 48 | + | return stream, nil | |
| 49 | + | }) | |
| 50 | + | picker.commit(sessionTestRequest(true)) | |
| 51 | + | awaitStreamHello(t, &sync.Mutex{}, &stream) | |
| 52 | + | deliverReadyStream(t, stream) | |
| 53 | + | awaitAttachedState(t, harness.sup) | |
| 54 | + | return stream | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func awaitPresentation(t *testing.T, sup *Supervisor, want Presentation) { | |
| 58 | + | t.Helper() | |
| 59 | + | require.Eventually(t, func() bool { return sup.State().Presentation == want }, 5*time.Second, time.Millisecond, "presentation never became %s", want) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // awaitSent waits for the first client message matching match. | |
| 63 | + | func awaitSent(t *testing.T, stream *sessionTestStream, what string, match func(protocol.ClientMessage) bool) protocol.ClientMessage { | |
| 64 | + | t.Helper() | |
| 65 | + | var found protocol.ClientMessage | |
| 66 | + | require.Eventually(t, func() bool { | |
| 67 | + | for _, message := range stream.messages() { | |
| 68 | + | if match(message) { | |
| 69 | + | found = message | |
| 70 | + | return true | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | return false | |
| 74 | + | }, 5*time.Second, time.Millisecond, "client never sent %s", what) | |
| 75 | + | return found | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func countSent[T protocol.ClientMessage](stream *sessionTestStream) int { | |
| 79 | + | count := 0 | |
| 80 | + | for _, message := range stream.messages() { | |
| 81 | + | if _, ok := message.(T); ok { | |
| 82 | + | count++ | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | return count | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func isSent[T protocol.ClientMessage](message protocol.ClientMessage) bool { | |
| 89 | + | _, ok := message.(T) | |
| 90 | + | return ok | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // laterOutput is a full frame on a later epoch, which the attachment accepts | |
| 94 | + | // after its initial publication. | |
| 95 | + | func laterOutput(epoch, publication uint64, data string) protocol.Output { | |
| 96 | + | output := sessionTestOutput(publication, data) | |
| 97 | + | output.Epoch = epoch | |
| 98 | + | return output | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func navigationOffer(id uint64) protocol.PickerOffer { | |
| 102 | + | return protocol.PickerOffer{InteractionID: id, Intent: protocol.PickerIntentNavigation, Title: " Sessions · recent "} | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // TestPickerOverlayReturnsToLiveAttachment pins PA1: SSP composes the picker | |
| 106 | + | // over the live attachment, its input and output stay with the picker, and | |
| 107 | + | // every cancel (and a commit to the attached session) returns to the very same | |
| 108 | + | // attachment without reconnecting, then asks for an authoritative repaint. | |
| 109 | + | func TestPickerOverlayReturnsToLiveAttachment(t *testing.T) { | |
| 110 | + | tests := []struct { | |
| 111 | + | name string | |
| 112 | + | op pickerOp | |
| 113 | + | key string | |
| 114 | + | }{ | |
| 115 | + | {name: "escape or q cancels", op: pickerOp{close: true}}, | |
| 116 | + | {name: "ctrl+c cancels without exiting", op: pickerOp{close: true, exit: true}}, | |
| 117 | + | {name: "commit to the attached session closes", op: pickerOp{commit: true}, key: "row"}, | |
| 118 | + | } | |
| 119 | + | for _, tt := range tests { | |
| 120 | + | t.Run(tt.name, func(t *testing.T) { | |
| 121 | + | picker := newAttachTestPicker() | |
| 122 | + | harness := startAttachHarness(t, picker) | |
| 123 | + | stream := attachLiveSession(t, harness, picker) | |
| 124 | + | foreground := harness.sup.attachments.authority.foreground() | |
| 125 | + | require.NotNil(t, foreground) | |
| 126 | + | require.False(t, picker.owns()) | |
| 127 | + | ||
| 128 | + | stream.deliver(navigationOffer(1)) | |
| 129 | + | awaitPresentation(t, harness.sup, PresentAttachedPicker) | |
| 130 | + | require.True(t, picker.owns(), "the picker owns input while it is open") | |
| 131 | + | ||
| 132 | + | // Input belongs to the picker: it is consumed, never sent as session | |
| 133 | + | // Input. | |
| 134 | + | harness.reader.push([]byte("j")) | |
| 135 | + | require.Eventually(t, func() bool { return picker.consumedCount() == 1 }, 5*time.Second, time.Millisecond) | |
| 136 | + | require.Zero(t, countSent[protocol.Input](stream), "picker input must never reach the session") | |
| 137 | + | ||
| 138 | + | // Attachment output is applied and acknowledged, never written over | |
| 139 | + | // the picker. | |
| 140 | + | acks := countSent[protocol.Ack](stream) | |
| 141 | + | stream.deliver(laterOutput(2, 2, "hidden-frame")) | |
| 142 | + | require.Eventually(t, func() bool { return countSent[protocol.Ack](stream) > acks }, 5*time.Second, time.Millisecond) | |
| 143 | + | require.NotContains(t, harness.terminal.written(), "hidden-frame") | |
| 144 | + | ||
| 145 | + | picker.recordOp(tt.op, tt.key, sessionTestRequest(true)) | |
| 146 | + | awaitPresentation(t, harness.sup, PresentAttached) | |
| 147 | + | awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest]) | |
| 148 | + | ||
| 149 | + | require.Same(t, foreground, harness.sup.attachments.authority.foreground(), "cancel must keep the same live attachment") | |
| 150 | + | require.False(t, stream.closedNow()) | |
| 151 | + | require.Len(t, harness.service.openedRequests(), 1, "cancel must not reconnect") | |
| 152 | + | require.Zero(t, countSent[protocol.Detach](stream)) | |
| 153 | + | require.False(t, picker.owns()) | |
| 154 | + | ||
| 155 | + | // The session owns input again. | |
| 156 | + | harness.reader.push([]byte("z")) | |
| 157 | + | input := awaitSent(t, stream, "session Input", isSent[protocol.Input]).(protocol.Input) | |
| 158 | + | require.Equal(t, []byte("z"), input.Data) | |
| 159 | + | select { | |
| 160 | + | case <-harness.runDone: | |
| 161 | + | t.Fatal("cancelling the overlay ended the process") | |
| 162 | + | default: | |
| 163 | + | } | |
| 164 | + | }) | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // TestPickerOverlayCommitElsewhereSwapsAttachment pins the swap: a commit to | |
| 169 | + | // another session detaches the live attachment cleanly and attaches the | |
| 170 | + | // committed target through Connecting, without passing through the plain | |
| 171 | + | // picker. | |
| 172 | + | func TestPickerOverlayCommitElsewhereSwapsAttachment(t *testing.T) { | |
| 173 | + | picker := newAttachTestPicker() | |
| 174 | + | notices := make(chan LifecycleNotice, 8) | |
| 175 | + | harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) { | |
| 176 | + | cfg.NotifyLifecycle = func(notice LifecycleNotice) { notices <- notice } | |
| 177 | + | }) | |
| 178 | + | first := attachLiveSession(t, harness, picker) | |
| 179 | + | first.deliver(navigationOffer(1)) | |
| 180 | + | awaitPresentation(t, harness.sup, PresentAttachedPicker) | |
| 181 | + | ||
| 182 | + | var mu sync.Mutex | |
| 183 | + | second := newSessionTestStream() | |
| 184 | + | harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 185 | + | return second, nil | |
| 186 | + | }) | |
| 187 | + | other := sessionTestRequest(true) | |
| 188 | + | other.Target = protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"} | |
| 189 | + | picker.recordOp(pickerOp{commit: true}, "row", other) | |
| 190 | + | ||
| 191 | + | awaitSent(t, first, "Detach", isSent[protocol.Detach]) | |
| 192 | + | awaitStreamHello(t, &mu, &second) | |
| 193 | + | second.deliver(protocol.Welcome{SessionName: "beta"}) | |
| 194 | + | output := sessionTestOutput(1, "\x1b[Hbeta") | |
| 195 | + | output.Context.Route.Target = other.Target | |
| 196 | + | second.deliver(output) | |
| 197 | + | awaitAttachedState(t, harness.sup) | |
| 198 | + | ||
| 199 | + | opened := harness.service.openedRequests() | |
| 200 | + | require.Len(t, opened, 2) | |
| 201 | + | require.Equal(t, other.Target, opened[1].Target) | |
| 202 | + | require.True(t, first.closedNow(), "the previous attachment is released") | |
| 203 | + | select { | |
| 204 | + | case notice := <-notices: | |
| 205 | + | t.Fatalf("a swap must not report a lifecycle transition to the picker: %+v", notice) | |
| 206 | + | default: | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | // TestPickerCloseWithoutAttachment pins the unattached decision: with nothing | |
| 211 | + | // to return to, Escape and q keep the picker (and the process) alive; only the | |
| 212 | + | // explicit Ctrl+C exit ends the run. | |
| 213 | + | func TestPickerCloseWithoutAttachment(t *testing.T) { | |
| 214 | + | tests := []struct { | |
| 215 | + | name string | |
| 216 | + | op pickerOp | |
| 217 | + | wantExit bool | |
| 218 | + | }{ | |
| 219 | + | {name: "escape or q keeps the picker", op: pickerOp{close: true}}, | |
| 220 | + | {name: "ctrl+c exits explicitly", op: pickerOp{close: true, exit: true}, wantExit: true}, | |
| 221 | + | } | |
| 222 | + | for _, tt := range tests { | |
| 223 | + | t.Run(tt.name, func(t *testing.T) { | |
| 224 | + | picker := newAttachTestPicker() | |
| 225 | + | harness := startAttachHarness(t, picker) | |
| 226 | + | picker.recordOp(tt.op, "", ports.BrokerOpenStreamRequest{}) | |
| 227 | + | if tt.wantExit { | |
| 228 | + | require.NoError(t, harness.waitRun(t)) | |
| 229 | + | return | |
| 230 | + | } | |
| 231 | + | // Prove the close was consumed, then that the run survived it: a | |
| 232 | + | // later commit is still admitted. | |
| 233 | + | require.Eventually(t, picker.opTaken, 5*time.Second, time.Millisecond) | |
| 234 | + | stream := newSessionTestStream() | |
| 235 | + | harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 236 | + | return stream, nil | |
| 237 | + | }) | |
| 238 | + | picker.commit(sessionTestRequest(true)) | |
| 239 | + | awaitStreamHello(t, &sync.Mutex{}, &stream) | |
| 240 | + | select { | |
| 241 | + | case <-harness.runDone: | |
| 242 | + | t.Fatal("a cancel with no attachment ended the process") | |
| 243 | + | default: | |
| 244 | + | } | |
| 245 | + | }) | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // moveSnapshot is the two-destination publication a move interaction opens | |
| 250 | + | // with; both rows authorise a move. | |
| 251 | + | func moveSnapshot(interaction, revision uint64) protocol.PickerSnapshot { | |
| 252 | + | return protocol.PickerSnapshot{ | |
| 253 | + | InteractionID: interaction, SourceID: "serving", SourceRevision: revision, Status: protocol.PickerSourceOK, | |
| 254 | + | Lines: []protocol.PickerLine{ | |
| 255 | + | {Key: "aa/first", Kind: protocol.PickerLineSession, Label: "first", Focusable: true, Actions: protocol.PickerCanMove}, | |
| 256 | + | {Key: "bb/second", Kind: protocol.PickerLineSession, Label: "second", Focusable: true, Actions: protocol.PickerCanMove}, | |
| 257 | + | }, | |
| 258 | + | Cursor: protocol.PickerCursor{Key: "aa/first", Index: 0}, | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func moveOffer(interaction uint64) protocol.PickerOffer { | |
| 263 | + | return protocol.PickerOffer{InteractionID: interaction, Intent: protocol.PickerIntentMovePane, Title: " Move "} | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | // openMovePicker opens one move interaction on a live attachment and waits | |
| 267 | + | // until its box is drawn. | |
| 268 | + | func openMovePicker(t *testing.T, harness *attachTestHarness, stream *sessionTestStream, interaction uint64) { | |
| 269 | + | t.Helper() | |
| 270 | + | stream.deliver(moveOffer(interaction)) | |
| 271 | + | stream.deliver(moveSnapshot(interaction, 1)) | |
| 272 | + | require.Eventually(t, func() bool { return strings.Contains(harness.terminal.written(), "second") }, 5*time.Second, time.Millisecond, "the move picker was never drawn") | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // TestMovePickerOfferIsPresentedAndAnswered pins PA3: the daemon's move offer | |
| 276 | + | // opens a working destination picker whose input never reaches the session, | |
| 277 | + | // and every exit path answers with the typed replies and hands input back. | |
| 278 | + | func TestMovePickerOfferIsPresentedAndAnswered(t *testing.T) { | |
| 279 | + | tests := []struct { | |
| 280 | + | name string | |
| 281 | + | keys []string | |
| 282 | + | check func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) | |
| 283 | + | }{ | |
| 284 | + | { | |
| 285 | + | name: "enter commits the cursor destination", | |
| 286 | + | keys: []string{"\x1b[B", "\r"}, | |
| 287 | + | check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) { | |
| 288 | + | selection := awaitSent(t, stream, "PickerSelection", isSent[protocol.PickerSelection]).(protocol.PickerSelection) | |
| 289 | + | require.Equal(t, uint64(7), selection.InteractionID) | |
| 290 | + | require.Equal(t, "serving", selection.SourceID) | |
| 291 | + | require.Equal(t, uint64(1), selection.SourceRevision) | |
| 292 | + | require.Equal(t, "bb/second", selection.Key) | |
| 293 | + | require.Equal(t, protocol.PickerActionMove, selection.Action) | |
| 294 | + | // The daemon closes the interaction; the terminal returns to the | |
| 295 | + | // attachment with an authoritative repaint. | |
| 296 | + | stream.deliver(protocol.PickerClosed{InteractionID: 7}) | |
| 297 | + | awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest]) | |
| 298 | + | }, | |
| 299 | + | }, | |
| 300 | + | { | |
| 301 | + | name: "q cancels with a typed close", | |
| 302 | + | keys: []string{"q"}, | |
| 303 | + | check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) { | |
| 304 | + | closed := awaitSent(t, stream, "PickerClose", isSent[protocol.PickerClose]).(protocol.PickerClose) | |
| 305 | + | require.Equal(t, uint64(7), closed.InteractionID) | |
| 306 | + | awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest]) | |
| 307 | + | }, | |
| 308 | + | }, | |
| 309 | + | { | |
| 310 | + | name: "a lone escape cancels once its window elapses", | |
| 311 | + | keys: []string{"\x1b"}, | |
| 312 | + | check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) { | |
| 313 | + | var escape *supervisorTestTimer | |
| 314 | + | require.Eventually(t, func() bool { | |
| 315 | + | for _, timer := range harness.clock.snapshotTimers() { | |
| 316 | + | if timer.delay == pickerEscapeDeadline && !timer.stopped() { | |
| 317 | + | escape = timer | |
| 318 | + | return true | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | return false | |
| 322 | + | }, 5*time.Second, time.Millisecond, "the escape window was never armed") | |
| 323 | + | require.Zero(t, countSent[protocol.PickerClose](stream), "a withheld escape must not close early") | |
| 324 | + | escape.fire() | |
| 325 | + | closed := awaitSent(t, stream, "PickerClose", isSent[protocol.PickerClose]).(protocol.PickerClose) | |
| 326 | + | require.Equal(t, uint64(7), closed.InteractionID) | |
| 327 | + | }, | |
| 328 | + | }, | |
| 329 | + | } | |
| 330 | + | for _, tt := range tests { | |
| 331 | + | t.Run(tt.name, func(t *testing.T) { | |
| 332 | + | picker := newAttachTestPicker() | |
| 333 | + | harness := startAttachHarness(t, picker) | |
| 334 | + | stream := attachLiveSession(t, harness, picker) | |
| 335 | + | openMovePicker(t, harness, stream, 7) | |
| 336 | + | ||
| 337 | + | // Output under the box is applied and acknowledged, never written. | |
| 338 | + | acks := countSent[protocol.Ack](stream) | |
| 339 | + | stream.deliver(laterOutput(2, 2, "hidden-frame")) | |
| 340 | + | require.Eventually(t, func() bool { return countSent[protocol.Ack](stream) > acks }, 5*time.Second, time.Millisecond) | |
| 341 | + | require.NotContains(t, harness.terminal.written(), "hidden-frame") | |
| 342 | + | ||
| 343 | + | for _, key := range tt.keys { | |
| 344 | + | harness.reader.push([]byte(key)) | |
| 345 | + | } | |
| 346 | + | tt.check(t, harness, stream) | |
| 347 | + | require.Zero(t, countSent[protocol.Input](stream), "move picker input must never reach the session") | |
| 348 | + | ||
| 349 | + | // Input is never swallowed after the picker ended: the next key is | |
| 350 | + | // session Input again. | |
| 351 | + | harness.reader.push([]byte("z")) | |
| 352 | + | input := awaitSent(t, stream, "session Input", isSent[protocol.Input]).(protocol.Input) | |
| 353 | + | require.Equal(t, []byte("z"), input.Data) | |
| 354 | + | require.Equal(t, PresentAttached, harness.sup.State().Presentation) | |
| 355 | + | }) | |
| 356 | + | } | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | // TestMovePickerOfferDeclinedUnderSessionPicker pins the exit path when the | |
| 360 | + | // move picker cannot be presented: the daemon's interaction is closed at once | |
| 361 | + | // instead of swallowing input with nothing on screen. | |
| 362 | + | func TestMovePickerOfferDeclinedUnderSessionPicker(t *testing.T) { | |
| 363 | + | picker := newAttachTestPicker() | |
| 364 | + | harness := startAttachHarness(t, picker) | |
| 365 | + | stream := attachLiveSession(t, harness, picker) | |
| 366 | + | stream.deliver(navigationOffer(1)) | |
| 367 | + | awaitPresentation(t, harness.sup, PresentAttachedPicker) | |
| 368 | + | ||
| 369 | + | stream.deliver(moveOffer(9)) | |
| 370 | + | closed := awaitSent(t, stream, "PickerClose", isSent[protocol.PickerClose]).(protocol.PickerClose) | |
| 371 | + | require.Equal(t, uint64(9), closed.InteractionID) | |
| 372 | + | require.Equal(t, PresentAttachedPicker, harness.sup.State().Presentation) | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | // TestSupervisorReducerPickerOverlay pins the Attached+Picker transitions and | |
| 376 | + | // the paint fence: only a committed attachment hosts the overlay, cancel | |
| 377 | + | // returns to Attached, and a settled attachment falls back to the plain | |
| 378 | + | // picker. | |
| 379 | + | func TestSupervisorReducerPickerOverlay(t *testing.T) { | |
| 380 | + | tests := []struct { | |
| 381 | + | name string | |
| 382 | + | from Presentation | |
| 383 | + | event supervisorEventKind | |
| 384 | + | want Presentation | |
| 385 | + | wantPaint bool | |
| 386 | + | }{ | |
| 387 | + | {name: "attached opens the overlay", from: PresentAttached, event: supervisorOverlayOpened, want: PresentAttachedPicker, wantPaint: true}, | |
| 388 | + | {name: "connecting cannot host the overlay", from: PresentConnecting, event: supervisorOverlayOpened, want: PresentConnecting}, | |
| 389 | + | {name: "plain picker ignores an overlay request", from: PresentPicker, event: supervisorOverlayOpened, want: PresentPicker, wantPaint: true}, | |
| 390 | + | {name: "cancel returns to the attachment", from: PresentAttachedPicker, event: supervisorOverlayClosed, want: PresentAttached}, | |
| 391 | + | {name: "close outside the overlay is inert", from: PresentAttached, event: supervisorOverlayClosed, want: PresentAttached}, | |
| 392 | + | {name: "attachment end falls back to the picker", from: PresentAttachedPicker, event: supervisorAttachEnded, want: PresentPicker, wantPaint: true}, | |
| 393 | + | {name: "swap goes through connecting", from: PresentAttachedPicker, event: supervisorAttachBegin, want: PresentConnecting}, | |
| 394 | + | } | |
| 395 | + | for _, tt := range tests { | |
| 396 | + | t.Run(tt.name, func(t *testing.T) { | |
| 397 | + | state := reduceSupervisor(State{Presentation: tt.from, Connectivity: ConnectivityReady}, supervisorEvent{kind: tt.event}) | |
| 398 | + | require.Equal(t, tt.want, state.Presentation) | |
| 399 | + | require.Equal(t, ConnectivityReady, state.Connectivity) | |
| 400 | + | require.Equal(t, tt.wantPaint, PickerPresentation(state)) | |
| 401 | + | }) | |
| 402 | + | } | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | // TestMovePickerDrawsAFloatingModalOverTheSession ports main's modal | |
| 406 | + | // presentation pin: the move picker is a bordered box over the session, never | |
| 407 | + | // a full-screen takeover, so it neither clears the screen nor spans it. | |
| 408 | + | func TestMovePickerDrawsAFloatingModalOverTheSession(t *testing.T) { | |
| 409 | + | picker := newAttachTestPicker() | |
| 410 | + | harness := startAttachHarness(t, picker) | |
| 411 | + | stream := attachLiveSession(t, harness, picker) | |
| 412 | + | before := len(harness.terminal.written()) | |
| 413 | + | openMovePicker(t, harness, stream, 7) | |
| 414 | + | ||
| 415 | + | frame := harness.terminal.written()[before:] | |
| 416 | + | require.Contains(t, frame, "first", "the destination rows must be drawn") | |
| 417 | + | require.Contains(t, frame, "─", "the modal border must be drawn") | |
| 418 | + | require.NotContains(t, frame, "\x1b[2J", "the box must not clear the session under it") | |
| 419 | + | require.Contains(t, harness.terminal.written(), "ready", "the session frame stays under the box") | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | // TestPickerControllerLoneEscapeResolvesOnItsWindow pins that a real Escape | |
| 423 | + | // keypress (one lone byte) cancels once the disambiguation window elapses on | |
| 424 | + | // the controller clock, and that a following byte disarms the window instead. | |
| 425 | + | func TestPickerControllerLoneEscapeResolvesOnItsWindow(t *testing.T) { | |
| 426 | + | tests := []struct { | |
| 427 | + | name string | |
| 428 | + | follow []byte | |
| 429 | + | wantClose bool | |
| 430 | + | }{ | |
| 431 | + | {name: "window elapses into Escape", wantClose: true}, | |
| 432 | + | {name: "a following byte resolves the prefix itself", follow: []byte("[B")}, | |
| 433 | + | } | |
| 434 | + | for _, tt := range tests { | |
| 435 | + | t.Run(tt.name, func(t *testing.T) { | |
| 436 | + | controller, clock := pickerTestController(t) | |
| 437 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta")) | |
| 438 | + | ||
| 439 | + | require.True(t, controller.ConsumeTerminalRead([]byte("\x1b"))) | |
| 440 | + | escape := clock.awaitTimer(t) | |
| 441 | + | require.Equal(t, pickerEscapeDeadline, escape.delay) | |
| 442 | + | if tt.follow != nil { | |
| 443 | + | require.True(t, controller.ConsumeTerminalRead(tt.follow)) | |
| 444 | + | require.Eventually(t, escape.stopped, 5*time.Second, time.Millisecond, "a resolving read must disarm the window") | |
| 445 | + | op, _ := controller.TakeOp() | |
| 446 | + | require.False(t, op.close) | |
| 447 | + | return | |
| 448 | + | } | |
| 449 | + | escape.fire() | |
| 450 | + | select { | |
| 451 | + | case <-controller.OpsReady(): | |
| 452 | + | case <-time.After(5 * time.Second): | |
| 453 | + | t.Fatal("the elapsed window never woke the supervisor") | |
| 454 | + | } | |
| 455 | + | op, _ := controller.TakeOp() | |
| 456 | + | require.Equal(t, tt.wantClose, op.close) | |
| 457 | + | require.False(t, op.exit, "Escape is a cancel, never the explicit exit") | |
| 458 | + | }) | |
| 459 | + | } | |
| 460 | + | } | |
| @@ -0,0 +1,460 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "strings" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/domain" | |||
| 13 | + | "github.com/bnema/vev/internal/ports" | |||
| 14 | + | "github.com/bnema/vev/internal/protocol" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // Picker overlay and move picker tests (Plan 003 A1/A2). They drive the real | |||
| 18 | + | // supervisor, host, foreground, and worker over the in-package broker fakes | |||
| 19 | + | // with the scripted test clock: no sleeps, and every wait is bounded. | |||
| 20 | + | ||||
| 21 | + | // recordOp records one picker decision on the scripted picker, exactly like | |||
| 22 | + | // the controller's coalesced wake. | |||
| 23 | + | func (p *attachTestPicker) recordOp(op pickerOp, key string, request ports.BrokerOpenStreamRequest) { | |||
| 24 | + | p.mu.Lock() | |||
| 25 | + | p.op = op | |||
| 26 | + | p.key = key | |||
| 27 | + | p.request = request | |||
| 28 | + | p.mu.Unlock() | |||
| 29 | + | select { | |||
| 30 | + | case p.opsReady <- struct{}{}: | |||
| 31 | + | default: | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // opTaken reports whether the supervisor consumed the recorded decision. | |||
| 36 | + | func (p *attachTestPicker) opTaken() bool { | |||
| 37 | + | p.mu.Lock() | |||
| 38 | + | defer p.mu.Unlock() | |||
| 39 | + | return p.op == (pickerOp{}) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // attachLiveSession commits one attachment on the harness and returns its | |||
| 43 | + | // stream once the attachment is presented. | |||
| 44 | + | func attachLiveSession(t *testing.T, harness *attachTestHarness, picker *attachTestPicker) *sessionTestStream { | |||
| 45 | + | t.Helper() | |||
| 46 | + | stream := newSessionTestStream() | |||
| 47 | + | harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 48 | + | return stream, nil | |||
| 49 | + | }) | |||
| 50 | + | picker.commit(sessionTestRequest(true)) | |||
| 51 | + | awaitStreamHello(t, &sync.Mutex{}, &stream) | |||
| 52 | + | deliverReadyStream(t, stream) | |||
| 53 | + | awaitAttachedState(t, harness.sup) | |||
| 54 | + | return stream | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func awaitPresentation(t *testing.T, sup *Supervisor, want Presentation) { | |||
| 58 | + | t.Helper() | |||
| 59 | + | require.Eventually(t, func() bool { return sup.State().Presentation == want }, 5*time.Second, time.Millisecond, "presentation never became %s", want) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // awaitSent waits for the first client message matching match. | |||
| 63 | + | func awaitSent(t *testing.T, stream *sessionTestStream, what string, match func(protocol.ClientMessage) bool) protocol.ClientMessage { | |||
| 64 | + | t.Helper() | |||
| 65 | + | var found protocol.ClientMessage | |||
| 66 | + | require.Eventually(t, func() bool { | |||
| 67 | + | for _, message := range stream.messages() { | |||
| 68 | + | if match(message) { | |||
| 69 | + | found = message | |||
| 70 | + | return true | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | return false | |||
| 74 | + | }, 5*time.Second, time.Millisecond, "client never sent %s", what) | |||
| 75 | + | return found | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func countSent[T protocol.ClientMessage](stream *sessionTestStream) int { | |||
| 79 | + | count := 0 | |||
| 80 | + | for _, message := range stream.messages() { | |||
| 81 | + | if _, ok := message.(T); ok { | |||
| 82 | + | count++ | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | return count | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func isSent[T protocol.ClientMessage](message protocol.ClientMessage) bool { | |||
| 89 | + | _, ok := message.(T) | |||
| 90 | + | return ok | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // laterOutput is a full frame on a later epoch, which the attachment accepts | |||
| 94 | + | // after its initial publication. | |||
| 95 | + | func laterOutput(epoch, publication uint64, data string) protocol.Output { | |||
| 96 | + | output := sessionTestOutput(publication, data) | |||
| 97 | + | output.Epoch = epoch | |||
| 98 | + | return output | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func navigationOffer(id uint64) protocol.PickerOffer { | |||
| 102 | + | return protocol.PickerOffer{InteractionID: id, Intent: protocol.PickerIntentNavigation, Title: " Sessions · recent "} | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // TestPickerOverlayReturnsToLiveAttachment pins PA1: SSP composes the picker | |||
| 106 | + | // over the live attachment, its input and output stay with the picker, and | |||
| 107 | + | // every cancel (and a commit to the attached session) returns to the very same | |||
| 108 | + | // attachment without reconnecting, then asks for an authoritative repaint. | |||
| 109 | + | func TestPickerOverlayReturnsToLiveAttachment(t *testing.T) { | |||
| 110 | + | tests := []struct { | |||
| 111 | + | name string | |||
| 112 | + | op pickerOp | |||
| 113 | + | key string | |||
| 114 | + | }{ | |||
| 115 | + | {name: "escape or q cancels", op: pickerOp{close: true}}, | |||
| 116 | + | {name: "ctrl+c cancels without exiting", op: pickerOp{close: true, exit: true}}, | |||
| 117 | + | {name: "commit to the attached session closes", op: pickerOp{commit: true}, key: "row"}, | |||
| 118 | + | } | |||
| 119 | + | for _, tt := range tests { | |||
| 120 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 121 | + | picker := newAttachTestPicker() | |||
| 122 | + | harness := startAttachHarness(t, picker) | |||
| 123 | + | stream := attachLiveSession(t, harness, picker) | |||
| 124 | + | foreground := harness.sup.attachments.authority.foreground() | |||
| 125 | + | require.NotNil(t, foreground) | |||
| 126 | + | require.False(t, picker.owns()) | |||
| 127 | + | ||||
| 128 | + | stream.deliver(navigationOffer(1)) | |||
| 129 | + | awaitPresentation(t, harness.sup, PresentAttachedPicker) | |||
| 130 | + | require.True(t, picker.owns(), "the picker owns input while it is open") | |||
| 131 | + | ||||
| 132 | + | // Input belongs to the picker: it is consumed, never sent as session | |||
| 133 | + | // Input. | |||
| 134 | + | harness.reader.push([]byte("j")) | |||
| 135 | + | require.Eventually(t, func() bool { return picker.consumedCount() == 1 }, 5*time.Second, time.Millisecond) | |||
| 136 | + | require.Zero(t, countSent[protocol.Input](stream), "picker input must never reach the session") | |||
| 137 | + | ||||
| 138 | + | // Attachment output is applied and acknowledged, never written over | |||
| 139 | + | // the picker. | |||
| 140 | + | acks := countSent[protocol.Ack](stream) | |||
| 141 | + | stream.deliver(laterOutput(2, 2, "hidden-frame")) | |||
| 142 | + | require.Eventually(t, func() bool { return countSent[protocol.Ack](stream) > acks }, 5*time.Second, time.Millisecond) | |||
| 143 | + | require.NotContains(t, harness.terminal.written(), "hidden-frame") | |||
| 144 | + | ||||
| 145 | + | picker.recordOp(tt.op, tt.key, sessionTestRequest(true)) | |||
| 146 | + | awaitPresentation(t, harness.sup, PresentAttached) | |||
| 147 | + | awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest]) | |||
| 148 | + | ||||
| 149 | + | require.Same(t, foreground, harness.sup.attachments.authority.foreground(), "cancel must keep the same live attachment") | |||
| 150 | + | require.False(t, stream.closedNow()) | |||
| 151 | + | require.Len(t, harness.service.openedRequests(), 1, "cancel must not reconnect") | |||
| 152 | + | require.Zero(t, countSent[protocol.Detach](stream)) | |||
| 153 | + | require.False(t, picker.owns()) | |||
| 154 | + | ||||
| 155 | + | // The session owns input again. | |||
| 156 | + | harness.reader.push([]byte("z")) | |||
| 157 | + | input := awaitSent(t, stream, "session Input", isSent[protocol.Input]).(protocol.Input) | |||
| 158 | + | require.Equal(t, []byte("z"), input.Data) | |||
| 159 | + | select { | |||
| 160 | + | case <-harness.runDone: | |||
| 161 | + | t.Fatal("cancelling the overlay ended the process") | |||
| 162 | + | default: | |||
| 163 | + | } | |||
| 164 | + | }) | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // TestPickerOverlayCommitElsewhereSwapsAttachment pins the swap: a commit to | |||
| 169 | + | // another session detaches the live attachment cleanly and attaches the | |||
| 170 | + | // committed target through Connecting, without passing through the plain | |||
| 171 | + | // picker. | |||
| 172 | + | func TestPickerOverlayCommitElsewhereSwapsAttachment(t *testing.T) { | |||
| 173 | + | picker := newAttachTestPicker() | |||
| 174 | + | notices := make(chan LifecycleNotice, 8) | |||
| 175 | + | harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) { | |||
| 176 | + | cfg.NotifyLifecycle = func(notice LifecycleNotice) { notices <- notice } | |||
| 177 | + | }) | |||
| 178 | + | first := attachLiveSession(t, harness, picker) | |||
| 179 | + | first.deliver(navigationOffer(1)) | |||
| 180 | + | awaitPresentation(t, harness.sup, PresentAttachedPicker) | |||
| 181 | + | ||||
| 182 | + | var mu sync.Mutex | |||
| 183 | + | second := newSessionTestStream() | |||
| 184 | + | harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 185 | + | return second, nil | |||
| 186 | + | }) | |||
| 187 | + | other := sessionTestRequest(true) | |||
| 188 | + | other.Target = protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"} | |||
| 189 | + | picker.recordOp(pickerOp{commit: true}, "row", other) | |||
| 190 | + | ||||
| 191 | + | awaitSent(t, first, "Detach", isSent[protocol.Detach]) | |||
| 192 | + | awaitStreamHello(t, &mu, &second) | |||
| 193 | + | second.deliver(protocol.Welcome{SessionName: "beta"}) | |||
| 194 | + | output := sessionTestOutput(1, "\x1b[Hbeta") | |||
| 195 | + | output.Context.Route.Target = other.Target | |||
| 196 | + | second.deliver(output) | |||
| 197 | + | awaitAttachedState(t, harness.sup) | |||
| 198 | + | ||||
| 199 | + | opened := harness.service.openedRequests() | |||
| 200 | + | require.Len(t, opened, 2) | |||
| 201 | + | require.Equal(t, other.Target, opened[1].Target) | |||
| 202 | + | require.True(t, first.closedNow(), "the previous attachment is released") | |||
| 203 | + | select { | |||
| 204 | + | case notice := <-notices: | |||
| 205 | + | t.Fatalf("a swap must not report a lifecycle transition to the picker: %+v", notice) | |||
| 206 | + | default: | |||
| 207 | + | } | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | // TestPickerCloseWithoutAttachment pins the unattached decision: with nothing | |||
| 211 | + | // to return to, Escape and q keep the picker (and the process) alive; only the | |||
| 212 | + | // explicit Ctrl+C exit ends the run. | |||
| 213 | + | func TestPickerCloseWithoutAttachment(t *testing.T) { | |||
| 214 | + | tests := []struct { | |||
| 215 | + | name string | |||
| 216 | + | op pickerOp | |||
| 217 | + | wantExit bool | |||
| 218 | + | }{ | |||
| 219 | + | {name: "escape or q keeps the picker", op: pickerOp{close: true}}, | |||
| 220 | + | {name: "ctrl+c exits explicitly", op: pickerOp{close: true, exit: true}, wantExit: true}, | |||
| 221 | + | } | |||
| 222 | + | for _, tt := range tests { | |||
| 223 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 224 | + | picker := newAttachTestPicker() | |||
| 225 | + | harness := startAttachHarness(t, picker) | |||
| 226 | + | picker.recordOp(tt.op, "", ports.BrokerOpenStreamRequest{}) | |||
| 227 | + | if tt.wantExit { | |||
| 228 | + | require.NoError(t, harness.waitRun(t)) | |||
| 229 | + | return | |||
| 230 | + | } | |||
| 231 | + | // Prove the close was consumed, then that the run survived it: a | |||
| 232 | + | // later commit is still admitted. | |||
| 233 | + | require.Eventually(t, picker.opTaken, 5*time.Second, time.Millisecond) | |||
| 234 | + | stream := newSessionTestStream() | |||
| 235 | + | harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 236 | + | return stream, nil | |||
| 237 | + | }) | |||
| 238 | + | picker.commit(sessionTestRequest(true)) | |||
| 239 | + | awaitStreamHello(t, &sync.Mutex{}, &stream) | |||
| 240 | + | select { | |||
| 241 | + | case <-harness.runDone: | |||
| 242 | + | t.Fatal("a cancel with no attachment ended the process") | |||
| 243 | + | default: | |||
| 244 | + | } | |||
| 245 | + | }) | |||
| 246 | + | } | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // moveSnapshot is the two-destination publication a move interaction opens | |||
| 250 | + | // with; both rows authorise a move. | |||
| 251 | + | func moveSnapshot(interaction, revision uint64) protocol.PickerSnapshot { | |||
| 252 | + | return protocol.PickerSnapshot{ | |||
| 253 | + | InteractionID: interaction, SourceID: "serving", SourceRevision: revision, Status: protocol.PickerSourceOK, | |||
| 254 | + | Lines: []protocol.PickerLine{ | |||
| 255 | + | {Key: "aa/first", Kind: protocol.PickerLineSession, Label: "first", Focusable: true, Actions: protocol.PickerCanMove}, | |||
| 256 | + | {Key: "bb/second", Kind: protocol.PickerLineSession, Label: "second", Focusable: true, Actions: protocol.PickerCanMove}, | |||
| 257 | + | }, | |||
| 258 | + | Cursor: protocol.PickerCursor{Key: "aa/first", Index: 0}, | |||
| 259 | + | } | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func moveOffer(interaction uint64) protocol.PickerOffer { | |||
| 263 | + | return protocol.PickerOffer{InteractionID: interaction, Intent: protocol.PickerIntentMovePane, Title: " Move "} | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | // openMovePicker opens one move interaction on a live attachment and waits | |||
| 267 | + | // until its box is drawn. | |||
| 268 | + | func openMovePicker(t *testing.T, harness *attachTestHarness, stream *sessionTestStream, interaction uint64) { | |||
| 269 | + | t.Helper() | |||
| 270 | + | stream.deliver(moveOffer(interaction)) | |||
| 271 | + | stream.deliver(moveSnapshot(interaction, 1)) | |||
| 272 | + | require.Eventually(t, func() bool { return strings.Contains(harness.terminal.written(), "second") }, 5*time.Second, time.Millisecond, "the move picker was never drawn") | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // TestMovePickerOfferIsPresentedAndAnswered pins PA3: the daemon's move offer | |||
| 276 | + | // opens a working destination picker whose input never reaches the session, | |||
| 277 | + | // and every exit path answers with the typed replies and hands input back. | |||
| 278 | + | func TestMovePickerOfferIsPresentedAndAnswered(t *testing.T) { | |||
| 279 | + | tests := []struct { | |||
| 280 | + | name string | |||
| 281 | + | keys []string | |||
| 282 | + | check func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) | |||
| 283 | + | }{ | |||
| 284 | + | { | |||
| 285 | + | name: "enter commits the cursor destination", | |||
| 286 | + | keys: []string{"\x1b[B", "\r"}, | |||
| 287 | + | check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) { | |||
| 288 | + | selection := awaitSent(t, stream, "PickerSelection", isSent[protocol.PickerSelection]).(protocol.PickerSelection) | |||
| 289 | + | require.Equal(t, uint64(7), selection.InteractionID) | |||
| 290 | + | require.Equal(t, "serving", selection.SourceID) | |||
| 291 | + | require.Equal(t, uint64(1), selection.SourceRevision) | |||
| 292 | + | require.Equal(t, "bb/second", selection.Key) | |||
| 293 | + | require.Equal(t, protocol.PickerActionMove, selection.Action) | |||
| 294 | + | // The daemon closes the interaction; the terminal returns to the | |||
| 295 | + | // attachment with an authoritative repaint. | |||
| 296 | + | stream.deliver(protocol.PickerClosed{InteractionID: 7}) | |||
| 297 | + | awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest]) | |||
| 298 | + | }, | |||
| 299 | + | }, | |||
| 300 | + | { | |||
| 301 | + | name: "q cancels with a typed close", | |||
| 302 | + | keys: []string{"q"}, | |||
| 303 | + | check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) { | |||
| 304 | + | closed := awaitSent(t, stream, "PickerClose", isSent[protocol.PickerClose]).(protocol.PickerClose) | |||
| 305 | + | require.Equal(t, uint64(7), closed.InteractionID) | |||
| 306 | + | awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest]) | |||
| 307 | + | }, | |||
| 308 | + | }, | |||
| 309 | + | { | |||
| 310 | + | name: "a lone escape cancels once its window elapses", | |||
| 311 | + | keys: []string{"\x1b"}, | |||
| 312 | + | check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) { | |||
| 313 | + | var escape *supervisorTestTimer | |||
| 314 | + | require.Eventually(t, func() bool { | |||
| 315 | + | for _, timer := range harness.clock.snapshotTimers() { | |||
| 316 | + | if timer.delay == pickerEscapeDeadline && !timer.stopped() { | |||
| 317 | + | escape = timer | |||
| 318 | + | return true | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | return false | |||
| 322 | + | }, 5*time.Second, time.Millisecond, "the escape window was never armed") | |||
| 323 | + | require.Zero(t, countSent[protocol.PickerClose](stream), "a withheld escape must not close early") | |||
| 324 | + | escape.fire() | |||
| 325 | + | closed := awaitSent(t, stream, "PickerClose", isSent[protocol.PickerClose]).(protocol.PickerClose) | |||
| 326 | + | require.Equal(t, uint64(7), closed.InteractionID) | |||
| 327 | + | }, | |||
| 328 | + | }, | |||
| 329 | + | } | |||
| 330 | + | for _, tt := range tests { | |||
| 331 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 332 | + | picker := newAttachTestPicker() | |||
| 333 | + | harness := startAttachHarness(t, picker) | |||
| 334 | + | stream := attachLiveSession(t, harness, picker) | |||
| 335 | + | openMovePicker(t, harness, stream, 7) | |||
| 336 | + | ||||
| 337 | + | // Output under the box is applied and acknowledged, never written. | |||
| 338 | + | acks := countSent[protocol.Ack](stream) | |||
| 339 | + | stream.deliver(laterOutput(2, 2, "hidden-frame")) | |||
| 340 | + | require.Eventually(t, func() bool { return countSent[protocol.Ack](stream) > acks }, 5*time.Second, time.Millisecond) | |||
| 341 | + | require.NotContains(t, harness.terminal.written(), "hidden-frame") | |||
| 342 | + | ||||
| 343 | + | for _, key := range tt.keys { | |||
| 344 | + | harness.reader.push([]byte(key)) | |||
| 345 | + | } | |||
| 346 | + | tt.check(t, harness, stream) | |||
| 347 | + | require.Zero(t, countSent[protocol.Input](stream), "move picker input must never reach the session") | |||
| 348 | + | ||||
| 349 | + | // Input is never swallowed after the picker ended: the next key is | |||
| 350 | + | // session Input again. | |||
| 351 | + | harness.reader.push([]byte("z")) | |||
| 352 | + | input := awaitSent(t, stream, "session Input", isSent[protocol.Input]).(protocol.Input) | |||
| 353 | + | require.Equal(t, []byte("z"), input.Data) | |||
| 354 | + | require.Equal(t, PresentAttached, harness.sup.State().Presentation) | |||
| 355 | + | }) | |||
| 356 | + | } | |||
| 357 | + | } | |||
| 358 | + | ||||
| 359 | + | // TestMovePickerOfferDeclinedUnderSessionPicker pins the exit path when the | |||
| 360 | + | // move picker cannot be presented: the daemon's interaction is closed at once | |||
| 361 | + | // instead of swallowing input with nothing on screen. | |||
| 362 | + | func TestMovePickerOfferDeclinedUnderSessionPicker(t *testing.T) { | |||
| 363 | + | picker := newAttachTestPicker() | |||
| 364 | + | harness := startAttachHarness(t, picker) | |||
| 365 | + | stream := attachLiveSession(t, harness, picker) | |||
| 366 | + | stream.deliver(navigationOffer(1)) | |||
| 367 | + | awaitPresentation(t, harness.sup, PresentAttachedPicker) | |||
| 368 | + | ||||
| 369 | + | stream.deliver(moveOffer(9)) | |||
| 370 | + | closed := awaitSent(t, stream, "PickerClose", isSent[protocol.PickerClose]).(protocol.PickerClose) | |||
| 371 | + | require.Equal(t, uint64(9), closed.InteractionID) | |||
| 372 | + | require.Equal(t, PresentAttachedPicker, harness.sup.State().Presentation) | |||
| 373 | + | } | |||
| 374 | + | ||||
| 375 | + | // TestSupervisorReducerPickerOverlay pins the Attached+Picker transitions and | |||
| 376 | + | // the paint fence: only a committed attachment hosts the overlay, cancel | |||
| 377 | + | // returns to Attached, and a settled attachment falls back to the plain | |||
| 378 | + | // picker. | |||
| 379 | + | func TestSupervisorReducerPickerOverlay(t *testing.T) { | |||
| 380 | + | tests := []struct { | |||
| 381 | + | name string | |||
| 382 | + | from Presentation | |||
| 383 | + | event supervisorEventKind | |||
| 384 | + | want Presentation | |||
| 385 | + | wantPaint bool | |||
| 386 | + | }{ | |||
| 387 | + | {name: "attached opens the overlay", from: PresentAttached, event: supervisorOverlayOpened, want: PresentAttachedPicker, wantPaint: true}, | |||
| 388 | + | {name: "connecting cannot host the overlay", from: PresentConnecting, event: supervisorOverlayOpened, want: PresentConnecting}, | |||
| 389 | + | {name: "plain picker ignores an overlay request", from: PresentPicker, event: supervisorOverlayOpened, want: PresentPicker, wantPaint: true}, | |||
| 390 | + | {name: "cancel returns to the attachment", from: PresentAttachedPicker, event: supervisorOverlayClosed, want: PresentAttached}, | |||
| 391 | + | {name: "close outside the overlay is inert", from: PresentAttached, event: supervisorOverlayClosed, want: PresentAttached}, | |||
| 392 | + | {name: "attachment end falls back to the picker", from: PresentAttachedPicker, event: supervisorAttachEnded, want: PresentPicker, wantPaint: true}, | |||
| 393 | + | {name: "swap goes through connecting", from: PresentAttachedPicker, event: supervisorAttachBegin, want: PresentConnecting}, | |||
| 394 | + | } | |||
| 395 | + | for _, tt := range tests { | |||
| 396 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 397 | + | state := reduceSupervisor(State{Presentation: tt.from, Connectivity: ConnectivityReady}, supervisorEvent{kind: tt.event}) | |||
| 398 | + | require.Equal(t, tt.want, state.Presentation) | |||
| 399 | + | require.Equal(t, ConnectivityReady, state.Connectivity) | |||
| 400 | + | require.Equal(t, tt.wantPaint, PickerPresentation(state)) | |||
| 401 | + | }) | |||
| 402 | + | } | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | // TestMovePickerDrawsAFloatingModalOverTheSession ports main's modal | |||
| 406 | + | // presentation pin: the move picker is a bordered box over the session, never | |||
| 407 | + | // a full-screen takeover, so it neither clears the screen nor spans it. | |||
| 408 | + | func TestMovePickerDrawsAFloatingModalOverTheSession(t *testing.T) { | |||
| 409 | + | picker := newAttachTestPicker() | |||
| 410 | + | harness := startAttachHarness(t, picker) | |||
| 411 | + | stream := attachLiveSession(t, harness, picker) | |||
| 412 | + | before := len(harness.terminal.written()) | |||
| 413 | + | openMovePicker(t, harness, stream, 7) | |||
| 414 | + | ||||
| 415 | + | frame := harness.terminal.written()[before:] | |||
| 416 | + | require.Contains(t, frame, "first", "the destination rows must be drawn") | |||
| 417 | + | require.Contains(t, frame, "─", "the modal border must be drawn") | |||
| 418 | + | require.NotContains(t, frame, "\x1b[2J", "the box must not clear the session under it") | |||
| 419 | + | require.Contains(t, harness.terminal.written(), "ready", "the session frame stays under the box") | |||
| 420 | + | } | |||
| 421 | + | ||||
| 422 | + | // TestPickerControllerLoneEscapeResolvesOnItsWindow pins that a real Escape | |||
| 423 | + | // keypress (one lone byte) cancels once the disambiguation window elapses on | |||
| 424 | + | // the controller clock, and that a following byte disarms the window instead. | |||
| 425 | + | func TestPickerControllerLoneEscapeResolvesOnItsWindow(t *testing.T) { | |||
| 426 | + | tests := []struct { | |||
| 427 | + | name string | |||
| 428 | + | follow []byte | |||
| 429 | + | wantClose bool | |||
| 430 | + | }{ | |||
| 431 | + | {name: "window elapses into Escape", wantClose: true}, | |||
| 432 | + | {name: "a following byte resolves the prefix itself", follow: []byte("[B")}, | |||
| 433 | + | } | |||
| 434 | + | for _, tt := range tests { | |||
| 435 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 436 | + | controller, clock := pickerTestController(t) | |||
| 437 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta")) | |||
| 438 | + | ||||
| 439 | + | require.True(t, controller.ConsumeTerminalRead([]byte("\x1b"))) | |||
| 440 | + | escape := clock.awaitTimer(t) | |||
| 441 | + | require.Equal(t, pickerEscapeDeadline, escape.delay) | |||
| 442 | + | if tt.follow != nil { | |||
| 443 | + | require.True(t, controller.ConsumeTerminalRead(tt.follow)) | |||
| 444 | + | require.Eventually(t, escape.stopped, 5*time.Second, time.Millisecond, "a resolving read must disarm the window") | |||
| 445 | + | op, _ := controller.TakeOp() | |||
| 446 | + | require.False(t, op.close) | |||
| 447 | + | return | |||
| 448 | + | } | |||
| 449 | + | escape.fire() | |||
| 450 | + | select { | |||
| 451 | + | case <-controller.OpsReady(): | |||
| 452 | + | case <-time.After(5 * time.Second): | |||
| 453 | + | t.Fatal("the elapsed window never woke the supervisor") | |||
| 454 | + | } | |||
| 455 | + | op, _ := controller.TakeOp() | |||
| 456 | + | require.Equal(t, tt.wantClose, op.close) | |||
| 457 | + | require.False(t, op.exit, "Escape is a cancel, never the explicit exit") | |||
| 458 | + | }) | |||
| 459 | + | } | |||
| 460 | + | } | |||
M internal/usecase/client/supervisor.go +76 -4
| @@ -70,6 +70,12 @@ const ( | |||
| 70 | 70 | // PresentTerminating is the terminal state: raw mode is being restored and | |
| 71 | 71 | // the process is leaving. | |
| 72 | 72 | PresentTerminating | |
| 73 | + | // PresentAttachedPicker composes the client picker over a live attachment | |
| 74 | + | // (ADR 001 Amendment 1). The logical attachment stays attached: its output | |
| 75 | + | // is applied and acknowledged but not written while the picker owns the | |
| 76 | + | // terminal and its input. Cancel returns to the same attachment without | |
| 77 | + | // reconnecting. | |
| 78 | + | PresentAttachedPicker | |
| 73 | 79 | ) | |
| 74 | 80 | ||
| 75 | 81 | func (p Presentation) String() string { | |
| @@ -82,6 +88,8 @@ func (p Presentation) String() string { | |||
| 82 | 88 | return "attached" | |
| 83 | 89 | case PresentTerminating: | |
| 84 | 90 | return "terminating" | |
| 91 | + | case PresentAttachedPicker: | |
| 92 | + | return "attached_picker" | |
| 85 | 93 | default: | |
| 86 | 94 | return "unknown" | |
| 87 | 95 | } | |
| @@ -173,6 +181,11 @@ const ( | |||
| 173 | 181 | // supervisorTerminal ends the process: terminal EOF, process cancellation, | |
| 174 | 182 | // or a terminal broker failure. | |
| 175 | 183 | supervisorTerminal | |
| 184 | + | // supervisorOverlayOpened composes the picker over the live attachment. | |
| 185 | + | supervisorOverlayOpened | |
| 186 | + | // supervisorOverlayClosed returns from the picker overlay to the same live | |
| 187 | + | // attachment. | |
| 188 | + | supervisorOverlayClosed | |
| 176 | 189 | ) | |
| 177 | 190 | ||
| 178 | 191 | type supervisorEvent struct { | |
| @@ -221,6 +234,17 @@ func reduceSupervisor(state State, event supervisorEvent) State { | |||
| 221 | 234 | case supervisorNonRetryable: | |
| 222 | 235 | state.Connectivity = ConnectivityDisconnected | |
| 223 | 236 | state.Err = event.err | |
| 237 | + | case supervisorOverlayOpened: | |
| 238 | + | // Only a committed attachment can host the overlay: a connecting | |
| 239 | + | // foreground has not proven its first frame yet. | |
| 240 | + | if state.Presentation == PresentAttached { | |
| 241 | + | state.Presentation = PresentAttachedPicker | |
| 242 | + | state.Err = nil | |
| 243 | + | } | |
| 244 | + | case supervisorOverlayClosed: | |
| 245 | + | if state.Presentation == PresentAttachedPicker { | |
| 246 | + | state.Presentation = PresentAttached | |
| 247 | + | } | |
| 224 | 248 | case supervisorTerminal: | |
| 225 | 249 | state.Presentation = PresentTerminating | |
| 226 | 250 | state.Connectivity = ConnectivityDisconnected | |
| @@ -375,6 +399,14 @@ type Supervisor struct { | |||
| 375 | 399 | // clientID is the stable client identity carried in every Hello this | |
| 376 | 400 | // supervisor sends, across reconnects and attachments. | |
| 377 | 401 | clientID [16]byte | |
| 402 | + | // readySub is the adopted connection's subscription while the ready phase | |
| 403 | + | // runs, so the picker overlay over a live attachment keeps folding broker | |
| 404 | + | // publications. It is only touched from the run goroutine. | |
| 405 | + | readySub ports.BrokerSubscription | |
| 406 | + | // pendingSwap is the request the picker overlay committed to another | |
| 407 | + | // target while an attachment was live. It is only touched from the run | |
| 408 | + | // goroutine and consumed by runResolvedAttachment. | |
| 409 | + | pendingSwap *ports.BrokerOpenStreamRequest | |
| 378 | 410 | } | |
| 379 | 411 | ||
| 380 | 412 | // NewSupervisor validates the required dependencies and returns a supervisor | |
| @@ -474,6 +506,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||
| 474 | 506 | ) | |
| 475 | 507 | retire := func() { | |
| 476 | 508 | s.preview.close(s.cfg.Picker) | |
| 509 | + | s.readySub = nil | |
| 477 | 510 | if sub != nil { | |
| 478 | 511 | sub.Close() | |
| 479 | 512 | sub = nil | |
| @@ -542,6 +575,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||
| 542 | 575 | } | |
| 543 | 576 | ||
| 544 | 577 | service, sub = result.service, result.sub | |
| 578 | + | s.readySub = sub | |
| 545 | 579 | if s.cfg.Picker != nil { | |
| 546 | 580 | // Render the first committed publication immediately, before waiting | |
| 547 | 581 | // for the next one; the picker never shows a stale empty catalogue | |
| @@ -796,8 +830,17 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim | |||
| 796 | 830 | continue | |
| 797 | 831 | } | |
| 798 | 832 | if op.close { | |
| 799 | - | s.preview.close(s.cfg.Picker) | |
| 800 | - | return readyOutcome{terminated: true} | |
| 833 | + | if op.exit { | |
| 834 | + | // Ctrl+C is the explicit exit. With no attachment to | |
| 835 | + | // return to, it is the only close that ends the process. | |
| 836 | + | s.preview.close(s.cfg.Picker) | |
| 837 | + | return readyOutcome{terminated: true} | |
| 838 | + | } | |
| 839 | + | // Escape and q cancel; with no attachment there is nothing to | |
| 840 | + | // cancel back to, so the picker stays and says how to leave. | |
| 841 | + | s.offerPickerNotice("picker-exit-hint", "no session attached: press Ctrl+C to quit") | |
| 842 | + | s.renderCurrent() | |
| 843 | + | continue | |
| 801 | 844 | } | |
| 802 | 845 | if op.commit && key != "" { | |
| 803 | 846 | s.preview.close(s.cfg.Picker) | |
| @@ -834,13 +877,42 @@ func (s *Supervisor) pickerOps() <-chan struct{} { | |||
| 834 | 877 | return s.cfg.Picker.OpsReady() | |
| 835 | 878 | } | |
| 836 | 879 | ||
| 837 | - | // takePickerClose honors exit while offline without losing a racing commit. | |
| 880 | + | // takePickerClose honors the explicit exit while offline without losing a | |
| 881 | + | // racing commit. A plain cancel (Escape, q) has no attachment to return to and | |
| 882 | + | // never ends the process. | |
| 838 | 883 | func (s *Supervisor) takePickerClose() bool { | |
| 839 | 884 | op, key := s.cfg.Picker.TakeOp() | |
| 840 | 885 | if op.commit && key != "" { | |
| 841 | 886 | s.pendingPickerKey = key | |
| 842 | 887 | } | |
| 843 | - | return op.close | |
| 888 | + | if op.close && !op.exit { | |
| 889 | + | s.offerPickerNotice("picker-exit-hint", "no session attached: press Ctrl+C to quit") | |
| 890 | + | s.renderCurrent() | |
| 891 | + | } | |
| 892 | + | return op.close && op.exit | |
| 893 | + | } | |
| 894 | + | ||
| 895 | + | // pickerPresentationHost is the optional presentation surface of the real | |
| 896 | + | // picker. Scripted pickers may omit it. | |
| 897 | + | type pickerPresentationHost interface { | |
| 898 | + | offerNotice(id, message string) | |
| 899 | + | invalidatePresentation() | |
| 900 | + | } | |
| 901 | + | ||
| 902 | + | // offerPickerNotice shows one bounded client-local notice when the picker | |
| 903 | + | // supports it. | |
| 904 | + | func (s *Supervisor) offerPickerNotice(id, message string) { | |
| 905 | + | if host, ok := s.cfg.Picker.(pickerPresentationHost); ok { | |
| 906 | + | host.offerNotice(id, message) | |
| 907 | + | } | |
| 908 | + | } | |
| 909 | + | ||
| 910 | + | // invalidatePickerPresentation forces the next picker frame to redraw the | |
| 911 | + | // whole box after another owner wrote the terminal. | |
| 912 | + | func (s *Supervisor) invalidatePickerPresentation() { | |
| 913 | + | if host, ok := s.cfg.Picker.(pickerPresentationHost); ok { | |
| 914 | + | host.invalidatePresentation() | |
| 915 | + | } | |
| 844 | 916 | } | |
| 845 | 917 | ||
| 846 | 918 | // awaitTermination parks the supervisor on the picker after a non-retryable | |
| @@ -70,6 +70,12 @@ const ( | |||||
| 70 | // PresentTerminating is the terminal state: raw mode is being restored and | 70 | // PresentTerminating is the terminal state: raw mode is being restored and | ||
| 71 | // the process is leaving. | 71 | // the process is leaving. | ||
| 72 | PresentTerminating | 72 | PresentTerminating | ||
| 73 | + | // PresentAttachedPicker composes the client picker over a live attachment | |||
| 74 | + | // (ADR 001 Amendment 1). The logical attachment stays attached: its output | |||
| 75 | + | // is applied and acknowledged but not written while the picker owns the | |||
| 76 | + | // terminal and its input. Cancel returns to the same attachment without | |||
| 77 | + | // reconnecting. | |||
| 78 | + | PresentAttachedPicker | |||
| 73 | ) | 79 | ) | ||
| 74 | 80 | ||||
| 75 | func (p Presentation) String() string { | 81 | func (p Presentation) String() string { | ||
| @@ -82,6 +88,8 @@ func (p Presentation) String() string { | |||||
| 82 | return "attached" | 88 | return "attached" | ||
| 83 | case PresentTerminating: | 89 | case PresentTerminating: | ||
| 84 | return "terminating" | 90 | return "terminating" | ||
| 91 | + | case PresentAttachedPicker: | |||
| 92 | + | return "attached_picker" | |||
| 85 | default: | 93 | default: | ||
| 86 | return "unknown" | 94 | return "unknown" | ||
| 87 | } | 95 | } | ||
| @@ -173,6 +181,11 @@ const ( | |||||
| 173 | // supervisorTerminal ends the process: terminal EOF, process cancellation, | 181 | // supervisorTerminal ends the process: terminal EOF, process cancellation, | ||
| 174 | // or a terminal broker failure. | 182 | // or a terminal broker failure. | ||
| 175 | supervisorTerminal | 183 | supervisorTerminal | ||
| 184 | + | // supervisorOverlayOpened composes the picker over the live attachment. | |||
| 185 | + | supervisorOverlayOpened | |||
| 186 | + | // supervisorOverlayClosed returns from the picker overlay to the same live | |||
| 187 | + | // attachment. | |||
| 188 | + | supervisorOverlayClosed | |||
| 176 | ) | 189 | ) | ||
| 177 | 190 | ||||
| 178 | type supervisorEvent struct { | 191 | type supervisorEvent struct { | ||
| @@ -221,6 +234,17 @@ func reduceSupervisor(state State, event supervisorEvent) State { | |||||
| 221 | case supervisorNonRetryable: | 234 | case supervisorNonRetryable: | ||
| 222 | state.Connectivity = ConnectivityDisconnected | 235 | state.Connectivity = ConnectivityDisconnected | ||
| 223 | state.Err = event.err | 236 | state.Err = event.err | ||
| 237 | + | case supervisorOverlayOpened: | |||
| 238 | + | // Only a committed attachment can host the overlay: a connecting | |||
| 239 | + | // foreground has not proven its first frame yet. | |||
| 240 | + | if state.Presentation == PresentAttached { | |||
| 241 | + | state.Presentation = PresentAttachedPicker | |||
| 242 | + | state.Err = nil | |||
| 243 | + | } | |||
| 244 | + | case supervisorOverlayClosed: | |||
| 245 | + | if state.Presentation == PresentAttachedPicker { | |||
| 246 | + | state.Presentation = PresentAttached | |||
| 247 | + | } | |||
| 224 | case supervisorTerminal: | 248 | case supervisorTerminal: | ||
| 225 | state.Presentation = PresentTerminating | 249 | state.Presentation = PresentTerminating | ||
| 226 | state.Connectivity = ConnectivityDisconnected | 250 | state.Connectivity = ConnectivityDisconnected | ||
| @@ -375,6 +399,14 @@ type Supervisor struct { | |||||
| 375 | // clientID is the stable client identity carried in every Hello this | 399 | // clientID is the stable client identity carried in every Hello this | ||
| 376 | // supervisor sends, across reconnects and attachments. | 400 | // supervisor sends, across reconnects and attachments. | ||
| 377 | clientID [16]byte | 401 | clientID [16]byte | ||
| 402 | + | // readySub is the adopted connection's subscription while the ready phase | |||
| 403 | + | // runs, so the picker overlay over a live attachment keeps folding broker | |||
| 404 | + | // publications. It is only touched from the run goroutine. | |||
| 405 | + | readySub ports.BrokerSubscription | |||
| 406 | + | // pendingSwap is the request the picker overlay committed to another | |||
| 407 | + | // target while an attachment was live. It is only touched from the run | |||
| 408 | + | // goroutine and consumed by runResolvedAttachment. | |||
| 409 | + | pendingSwap *ports.BrokerOpenStreamRequest | |||
| 378 | } | 410 | } | ||
| 379 | 411 | ||||
| 380 | // NewSupervisor validates the required dependencies and returns a supervisor | 412 | // NewSupervisor validates the required dependencies and returns a supervisor | ||
| @@ -474,6 +506,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||||
| 474 | ) | 506 | ) | ||
| 475 | retire := func() { | 507 | retire := func() { | ||
| 476 | s.preview.close(s.cfg.Picker) | 508 | s.preview.close(s.cfg.Picker) | ||
| 509 | + | s.readySub = nil | |||
| 477 | if sub != nil { | 510 | if sub != nil { | ||
| 478 | sub.Close() | 511 | sub.Close() | ||
| 479 | sub = nil | 512 | sub = nil | ||
| @@ -542,6 +575,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||||
| 542 | } | 575 | } | ||
| 543 | 576 | ||||
| 544 | service, sub = result.service, result.sub | 577 | service, sub = result.service, result.sub | ||
| 578 | + | s.readySub = sub | |||
| 545 | if s.cfg.Picker != nil { | 579 | if s.cfg.Picker != nil { | ||
| 546 | // Render the first committed publication immediately, before waiting | 580 | // Render the first committed publication immediately, before waiting | ||
| 547 | // for the next one; the picker never shows a stale empty catalogue | 581 | // for the next one; the picker never shows a stale empty catalogue | ||
| @@ -796,8 +830,17 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim | |||||
| 796 | continue | 830 | continue | ||
| 797 | } | 831 | } | ||
| 798 | if op.close { | 832 | if op.close { | ||
| 799 | - | s.preview.close(s.cfg.Picker) | 833 | + | if op.exit { |
| 800 | - | return readyOutcome{terminated: true} | 834 | + | // Ctrl+C is the explicit exit. With no attachment to |
| 835 | + | // return to, it is the only close that ends the process. | |||
| 836 | + | s.preview.close(s.cfg.Picker) | |||
| 837 | + | return readyOutcome{terminated: true} | |||
| 838 | + | } | |||
| 839 | + | // Escape and q cancel; with no attachment there is nothing to | |||
| 840 | + | // cancel back to, so the picker stays and says how to leave. | |||
| 841 | + | s.offerPickerNotice("picker-exit-hint", "no session attached: press Ctrl+C to quit") | |||
| 842 | + | s.renderCurrent() | |||
| 843 | + | continue | |||
| 801 | } | 844 | } | ||
| 802 | if op.commit && key != "" { | 845 | if op.commit && key != "" { | ||
| 803 | s.preview.close(s.cfg.Picker) | 846 | s.preview.close(s.cfg.Picker) | ||
| @@ -834,13 +877,42 @@ func (s *Supervisor) pickerOps() <-chan struct{} { | |||||
| 834 | return s.cfg.Picker.OpsReady() | 877 | return s.cfg.Picker.OpsReady() | ||
| 835 | } | 878 | } | ||
| 836 | 879 | ||||
| 837 | - | // takePickerClose honors exit while offline without losing a racing commit. | 880 | + | // takePickerClose honors the explicit exit while offline without losing a |
| 881 | + | // racing commit. A plain cancel (Escape, q) has no attachment to return to and | |||
| 882 | + | // never ends the process. | |||
| 838 | func (s *Supervisor) takePickerClose() bool { | 883 | func (s *Supervisor) takePickerClose() bool { | ||
| 839 | op, key := s.cfg.Picker.TakeOp() | 884 | op, key := s.cfg.Picker.TakeOp() | ||
| 840 | if op.commit && key != "" { | 885 | if op.commit && key != "" { | ||
| 841 | s.pendingPickerKey = key | 886 | s.pendingPickerKey = key | ||
| 842 | } | 887 | } | ||
| 843 | - | return op.close | 888 | + | if op.close && !op.exit { |
| 889 | + | s.offerPickerNotice("picker-exit-hint", "no session attached: press Ctrl+C to quit") | |||
| 890 | + | s.renderCurrent() | |||
| 891 | + | } | |||
| 892 | + | return op.close && op.exit | |||
| 893 | + | } | |||
| 894 | + | ||||
| 895 | + | // pickerPresentationHost is the optional presentation surface of the real | |||
| 896 | + | // picker. Scripted pickers may omit it. | |||
| 897 | + | type pickerPresentationHost interface { | |||
| 898 | + | offerNotice(id, message string) | |||
| 899 | + | invalidatePresentation() | |||
| 900 | + | } | |||
| 901 | + | ||||
| 902 | + | // offerPickerNotice shows one bounded client-local notice when the picker | |||
| 903 | + | // supports it. | |||
| 904 | + | func (s *Supervisor) offerPickerNotice(id, message string) { | |||
| 905 | + | if host, ok := s.cfg.Picker.(pickerPresentationHost); ok { | |||
| 906 | + | host.offerNotice(id, message) | |||
| 907 | + | } | |||
| 908 | + | } | |||
| 909 | + | ||||
| 910 | + | // invalidatePickerPresentation forces the next picker frame to redraw the | |||
| 911 | + | // whole box after another owner wrote the terminal. | |||
| 912 | + | func (s *Supervisor) invalidatePickerPresentation() { | |||
| 913 | + | if host, ok := s.cfg.Picker.(pickerPresentationHost); ok { | |||
| 914 | + | host.invalidatePresentation() | |||
| 915 | + | } | |||
| 844 | } | 916 | } | ||
| 845 | 917 | ||||
| 846 | // awaitTermination parks the supervisor on the picker after a non-retryable | 918 | // awaitTermination parks the supervisor on the picker after a non-retryable | ||
M internal/usecase/client/supervisor_attach.go +59 -4
| @@ -306,6 +306,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, | |||
| 306 | 306 | TrueColor: environment.TrueColor, | |
| 307 | 307 | SessionEnvironment: sessionEnv, | |
| 308 | 308 | BeforeAttached: beforeAttached, | |
| 309 | + | Clock: s.cfg.Clock, | |
| 309 | 310 | }) | |
| 310 | 311 | if err != nil { | |
| 311 | 312 | return nil, err | |
| @@ -411,9 +412,37 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI | |||
| 411 | 412 | picker.SetOwnsInput(false) | |
| 412 | 413 | input.releasePicker() | |
| 413 | 414 | defer func() { | |
| 415 | + | // Whoever owned the terminal since the last picker frame, the next | |
| 416 | + | // frame must draw the whole box again. | |
| 417 | + | s.invalidatePickerPresentation() | |
| 414 | 418 | picker.SetOwnsInput(true) | |
| 415 | 419 | input.acquirePicker() | |
| 416 | 420 | }() | |
| 421 | + | for { | |
| 422 | + | terminated, termErr, swap := s.attachOnce(ctx, input, service, request, localProvenance) | |
| 423 | + | if terminated || swap == nil { | |
| 424 | + | return terminated, termErr | |
| 425 | + | } | |
| 426 | + | // A commit from the picker overlay to another target: the previous | |
| 427 | + | // attachment detached cleanly, and the swap goes through Connecting | |
| 428 | + | // exactly like any committed selection. | |
| 429 | + | request, localProvenance = *swap, SessionEnvironmentLocalPicker | |
| 430 | + | } | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | // attachOnce runs one resolved attachment end to end. A non-nil swap names the | |
| 434 | + | // next request the picker overlay committed while this attachment was live. | |
| 435 | + | func (s *Supervisor) attachOnce(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, request ports.BrokerOpenStreamRequest, localProvenance SessionEnvironmentProvenance) (bool, error, *ports.BrokerOpenStreamRequest) { | |
| 436 | + | terminated, termErr := s.attachResolved(ctx, input, service, request, localProvenance) | |
| 437 | + | swap := s.pendingSwap | |
| 438 | + | s.pendingSwap = nil | |
| 439 | + | if terminated { | |
| 440 | + | return terminated, termErr, nil | |
| 441 | + | } | |
| 442 | + | return false, nil, swap | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, request ports.BrokerOpenStreamRequest, localProvenance SessionEnvironmentProvenance) (bool, error) { | |
| 417 | 446 | s.transition(supervisorEvent{kind: supervisorAttachBegin}) | |
| 418 | 447 | deadline := startAttachmentDeadline(ctx, s.cfg.Clock) | |
| 419 | 448 | defer deadline.finish() | |
| @@ -471,20 +500,23 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI | |||
| 471 | 500 | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | |
| 472 | 501 | return false, nil | |
| 473 | 502 | } | |
| 474 | - | return s.settleAttachment(ctx, input, service, deadline, run) | |
| 503 | + | return s.settleAttachment(ctx, input, service, deadline, run, request) | |
| 475 | 504 | } | |
| 476 | 505 | ||
| 477 | 506 | // settleAttachment joins one admitted attachment while watching the parent | |
| 478 | 507 | // context, terminal EOF, and broker-connection loss. The deadline is the only | |
| 479 | 508 | // other stop: when it fires before the worker settled, the attachment ends as | |
| 480 | 509 | // a typed timeout without claiming attachment. | |
| 481 | - | func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, deadline *attachmentDeadline, run *attachmentRun) (bool, error) { | |
| 510 | + | func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, deadline *attachmentDeadline, run *attachmentRun, request ports.BrokerOpenStreamRequest) (bool, error) { | |
| 482 | 511 | settled := make(chan attachmentSettlement, 1) | |
| 483 | 512 | go func() { | |
| 484 | 513 | event, adopted := run.Wait(deadline.Context()) | |
| 485 | 514 | settled <- attachmentSettlement{event: event, adopted: adopted} | |
| 486 | 515 | }() | |
| 487 | 516 | ||
| 517 | + | overlay := &attachmentPickerOverlay{sup: s, run: run, service: service, request: request} | |
| 518 | + | defer overlay.drop() | |
| 519 | + | ||
| 488 | 520 | var result attachmentSettlement | |
| 489 | 521 | var brokerLost bool | |
| 490 | 522 | var terminated bool | |
| @@ -493,11 +525,22 @@ settlement: | |||
| 493 | 525 | for { | |
| 494 | 526 | // Begin has admitted the foreground, which can write before MarkAttached | |
| 495 | 527 | // and before the initial publication. Keep presentation invalidation | |
| 496 | - | // buffered for the next picker state; consuming it here could overlay | |
| 497 | - | // session output with a second terminal writer. | |
| 528 | + | // buffered for the next picker state unless the picker overlay owns the | |
| 529 | + | // terminal; consuming it otherwise could overlay session output with a | |
| 530 | + | // second terminal writer. | |
| 498 | 531 | select { | |
| 499 | 532 | case result = <-settled: | |
| 500 | 533 | break settlement | |
| 534 | + | case <-s.attachments.NavigationRequests(): | |
| 535 | + | overlay.enter() | |
| 536 | + | case <-overlay.changed(): | |
| 537 | + | overlay.applyPublication() | |
| 538 | + | case <-overlay.previewChanged(): | |
| 539 | + | overlay.publishPreview() | |
| 540 | + | case <-overlay.ops(): | |
| 541 | + | overlay.takeOp() | |
| 542 | + | case <-overlay.invalidation(): | |
| 543 | + | overlay.resize() | |
| 501 | 544 | case <-service.Done(): | |
| 502 | 545 | brokerLost = true | |
| 503 | 546 | // The broker connection is gone; the supervisor cancels the run and | |
| @@ -518,9 +561,21 @@ settlement: | |||
| 518 | 561 | } | |
| 519 | 562 | } | |
| 520 | 563 | ||
| 564 | + | // The attachment settled, so its foreground (and overlay slot) is gone. | |
| 565 | + | overlay.drop() | |
| 521 | 566 | if terminated { | |
| 567 | + | s.pendingSwap = nil | |
| 522 | 568 | return true, termErr | |
| 523 | 569 | } | |
| 570 | + | if s.pendingSwap != nil { | |
| 571 | + | if !brokerLost { | |
| 572 | + | // The overlay committed another target and this attachment | |
| 573 | + | // detached for it: go straight to Connecting for the swap, never | |
| 574 | + | // through the plain picker. | |
| 575 | + | return false, nil | |
| 576 | + | } | |
| 577 | + | s.pendingSwap = nil | |
| 578 | + | } | |
| 524 | 579 | if deadline.TimedOut() && !result.adopted { | |
| 525 | 580 | timeout := attachmentTimeoutError(errAttachmentDeadline) | |
| 526 | 581 | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: timeout}) | |
| @@ -306,6 +306,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, | |||||
| 306 | TrueColor: environment.TrueColor, | 306 | TrueColor: environment.TrueColor, | ||
| 307 | SessionEnvironment: sessionEnv, | 307 | SessionEnvironment: sessionEnv, | ||
| 308 | BeforeAttached: beforeAttached, | 308 | BeforeAttached: beforeAttached, | ||
| 309 | + | Clock: s.cfg.Clock, | |||
| 309 | }) | 310 | }) | ||
| 310 | if err != nil { | 311 | if err != nil { | ||
| 311 | return nil, err | 312 | return nil, err | ||
| @@ -411,9 +412,37 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI | |||||
| 411 | picker.SetOwnsInput(false) | 412 | picker.SetOwnsInput(false) | ||
| 412 | input.releasePicker() | 413 | input.releasePicker() | ||
| 413 | defer func() { | 414 | defer func() { | ||
| 415 | + | // Whoever owned the terminal since the last picker frame, the next | |||
| 416 | + | // frame must draw the whole box again. | |||
| 417 | + | s.invalidatePickerPresentation() | |||
| 414 | picker.SetOwnsInput(true) | 418 | picker.SetOwnsInput(true) | ||
| 415 | input.acquirePicker() | 419 | input.acquirePicker() | ||
| 416 | }() | 420 | }() | ||
| 421 | + | for { | |||
| 422 | + | terminated, termErr, swap := s.attachOnce(ctx, input, service, request, localProvenance) | |||
| 423 | + | if terminated || swap == nil { | |||
| 424 | + | return terminated, termErr | |||
| 425 | + | } | |||
| 426 | + | // A commit from the picker overlay to another target: the previous | |||
| 427 | + | // attachment detached cleanly, and the swap goes through Connecting | |||
| 428 | + | // exactly like any committed selection. | |||
| 429 | + | request, localProvenance = *swap, SessionEnvironmentLocalPicker | |||
| 430 | + | } | |||
| 431 | + | } | |||
| 432 | + | ||||
| 433 | + | // attachOnce runs one resolved attachment end to end. A non-nil swap names the | |||
| 434 | + | // next request the picker overlay committed while this attachment was live. | |||
| 435 | + | func (s *Supervisor) attachOnce(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, request ports.BrokerOpenStreamRequest, localProvenance SessionEnvironmentProvenance) (bool, error, *ports.BrokerOpenStreamRequest) { | |||
| 436 | + | terminated, termErr := s.attachResolved(ctx, input, service, request, localProvenance) | |||
| 437 | + | swap := s.pendingSwap | |||
| 438 | + | s.pendingSwap = nil | |||
| 439 | + | if terminated { | |||
| 440 | + | return terminated, termErr, nil | |||
| 441 | + | } | |||
| 442 | + | return false, nil, swap | |||
| 443 | + | } | |||
| 444 | + | ||||
| 445 | + | func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, request ports.BrokerOpenStreamRequest, localProvenance SessionEnvironmentProvenance) (bool, error) { | |||
| 417 | s.transition(supervisorEvent{kind: supervisorAttachBegin}) | 446 | s.transition(supervisorEvent{kind: supervisorAttachBegin}) | ||
| 418 | deadline := startAttachmentDeadline(ctx, s.cfg.Clock) | 447 | deadline := startAttachmentDeadline(ctx, s.cfg.Clock) | ||
| 419 | defer deadline.finish() | 448 | defer deadline.finish() | ||
| @@ -471,20 +500,23 @@ func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalI | |||||
| 471 | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | 500 | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | ||
| 472 | return false, nil | 501 | return false, nil | ||
| 473 | } | 502 | } | ||
| 474 | - | return s.settleAttachment(ctx, input, service, deadline, run) | 503 | + | return s.settleAttachment(ctx, input, service, deadline, run, request) |
| 475 | } | 504 | } | ||
| 476 | 505 | ||||
| 477 | // settleAttachment joins one admitted attachment while watching the parent | 506 | // settleAttachment joins one admitted attachment while watching the parent | ||
| 478 | // context, terminal EOF, and broker-connection loss. The deadline is the only | 507 | // context, terminal EOF, and broker-connection loss. The deadline is the only | ||
| 479 | // other stop: when it fires before the worker settled, the attachment ends as | 508 | // other stop: when it fires before the worker settled, the attachment ends as | ||
| 480 | // a typed timeout without claiming attachment. | 509 | // a typed timeout without claiming attachment. | ||
| 481 | - | func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, deadline *attachmentDeadline, run *attachmentRun) (bool, error) { | 510 | + | func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, deadline *attachmentDeadline, run *attachmentRun, request ports.BrokerOpenStreamRequest) (bool, error) { |
| 482 | settled := make(chan attachmentSettlement, 1) | 511 | settled := make(chan attachmentSettlement, 1) | ||
| 483 | go func() { | 512 | go func() { | ||
| 484 | event, adopted := run.Wait(deadline.Context()) | 513 | event, adopted := run.Wait(deadline.Context()) | ||
| 485 | settled <- attachmentSettlement{event: event, adopted: adopted} | 514 | settled <- attachmentSettlement{event: event, adopted: adopted} | ||
| 486 | }() | 515 | }() | ||
| 487 | 516 | ||||
| 517 | + | overlay := &attachmentPickerOverlay{sup: s, run: run, service: service, request: request} | |||
| 518 | + | defer overlay.drop() | |||
| 519 | + | ||||
| 488 | var result attachmentSettlement | 520 | var result attachmentSettlement | ||
| 489 | var brokerLost bool | 521 | var brokerLost bool | ||
| 490 | var terminated bool | 522 | var terminated bool | ||
| @@ -493,11 +525,22 @@ settlement: | |||||
| 493 | for { | 525 | for { | ||
| 494 | // Begin has admitted the foreground, which can write before MarkAttached | 526 | // Begin has admitted the foreground, which can write before MarkAttached | ||
| 495 | // and before the initial publication. Keep presentation invalidation | 527 | // and before the initial publication. Keep presentation invalidation | ||
| 496 | - | // buffered for the next picker state; consuming it here could overlay | 528 | + | // buffered for the next picker state unless the picker overlay owns the |
| 497 | - | // session output with a second terminal writer. | 529 | + | // terminal; consuming it otherwise could overlay session output with a |
| 530 | + | // second terminal writer. | |||
| 498 | select { | 531 | select { | ||
| 499 | case result = <-settled: | 532 | case result = <-settled: | ||
| 500 | break settlement | 533 | break settlement | ||
| 534 | + | case <-s.attachments.NavigationRequests(): | |||
| 535 | + | overlay.enter() | |||
| 536 | + | case <-overlay.changed(): | |||
| 537 | + | overlay.applyPublication() | |||
| 538 | + | case <-overlay.previewChanged(): | |||
| 539 | + | overlay.publishPreview() | |||
| 540 | + | case <-overlay.ops(): | |||
| 541 | + | overlay.takeOp() | |||
| 542 | + | case <-overlay.invalidation(): | |||
| 543 | + | overlay.resize() | |||
| 501 | case <-service.Done(): | 544 | case <-service.Done(): | ||
| 502 | brokerLost = true | 545 | brokerLost = true | ||
| 503 | // The broker connection is gone; the supervisor cancels the run and | 546 | // The broker connection is gone; the supervisor cancels the run and | ||
| @@ -518,9 +561,21 @@ settlement: | |||||
| 518 | } | 561 | } | ||
| 519 | } | 562 | } | ||
| 520 | 563 | ||||
| 564 | + | // The attachment settled, so its foreground (and overlay slot) is gone. | |||
| 565 | + | overlay.drop() | |||
| 521 | if terminated { | 566 | if terminated { | ||
| 567 | + | s.pendingSwap = nil | |||
| 522 | return true, termErr | 568 | return true, termErr | ||
| 523 | } | 569 | } | ||
| 570 | + | if s.pendingSwap != nil { | |||
| 571 | + | if !brokerLost { | |||
| 572 | + | // The overlay committed another target and this attachment | |||
| 573 | + | // detached for it: go straight to Connecting for the swap, never | |||
| 574 | + | // through the plain picker. | |||
| 575 | + | return false, nil | |||
| 576 | + | } | |||
| 577 | + | s.pendingSwap = nil | |||
| 578 | + | } | |||
| 524 | if deadline.TimedOut() && !result.adopted { | 579 | if deadline.TimedOut() && !result.adopted { | ||
| 525 | timeout := attachmentTimeoutError(errAttachmentDeadline) | 580 | timeout := attachmentTimeoutError(errAttachmentDeadline) | ||
| 526 | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: timeout}) | 581 | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: timeout}) | ||
A internal/usecase/client/supervisor_overlay.go +186 -0
| @@ -0,0 +1,186 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/ports" | |
| 5 | + | "github.com/bnema/vev/internal/protocol" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // Picker overlay over a live attachment (ADR 001 Amendment 1, Plan 003 A1). | |
| 9 | + | // | |
| 10 | + | // The daemon's session-picker command no longer ends the attachment: it sends | |
| 11 | + | // a navigation PickerOffer and the attached worker asks the supervisor for the | |
| 12 | + | // client picker. The supervisor then composes the same client-owned picker it | |
| 13 | + | // presents with no attachment over the live one: | |
| 14 | + | // | |
| 15 | + | // - The foreground's overlay slot suppresses attachment output (frames are | |
| 16 | + | // still applied and acknowledged) and diverts every authorized input | |
| 17 | + | // delivery to the picker, so no picker byte reaches the session and the | |
| 18 | + | // single terminal reader is never duplicated. | |
| 19 | + | // - Broker publications, previews, and resizes repaint the picker while the | |
| 20 | + | // overlay is open, exactly as in the plain picker. | |
| 21 | + | // - Cancel (Escape, q, Ctrl+C) and a commit to the attached session release | |
| 22 | + | // the slot; the worker then asks the daemon for an authoritative repaint. | |
| 23 | + | // The attachment is never reconnected. | |
| 24 | + | // - A commit to another target detaches the live attachment cleanly and | |
| 25 | + | // swaps to the committed request through Connecting. | |
| 26 | + | // | |
| 27 | + | // Every method runs on the supervisor's run goroutine. | |
| 28 | + | type attachmentPickerOverlay struct { | |
| 29 | + | sup *Supervisor | |
| 30 | + | run *attachmentRun | |
| 31 | + | service ports.BrokerService | |
| 32 | + | request ports.BrokerOpenStreamRequest | |
| 33 | + | active bool | |
| 34 | + | // swapping is set once a commit elsewhere asked the live attachment to | |
| 35 | + | // detach; the overlay keeps the terminal until the attachment settles. | |
| 36 | + | swapping bool | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (o *attachmentPickerOverlay) picker() pickerHost { return o.sup.cfg.Picker } | |
| 40 | + | ||
| 41 | + | // changed is the broker publication wake while the overlay is presented. | |
| 42 | + | func (o *attachmentPickerOverlay) changed() <-chan struct{} { | |
| 43 | + | if !o.active || supervisorNil(o.sup.readySub) { | |
| 44 | + | return nil | |
| 45 | + | } | |
| 46 | + | return o.sup.readySub.Changed() | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func (o *attachmentPickerOverlay) previewChanged() <-chan struct{} { | |
| 50 | + | if !o.active { | |
| 51 | + | return nil | |
| 52 | + | } | |
| 53 | + | return o.sup.preview.changed() | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func (o *attachmentPickerOverlay) ops() <-chan struct{} { | |
| 57 | + | if !o.active || o.swapping { | |
| 58 | + | return nil | |
| 59 | + | } | |
| 60 | + | return o.picker().OpsReady() | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // invalidation is consumed only while the overlay owns the terminal; otherwise | |
| 64 | + | // the attachment foreground does and the signal stays buffered. | |
| 65 | + | func (o *attachmentPickerOverlay) invalidation() <-chan struct{} { | |
| 66 | + | if !o.active { | |
| 67 | + | return nil | |
| 68 | + | } | |
| 69 | + | return o.sup.presentationInvalidation() | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // enter composes the picker over the attachment. It is refused unless the | |
| 73 | + | // attachment is committed and presented, and while a swap is in progress. | |
| 74 | + | func (o *attachmentPickerOverlay) enter() { | |
| 75 | + | s := o.sup | |
| 76 | + | picker := o.picker() | |
| 77 | + | if o.active || o.swapping || picker == nil || s.State().Presentation != PresentAttached { | |
| 78 | + | return | |
| 79 | + | } | |
| 80 | + | picker.ApplySnapshot(o.service.Snapshot()) | |
| 81 | + | // A decision recorded before the overlay existed never applies to it. | |
| 82 | + | picker.TakeOp() | |
| 83 | + | picker.SetOwnsInput(true) | |
| 84 | + | if !s.attachments.beginNavigationOverlay(picker) { | |
| 85 | + | picker.SetOwnsInput(false) | |
| 86 | + | return | |
| 87 | + | } | |
| 88 | + | o.active = true | |
| 89 | + | s.invalidatePickerPresentation() | |
| 90 | + | s.transition(supervisorEvent{kind: supervisorOverlayOpened}) | |
| 91 | + | s.refreshPreview(o.service) | |
| 92 | + | s.renderCurrent() | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // exit returns the terminal to the same live attachment. | |
| 96 | + | func (o *attachmentPickerOverlay) exit() { | |
| 97 | + | if !o.active { | |
| 98 | + | return | |
| 99 | + | } | |
| 100 | + | // Release the slot before the picker stops owning input, so a delivery | |
| 101 | + | // racing the cancel reaches the session instead of a picker that no | |
| 102 | + | // longer consumes it. | |
| 103 | + | o.sup.attachments.endNavigationOverlay() | |
| 104 | + | o.drop() | |
| 105 | + | o.sup.transition(supervisorEvent{kind: supervisorOverlayClosed}) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // drop forgets the overlay without touching the foreground: the attachment | |
| 109 | + | // settled, so its grant (and overlay slot) is already gone. | |
| 110 | + | func (o *attachmentPickerOverlay) drop() { | |
| 111 | + | if !o.active { | |
| 112 | + | return | |
| 113 | + | } | |
| 114 | + | o.active = false | |
| 115 | + | o.sup.preview.close(o.picker()) | |
| 116 | + | o.picker().SetOwnsInput(false) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func (o *attachmentPickerOverlay) applyPublication() { | |
| 120 | + | o.picker().ApplySnapshot(o.service.Snapshot()) | |
| 121 | + | o.sup.refreshPreview(o.service) | |
| 122 | + | o.sup.renderCurrent() | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func (o *attachmentPickerOverlay) publishPreview() { | |
| 126 | + | if o.sup.preview.publish(o.picker()) { | |
| 127 | + | o.sup.renderCurrent() | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func (o *attachmentPickerOverlay) resize() { | |
| 132 | + | o.sup.refreshPreview(o.service) | |
| 133 | + | o.sup.renderResizeInvalidation() | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // takeOp carries out one picker decision over the live attachment. | |
| 137 | + | func (o *attachmentPickerOverlay) takeOp() { | |
| 138 | + | s := o.sup | |
| 139 | + | op, key := o.picker().TakeOp() | |
| 140 | + | switch { | |
| 141 | + | case op.close: | |
| 142 | + | // Escape, q, and Ctrl+C all cancel back to the attachment: there is an | |
| 143 | + | // attachment to return to, so nothing here ends the process. | |
| 144 | + | o.exit() | |
| 145 | + | case op.commit && key != "": | |
| 146 | + | request, err := s.resolveCommittedStreamRequest(o.service, key) | |
| 147 | + | if err != nil { | |
| 148 | + | // The picker already shows the bounded refusal; the attachment | |
| 149 | + | // stays live under the overlay. | |
| 150 | + | s.renderCurrent() | |
| 151 | + | return | |
| 152 | + | } | |
| 153 | + | if o.sameTarget(request) { | |
| 154 | + | o.exit() | |
| 155 | + | return | |
| 156 | + | } | |
| 157 | + | s.pendingSwap = &request | |
| 158 | + | o.swapping = true | |
| 159 | + | s.preview.close(o.picker()) | |
| 160 | + | s.attachments.requestDetach(o.run.token) | |
| 161 | + | default: | |
| 162 | + | s.refreshPreview(o.service) | |
| 163 | + | s.renderCurrent() | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // sameTarget reports whether request names the session this attachment is | |
| 168 | + | // already showing, in which case committing it is a close. | |
| 169 | + | func (o *attachmentPickerOverlay) sameTarget(request ports.BrokerOpenStreamRequest) bool { | |
| 170 | + | return sameAttachmentTarget(o.request, o.sup.attachments.committedTargetOrZero(), request) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // sameAttachmentTarget compares one committed selection with the live | |
| 174 | + | // attachment: same daemon authority and same session lifecycle. | |
| 175 | + | func sameAttachmentTarget(current ports.BrokerOpenStreamRequest, committed protocol.ExactSessionTarget, next ports.BrokerOpenStreamRequest) bool { | |
| 176 | + | if next.Admission != ports.BrokerAdmissionExact { | |
| 177 | + | return false | |
| 178 | + | } | |
| 179 | + | if current.Local != next.Local || current.Endpoint != next.Endpoint { | |
| 180 | + | return false | |
| 181 | + | } | |
| 182 | + | if committed != (protocol.ExactSessionTarget{}) { | |
| 183 | + | return committed.LifecycleID == next.Target.LifecycleID | |
| 184 | + | } | |
| 185 | + | return current.Admission == ports.BrokerAdmissionExact && current.Target.LifecycleID == next.Target.LifecycleID | |
| 186 | + | } | |
| @@ -0,0 +1,186 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/ports" | |||
| 5 | + | "github.com/bnema/vev/internal/protocol" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // Picker overlay over a live attachment (ADR 001 Amendment 1, Plan 003 A1). | |||
| 9 | + | // | |||
| 10 | + | // The daemon's session-picker command no longer ends the attachment: it sends | |||
| 11 | + | // a navigation PickerOffer and the attached worker asks the supervisor for the | |||
| 12 | + | // client picker. The supervisor then composes the same client-owned picker it | |||
| 13 | + | // presents with no attachment over the live one: | |||
| 14 | + | // | |||
| 15 | + | // - The foreground's overlay slot suppresses attachment output (frames are | |||
| 16 | + | // still applied and acknowledged) and diverts every authorized input | |||
| 17 | + | // delivery to the picker, so no picker byte reaches the session and the | |||
| 18 | + | // single terminal reader is never duplicated. | |||
| 19 | + | // - Broker publications, previews, and resizes repaint the picker while the | |||
| 20 | + | // overlay is open, exactly as in the plain picker. | |||
| 21 | + | // - Cancel (Escape, q, Ctrl+C) and a commit to the attached session release | |||
| 22 | + | // the slot; the worker then asks the daemon for an authoritative repaint. | |||
| 23 | + | // The attachment is never reconnected. | |||
| 24 | + | // - A commit to another target detaches the live attachment cleanly and | |||
| 25 | + | // swaps to the committed request through Connecting. | |||
| 26 | + | // | |||
| 27 | + | // Every method runs on the supervisor's run goroutine. | |||
| 28 | + | type attachmentPickerOverlay struct { | |||
| 29 | + | sup *Supervisor | |||
| 30 | + | run *attachmentRun | |||
| 31 | + | service ports.BrokerService | |||
| 32 | + | request ports.BrokerOpenStreamRequest | |||
| 33 | + | active bool | |||
| 34 | + | // swapping is set once a commit elsewhere asked the live attachment to | |||
| 35 | + | // detach; the overlay keeps the terminal until the attachment settles. | |||
| 36 | + | swapping bool | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func (o *attachmentPickerOverlay) picker() pickerHost { return o.sup.cfg.Picker } | |||
| 40 | + | ||||
| 41 | + | // changed is the broker publication wake while the overlay is presented. | |||
| 42 | + | func (o *attachmentPickerOverlay) changed() <-chan struct{} { | |||
| 43 | + | if !o.active || supervisorNil(o.sup.readySub) { | |||
| 44 | + | return nil | |||
| 45 | + | } | |||
| 46 | + | return o.sup.readySub.Changed() | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func (o *attachmentPickerOverlay) previewChanged() <-chan struct{} { | |||
| 50 | + | if !o.active { | |||
| 51 | + | return nil | |||
| 52 | + | } | |||
| 53 | + | return o.sup.preview.changed() | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func (o *attachmentPickerOverlay) ops() <-chan struct{} { | |||
| 57 | + | if !o.active || o.swapping { | |||
| 58 | + | return nil | |||
| 59 | + | } | |||
| 60 | + | return o.picker().OpsReady() | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // invalidation is consumed only while the overlay owns the terminal; otherwise | |||
| 64 | + | // the attachment foreground does and the signal stays buffered. | |||
| 65 | + | func (o *attachmentPickerOverlay) invalidation() <-chan struct{} { | |||
| 66 | + | if !o.active { | |||
| 67 | + | return nil | |||
| 68 | + | } | |||
| 69 | + | return o.sup.presentationInvalidation() | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // enter composes the picker over the attachment. It is refused unless the | |||
| 73 | + | // attachment is committed and presented, and while a swap is in progress. | |||
| 74 | + | func (o *attachmentPickerOverlay) enter() { | |||
| 75 | + | s := o.sup | |||
| 76 | + | picker := o.picker() | |||
| 77 | + | if o.active || o.swapping || picker == nil || s.State().Presentation != PresentAttached { | |||
| 78 | + | return | |||
| 79 | + | } | |||
| 80 | + | picker.ApplySnapshot(o.service.Snapshot()) | |||
| 81 | + | // A decision recorded before the overlay existed never applies to it. | |||
| 82 | + | picker.TakeOp() | |||
| 83 | + | picker.SetOwnsInput(true) | |||
| 84 | + | if !s.attachments.beginNavigationOverlay(picker) { | |||
| 85 | + | picker.SetOwnsInput(false) | |||
| 86 | + | return | |||
| 87 | + | } | |||
| 88 | + | o.active = true | |||
| 89 | + | s.invalidatePickerPresentation() | |||
| 90 | + | s.transition(supervisorEvent{kind: supervisorOverlayOpened}) | |||
| 91 | + | s.refreshPreview(o.service) | |||
| 92 | + | s.renderCurrent() | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // exit returns the terminal to the same live attachment. | |||
| 96 | + | func (o *attachmentPickerOverlay) exit() { | |||
| 97 | + | if !o.active { | |||
| 98 | + | return | |||
| 99 | + | } | |||
| 100 | + | // Release the slot before the picker stops owning input, so a delivery | |||
| 101 | + | // racing the cancel reaches the session instead of a picker that no | |||
| 102 | + | // longer consumes it. | |||
| 103 | + | o.sup.attachments.endNavigationOverlay() | |||
| 104 | + | o.drop() | |||
| 105 | + | o.sup.transition(supervisorEvent{kind: supervisorOverlayClosed}) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // drop forgets the overlay without touching the foreground: the attachment | |||
| 109 | + | // settled, so its grant (and overlay slot) is already gone. | |||
| 110 | + | func (o *attachmentPickerOverlay) drop() { | |||
| 111 | + | if !o.active { | |||
| 112 | + | return | |||
| 113 | + | } | |||
| 114 | + | o.active = false | |||
| 115 | + | o.sup.preview.close(o.picker()) | |||
| 116 | + | o.picker().SetOwnsInput(false) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func (o *attachmentPickerOverlay) applyPublication() { | |||
| 120 | + | o.picker().ApplySnapshot(o.service.Snapshot()) | |||
| 121 | + | o.sup.refreshPreview(o.service) | |||
| 122 | + | o.sup.renderCurrent() | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func (o *attachmentPickerOverlay) publishPreview() { | |||
| 126 | + | if o.sup.preview.publish(o.picker()) { | |||
| 127 | + | o.sup.renderCurrent() | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func (o *attachmentPickerOverlay) resize() { | |||
| 132 | + | o.sup.refreshPreview(o.service) | |||
| 133 | + | o.sup.renderResizeInvalidation() | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // takeOp carries out one picker decision over the live attachment. | |||
| 137 | + | func (o *attachmentPickerOverlay) takeOp() { | |||
| 138 | + | s := o.sup | |||
| 139 | + | op, key := o.picker().TakeOp() | |||
| 140 | + | switch { | |||
| 141 | + | case op.close: | |||
| 142 | + | // Escape, q, and Ctrl+C all cancel back to the attachment: there is an | |||
| 143 | + | // attachment to return to, so nothing here ends the process. | |||
| 144 | + | o.exit() | |||
| 145 | + | case op.commit && key != "": | |||
| 146 | + | request, err := s.resolveCommittedStreamRequest(o.service, key) | |||
| 147 | + | if err != nil { | |||
| 148 | + | // The picker already shows the bounded refusal; the attachment | |||
| 149 | + | // stays live under the overlay. | |||
| 150 | + | s.renderCurrent() | |||
| 151 | + | return | |||
| 152 | + | } | |||
| 153 | + | if o.sameTarget(request) { | |||
| 154 | + | o.exit() | |||
| 155 | + | return | |||
| 156 | + | } | |||
| 157 | + | s.pendingSwap = &request | |||
| 158 | + | o.swapping = true | |||
| 159 | + | s.preview.close(o.picker()) | |||
| 160 | + | s.attachments.requestDetach(o.run.token) | |||
| 161 | + | default: | |||
| 162 | + | s.refreshPreview(o.service) | |||
| 163 | + | s.renderCurrent() | |||
| 164 | + | } | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | // sameTarget reports whether request names the session this attachment is | |||
| 168 | + | // already showing, in which case committing it is a close. | |||
| 169 | + | func (o *attachmentPickerOverlay) sameTarget(request ports.BrokerOpenStreamRequest) bool { | |||
| 170 | + | return sameAttachmentTarget(o.request, o.sup.attachments.committedTargetOrZero(), request) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | // sameAttachmentTarget compares one committed selection with the live | |||
| 174 | + | // attachment: same daemon authority and same session lifecycle. | |||
| 175 | + | func sameAttachmentTarget(current ports.BrokerOpenStreamRequest, committed protocol.ExactSessionTarget, next ports.BrokerOpenStreamRequest) bool { | |||
| 176 | + | if next.Admission != ports.BrokerAdmissionExact { | |||
| 177 | + | return false | |||
| 178 | + | } | |||
| 179 | + | if current.Local != next.Local || current.Endpoint != next.Endpoint { | |||
| 180 | + | return false | |||
| 181 | + | } | |||
| 182 | + | if committed != (protocol.ExactSessionTarget{}) { | |||
| 183 | + | return committed.LifecycleID == next.Target.LifecycleID | |||
| 184 | + | } | |||
| 185 | + | return current.Admission == ports.BrokerAdmissionExact && current.Target.LifecycleID == next.Target.LifecycleID | |||
| 186 | + | } | |||
M internal/usecase/daemon/overlay_runtime.go +5 -0
| @@ -49,6 +49,11 @@ type overlayRuntime struct { | |||
| 49 | 49 | pickerIntent protocol.PickerIntent | |
| 50 | 50 | pickerMoveSource moveSourceLocator | |
| 51 | 51 | pickerRequestID uint64 | |
| 52 | + | // pickerNavigationOffers numbers the fire-and-forget navigation offers | |
| 53 | + | // that ask the client to open its own session picker over this live | |
| 54 | + | // attachment. They open no daemon interaction, so they never touch | |
| 55 | + | // pickerInteraction. | |
| 56 | + | pickerNavigationOffers uint64 | |
| 52 | 57 | // pickerPreview* track the row the client asked to preview. The generation | |
| 53 | 58 | // supersedes an in-flight capture and names the render subscription, so a | |
| 54 | 59 | // delayed preview can never replace the row the user is displaying. | |
| @@ -49,6 +49,11 @@ type overlayRuntime struct { | |||||
| 49 | pickerIntent protocol.PickerIntent | 49 | pickerIntent protocol.PickerIntent | ||
| 50 | pickerMoveSource moveSourceLocator | 50 | pickerMoveSource moveSourceLocator | ||
| 51 | pickerRequestID uint64 | 51 | pickerRequestID uint64 | ||
| 52 | + | // pickerNavigationOffers numbers the fire-and-forget navigation offers | |||
| 53 | + | // that ask the client to open its own session picker over this live | |||
| 54 | + | // attachment. They open no daemon interaction, so they never touch | |||
| 55 | + | // pickerInteraction. | |||
| 56 | + | pickerNavigationOffers uint64 | |||
| 52 | // pickerPreview* track the row the client asked to preview. The generation | 57 | // pickerPreview* track the row the client asked to preview. The generation | ||
| 53 | // supersedes an in-flight capture and names the render subscription, so a | 58 | // supersedes an in-flight capture and names the render subscription, so a | ||
| 54 | // delayed preview can never replace the row the user is displaying. | 59 | // delayed preview can never replace the row the user is displaying. | ||
M internal/usecase/daemon/palette.go +5 -6
| @@ -1206,12 +1206,11 @@ func (e paletteExec) OpenSessionPicker() error { | |||
| 1206 | 1206 | defer fresh.End() | |
| 1207 | 1207 | effect = fresh | |
| 1208 | 1208 | } | |
| 1209 | - | // End the attachment; the client supervisor, not an interaction on this | |
| 1210 | - | // connection, owns the next picker presentation and its input. | |
| 1211 | - | if !e.d.clientGoneForAttachmentReason(effect, true, protocol.ReasonDetachToPicker) { | |
| 1212 | - | return errAttachmentTransition | |
| 1213 | - | } | |
| 1214 | - | return nil | |
| 1209 | + | // Keep the attachment live: the client supervisor composes its own picker | |
| 1210 | + | // over this attachment and owns its input until the user commits or | |
| 1211 | + | // cancels. Detach-to-picker is reserved for attachment loss, session end, | |
| 1212 | + | // and explicit detach. | |
| 1213 | + | return e.d.offerClientNavigationPicker(e.ac, effect) | |
| 1215 | 1214 | } | |
| 1216 | 1215 | ||
| 1217 | 1216 | func (e paletteExec) OpenNotifications() error { | |
| @@ -1206,12 +1206,11 @@ func (e paletteExec) OpenSessionPicker() error { | |||||
| 1206 | defer fresh.End() | 1206 | defer fresh.End() | ||
| 1207 | effect = fresh | 1207 | effect = fresh | ||
| 1208 | } | 1208 | } | ||
| 1209 | - | // End the attachment; the client supervisor, not an interaction on this | 1209 | + | // Keep the attachment live: the client supervisor composes its own picker |
| 1210 | - | // connection, owns the next picker presentation and its input. | 1210 | + | // over this attachment and owns its input until the user commits or |
| 1211 | - | if !e.d.clientGoneForAttachmentReason(effect, true, protocol.ReasonDetachToPicker) { | 1211 | + | // cancels. Detach-to-picker is reserved for attachment loss, session end, |
| 1212 | - | return errAttachmentTransition | 1212 | + | // and explicit detach. |
| 1213 | - | } | 1213 | + | return e.d.offerClientNavigationPicker(e.ac, effect) |
| 1214 | - | return nil | |||
| 1215 | } | 1214 | } | ||
| 1216 | 1215 | ||||
| 1217 | func (e paletteExec) OpenNotifications() error { | 1216 | func (e paletteExec) OpenNotifications() error { | ||
M internal/usecase/daemon/picker_interaction.go +27 -2
| @@ -11,14 +11,39 @@ import ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | // This file retains the serving-daemon move-destination interaction. It must | |
| 14 | - | // never admit a navigation intent: session-picker ends the attachment and the | |
| 15 | - | // client supervisor owns all subsequent navigation and terminal input. | |
| 14 | + | // never admit a navigation interaction: session-picker only offers the client | |
| 15 | + | // its own picker over the live attachment (offerClientNavigationPicker), and | |
| 16 | + | // the client supervisor owns all navigation and terminal input while it is | |
| 17 | + | // open. | |
| 16 | 18 | ||
| 17 | 19 | // servingPickerSourceID names the source the serving daemon itself owns. | |
| 18 | 20 | const servingPickerSourceID = protocol.PickerServingSourceID | |
| 19 | 21 | ||
| 20 | 22 | var errClientOwnedNavigation = errors.New("session navigation is client-owned") | |
| 21 | 23 | ||
| 24 | + | // offerClientNavigationPicker asks the client to open its own session picker | |
| 25 | + | // over this live attachment. The daemon opens no interaction: navigation rows | |
| 26 | + | // come from the client's broker catalogue, the client picker consumes the | |
| 27 | + | // input it owns locally, and the attachment stays attached so cancelling the | |
| 28 | + | // picker returns to it without reconnecting. | |
| 29 | + | func (d *Daemon) offerClientNavigationPicker(ac *attachedClient, effect *attachmentEffect) error { | |
| 30 | + | if ac == nil || ac.overlays == nil || effect == nil || !effect.current() { | |
| 31 | + | return errAttachmentTransition | |
| 32 | + | } | |
| 33 | + | ac.overlays.pickerMu.Lock() | |
| 34 | + | ac.overlays.pickerNavigationOffers++ | |
| 35 | + | if ac.overlays.pickerNavigationOffers == 0 { | |
| 36 | + | ac.overlays.pickerNavigationOffers = 1 | |
| 37 | + | } | |
| 38 | + | offerID := ac.overlays.pickerNavigationOffers | |
| 39 | + | ac.overlays.pickerMu.Unlock() | |
| 40 | + | offer := protocol.PickerOffer{InteractionID: offerID, Intent: protocol.PickerIntentNavigation, Title: picker.SortRecent.Title()} | |
| 41 | + | if err := protocol.ValidatePickerOffer(offer); err != nil { | |
| 42 | + | return err | |
| 43 | + | } | |
| 44 | + | return effect.sendControl(offer) | |
| 45 | + | } | |
| 46 | + | ||
| 22 | 47 | // pickerDuplicateRows reports the first repeated row key with a readable | |
| 23 | 48 | // identity for both rows. A refused snapshot otherwise says only that a key | |
| 24 | 49 | // repeated, which is not enough to find the two rows that collided. | |
| @@ -11,14 +11,39 @@ import ( | |||||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | // This file retains the serving-daemon move-destination interaction. It must | 13 | // This file retains the serving-daemon move-destination interaction. It must | ||
| 14 | - | // never admit a navigation intent: session-picker ends the attachment and the | 14 | + | // never admit a navigation interaction: session-picker only offers the client |
| 15 | - | // client supervisor owns all subsequent navigation and terminal input. | 15 | + | // its own picker over the live attachment (offerClientNavigationPicker), and |
| 16 | + | // the client supervisor owns all navigation and terminal input while it is | |||
| 17 | + | // open. | |||
| 16 | 18 | ||||
| 17 | // servingPickerSourceID names the source the serving daemon itself owns. | 19 | // servingPickerSourceID names the source the serving daemon itself owns. | ||
| 18 | const servingPickerSourceID = protocol.PickerServingSourceID | 20 | const servingPickerSourceID = protocol.PickerServingSourceID | ||
| 19 | 21 | ||||
| 20 | var errClientOwnedNavigation = errors.New("session navigation is client-owned") | 22 | var errClientOwnedNavigation = errors.New("session navigation is client-owned") | ||
| 21 | 23 | ||||
| 24 | + | // offerClientNavigationPicker asks the client to open its own session picker | |||
| 25 | + | // over this live attachment. The daemon opens no interaction: navigation rows | |||
| 26 | + | // come from the client's broker catalogue, the client picker consumes the | |||
| 27 | + | // input it owns locally, and the attachment stays attached so cancelling the | |||
| 28 | + | // picker returns to it without reconnecting. | |||
| 29 | + | func (d *Daemon) offerClientNavigationPicker(ac *attachedClient, effect *attachmentEffect) error { | |||
| 30 | + | if ac == nil || ac.overlays == nil || effect == nil || !effect.current() { | |||
| 31 | + | return errAttachmentTransition | |||
| 32 | + | } | |||
| 33 | + | ac.overlays.pickerMu.Lock() | |||
| 34 | + | ac.overlays.pickerNavigationOffers++ | |||
| 35 | + | if ac.overlays.pickerNavigationOffers == 0 { | |||
| 36 | + | ac.overlays.pickerNavigationOffers = 1 | |||
| 37 | + | } | |||
| 38 | + | offerID := ac.overlays.pickerNavigationOffers | |||
| 39 | + | ac.overlays.pickerMu.Unlock() | |||
| 40 | + | offer := protocol.PickerOffer{InteractionID: offerID, Intent: protocol.PickerIntentNavigation, Title: picker.SortRecent.Title()} | |||
| 41 | + | if err := protocol.ValidatePickerOffer(offer); err != nil { | |||
| 42 | + | return err | |||
| 43 | + | } | |||
| 44 | + | return effect.sendControl(offer) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 22 | // pickerDuplicateRows reports the first repeated row key with a readable | 47 | // pickerDuplicateRows reports the first repeated row key with a readable | ||
| 23 | // identity for both rows. A refused snapshot otherwise says only that a key | 48 | // identity for both rows. A refused snapshot otherwise says only that a key | ||
| 24 | // repeated, which is not enough to find the two rows that collided. | 49 | // repeated, which is not enough to find the two rows that collided. | ||
A internal/usecase/daemon/session_picker_offer_test.go +52 -0
| @@ -0,0 +1,52 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/protocol" | |
| 10 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // awaitSentServerMessage drains captured frames until one satisfies match. | |
| 14 | + | func awaitSentServerMessage(t *testing.T, sends <-chan wire.Envelope, what string, match func(protocol.ServerMessage) bool) protocol.ServerMessage { | |
| 15 | + | t.Helper() | |
| 16 | + | deadline := time.After(2 * time.Second) | |
| 17 | + | for { | |
| 18 | + | select { | |
| 19 | + | case frame := <-sends: | |
| 20 | + | message := decodeServerMessage(t, frame) | |
| 21 | + | if match(message) { | |
| 22 | + | return message | |
| 23 | + | } | |
| 24 | + | case <-deadline: | |
| 25 | + | t.Fatalf("daemon sent no %s", what) | |
| 26 | + | return nil | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // TestSessionPickerCommandOffersClientPickerOverLiveAttachment pins PA1 on the | |
| 32 | + | // daemon side: SSP keeps the attachment, sends a navigation offer the client | |
| 33 | + | // composes its own picker for, and opens no daemon interaction that could | |
| 34 | + | // swallow input. | |
| 35 | + | func TestSessionPickerCommandOffersClientPickerOverLiveAttachment(t *testing.T) { | |
| 36 | + | d, source, ac, _, _, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0) | |
| 37 | + | defer releaseAll(releases) | |
| 38 | + | ||
| 39 | + | d.handleInput(source, ac, []byte("\x1b ")) | |
| 40 | + | require.True(t, ac.overlays.paletteActive()) | |
| 41 | + | d.handleInput(source, ac, []byte("SSP\r")) | |
| 42 | + | ||
| 43 | + | offer := awaitSentServerMessage(t, sends, "navigation PickerOffer", func(message protocol.ServerMessage) bool { | |
| 44 | + | _, ok := message.(protocol.PickerOffer) | |
| 45 | + | return ok | |
| 46 | + | }).(protocol.PickerOffer) | |
| 47 | + | require.Equal(t, protocol.PickerIntentNavigation, offer.Intent) | |
| 48 | + | require.NotZero(t, offer.InteractionID) | |
| 49 | + | require.False(t, ac.overlays.pickerClientActive(), "a navigation offer must not open a daemon interaction") | |
| 50 | + | require.Same(t, source, ac.currentAttachmentSession(), "SSP must keep the attachment live") | |
| 51 | + | assertNoFurtherFrame(t, sends, "Detached", "SSP must not detach the attachment") | |
| 52 | + | } | |
| @@ -0,0 +1,52 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/protocol" | |||
| 10 | + | "github.com/bnema/vev/internal/protocol/wire" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // awaitSentServerMessage drains captured frames until one satisfies match. | |||
| 14 | + | func awaitSentServerMessage(t *testing.T, sends <-chan wire.Envelope, what string, match func(protocol.ServerMessage) bool) protocol.ServerMessage { | |||
| 15 | + | t.Helper() | |||
| 16 | + | deadline := time.After(2 * time.Second) | |||
| 17 | + | for { | |||
| 18 | + | select { | |||
| 19 | + | case frame := <-sends: | |||
| 20 | + | message := decodeServerMessage(t, frame) | |||
| 21 | + | if match(message) { | |||
| 22 | + | return message | |||
| 23 | + | } | |||
| 24 | + | case <-deadline: | |||
| 25 | + | t.Fatalf("daemon sent no %s", what) | |||
| 26 | + | return nil | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // TestSessionPickerCommandOffersClientPickerOverLiveAttachment pins PA1 on the | |||
| 32 | + | // daemon side: SSP keeps the attachment, sends a navigation offer the client | |||
| 33 | + | // composes its own picker for, and opens no daemon interaction that could | |||
| 34 | + | // swallow input. | |||
| 35 | + | func TestSessionPickerCommandOffersClientPickerOverLiveAttachment(t *testing.T) { | |||
| 36 | + | d, source, ac, _, _, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0) | |||
| 37 | + | defer releaseAll(releases) | |||
| 38 | + | ||||
| 39 | + | d.handleInput(source, ac, []byte("\x1b ")) | |||
| 40 | + | require.True(t, ac.overlays.paletteActive()) | |||
| 41 | + | d.handleInput(source, ac, []byte("SSP\r")) | |||
| 42 | + | ||||
| 43 | + | offer := awaitSentServerMessage(t, sends, "navigation PickerOffer", func(message protocol.ServerMessage) bool { | |||
| 44 | + | _, ok := message.(protocol.PickerOffer) | |||
| 45 | + | return ok | |||
| 46 | + | }).(protocol.PickerOffer) | |||
| 47 | + | require.Equal(t, protocol.PickerIntentNavigation, offer.Intent) | |||
| 48 | + | require.NotZero(t, offer.InteractionID) | |||
| 49 | + | require.False(t, ac.overlays.pickerClientActive(), "a navigation offer must not open a daemon interaction") | |||
| 50 | + | require.Same(t, source, ac.currentAttachmentSession(), "SSP must keep the attachment live") | |||
| 51 | + | assertNoFurtherFrame(t, sends, "Detached", "SSP must not detach the attachment") | |||
| 52 | + | } | |||