Merge pull request #95 from bnema/feature/dynamic-session-palette
feat(palette): suggest named sessions
21 files · +1443 -301
M README.md +1 -1
| @@ -47,7 +47,7 @@ No prefix key; everything is Alt. | |||
| 47 | 47 | | Alt+h/j/k/l, Alt+Arrow | focus pane | | |
| 48 | 48 | | Alt+a | jump to a session needing attention | | |
| 49 | 49 | ||
| 50 | - | The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52. | |
| 50 | + | The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list. Named active and stopped sessions are fuzzy-searchable, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52. | |
| 51 | 51 | ||
| 52 | 52 | ## Remote attach | |
| 53 | 53 | ||
| @@ -47,7 +47,7 @@ No prefix key; everything is Alt. | |||||
| 47 | | Alt+h/j/k/l, Alt+Arrow | focus pane | | 47 | | Alt+h/j/k/l, Alt+Arrow | focus pane | | ||
| 48 | | Alt+a | jump to a session needing attention | | 48 | | Alt+a | jump to a session needing attention | | ||
| 49 | 49 | ||||
| 50 | - | The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52. | 50 | + | The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list. Named active and stopped sessions are fuzzy-searchable, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52. |
| 51 | 51 | ||||
| 52 | ## Remote attach | 52 | ## Remote attach | ||
| 53 | 53 | ||||
M internal/usecase/daemon/client.go +6 -3
| @@ -51,7 +51,7 @@ type attachedClient struct { | |||
| 51 | 51 | theme themeui.Theme | |
| 52 | 52 | clientTheme themeui.Theme | |
| 53 | 53 | lastCursor cursorOut | |
| 54 | - | renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held | |
| 54 | + | renderStages renderStageHooks // optional render and handoff observability hooks | |
| 55 | 55 | // previousSession is guarded independently. It is retained through temporary | |
| 56 | 56 | // setSession(nil) hand-offs and cleared only on terminal teardown. | |
| 57 | 57 | previousSession Guarded[*session] | |
| @@ -437,13 +437,16 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma | |||
| 437 | 437 | } | |
| 438 | 438 | ||
| 439 | 439 | // handoffCoordinator prepares a cross-session ownership transfer before the | |
| 440 | - | // destination publishes sess.client. It never takes daemon/session locks while | |
| 441 | - | // taking sendMu, avoiding a d.mu/sendMu cycle with transport callbacks. | |
| 440 | + | // destination publishes sess.client. Callers may hold d.mu, but this function | |
| 441 | + | // acquires no daemon or session locks while it holds sendMu. | |
| 442 | 442 | func (d *Daemon) handoffCoordinator(from, target *session, old, current *attachedClient) []renderLifecycleCleanup { | |
| 443 | 443 | cleanups := make([]renderLifecycleCleanup, 0, 2) | |
| 444 | 444 | if rc := from.renderCoordinator(); rc != nil { | |
| 445 | 445 | cleanups = append(cleanups, rc.beginDetach(current)) | |
| 446 | 446 | } | |
| 447 | + | if current.renderStages.handoffRebase != nil { | |
| 448 | + | current.renderStages.handoffRebase() | |
| 449 | + | } | |
| 447 | 450 | current.sendMu.Lock() | |
| 448 | 451 | current.output.rebase() | |
| 449 | 452 | // Capture snapshots are attachment-owned. Discard them while the attachment | |
| @@ -51,7 +51,7 @@ type attachedClient struct { | |||||
| 51 | theme themeui.Theme | 51 | theme themeui.Theme | ||
| 52 | clientTheme themeui.Theme | 52 | clientTheme themeui.Theme | ||
| 53 | lastCursor cursorOut | 53 | lastCursor cursorOut | ||
| 54 | - | renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held | 54 | + | renderStages renderStageHooks // optional render and handoff observability hooks |
| 55 | // previousSession is guarded independently. It is retained through temporary | 55 | // previousSession is guarded independently. It is retained through temporary | ||
| 56 | // setSession(nil) hand-offs and cleared only on terminal teardown. | 56 | // setSession(nil) hand-offs and cleared only on terminal teardown. | ||
| 57 | previousSession Guarded[*session] | 57 | previousSession Guarded[*session] | ||
| @@ -437,13 +437,16 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma | |||||
| 437 | } | 437 | } | ||
| 438 | 438 | ||||
| 439 | // handoffCoordinator prepares a cross-session ownership transfer before the | 439 | // handoffCoordinator prepares a cross-session ownership transfer before the | ||
| 440 | - | // destination publishes sess.client. It never takes daemon/session locks while | 440 | + | // destination publishes sess.client. Callers may hold d.mu, but this function |
| 441 | - | // taking sendMu, avoiding a d.mu/sendMu cycle with transport callbacks. | 441 | + | // acquires no daemon or session locks while it holds sendMu. |
| 442 | func (d *Daemon) handoffCoordinator(from, target *session, old, current *attachedClient) []renderLifecycleCleanup { | 442 | func (d *Daemon) handoffCoordinator(from, target *session, old, current *attachedClient) []renderLifecycleCleanup { | ||
| 443 | cleanups := make([]renderLifecycleCleanup, 0, 2) | 443 | cleanups := make([]renderLifecycleCleanup, 0, 2) | ||
| 444 | if rc := from.renderCoordinator(); rc != nil { | 444 | if rc := from.renderCoordinator(); rc != nil { | ||
| 445 | cleanups = append(cleanups, rc.beginDetach(current)) | 445 | cleanups = append(cleanups, rc.beginDetach(current)) | ||
| 446 | } | 446 | } | ||
| 447 | + | if current.renderStages.handoffRebase != nil { | |||
| 448 | + | current.renderStages.handoffRebase() | |||
| 449 | + | } | |||
| 447 | current.sendMu.Lock() | 450 | current.sendMu.Lock() | ||
| 448 | current.output.rebase() | 451 | current.output.rebase() | ||
| 449 | // Capture snapshots are attachment-owned. Discard them while the attachment | 452 | // Capture snapshots are attachment-owned. Discard them while the attachment | ||
M internal/usecase/daemon/config_test.go +6 -2
| @@ -142,8 +142,12 @@ func TestApplyConfigRepaintsActivePaletteWithoutReplacingModel(t *testing.T) { | |||
| 142 | 142 | require.Equal(t, query, ac.overlays.palette.Query()) | |
| 143 | 143 | selectedAfter, ok := ac.overlays.palette.Selected() | |
| 144 | 144 | require.True(t, ok) | |
| 145 | - | require.Equal(t, selected.Slug, selectedAfter.Slug) | |
| 146 | - | require.Equal(t, selected.Code, selectedAfter.Code) | |
| 145 | + | selectedCommand, ok := selected.Command() | |
| 146 | + | require.True(t, ok) | |
| 147 | + | selectedAfterCommand, ok := selectedAfter.Command() | |
| 148 | + | require.True(t, ok) | |
| 149 | + | require.Equal(t, selectedCommand.Slug, selectedAfterCommand.Slug) | |
| 150 | + | require.Equal(t, selectedCommand.Code, selectedAfterCommand.Code) | |
| 147 | 151 | } | |
| 148 | 152 | ||
| 149 | 153 | func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) { | |
| @@ -142,8 +142,12 @@ func TestApplyConfigRepaintsActivePaletteWithoutReplacingModel(t *testing.T) { | |||||
| 142 | require.Equal(t, query, ac.overlays.palette.Query()) | 142 | require.Equal(t, query, ac.overlays.palette.Query()) | ||
| 143 | selectedAfter, ok := ac.overlays.palette.Selected() | 143 | selectedAfter, ok := ac.overlays.palette.Selected() | ||
| 144 | require.True(t, ok) | 144 | require.True(t, ok) | ||
| 145 | - | require.Equal(t, selected.Slug, selectedAfter.Slug) | 145 | + | selectedCommand, ok := selected.Command() |
| 146 | - | require.Equal(t, selected.Code, selectedAfter.Code) | 146 | + | require.True(t, ok) |
| 147 | + | selectedAfterCommand, ok := selectedAfter.Command() | |||
| 148 | + | require.True(t, ok) | |||
| 149 | + | require.Equal(t, selectedCommand.Slug, selectedAfterCommand.Slug) | |||
| 150 | + | require.Equal(t, selectedCommand.Code, selectedAfterCommand.Code) | |||
| 147 | } | 151 | } | ||
| 148 | 152 | ||||
| 149 | func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) { | 153 | func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) { | ||
M internal/usecase/daemon/daemon.go +34 -1
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "log/slog" | |
| 8 | + | "math" | |
| 8 | 9 | "os" | |
| 9 | 10 | "slices" | |
| 10 | 11 | "sort" | |
| @@ -67,7 +68,11 @@ type Daemon struct { | |||
| 67 | 68 | sessions map[domain.SessionID]*session | |
| 68 | 69 | stopped map[string]stoppedSession | |
| 69 | 70 | nextID uint64 | |
| 70 | - | mruSeq atomic.Uint64 | |
| 71 | + | // lastAllocatedCreatedAt is the named-session lifecycle timestamp high-water | |
| 72 | + | // mark. It is guarded by mu and prevents a wall-clock regression from | |
| 73 | + | // reusing a lifecycle identity. | |
| 74 | + | lastAllocatedCreatedAt int64 | |
| 75 | + | mruSeq atomic.Uint64 | |
| 71 | 76 | // closing marks that shutdown has irreversibly begun. It is set under mu, | |
| 72 | 77 | // atomically with the event that makes shutdown inevitable (the registry | |
| 73 | 78 | // emptying in killSession, or shutdownAll starting), and checked by route | |
| @@ -300,6 +305,25 @@ func WithConfig(cfg domain.Config) Option { | |||
| 300 | 305 | } | |
| 301 | 306 | } | |
| 302 | 307 | ||
| 308 | + | // allocateLifecycleCreatedAtLocked returns a unique lifecycle timestamp. Caller | |
| 309 | + | // holds d.mu. The persisted high-water mark is loaded by New before any named | |
| 310 | + | // session can be resumed or created. | |
| 311 | + | func (d *Daemon) allocateLifecycleCreatedAtLocked() (int64, error) { | |
| 312 | + | now := d.nowUnixNano() | |
| 313 | + | if now <= d.lastAllocatedCreatedAt { | |
| 314 | + | if d.lastAllocatedCreatedAt == math.MaxInt64 { | |
| 315 | + | return 0, errors.New("daemon: lifecycle identities exhausted") | |
| 316 | + | } | |
| 317 | + | now = d.lastAllocatedCreatedAt + 1 | |
| 318 | + | } | |
| 319 | + | d.lastAllocatedCreatedAt = now | |
| 320 | + | return now, nil | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | func (d *Daemon) nowUnixNano() int64 { | |
| 324 | + | return d.clock.Now().UnixNano() | |
| 325 | + | } | |
| 326 | + | ||
| 303 | 327 | // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the | |
| 304 | 328 | // render debounce, and log receives diagnostics (defaults to slog.Default). | |
| 305 | 329 | func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon { | |
| @@ -366,12 +390,21 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 366 | 390 | d.log.Warn("loading persisted sessions failed", "err", err) | |
| 367 | 391 | } else { | |
| 368 | 392 | var maxSeq uint64 | |
| 393 | + | var maxCreatedAt int64 | |
| 394 | + | hasCreatedAt := false | |
| 369 | 395 | for _, r := range records { | |
| 370 | 396 | d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq, tabNames: r.TabNames} | |
| 397 | + | if !hasCreatedAt || r.CreatedAt > maxCreatedAt { | |
| 398 | + | maxCreatedAt = r.CreatedAt | |
| 399 | + | hasCreatedAt = true | |
| 400 | + | } | |
| 371 | 401 | if r.LastUsedSeq > maxSeq { | |
| 372 | 402 | maxSeq = r.LastUsedSeq | |
| 373 | 403 | } | |
| 374 | 404 | } | |
| 405 | + | if hasCreatedAt { | |
| 406 | + | d.lastAllocatedCreatedAt = maxCreatedAt | |
| 407 | + | } | |
| 375 | 408 | d.mruSeq.Store(maxSeq) | |
| 376 | 409 | } | |
| 377 | 410 | return d | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "context" | 5 | "context" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "log/slog" | 7 | "log/slog" | ||
| 8 | + | "math" | |||
| 8 | "os" | 9 | "os" | ||
| 9 | "slices" | 10 | "slices" | ||
| 10 | "sort" | 11 | "sort" | ||
| @@ -67,7 +68,11 @@ type Daemon struct { | |||||
| 67 | sessions map[domain.SessionID]*session | 68 | sessions map[domain.SessionID]*session | ||
| 68 | stopped map[string]stoppedSession | 69 | stopped map[string]stoppedSession | ||
| 69 | nextID uint64 | 70 | nextID uint64 | ||
| 70 | - | mruSeq atomic.Uint64 | 71 | + | // lastAllocatedCreatedAt is the named-session lifecycle timestamp high-water |
| 72 | + | // mark. It is guarded by mu and prevents a wall-clock regression from | |||
| 73 | + | // reusing a lifecycle identity. | |||
| 74 | + | lastAllocatedCreatedAt int64 | |||
| 75 | + | mruSeq atomic.Uint64 | |||
| 71 | // closing marks that shutdown has irreversibly begun. It is set under mu, | 76 | // closing marks that shutdown has irreversibly begun. It is set under mu, | ||
| 72 | // atomically with the event that makes shutdown inevitable (the registry | 77 | // atomically with the event that makes shutdown inevitable (the registry | ||
| 73 | // emptying in killSession, or shutdownAll starting), and checked by route | 78 | // emptying in killSession, or shutdownAll starting), and checked by route | ||
| @@ -300,6 +305,25 @@ func WithConfig(cfg domain.Config) Option { | |||||
| 300 | } | 305 | } | ||
| 301 | } | 306 | } | ||
| 302 | 307 | ||||
| 308 | + | // allocateLifecycleCreatedAtLocked returns a unique lifecycle timestamp. Caller | |||
| 309 | + | // holds d.mu. The persisted high-water mark is loaded by New before any named | |||
| 310 | + | // session can be resumed or created. | |||
| 311 | + | func (d *Daemon) allocateLifecycleCreatedAtLocked() (int64, error) { | |||
| 312 | + | now := d.nowUnixNano() | |||
| 313 | + | if now <= d.lastAllocatedCreatedAt { | |||
| 314 | + | if d.lastAllocatedCreatedAt == math.MaxInt64 { | |||
| 315 | + | return 0, errors.New("daemon: lifecycle identities exhausted") | |||
| 316 | + | } | |||
| 317 | + | now = d.lastAllocatedCreatedAt + 1 | |||
| 318 | + | } | |||
| 319 | + | d.lastAllocatedCreatedAt = now | |||
| 320 | + | return now, nil | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | func (d *Daemon) nowUnixNano() int64 { | |||
| 324 | + | return d.clock.Now().UnixNano() | |||
| 325 | + | } | |||
| 326 | + | ||||
| 303 | // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the | 327 | // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the | ||
| 304 | // render debounce, and log receives diagnostics (defaults to slog.Default). | 328 | // render debounce, and log receives diagnostics (defaults to slog.Default). | ||
| 305 | func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon { | 329 | func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon { | ||
| @@ -366,12 +390,21 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 366 | d.log.Warn("loading persisted sessions failed", "err", err) | 390 | d.log.Warn("loading persisted sessions failed", "err", err) | ||
| 367 | } else { | 391 | } else { | ||
| 368 | var maxSeq uint64 | 392 | var maxSeq uint64 | ||
| 393 | + | var maxCreatedAt int64 | |||
| 394 | + | hasCreatedAt := false | |||
| 369 | for _, r := range records { | 395 | for _, r := range records { | ||
| 370 | d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq, tabNames: r.TabNames} | 396 | d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq, tabNames: r.TabNames} | ||
| 397 | + | if !hasCreatedAt || r.CreatedAt > maxCreatedAt { | |||
| 398 | + | maxCreatedAt = r.CreatedAt | |||
| 399 | + | hasCreatedAt = true | |||
| 400 | + | } | |||
| 371 | if r.LastUsedSeq > maxSeq { | 401 | if r.LastUsedSeq > maxSeq { | ||
| 372 | maxSeq = r.LastUsedSeq | 402 | maxSeq = r.LastUsedSeq | ||
| 373 | } | 403 | } | ||
| 374 | } | 404 | } | ||
| 405 | + | if hasCreatedAt { | |||
| 406 | + | d.lastAllocatedCreatedAt = maxCreatedAt | |||
| 407 | + | } | |||
| 375 | d.mruSeq.Store(maxSeq) | 408 | d.mruSeq.Store(maxSeq) | ||
| 376 | } | 409 | } | ||
| 377 | return d | 410 | return d | ||
M internal/usecase/daemon/overlay_runtime.go +6 -3
| @@ -27,6 +27,7 @@ type overlayRuntime struct { | |||
| 27 | 27 | paletteRecent []recentSession // immutable for this palette interaction | |
| 28 | 28 | paletteGeneration uint64 | |
| 29 | 29 | paletteHints palette.ContextualHints | |
| 30 | + | paletteFeedback string | |
| 30 | 31 | palettePending []byte | |
| 31 | 32 | ||
| 32 | 33 | promptMu sync.Mutex | |
| @@ -224,9 +225,10 @@ type overlayRenderSnapshot struct { | |||
| 224 | 225 | paletteModel *palette.Model | |
| 225 | 226 | // paletteHints is a copy captured under paletteMu. Rendering must use this | |
| 226 | 227 | // immutable interaction snapshot rather than consult live session state. | |
| 227 | - | paletteHints *palette.ContextualHints | |
| 228 | - | paletteRecent []recentSession | |
| 229 | - | paletteLocked bool | |
| 228 | + | paletteHints *palette.ContextualHints | |
| 229 | + | paletteFeedback string | |
| 230 | + | paletteRecent []recentSession | |
| 231 | + | paletteLocked bool | |
| 230 | 232 | ||
| 231 | 233 | promptActive bool | |
| 232 | 234 | promptModel *promptui.Model | |
| @@ -273,6 +275,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||
| 273 | 275 | hints := rt.paletteHints | |
| 274 | 276 | hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...) | |
| 275 | 277 | snap.paletteHints = &hints | |
| 278 | + | snap.paletteFeedback = rt.paletteFeedback | |
| 276 | 279 | snap.paletteRecent = append([]recentSession(nil), rt.paletteRecent...) | |
| 277 | 280 | snap.paletteLocked = true | |
| 278 | 281 | } else { | |
| @@ -27,6 +27,7 @@ type overlayRuntime struct { | |||||
| 27 | paletteRecent []recentSession // immutable for this palette interaction | 27 | paletteRecent []recentSession // immutable for this palette interaction | ||
| 28 | paletteGeneration uint64 | 28 | paletteGeneration uint64 | ||
| 29 | paletteHints palette.ContextualHints | 29 | paletteHints palette.ContextualHints | ||
| 30 | + | paletteFeedback string | |||
| 30 | palettePending []byte | 31 | palettePending []byte | ||
| 31 | 32 | ||||
| 32 | promptMu sync.Mutex | 33 | promptMu sync.Mutex | ||
| @@ -224,9 +225,10 @@ type overlayRenderSnapshot struct { | |||||
| 224 | paletteModel *palette.Model | 225 | paletteModel *palette.Model | ||
| 225 | // paletteHints is a copy captured under paletteMu. Rendering must use this | 226 | // paletteHints is a copy captured under paletteMu. Rendering must use this | ||
| 226 | // immutable interaction snapshot rather than consult live session state. | 227 | // immutable interaction snapshot rather than consult live session state. | ||
| 227 | - | paletteHints *palette.ContextualHints | 228 | + | paletteHints *palette.ContextualHints |
| 228 | - | paletteRecent []recentSession | 229 | + | paletteFeedback string |
| 229 | - | paletteLocked bool | 230 | + | paletteRecent []recentSession |
| 231 | + | paletteLocked bool | |||
| 230 | 232 | ||||
| 231 | promptActive bool | 233 | promptActive bool | ||
| 232 | promptModel *promptui.Model | 234 | promptModel *promptui.Model | ||
| @@ -273,6 +275,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||||
| 273 | hints := rt.paletteHints | 275 | hints := rt.paletteHints | ||
| 274 | hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...) | 276 | hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...) | ||
| 275 | snap.paletteHints = &hints | 277 | snap.paletteHints = &hints | ||
| 278 | + | snap.paletteFeedback = rt.paletteFeedback | |||
| 276 | snap.paletteRecent = append([]recentSession(nil), rt.paletteRecent...) | 279 | snap.paletteRecent = append([]recentSession(nil), rt.paletteRecent...) | ||
| 277 | snap.paletteLocked = true | 280 | snap.paletteLocked = true | ||
| 278 | } else { | 281 | } else { | ||
M internal/usecase/daemon/palette.go +130 -37
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "sort" | |
| 5 | + | "time" | |
| 6 | + | ||
| 4 | 7 | "github.com/bnema/vev/internal/domain" | |
| 5 | 8 | "github.com/bnema/vev/internal/usecase/command" | |
| 6 | 9 | "github.com/bnema/vev/internal/usecase/layout" | |
| @@ -35,16 +38,61 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |||
| 35 | 38 | // holding an overlay lock while inspecting live sessions. | |
| 36 | 39 | recent := d.recentSessions(sess) | |
| 37 | 40 | commands := d.paletteCommands() | |
| 41 | + | results := d.paletteResults(sess, commands) | |
| 38 | 42 | ac.overlays.paletteMu.Lock() | |
| 39 | 43 | ac.overlays.paletteGeneration++ | |
| 40 | - | ac.overlays.palette = palette.New(commands) | |
| 44 | + | ac.overlays.palette = palette.New(results) | |
| 41 | 45 | ac.overlays.paletteRecent = recent | |
| 42 | 46 | ac.overlays.paletteHints = palette.ContextualHints{} | |
| 47 | + | ac.overlays.paletteFeedback = "" | |
| 43 | 48 | ac.overlays.palettePending = nil | |
| 44 | 49 | ac.overlays.paletteMu.Unlock() | |
| 45 | 50 | d.invalidateRender(sess, ac, true, "palette.go") | |
| 46 | 51 | } | |
| 47 | 52 | ||
| 53 | + | // paletteResults captures eligible named sessions before paletteMu so the | |
| 54 | + | // palette has an immutable lifecycle target without violating lock ordering. | |
| 55 | + | func (d *Daemon) paletteResults(current *session, commands []command.Command) []palette.Result { | |
| 56 | + | d.mu.Lock() | |
| 57 | + | sessions := d.sessionsSnapshotLocked() | |
| 58 | + | stopped := make([]stoppedSession, 0, len(d.stopped)) | |
| 59 | + | for _, entry := range d.stopped { | |
| 60 | + | if !entry.purging { | |
| 61 | + | stopped = append(stopped, entry) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | d.mu.Unlock() | |
| 65 | + | ||
| 66 | + | results := make([]palette.Result, 0, len(commands)+len(sessions)+len(stopped)) | |
| 67 | + | for _, cmd := range commands { | |
| 68 | + | results = append(results, palette.NewCommandResult(cmd)) | |
| 69 | + | } | |
| 70 | + | active := make([]palette.Result, 0, len(sessions)) | |
| 71 | + | for _, candidate := range sessions { | |
| 72 | + | if candidate == current { | |
| 73 | + | continue | |
| 74 | + | } | |
| 75 | + | candidate.mu.Lock() | |
| 76 | + | name, createdAt, ephemeral := candidate.name, candidate.createdAt, candidate.ephemeral | |
| 77 | + | id := candidate.id | |
| 78 | + | candidate.mu.Unlock() | |
| 79 | + | if name != "" && !ephemeral { | |
| 80 | + | active = append(active, palette.NewActiveSessionResult(name, time.Unix(0, createdAt), id)) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | sort.Slice(active, func(i, j int) bool { | |
| 84 | + | left, _ := active[i].SessionName() | |
| 85 | + | right, _ := active[j].SessionName() | |
| 86 | + | return left < right | |
| 87 | + | }) | |
| 88 | + | sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name }) | |
| 89 | + | results = append(results, active...) | |
| 90 | + | for _, candidate := range stopped { | |
| 91 | + | results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt))) | |
| 92 | + | } | |
| 93 | + | return results | |
| 94 | + | } | |
| 95 | + | ||
| 48 | 96 | func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints { | |
| 49 | 97 | names := make([]string, len(recent)) | |
| 50 | 98 | for i, entry := range recent { | |
| @@ -104,6 +152,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 104 | 152 | return | |
| 105 | 153 | } | |
| 106 | 154 | var cmd command.Command | |
| 155 | + | var sessionTarget palette.Result | |
| 156 | + | var hasSessionTarget bool | |
| 107 | 157 | var args []string | |
| 108 | 158 | var recent []recentSession | |
| 109 | 159 | var generation uint64 | |
| @@ -118,47 +168,78 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 118 | 168 | } | |
| 119 | 169 | routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{ | |
| 120 | 170 | rune: func(r rune) { | |
| 121 | - | ac.overlays.palette.Insert(r) | |
| 122 | - | changed = true | |
| 171 | + | if !execute { | |
| 172 | + | ac.overlays.palette.Insert(r) | |
| 173 | + | changed = true | |
| 174 | + | } | |
| 123 | 175 | }, | |
| 124 | 176 | backspace: func() { | |
| 125 | - | ac.overlays.palette.Backspace() | |
| 126 | - | changed = true | |
| 177 | + | if !execute { | |
| 178 | + | ac.overlays.palette.Backspace() | |
| 179 | + | changed = true | |
| 180 | + | } | |
| 181 | + | }, | |
| 182 | + | tab: func() { | |
| 183 | + | if !execute { | |
| 184 | + | changed = ac.overlays.palette.CompleteSelected() || changed | |
| 185 | + | } | |
| 186 | + | }, | |
| 187 | + | up: func() { | |
| 188 | + | if !execute { | |
| 189 | + | ac.overlays.palette.Up() | |
| 190 | + | changed = true | |
| 191 | + | } | |
| 192 | + | }, | |
| 193 | + | down: func() { | |
| 194 | + | if !execute { | |
| 195 | + | ac.overlays.palette.Down() | |
| 196 | + | changed = true | |
| 197 | + | } | |
| 198 | + | }, | |
| 199 | + | cancel: func() { | |
| 200 | + | if !execute { | |
| 201 | + | cancel = true | |
| 202 | + | } | |
| 127 | 203 | }, | |
| 128 | - | tab: func() { changed = ac.overlays.palette.CompleteSelected() || changed }, | |
| 129 | - | up: func() { ac.overlays.palette.Up(); changed = true }, | |
| 130 | - | down: func() { ac.overlays.palette.Down(); changed = true }, | |
| 131 | - | cancel: func() { cancel = true }, | |
| 132 | 204 | enter: func() { | |
| 133 | - | var selected bool | |
| 134 | - | cmd, selected = ac.overlays.palette.Selected() | |
| 135 | - | if !selected { | |
| 205 | + | if execute { | |
| 206 | + | return | |
| 207 | + | } | |
| 208 | + | selected, ok := ac.overlays.palette.Selected() | |
| 209 | + | if !ok { | |
| 136 | 210 | changed = true | |
| 137 | 211 | return | |
| 138 | 212 | } | |
| 139 | 213 | rawQuery = ac.overlays.palette.Query() | |
| 140 | - | // JRS is contextual: it is executable only from its exact token so | |
| 141 | - | // fuzzy selection can never turn an unrelated query into a jump. | |
| 142 | - | // Static commands retain normal palette behavior and run the selected | |
| 143 | - | // fuzzy match. | |
| 144 | - | if cmd.Slug == "jump-recent-session" { | |
| 145 | - | action, valid := palette.ParseAction([]command.Command{cmd}, rawQuery) | |
| 146 | - | if !valid { | |
| 147 | - | changed = true | |
| 148 | - | return | |
| 214 | + | if selectedCommand, ok := selected.Command(); ok { | |
| 215 | + | cmd = selectedCommand | |
| 216 | + | // JRS is contextual: fuzzy selection cannot turn an unrelated | |
| 217 | + | // query into a jump. | |
| 218 | + | if cmd.Slug == "jump-recent-session" { | |
| 219 | + | action, valid := palette.ParseAction([]palette.Result{selected}, rawQuery) | |
| 220 | + | if !valid { | |
| 221 | + | changed = true | |
| 222 | + | return | |
| 223 | + | } | |
| 224 | + | args = action.Args | |
| 225 | + | recent = append([]recentSession(nil), ac.overlays.paletteRecent...) | |
| 149 | 226 | } | |
| 150 | - | args = action.Args | |
| 151 | - | recent = append([]recentSession(nil), ac.overlays.paletteRecent...) | |
| 227 | + | } else if _, ok := selected.SessionName(); ok { | |
| 228 | + | sessionTarget = selected | |
| 229 | + | hasSessionTarget = true | |
| 230 | + | } else { | |
| 231 | + | changed = true | |
| 232 | + | return | |
| 152 | 233 | } | |
| 153 | 234 | generation = ac.overlays.paletteGeneration | |
| 154 | 235 | execute = true | |
| 155 | 236 | }, | |
| 156 | 237 | }) | |
| 157 | 238 | if changed { | |
| 239 | + | ac.overlays.paletteFeedback = "" | |
| 158 | 240 | active, ok := ac.overlays.palette.ArgumentCommand() | |
| 159 | 241 | if ok && active.Slug == "jump-recent-session" { | |
| 160 | - | hints := recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), active)) | |
| 161 | - | ac.overlays.paletteHints = hints | |
| 242 | + | ac.overlays.paletteHints = recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), active)) | |
| 162 | 243 | } else { | |
| 163 | 244 | ac.overlays.paletteHints = palette.ContextualHints{} | |
| 164 | 245 | } | |
| @@ -178,8 +259,27 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 178 | 259 | return | |
| 179 | 260 | } | |
| 180 | 261 | ||
| 181 | - | // Revalidate live target without paletteMu. A captured ID is never replaced | |
| 182 | - | // by a newly-ranked session, so MRU changes cannot shift a requested rank. | |
| 262 | + | if hasSessionTarget { | |
| 263 | + | createdAt, _ := sessionTarget.SessionCreatedAt() | |
| 264 | + | expectedCreatedAt := createdAt.UnixNano() | |
| 265 | + | name, _ := sessionTarget.SessionName() | |
| 266 | + | target := picker.Target{Name: name, TabIndex: -1, ExpectedCreatedAt: &expectedCreatedAt} | |
| 267 | + | if id, active := sessionTarget.SessionID(); active { | |
| 268 | + | target.Session = id | |
| 269 | + | } else { | |
| 270 | + | target.Stopped = true | |
| 271 | + | } | |
| 272 | + | if !d.switchToTarget(sess, ac, target) { | |
| 273 | + | ac.paletteFailure(generation, rawQuery, "requested session is unavailable") | |
| 274 | + | d.invalidateRender(sess, ac, true, "palette.go") | |
| 275 | + | return | |
| 276 | + | } | |
| 277 | + | if ac.closeExecutedPalette(generation, rawQuery) { | |
| 278 | + | d.invalidateRender(ac.currentSession(), ac, true, "palette.go") | |
| 279 | + | } | |
| 280 | + | return | |
| 281 | + | } | |
| 282 | + | ||
| 183 | 283 | if cmd.Slug == "jump-recent-session" { | |
| 184 | 284 | rank, err := command.ParsePositiveDecimal(args) | |
| 185 | 285 | if err != nil { | |
| @@ -187,16 +287,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 187 | 287 | d.invalidateRender(sess, ac, true, "palette.go") | |
| 188 | 288 | return | |
| 189 | 289 | } | |
| 190 | - | // The captured ID is handed off atomically. A target can disappear after | |
| 191 | - | // the validation above, so closing is contingent on the committed switch. | |
| 192 | 290 | exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent} | |
| 193 | 291 | if err := exec.JumpRecentSession(rank); err != nil { | |
| 194 | 292 | ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable") | |
| 195 | 293 | d.invalidateRender(sess, ac, true, "palette.go") | |
| 196 | 294 | return | |
| 197 | 295 | } | |
| 198 | - | // Publication remains generation-safe; do not let stale work close a | |
| 199 | - | // newer palette interaction. | |
| 200 | 296 | if ac.closeExecutedPalette(generation, rawQuery) { | |
| 201 | 297 | d.recordPaletteUse(cmd.Code) | |
| 202 | 298 | d.invalidateRender(ac.currentSession(), ac, true, "palette.go") | |
| @@ -214,7 +310,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 214 | 310 | } | |
| 215 | 311 | ||
| 216 | 312 | func paletteArgs(query string, cmd command.Command) []string { | |
| 217 | - | action, ok := palette.ParseAction([]command.Command{cmd}, query) | |
| 313 | + | action, ok := palette.ParseAction(palette.CommandResults([]command.Command{cmd}), query) | |
| 218 | 314 | if !ok { | |
| 219 | 315 | return nil | |
| 220 | 316 | } | |
| @@ -226,6 +322,7 @@ func (ac *attachedClient) clearPaletteLocked() { | |||
| 226 | 322 | ac.overlays.palette = nil | |
| 227 | 323 | ac.overlays.paletteRecent = nil | |
| 228 | 324 | ac.overlays.paletteHints = palette.ContextualHints{} | |
| 325 | + | ac.overlays.paletteFeedback = "" | |
| 229 | 326 | ac.overlays.palettePending = nil | |
| 230 | 327 | } | |
| 231 | 328 | ||
| @@ -235,11 +332,7 @@ func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback s | |||
| 235 | 332 | if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery { | |
| 236 | 333 | return | |
| 237 | 334 | } | |
| 238 | - | // Rendering consumes the immutable contextual hint snapshot; preserve ranks | |
| 239 | - | // while making stale-target feedback visible. | |
| 240 | - | if ac.overlays.paletteHints.Kind == command.ContextHintRecentSessions { | |
| 241 | - | ac.overlays.paletteHints.Feedback = feedback | |
| 242 | - | } | |
| 335 | + | ac.overlays.paletteFeedback = feedback | |
| 243 | 336 | } | |
| 244 | 337 | ||
| 245 | 338 | func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery string) bool { | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "sort" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 4 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 5 | "github.com/bnema/vev/internal/usecase/command" | 8 | "github.com/bnema/vev/internal/usecase/command" | ||
| 6 | "github.com/bnema/vev/internal/usecase/layout" | 9 | "github.com/bnema/vev/internal/usecase/layout" | ||
| @@ -35,16 +38,61 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |||||
| 35 | // holding an overlay lock while inspecting live sessions. | 38 | // holding an overlay lock while inspecting live sessions. | ||
| 36 | recent := d.recentSessions(sess) | 39 | recent := d.recentSessions(sess) | ||
| 37 | commands := d.paletteCommands() | 40 | commands := d.paletteCommands() | ||
| 41 | + | results := d.paletteResults(sess, commands) | |||
| 38 | ac.overlays.paletteMu.Lock() | 42 | ac.overlays.paletteMu.Lock() | ||
| 39 | ac.overlays.paletteGeneration++ | 43 | ac.overlays.paletteGeneration++ | ||
| 40 | - | ac.overlays.palette = palette.New(commands) | 44 | + | ac.overlays.palette = palette.New(results) |
| 41 | ac.overlays.paletteRecent = recent | 45 | ac.overlays.paletteRecent = recent | ||
| 42 | ac.overlays.paletteHints = palette.ContextualHints{} | 46 | ac.overlays.paletteHints = palette.ContextualHints{} | ||
| 47 | + | ac.overlays.paletteFeedback = "" | |||
| 43 | ac.overlays.palettePending = nil | 48 | ac.overlays.palettePending = nil | ||
| 44 | ac.overlays.paletteMu.Unlock() | 49 | ac.overlays.paletteMu.Unlock() | ||
| 45 | d.invalidateRender(sess, ac, true, "palette.go") | 50 | d.invalidateRender(sess, ac, true, "palette.go") | ||
| 46 | } | 51 | } | ||
| 47 | 52 | ||||
| 53 | + | // paletteResults captures eligible named sessions before paletteMu so the | |||
| 54 | + | // palette has an immutable lifecycle target without violating lock ordering. | |||
| 55 | + | func (d *Daemon) paletteResults(current *session, commands []command.Command) []palette.Result { | |||
| 56 | + | d.mu.Lock() | |||
| 57 | + | sessions := d.sessionsSnapshotLocked() | |||
| 58 | + | stopped := make([]stoppedSession, 0, len(d.stopped)) | |||
| 59 | + | for _, entry := range d.stopped { | |||
| 60 | + | if !entry.purging { | |||
| 61 | + | stopped = append(stopped, entry) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | d.mu.Unlock() | |||
| 65 | + | ||||
| 66 | + | results := make([]palette.Result, 0, len(commands)+len(sessions)+len(stopped)) | |||
| 67 | + | for _, cmd := range commands { | |||
| 68 | + | results = append(results, palette.NewCommandResult(cmd)) | |||
| 69 | + | } | |||
| 70 | + | active := make([]palette.Result, 0, len(sessions)) | |||
| 71 | + | for _, candidate := range sessions { | |||
| 72 | + | if candidate == current { | |||
| 73 | + | continue | |||
| 74 | + | } | |||
| 75 | + | candidate.mu.Lock() | |||
| 76 | + | name, createdAt, ephemeral := candidate.name, candidate.createdAt, candidate.ephemeral | |||
| 77 | + | id := candidate.id | |||
| 78 | + | candidate.mu.Unlock() | |||
| 79 | + | if name != "" && !ephemeral { | |||
| 80 | + | active = append(active, palette.NewActiveSessionResult(name, time.Unix(0, createdAt), id)) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | sort.Slice(active, func(i, j int) bool { | |||
| 84 | + | left, _ := active[i].SessionName() | |||
| 85 | + | right, _ := active[j].SessionName() | |||
| 86 | + | return left < right | |||
| 87 | + | }) | |||
| 88 | + | sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name }) | |||
| 89 | + | results = append(results, active...) | |||
| 90 | + | for _, candidate := range stopped { | |||
| 91 | + | results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt))) | |||
| 92 | + | } | |||
| 93 | + | return results | |||
| 94 | + | } | |||
| 95 | + | ||||
| 48 | func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints { | 96 | func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints { | ||
| 49 | names := make([]string, len(recent)) | 97 | names := make([]string, len(recent)) | ||
| 50 | for i, entry := range recent { | 98 | for i, entry := range recent { | ||
| @@ -104,6 +152,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 104 | return | 152 | return | ||
| 105 | } | 153 | } | ||
| 106 | var cmd command.Command | 154 | var cmd command.Command | ||
| 155 | + | var sessionTarget palette.Result | |||
| 156 | + | var hasSessionTarget bool | |||
| 107 | var args []string | 157 | var args []string | ||
| 108 | var recent []recentSession | 158 | var recent []recentSession | ||
| 109 | var generation uint64 | 159 | var generation uint64 | ||
| @@ -118,47 +168,78 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 118 | } | 168 | } | ||
| 119 | routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{ | 169 | routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{ | ||
| 120 | rune: func(r rune) { | 170 | rune: func(r rune) { | ||
| 121 | - | ac.overlays.palette.Insert(r) | 171 | + | if !execute { |
| 122 | - | changed = true | 172 | + | ac.overlays.palette.Insert(r) |
| 173 | + | changed = true | |||
| 174 | + | } | |||
| 123 | }, | 175 | }, | ||
| 124 | backspace: func() { | 176 | backspace: func() { | ||
| 125 | - | ac.overlays.palette.Backspace() | 177 | + | if !execute { |
| 126 | - | changed = true | 178 | + | ac.overlays.palette.Backspace() |
| 179 | + | changed = true | |||
| 180 | + | } | |||
| 181 | + | }, | |||
| 182 | + | tab: func() { | |||
| 183 | + | if !execute { | |||
| 184 | + | changed = ac.overlays.palette.CompleteSelected() || changed | |||
| 185 | + | } | |||
| 186 | + | }, | |||
| 187 | + | up: func() { | |||
| 188 | + | if !execute { | |||
| 189 | + | ac.overlays.palette.Up() | |||
| 190 | + | changed = true | |||
| 191 | + | } | |||
| 192 | + | }, | |||
| 193 | + | down: func() { | |||
| 194 | + | if !execute { | |||
| 195 | + | ac.overlays.palette.Down() | |||
| 196 | + | changed = true | |||
| 197 | + | } | |||
| 198 | + | }, | |||
| 199 | + | cancel: func() { | |||
| 200 | + | if !execute { | |||
| 201 | + | cancel = true | |||
| 202 | + | } | |||
| 127 | }, | 203 | }, | ||
| 128 | - | tab: func() { changed = ac.overlays.palette.CompleteSelected() || changed }, | |||
| 129 | - | up: func() { ac.overlays.palette.Up(); changed = true }, | |||
| 130 | - | down: func() { ac.overlays.palette.Down(); changed = true }, | |||
| 131 | - | cancel: func() { cancel = true }, | |||
| 132 | enter: func() { | 204 | enter: func() { | ||
| 133 | - | var selected bool | 205 | + | if execute { |
| 134 | - | cmd, selected = ac.overlays.palette.Selected() | 206 | + | return |
| 135 | - | if !selected { | 207 | + | } |
| 208 | + | selected, ok := ac.overlays.palette.Selected() | |||
| 209 | + | if !ok { | |||
| 136 | changed = true | 210 | changed = true | ||
| 137 | return | 211 | return | ||
| 138 | } | 212 | } | ||
| 139 | rawQuery = ac.overlays.palette.Query() | 213 | rawQuery = ac.overlays.palette.Query() | ||
| 140 | - | // JRS is contextual: it is executable only from its exact token so | 214 | + | if selectedCommand, ok := selected.Command(); ok { |
| 141 | - | // fuzzy selection can never turn an unrelated query into a jump. | 215 | + | cmd = selectedCommand |
| 142 | - | // Static commands retain normal palette behavior and run the selected | 216 | + | // JRS is contextual: fuzzy selection cannot turn an unrelated |
| 143 | - | // fuzzy match. | 217 | + | // query into a jump. |
| 144 | - | if cmd.Slug == "jump-recent-session" { | 218 | + | if cmd.Slug == "jump-recent-session" { |
| 145 | - | action, valid := palette.ParseAction([]command.Command{cmd}, rawQuery) | 219 | + | action, valid := palette.ParseAction([]palette.Result{selected}, rawQuery) |
| 146 | - | if !valid { | 220 | + | if !valid { |
| 147 | - | changed = true | 221 | + | changed = true |
| 148 | - | return | 222 | + | return |
| 223 | + | } | |||
| 224 | + | args = action.Args | |||
| 225 | + | recent = append([]recentSession(nil), ac.overlays.paletteRecent...) | |||
| 149 | } | 226 | } | ||
| 150 | - | args = action.Args | 227 | + | } else if _, ok := selected.SessionName(); ok { |
| 151 | - | recent = append([]recentSession(nil), ac.overlays.paletteRecent...) | 228 | + | sessionTarget = selected |
| 229 | + | hasSessionTarget = true | |||
| 230 | + | } else { | |||
| 231 | + | changed = true | |||
| 232 | + | return | |||
| 152 | } | 233 | } | ||
| 153 | generation = ac.overlays.paletteGeneration | 234 | generation = ac.overlays.paletteGeneration | ||
| 154 | execute = true | 235 | execute = true | ||
| 155 | }, | 236 | }, | ||
| 156 | }) | 237 | }) | ||
| 157 | if changed { | 238 | if changed { | ||
| 239 | + | ac.overlays.paletteFeedback = "" | |||
| 158 | active, ok := ac.overlays.palette.ArgumentCommand() | 240 | active, ok := ac.overlays.palette.ArgumentCommand() | ||
| 159 | if ok && active.Slug == "jump-recent-session" { | 241 | if ok && active.Slug == "jump-recent-session" { | ||
| 160 | - | hints := recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), active)) | 242 | + | ac.overlays.paletteHints = recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), active)) |
| 161 | - | ac.overlays.paletteHints = hints | |||
| 162 | } else { | 243 | } else { | ||
| 163 | ac.overlays.paletteHints = palette.ContextualHints{} | 244 | ac.overlays.paletteHints = palette.ContextualHints{} | ||
| 164 | } | 245 | } | ||
| @@ -178,8 +259,27 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 178 | return | 259 | return | ||
| 179 | } | 260 | } | ||
| 180 | 261 | ||||
| 181 | - | // Revalidate live target without paletteMu. A captured ID is never replaced | 262 | + | if hasSessionTarget { |
| 182 | - | // by a newly-ranked session, so MRU changes cannot shift a requested rank. | 263 | + | createdAt, _ := sessionTarget.SessionCreatedAt() |
| 264 | + | expectedCreatedAt := createdAt.UnixNano() | |||
| 265 | + | name, _ := sessionTarget.SessionName() | |||
| 266 | + | target := picker.Target{Name: name, TabIndex: -1, ExpectedCreatedAt: &expectedCreatedAt} | |||
| 267 | + | if id, active := sessionTarget.SessionID(); active { | |||
| 268 | + | target.Session = id | |||
| 269 | + | } else { | |||
| 270 | + | target.Stopped = true | |||
| 271 | + | } | |||
| 272 | + | if !d.switchToTarget(sess, ac, target) { | |||
| 273 | + | ac.paletteFailure(generation, rawQuery, "requested session is unavailable") | |||
| 274 | + | d.invalidateRender(sess, ac, true, "palette.go") | |||
| 275 | + | return | |||
| 276 | + | } | |||
| 277 | + | if ac.closeExecutedPalette(generation, rawQuery) { | |||
| 278 | + | d.invalidateRender(ac.currentSession(), ac, true, "palette.go") | |||
| 279 | + | } | |||
| 280 | + | return | |||
| 281 | + | } | |||
| 282 | + | ||||
| 183 | if cmd.Slug == "jump-recent-session" { | 283 | if cmd.Slug == "jump-recent-session" { | ||
| 184 | rank, err := command.ParsePositiveDecimal(args) | 284 | rank, err := command.ParsePositiveDecimal(args) | ||
| 185 | if err != nil { | 285 | if err != nil { | ||
| @@ -187,16 +287,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 187 | d.invalidateRender(sess, ac, true, "palette.go") | 287 | d.invalidateRender(sess, ac, true, "palette.go") | ||
| 188 | return | 288 | return | ||
| 189 | } | 289 | } | ||
| 190 | - | // The captured ID is handed off atomically. A target can disappear after | |||
| 191 | - | // the validation above, so closing is contingent on the committed switch. | |||
| 192 | exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent} | 290 | exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent} | ||
| 193 | if err := exec.JumpRecentSession(rank); err != nil { | 291 | if err := exec.JumpRecentSession(rank); err != nil { | ||
| 194 | ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable") | 292 | ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable") | ||
| 195 | d.invalidateRender(sess, ac, true, "palette.go") | 293 | d.invalidateRender(sess, ac, true, "palette.go") | ||
| 196 | return | 294 | return | ||
| 197 | } | 295 | } | ||
| 198 | - | // Publication remains generation-safe; do not let stale work close a | |||
| 199 | - | // newer palette interaction. | |||
| 200 | if ac.closeExecutedPalette(generation, rawQuery) { | 296 | if ac.closeExecutedPalette(generation, rawQuery) { | ||
| 201 | d.recordPaletteUse(cmd.Code) | 297 | d.recordPaletteUse(cmd.Code) | ||
| 202 | d.invalidateRender(ac.currentSession(), ac, true, "palette.go") | 298 | d.invalidateRender(ac.currentSession(), ac, true, "palette.go") | ||
| @@ -214,7 +310,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 214 | } | 310 | } | ||
| 215 | 311 | ||||
| 216 | func paletteArgs(query string, cmd command.Command) []string { | 312 | func paletteArgs(query string, cmd command.Command) []string { | ||
| 217 | - | action, ok := palette.ParseAction([]command.Command{cmd}, query) | 313 | + | action, ok := palette.ParseAction(palette.CommandResults([]command.Command{cmd}), query) |
| 218 | if !ok { | 314 | if !ok { | ||
| 219 | return nil | 315 | return nil | ||
| 220 | } | 316 | } | ||
| @@ -226,6 +322,7 @@ func (ac *attachedClient) clearPaletteLocked() { | |||||
| 226 | ac.overlays.palette = nil | 322 | ac.overlays.palette = nil | ||
| 227 | ac.overlays.paletteRecent = nil | 323 | ac.overlays.paletteRecent = nil | ||
| 228 | ac.overlays.paletteHints = palette.ContextualHints{} | 324 | ac.overlays.paletteHints = palette.ContextualHints{} | ||
| 325 | + | ac.overlays.paletteFeedback = "" | |||
| 229 | ac.overlays.palettePending = nil | 326 | ac.overlays.palettePending = nil | ||
| 230 | } | 327 | } | ||
| 231 | 328 | ||||
| @@ -235,11 +332,7 @@ func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback s | |||||
| 235 | if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery { | 332 | if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery { | ||
| 236 | return | 333 | return | ||
| 237 | } | 334 | } | ||
| 238 | - | // Rendering consumes the immutable contextual hint snapshot; preserve ranks | 335 | + | ac.overlays.paletteFeedback = feedback |
| 239 | - | // while making stale-target feedback visible. | |||
| 240 | - | if ac.overlays.paletteHints.Kind == command.ContextHintRecentSessions { | |||
| 241 | - | ac.overlays.paletteHints.Feedback = feedback | |||
| 242 | - | } | |||
| 243 | } | 336 | } | ||
| 244 | 337 | ||||
| 245 | func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery string) bool { | 338 | func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery string) bool { | ||
M internal/usecase/daemon/palette_test.go +164 -7
| @@ -48,6 +48,140 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) { | |||
| 48 | 48 | awaitFrame(t, sends, ports.MsgOutput) | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | + | func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) { | |
| 52 | + | p, release := newBlockingPTY(t) | |
| 53 | + | defer release() | |
| 54 | + | d, current, ac, _ := newManualSessionWithPTYs(t, p) | |
| 55 | + | d.sessions["active"] = &session{id: "active", name: "active", createdAt: 10} | |
| 56 | + | d.sessions["ephemeral"] = &session{id: "ephemeral", name: "ephemeral", ephemeral: true, createdAt: 11} | |
| 57 | + | d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 12} | |
| 58 | + | d.stopped["purging"] = stoppedSession{name: "purging", createdAt: 13, purging: true} | |
| 59 | + | ||
| 60 | + | d.enterPalette(current, ac) | |
| 61 | + | ac.overlays.paletteMu.Lock() | |
| 62 | + | defer ac.overlays.paletteMu.Unlock() | |
| 63 | + | ||
| 64 | + | got := map[string]palette.ResultKind{} | |
| 65 | + | for _, match := range ac.overlays.palette.Matches() { | |
| 66 | + | if name, ok := match.Result.SessionName(); ok { | |
| 67 | + | got[name] = match.Result.Kind() | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | require.Equal(t, map[string]palette.ResultKind{ | |
| 71 | + | "active": palette.ResultKindActiveSession, | |
| 72 | + | "stopped": palette.ResultKindStoppedSession, | |
| 73 | + | }, got) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) { | |
| 77 | + | p, release := newBlockingPTY(t) | |
| 78 | + | defer release() | |
| 79 | + | d, current, ac, _ := newManualSessionWithPTYs(t, p) | |
| 80 | + | target := &session{id: "active", name: "active", createdAt: 10} | |
| 81 | + | d.sessions[target.id] = target | |
| 82 | + | ||
| 83 | + | d.enterPalette(current, ac) | |
| 84 | + | d.handlePaletteInput(ac, []byte("active")) | |
| 85 | + | ac.overlays.paletteMu.Lock() | |
| 86 | + | selected, ok := ac.overlays.palette.Selected() | |
| 87 | + | ac.overlays.paletteMu.Unlock() | |
| 88 | + | _, isSession := selected.SessionName() | |
| 89 | + | require.True(t, ok) | |
| 90 | + | require.True(t, isSession) | |
| 91 | + | d.mu.Lock() | |
| 92 | + | delete(d.sessions, target.id) | |
| 93 | + | d.mu.Unlock() | |
| 94 | + | ||
| 95 | + | d.handlePaletteInput(ac, []byte("\r")) | |
| 96 | + | ac.overlays.paletteMu.Lock() | |
| 97 | + | require.Equal(t, "requested session is unavailable", ac.overlays.paletteFeedback) | |
| 98 | + | ac.overlays.paletteMu.Unlock() | |
| 99 | + | d.handlePaletteInput(ac, []byte("x")) | |
| 100 | + | ac.overlays.paletteMu.Lock() | |
| 101 | + | require.Empty(t, ac.overlays.paletteFeedback) | |
| 102 | + | ac.overlays.paletteMu.Unlock() | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestPaletteEnterFreezesActiveSessionSelectionAgainstTrailingInput(t *testing.T) { | |
| 106 | + | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |
| 107 | + | defer releaseAll(releases) | |
| 108 | + | target := d.sessions[domain.SessionID("recent")] | |
| 109 | + | target.createdAt = 42 | |
| 110 | + | ||
| 111 | + | d.handleInput(current, ac, []byte("\x1b ")) | |
| 112 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 113 | + | d.handleInput(current, ac, []byte("recent\rx")) | |
| 114 | + | ||
| 115 | + | require.Same(t, target, ac.currentSession(), "Enter must retain the selected session despite trailing frame bytes") | |
| 116 | + | require.False(t, ac.overlays.paletteActive(), "trailing input must not prevent the captured selection from closing the palette") | |
| 117 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 118 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func TestPaletteSelectedActiveSessionSwitchesWithoutRecordingCommandRecency(t *testing.T) { | |
| 122 | + | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |
| 123 | + | defer releaseAll(releases) | |
| 124 | + | target := d.sessions[domain.SessionID("recent")] | |
| 125 | + | target.createdAt = 42 | |
| 126 | + | ||
| 127 | + | d.handleInput(current, ac, []byte("\x1b ")) | |
| 128 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 129 | + | d.handleInput(current, ac, []byte("recent\r")) | |
| 130 | + | ||
| 131 | + | require.Same(t, target, ac.currentSession()) | |
| 132 | + | require.Same(t, ac, target.client, "canonical handoff reuses the attached client") | |
| 133 | + | require.False(t, ac.overlays.paletteActive()) | |
| 134 | + | require.Empty(t, d.paletteRecent, "session selections are not command recency") | |
| 135 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 136 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestPaletteStoppedSessionResumeFailureKeepsPaletteAndSourceAttachment(t *testing.T) { | |
| 140 | + | p, release := newBlockingPTY(t) | |
| 141 | + | defer release() | |
| 142 | + | d, current, ac, sends := newManualSessionWithPTYs(t, p) | |
| 143 | + | stopped := stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 42, lastUsedSeq: 7, tabNames: []string{"shell", "logs"}} | |
| 144 | + | d.stopped[stopped.name] = stopped | |
| 145 | + | ptys := portsmocks.NewMockPTYFactory(t) | |
| 146 | + | ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed")).Once() | |
| 147 | + | d.ptys = ptys | |
| 148 | + | ||
| 149 | + | d.handleInput(current, ac, []byte("\x1b ")) | |
| 150 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 151 | + | d.handleInput(current, ac, []byte("stopped\r")) | |
| 152 | + | ||
| 153 | + | require.Same(t, current, ac.currentSession(), "failed resume must retain the source attachment") | |
| 154 | + | current.mu.Lock() | |
| 155 | + | require.Same(t, ac, current.client) | |
| 156 | + | current.mu.Unlock() | |
| 157 | + | require.True(t, ac.overlays.paletteActive()) | |
| 158 | + | ac.overlays.paletteMu.Lock() | |
| 159 | + | require.Equal(t, "requested session is unavailable", ac.overlays.paletteFeedback) | |
| 160 | + | ac.overlays.paletteMu.Unlock() | |
| 161 | + | require.Equal(t, stopped, d.stopped[stopped.name], "failed resume must retain stopped lifecycle metadata") | |
| 162 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) { | |
| 166 | + | p1, release1 := newBlockingPTY(t) | |
| 167 | + | p2, release2 := newBlockingPTY(t) | |
| 168 | + | defer release1() | |
| 169 | + | defer release2() | |
| 170 | + | d, current, ac, sends := newManualSessionWithPTYs(t, p1) | |
| 171 | + | d.ptys = newFactory(t, p2) | |
| 172 | + | d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 42} | |
| 173 | + | ||
| 174 | + | d.handleInput(current, ac, []byte("\x1b ")) | |
| 175 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 176 | + | d.handleInput(current, ac, []byte("stopped\r")) | |
| 177 | + | ||
| 178 | + | require.Equal(t, "stopped", ac.currentSession().name) | |
| 179 | + | require.Equal(t, int64(42), ac.currentSession().createdAt) | |
| 180 | + | require.False(t, ac.overlays.paletteActive()) | |
| 181 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 182 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 183 | + | } | |
| 184 | + | ||
| 51 | 185 | func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) { | |
| 52 | 186 | p, release := newBlockingPTY(t) | |
| 53 | 187 | defer release() | |
| @@ -172,7 +306,7 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) { | |||
| 172 | 306 | require.True(t, ac.overlays.paletteActive()) | |
| 173 | 307 | ac.overlays.paletteMu.Lock() | |
| 174 | 308 | require.Equal(t, "JRS 1", ac.overlays.palette.Query()) | |
| 175 | - | require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteHints.Feedback) | |
| 309 | + | require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback) | |
| 176 | 310 | ac.overlays.paletteMu.Unlock() | |
| 177 | 311 | } | |
| 178 | 312 | ||
| @@ -188,7 +322,7 @@ func TestPaletteJRSOutOfRangeKeepsPaletteOpenWithoutClamping(t *testing.T) { | |||
| 188 | 322 | require.True(t, ac.overlays.paletteActive()) | |
| 189 | 323 | ac.overlays.paletteMu.Lock() | |
| 190 | 324 | require.Equal(t, "JRS 3", ac.overlays.palette.Query()) | |
| 191 | - | require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteHints.Feedback) | |
| 325 | + | require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback) | |
| 192 | 326 | ac.overlays.paletteMu.Unlock() | |
| 193 | 327 | awaitFrame(t, sends, ports.MsgOutput) | |
| 194 | 328 | } | |
| @@ -205,11 +339,28 @@ func TestPaletteJRSMalformedRankFeedback(t *testing.T) { | |||
| 205 | 339 | require.True(t, ac.overlays.paletteActive()) | |
| 206 | 340 | ac.overlays.paletteMu.Lock() | |
| 207 | 341 | require.Equal(t, "JRS 0", ac.overlays.palette.Query()) | |
| 208 | - | require.Equal(t, "rank must be one positive decimal", ac.overlays.paletteHints.Feedback) | |
| 342 | + | require.Equal(t, "rank must be one positive decimal", ac.overlays.paletteFeedback) | |
| 209 | 343 | ac.overlays.paletteMu.Unlock() | |
| 210 | 344 | awaitFrame(t, sends, ports.MsgOutput) | |
| 211 | 345 | } | |
| 212 | 346 | ||
| 347 | + | func TestPaletteFailureDoesNotOverwriteChangedQueryInSameGeneration(t *testing.T) { | |
| 348 | + | p, release := newBlockingPTY(t) | |
| 349 | + | defer release() | |
| 350 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 351 | + | ||
| 352 | + | d.enterPalette(sess, ac) | |
| 353 | + | ac.overlays.paletteMu.Lock() | |
| 354 | + | generation := ac.overlays.paletteGeneration | |
| 355 | + | ac.overlays.palette.Insert('x') | |
| 356 | + | ac.overlays.paletteMu.Unlock() | |
| 357 | + | ac.paletteFailure(generation, "", "stale failure") | |
| 358 | + | ||
| 359 | + | ac.overlays.paletteMu.Lock() | |
| 360 | + | require.Empty(t, ac.overlays.paletteFeedback) | |
| 361 | + | ac.overlays.paletteMu.Unlock() | |
| 362 | + | } | |
| 363 | + | ||
| 213 | 364 | func TestPaletteFailureDoesNotOverwriteNewerInteraction(t *testing.T) { | |
| 214 | 365 | p, release := newBlockingPTY(t) | |
| 215 | 366 | defer release() | |
| @@ -223,7 +374,7 @@ func TestPaletteFailureDoesNotOverwriteNewerInteraction(t *testing.T) { | |||
| 223 | 374 | ac.paletteFailure(staleGeneration, "JRS 1", "stale failure") | |
| 224 | 375 | ||
| 225 | 376 | ac.overlays.paletteMu.Lock() | |
| 226 | - | require.Empty(t, ac.overlays.paletteHints.Feedback) | |
| 377 | + | require.Empty(t, ac.overlays.paletteFeedback) | |
| 227 | 378 | ac.overlays.paletteMu.Unlock() | |
| 228 | 379 | awaitFrame(t, sends, ports.MsgOutput) | |
| 229 | 380 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -302,7 +453,9 @@ func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) { | |||
| 302 | 453 | ||
| 303 | 454 | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 304 | 455 | awaitFrame(t, sends, ports.MsgOutput) | |
| 305 | - | cmd, ok := ac.overlays.palette.Selected() | |
| 456 | + | result, ok := ac.overlays.palette.Selected() | |
| 457 | + | require.True(t, ok) | |
| 458 | + | cmd, ok := result.Command() | |
| 306 | 459 | require.True(t, ok) | |
| 307 | 460 | require.Equal(t, "NXT", cmd.Code) | |
| 308 | 461 | } | |
| @@ -413,13 +566,17 @@ func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) { | |||
| 413 | 566 | ||
| 414 | 567 | d.handlePaletteInput(ac, []byte{0x0e}) | |
| 415 | 568 | awaitFrame(t, sends, ports.MsgOutput) | |
| 416 | - | cmd, ok := ac.overlays.palette.Selected() | |
| 569 | + | result, ok := ac.overlays.palette.Selected() | |
| 570 | + | require.True(t, ok) | |
| 571 | + | cmd, ok := result.Command() | |
| 417 | 572 | require.True(t, ok) | |
| 418 | 573 | require.Equal(t, "CNS", cmd.Code) | |
| 419 | 574 | ||
| 420 | 575 | d.handlePaletteInput(ac, []byte{0x10}) | |
| 421 | 576 | awaitFrame(t, sends, ports.MsgOutput) | |
| 422 | - | cmd, ok = ac.overlays.palette.Selected() | |
| 577 | + | result, ok = ac.overlays.palette.Selected() | |
| 578 | + | require.True(t, ok) | |
| 579 | + | cmd, ok = result.Command() | |
| 423 | 580 | require.True(t, ok) | |
| 424 | 581 | require.Equal(t, "CNT", cmd.Code) | |
| 425 | 582 | } | |
| @@ -48,6 +48,140 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) { | |||||
| 48 | awaitFrame(t, sends, ports.MsgOutput) | 48 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 49 | } | 49 | } | ||
| 50 | 50 | ||||
| 51 | + | func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) { | |||
| 52 | + | p, release := newBlockingPTY(t) | |||
| 53 | + | defer release() | |||
| 54 | + | d, current, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 55 | + | d.sessions["active"] = &session{id: "active", name: "active", createdAt: 10} | |||
| 56 | + | d.sessions["ephemeral"] = &session{id: "ephemeral", name: "ephemeral", ephemeral: true, createdAt: 11} | |||
| 57 | + | d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 12} | |||
| 58 | + | d.stopped["purging"] = stoppedSession{name: "purging", createdAt: 13, purging: true} | |||
| 59 | + | ||||
| 60 | + | d.enterPalette(current, ac) | |||
| 61 | + | ac.overlays.paletteMu.Lock() | |||
| 62 | + | defer ac.overlays.paletteMu.Unlock() | |||
| 63 | + | ||||
| 64 | + | got := map[string]palette.ResultKind{} | |||
| 65 | + | for _, match := range ac.overlays.palette.Matches() { | |||
| 66 | + | if name, ok := match.Result.SessionName(); ok { | |||
| 67 | + | got[name] = match.Result.Kind() | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | require.Equal(t, map[string]palette.ResultKind{ | |||
| 71 | + | "active": palette.ResultKindActiveSession, | |||
| 72 | + | "stopped": palette.ResultKindStoppedSession, | |||
| 73 | + | }, got) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) { | |||
| 77 | + | p, release := newBlockingPTY(t) | |||
| 78 | + | defer release() | |||
| 79 | + | d, current, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 80 | + | target := &session{id: "active", name: "active", createdAt: 10} | |||
| 81 | + | d.sessions[target.id] = target | |||
| 82 | + | ||||
| 83 | + | d.enterPalette(current, ac) | |||
| 84 | + | d.handlePaletteInput(ac, []byte("active")) | |||
| 85 | + | ac.overlays.paletteMu.Lock() | |||
| 86 | + | selected, ok := ac.overlays.palette.Selected() | |||
| 87 | + | ac.overlays.paletteMu.Unlock() | |||
| 88 | + | _, isSession := selected.SessionName() | |||
| 89 | + | require.True(t, ok) | |||
| 90 | + | require.True(t, isSession) | |||
| 91 | + | d.mu.Lock() | |||
| 92 | + | delete(d.sessions, target.id) | |||
| 93 | + | d.mu.Unlock() | |||
| 94 | + | ||||
| 95 | + | d.handlePaletteInput(ac, []byte("\r")) | |||
| 96 | + | ac.overlays.paletteMu.Lock() | |||
| 97 | + | require.Equal(t, "requested session is unavailable", ac.overlays.paletteFeedback) | |||
| 98 | + | ac.overlays.paletteMu.Unlock() | |||
| 99 | + | d.handlePaletteInput(ac, []byte("x")) | |||
| 100 | + | ac.overlays.paletteMu.Lock() | |||
| 101 | + | require.Empty(t, ac.overlays.paletteFeedback) | |||
| 102 | + | ac.overlays.paletteMu.Unlock() | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func TestPaletteEnterFreezesActiveSessionSelectionAgainstTrailingInput(t *testing.T) { | |||
| 106 | + | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |||
| 107 | + | defer releaseAll(releases) | |||
| 108 | + | target := d.sessions[domain.SessionID("recent")] | |||
| 109 | + | target.createdAt = 42 | |||
| 110 | + | ||||
| 111 | + | d.handleInput(current, ac, []byte("\x1b ")) | |||
| 112 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 113 | + | d.handleInput(current, ac, []byte("recent\rx")) | |||
| 114 | + | ||||
| 115 | + | require.Same(t, target, ac.currentSession(), "Enter must retain the selected session despite trailing frame bytes") | |||
| 116 | + | require.False(t, ac.overlays.paletteActive(), "trailing input must not prevent the captured selection from closing the palette") | |||
| 117 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 118 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func TestPaletteSelectedActiveSessionSwitchesWithoutRecordingCommandRecency(t *testing.T) { | |||
| 122 | + | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |||
| 123 | + | defer releaseAll(releases) | |||
| 124 | + | target := d.sessions[domain.SessionID("recent")] | |||
| 125 | + | target.createdAt = 42 | |||
| 126 | + | ||||
| 127 | + | d.handleInput(current, ac, []byte("\x1b ")) | |||
| 128 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 129 | + | d.handleInput(current, ac, []byte("recent\r")) | |||
| 130 | + | ||||
| 131 | + | require.Same(t, target, ac.currentSession()) | |||
| 132 | + | require.Same(t, ac, target.client, "canonical handoff reuses the attached client") | |||
| 133 | + | require.False(t, ac.overlays.paletteActive()) | |||
| 134 | + | require.Empty(t, d.paletteRecent, "session selections are not command recency") | |||
| 135 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 136 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestPaletteStoppedSessionResumeFailureKeepsPaletteAndSourceAttachment(t *testing.T) { | |||
| 140 | + | p, release := newBlockingPTY(t) | |||
| 141 | + | defer release() | |||
| 142 | + | d, current, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 143 | + | stopped := stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 42, lastUsedSeq: 7, tabNames: []string{"shell", "logs"}} | |||
| 144 | + | d.stopped[stopped.name] = stopped | |||
| 145 | + | ptys := portsmocks.NewMockPTYFactory(t) | |||
| 146 | + | ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed")).Once() | |||
| 147 | + | d.ptys = ptys | |||
| 148 | + | ||||
| 149 | + | d.handleInput(current, ac, []byte("\x1b ")) | |||
| 150 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 151 | + | d.handleInput(current, ac, []byte("stopped\r")) | |||
| 152 | + | ||||
| 153 | + | require.Same(t, current, ac.currentSession(), "failed resume must retain the source attachment") | |||
| 154 | + | current.mu.Lock() | |||
| 155 | + | require.Same(t, ac, current.client) | |||
| 156 | + | current.mu.Unlock() | |||
| 157 | + | require.True(t, ac.overlays.paletteActive()) | |||
| 158 | + | ac.overlays.paletteMu.Lock() | |||
| 159 | + | require.Equal(t, "requested session is unavailable", ac.overlays.paletteFeedback) | |||
| 160 | + | ac.overlays.paletteMu.Unlock() | |||
| 161 | + | require.Equal(t, stopped, d.stopped[stopped.name], "failed resume must retain stopped lifecycle metadata") | |||
| 162 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) { | |||
| 166 | + | p1, release1 := newBlockingPTY(t) | |||
| 167 | + | p2, release2 := newBlockingPTY(t) | |||
| 168 | + | defer release1() | |||
| 169 | + | defer release2() | |||
| 170 | + | d, current, ac, sends := newManualSessionWithPTYs(t, p1) | |||
| 171 | + | d.ptys = newFactory(t, p2) | |||
| 172 | + | d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 42} | |||
| 173 | + | ||||
| 174 | + | d.handleInput(current, ac, []byte("\x1b ")) | |||
| 175 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 176 | + | d.handleInput(current, ac, []byte("stopped\r")) | |||
| 177 | + | ||||
| 178 | + | require.Equal(t, "stopped", ac.currentSession().name) | |||
| 179 | + | require.Equal(t, int64(42), ac.currentSession().createdAt) | |||
| 180 | + | require.False(t, ac.overlays.paletteActive()) | |||
| 181 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 182 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 183 | + | } | |||
| 184 | + | ||||
| 51 | func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) { | 185 | func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) { | ||
| 52 | p, release := newBlockingPTY(t) | 186 | p, release := newBlockingPTY(t) | ||
| 53 | defer release() | 187 | defer release() | ||
| @@ -172,7 +306,7 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) { | |||||
| 172 | require.True(t, ac.overlays.paletteActive()) | 306 | require.True(t, ac.overlays.paletteActive()) | ||
| 173 | ac.overlays.paletteMu.Lock() | 307 | ac.overlays.paletteMu.Lock() | ||
| 174 | require.Equal(t, "JRS 1", ac.overlays.palette.Query()) | 308 | require.Equal(t, "JRS 1", ac.overlays.palette.Query()) | ||
| 175 | - | require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteHints.Feedback) | 309 | + | require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback) |
| 176 | ac.overlays.paletteMu.Unlock() | 310 | ac.overlays.paletteMu.Unlock() | ||
| 177 | } | 311 | } | ||
| 178 | 312 | ||||
| @@ -188,7 +322,7 @@ func TestPaletteJRSOutOfRangeKeepsPaletteOpenWithoutClamping(t *testing.T) { | |||||
| 188 | require.True(t, ac.overlays.paletteActive()) | 322 | require.True(t, ac.overlays.paletteActive()) | ||
| 189 | ac.overlays.paletteMu.Lock() | 323 | ac.overlays.paletteMu.Lock() | ||
| 190 | require.Equal(t, "JRS 3", ac.overlays.palette.Query()) | 324 | require.Equal(t, "JRS 3", ac.overlays.palette.Query()) | ||
| 191 | - | require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteHints.Feedback) | 325 | + | require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback) |
| 192 | ac.overlays.paletteMu.Unlock() | 326 | ac.overlays.paletteMu.Unlock() | ||
| 193 | awaitFrame(t, sends, ports.MsgOutput) | 327 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 194 | } | 328 | } | ||
| @@ -205,11 +339,28 @@ func TestPaletteJRSMalformedRankFeedback(t *testing.T) { | |||||
| 205 | require.True(t, ac.overlays.paletteActive()) | 339 | require.True(t, ac.overlays.paletteActive()) | ||
| 206 | ac.overlays.paletteMu.Lock() | 340 | ac.overlays.paletteMu.Lock() | ||
| 207 | require.Equal(t, "JRS 0", ac.overlays.palette.Query()) | 341 | require.Equal(t, "JRS 0", ac.overlays.palette.Query()) | ||
| 208 | - | require.Equal(t, "rank must be one positive decimal", ac.overlays.paletteHints.Feedback) | 342 | + | require.Equal(t, "rank must be one positive decimal", ac.overlays.paletteFeedback) |
| 209 | ac.overlays.paletteMu.Unlock() | 343 | ac.overlays.paletteMu.Unlock() | ||
| 210 | awaitFrame(t, sends, ports.MsgOutput) | 344 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 211 | } | 345 | } | ||
| 212 | 346 | ||||
| 347 | + | func TestPaletteFailureDoesNotOverwriteChangedQueryInSameGeneration(t *testing.T) { | |||
| 348 | + | p, release := newBlockingPTY(t) | |||
| 349 | + | defer release() | |||
| 350 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 351 | + | ||||
| 352 | + | d.enterPalette(sess, ac) | |||
| 353 | + | ac.overlays.paletteMu.Lock() | |||
| 354 | + | generation := ac.overlays.paletteGeneration | |||
| 355 | + | ac.overlays.palette.Insert('x') | |||
| 356 | + | ac.overlays.paletteMu.Unlock() | |||
| 357 | + | ac.paletteFailure(generation, "", "stale failure") | |||
| 358 | + | ||||
| 359 | + | ac.overlays.paletteMu.Lock() | |||
| 360 | + | require.Empty(t, ac.overlays.paletteFeedback) | |||
| 361 | + | ac.overlays.paletteMu.Unlock() | |||
| 362 | + | } | |||
| 363 | + | ||||
| 213 | func TestPaletteFailureDoesNotOverwriteNewerInteraction(t *testing.T) { | 364 | func TestPaletteFailureDoesNotOverwriteNewerInteraction(t *testing.T) { | ||
| 214 | p, release := newBlockingPTY(t) | 365 | p, release := newBlockingPTY(t) | ||
| 215 | defer release() | 366 | defer release() | ||
| @@ -223,7 +374,7 @@ func TestPaletteFailureDoesNotOverwriteNewerInteraction(t *testing.T) { | |||||
| 223 | ac.paletteFailure(staleGeneration, "JRS 1", "stale failure") | 374 | ac.paletteFailure(staleGeneration, "JRS 1", "stale failure") | ||
| 224 | 375 | ||||
| 225 | ac.overlays.paletteMu.Lock() | 376 | ac.overlays.paletteMu.Lock() | ||
| 226 | - | require.Empty(t, ac.overlays.paletteHints.Feedback) | 377 | + | require.Empty(t, ac.overlays.paletteFeedback) |
| 227 | ac.overlays.paletteMu.Unlock() | 378 | ac.overlays.paletteMu.Unlock() | ||
| 228 | awaitFrame(t, sends, ports.MsgOutput) | 379 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 229 | awaitFrame(t, sends, ports.MsgOutput) | 380 | awaitFrame(t, sends, ports.MsgOutput) | ||
| @@ -302,7 +453,9 @@ func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) { | |||||
| 302 | 453 | ||||
| 303 | d.handleInput(sess, ac, []byte("\x1b ")) | 454 | d.handleInput(sess, ac, []byte("\x1b ")) | ||
| 304 | awaitFrame(t, sends, ports.MsgOutput) | 455 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 305 | - | cmd, ok := ac.overlays.palette.Selected() | 456 | + | result, ok := ac.overlays.palette.Selected() |
| 457 | + | require.True(t, ok) | |||
| 458 | + | cmd, ok := result.Command() | |||
| 306 | require.True(t, ok) | 459 | require.True(t, ok) | ||
| 307 | require.Equal(t, "NXT", cmd.Code) | 460 | require.Equal(t, "NXT", cmd.Code) | ||
| 308 | } | 461 | } | ||
| @@ -413,13 +566,17 @@ func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) { | |||||
| 413 | 566 | ||||
| 414 | d.handlePaletteInput(ac, []byte{0x0e}) | 567 | d.handlePaletteInput(ac, []byte{0x0e}) | ||
| 415 | awaitFrame(t, sends, ports.MsgOutput) | 568 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 416 | - | cmd, ok := ac.overlays.palette.Selected() | 569 | + | result, ok := ac.overlays.palette.Selected() |
| 570 | + | require.True(t, ok) | |||
| 571 | + | cmd, ok := result.Command() | |||
| 417 | require.True(t, ok) | 572 | require.True(t, ok) | ||
| 418 | require.Equal(t, "CNS", cmd.Code) | 573 | require.Equal(t, "CNS", cmd.Code) | ||
| 419 | 574 | ||||
| 420 | d.handlePaletteInput(ac, []byte{0x10}) | 575 | d.handlePaletteInput(ac, []byte{0x10}) | ||
| 421 | awaitFrame(t, sends, ports.MsgOutput) | 576 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 422 | - | cmd, ok = ac.overlays.palette.Selected() | 577 | + | result, ok = ac.overlays.palette.Selected() |
| 578 | + | require.True(t, ok) | |||
| 579 | + | cmd, ok = result.Command() | |||
| 423 | require.True(t, ok) | 580 | require.True(t, ok) | ||
| 424 | require.Equal(t, "CNT", cmd.Code) | 581 | require.Equal(t, "CNT", cmd.Code) | ||
| 425 | } | 582 | } | ||
M internal/usecase/daemon/picker.go +136 -47
| @@ -49,6 +49,11 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||
| 49 | 49 | for _, s := range sessions { | |
| 50 | 50 | s.mu.Lock() | |
| 51 | 51 | view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))} | |
| 52 | + | if !s.ephemeral { | |
| 53 | + | createdAt := s.createdAt | |
| 54 | + | view.TargetName = s.name | |
| 55 | + | view.ExpectedCreatedAt = &createdAt | |
| 56 | + | } | |
| 52 | 57 | sessionAttention := false | |
| 53 | 58 | for i, tb := range s.tabs { | |
| 54 | 59 | name := tabDisplayName(tb, i) | |
| @@ -71,11 +76,14 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||
| 71 | 76 | views = append(views, view) | |
| 72 | 77 | } | |
| 73 | 78 | for _, s := range stopped { | |
| 79 | + | createdAt := s.createdAt | |
| 74 | 80 | views = append(views, picker.SessionView{ | |
| 75 | - | ID: domain.SessionID("stopped:" + s.name), | |
| 76 | - | Name: s.name, | |
| 77 | - | Tabs: []picker.TabEntry{{}}, | |
| 78 | - | Stopped: true, | |
| 81 | + | ID: domain.SessionID("stopped:" + s.name), | |
| 82 | + | Name: s.name, | |
| 83 | + | TargetName: s.name, | |
| 84 | + | Tabs: []picker.TabEntry{{}}, | |
| 85 | + | Stopped: true, | |
| 86 | + | ExpectedCreatedAt: &createdAt, | |
| 79 | 87 | }) | |
| 80 | 88 | } | |
| 81 | 89 | return views, curTab | |
| @@ -383,48 +391,102 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session { | |||
| 383 | 391 | return d.sessions[id] | |
| 384 | 392 | } | |
| 385 | 393 | ||
| 386 | - | func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) bool { | |
| 387 | - | if target.Stopped { | |
| 388 | - | return d.resumeStoppedAndSwitch(sess, ac, target) | |
| 389 | - | } | |
| 390 | - | targetSess := d.sessionByID(target.Session) | |
| 391 | - | if targetSess == nil { | |
| 392 | - | d.invalidateRender(sess, ac, true, "picker.go") | |
| 393 | - | return false | |
| 394 | - | } | |
| 395 | - | if targetSess == sess { | |
| 396 | - | if sess.switchTab(target.TabIndex) { | |
| 397 | - | d.activateTab(sess, sess.activeTab()) | |
| 394 | + | // switchToTarget resolves named lifecycle targets and commits their transition | |
| 395 | + | // while d.mu is held. A named palette result is allowed to cross an | |
| 396 | + | // active/stopped transition, but it never follows a same-name replacement. | |
| 397 | + | func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) bool { | |
| 398 | + | d.mu.Lock() | |
| 399 | + | var ( | |
| 400 | + | targetSess *session | |
| 401 | + | old *attachedClient | |
| 402 | + | cleanups []renderLifecycleCleanup | |
| 403 | + | switched bool | |
| 404 | + | ) | |
| 405 | + | if target.Name != "" { | |
| 406 | + | active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target) | |
| 407 | + | if ok { | |
| 408 | + | if isStopped { | |
| 409 | + | targetSess, cleanups, switched = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped) | |
| 410 | + | } else { | |
| 411 | + | // The snapshot ID may describe the stopped representation. The | |
| 412 | + | // locked name/lifecycle resolver chose this active incarnation. | |
| 413 | + | resolvedTarget := target | |
| 414 | + | resolvedTarget.Session = active.id | |
| 415 | + | targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget) | |
| 416 | + | } | |
| 398 | 417 | } | |
| 399 | - | d.invalidateRender(sess, ac, true, "picker.go") | |
| 400 | - | return true | |
| 418 | + | } else if active := d.sessions[target.Session]; active != nil { | |
| 419 | + | targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, target) | |
| 401 | 420 | } | |
| 402 | - | old := d.stealClientForTarget(sess, ac, targetSess, target) | |
| 403 | - | if ac.currentSession() != targetSess { | |
| 404 | - | d.invalidateRender(sess, ac, true, "picker.go") | |
| 421 | + | d.mu.Unlock() | |
| 422 | + | ||
| 423 | + | if !switched { | |
| 424 | + | d.invalidateRender(from, ac, true, "picker.go") | |
| 405 | 425 | return false | |
| 406 | 426 | } | |
| 427 | + | finishRenderLifecycleCleanups(cleanups) | |
| 407 | 428 | if old != nil && old != ac { | |
| 408 | 429 | d.unregisterPreview(old) | |
| 409 | 430 | old.clearPreviousSession() | |
| 410 | 431 | old.setSession(nil) | |
| 411 | 432 | d.notifyDetachedAsync(old, ports.ReasonDetach) | |
| 412 | 433 | } | |
| 413 | - | d.firstPaint(targetSess, ac, ac.size) | |
| 434 | + | if targetSess == from { | |
| 435 | + | d.activateTab(from, from.activeTab()) | |
| 436 | + | d.invalidateRender(from, ac, true, "picker.go") | |
| 437 | + | } else { | |
| 438 | + | d.firstPaint(targetSess, ac, ac.size) | |
| 439 | + | } | |
| 414 | 440 | return true | |
| 415 | 441 | } | |
| 416 | 442 | ||
| 417 | - | func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient { | |
| 418 | - | d.mu.Lock() | |
| 443 | + | // resolveNamedLifecycleTargetLocked chooses exactly one current representation | |
| 444 | + | // of a named lifecycle. Caller holds d.mu. Matching the name and lifecycle | |
| 445 | + | // identity here closes the lookup-to-handoff window for palette targets. | |
| 446 | + | func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*session, stoppedSession, bool, bool) { | |
| 447 | + | if target.Name == "" { | |
| 448 | + | return nil, stoppedSession{}, false, false | |
| 449 | + | } | |
| 450 | + | if active := d.findByNameLocked(target.Name); active != nil { | |
| 451 | + | active.mu.Lock() | |
| 452 | + | matches := targetMatchesLifecycle(target, active.name, active.createdAt) | |
| 453 | + | active.mu.Unlock() | |
| 454 | + | return active, stoppedSession{}, false, matches | |
| 455 | + | } | |
| 456 | + | stopped, ok := d.stopped[target.Name] | |
| 457 | + | if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt) { | |
| 458 | + | return nil, stoppedSession{}, false, false | |
| 459 | + | } | |
| 460 | + | return nil, stopped, true, true | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | // switchToActiveTargetLocked commits an active target handoff. Caller holds | |
| 464 | + | // d.mu and releases it before completing render cleanup or first paint. | |
| 465 | + | func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target) (*session, *attachedClient, []renderLifecycleCleanup, bool) { | |
| 419 | 466 | if d.sessions[target.Session] != targetSess { | |
| 420 | - | d.mu.Unlock() | |
| 421 | - | return nil | |
| 467 | + | return nil, nil, nil, false | |
| 468 | + | } | |
| 469 | + | if targetSess == from { | |
| 470 | + | targetSess.mu.Lock() | |
| 471 | + | defer targetSess.mu.Unlock() | |
| 472 | + | if targetSess.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) { | |
| 473 | + | return nil, nil, nil, false | |
| 474 | + | } | |
| 475 | + | targetSess.active = target.TabIndex | |
| 476 | + | return targetSess, nil, nil, true | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | targetSess.mu.Lock() | |
| 480 | + | matches := targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) | |
| 481 | + | if !matches { | |
| 482 | + | targetSess.mu.Unlock() | |
| 483 | + | return nil, nil, nil, false | |
| 422 | 484 | } | |
| 423 | 485 | from.mu.Lock() | |
| 424 | 486 | if from.client != ac { | |
| 425 | 487 | from.mu.Unlock() | |
| 426 | - | d.mu.Unlock() | |
| 427 | - | return nil | |
| 488 | + | targetSess.mu.Unlock() | |
| 489 | + | return nil, nil, nil, false | |
| 428 | 490 | } | |
| 429 | 491 | term := from.terminal | |
| 430 | 492 | env := copyEnvironment(from.env) | |
| @@ -432,7 +494,6 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||
| 432 | 494 | ac.setSession(nil) | |
| 433 | 495 | from.mu.Unlock() | |
| 434 | 496 | ||
| 435 | - | targetSess.mu.Lock() | |
| 436 | 497 | old := targetSess.client | |
| 437 | 498 | targetSess.terminal = term | |
| 438 | 499 | targetSess.env = copyEnvironment(env) | |
| @@ -441,8 +502,8 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||
| 441 | 502 | } | |
| 442 | 503 | targetSess.mu.Unlock() | |
| 443 | 504 | ||
| 444 | - | // Do not expose targetSess.client until the target coordinator has claimed | |
| 445 | - | // this attachment and the output dependency chain has been rebased. | |
| 505 | + | // Prepare source invalidation, output rebase, and destination coordinator | |
| 506 | + | // identity before publishing the destination attachment. | |
| 446 | 507 | cleanups := d.handoffCoordinator(from, targetSess, old, ac) | |
| 447 | 508 | ac.setSession(targetSess) | |
| 448 | 509 | targetSess.mu.Lock() | |
| @@ -450,24 +511,17 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||
| 450 | 511 | targetSess.mu.Unlock() | |
| 451 | 512 | d.touchMRU(targetSess) | |
| 452 | 513 | ac.recordPreviousSession(from) | |
| 453 | - | d.mu.Unlock() | |
| 454 | - | finishRenderLifecycleCleanups(cleanups) | |
| 455 | - | return old | |
| 514 | + | return targetSess, old, cleanups, true | |
| 456 | 515 | } | |
| 457 | 516 | ||
| 458 | - | func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool { | |
| 459 | - | d.mu.Lock() | |
| 460 | - | stopped, ok := d.stopped[target.Name] | |
| 461 | - | if !ok || stopped.purging { | |
| 462 | - | d.mu.Unlock() | |
| 463 | - | d.invalidateRender(from, ac, true, "picker.go") | |
| 464 | - | return false | |
| 465 | - | } | |
| 517 | + | // resumeStoppedAndSwitchLocked creates the stopped representation and commits | |
| 518 | + | // the handoff while d.mu is held. Creation failure leaves the source client | |
| 519 | + | // and stopped record untouched. | |
| 520 | + | func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool) { | |
| 466 | 521 | from.mu.Lock() | |
| 467 | 522 | if from.client != ac { | |
| 468 | 523 | from.mu.Unlock() | |
| 469 | - | d.mu.Unlock() | |
| 470 | - | return false | |
| 524 | + | return nil, nil, false | |
| 471 | 525 | } | |
| 472 | 526 | term := from.terminal | |
| 473 | 527 | env := copyEnvironment(from.env) | |
| @@ -475,10 +529,8 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 475 | 529 | targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, stopped.tabNames) | |
| 476 | 530 | if err != nil { | |
| 477 | 531 | from.mu.Unlock() | |
| 478 | - | d.mu.Unlock() | |
| 479 | 532 | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | |
| 480 | - | d.invalidateRender(from, ac, true, "picker.go") | |
| 481 | - | return false | |
| 533 | + | return nil, nil, false | |
| 482 | 534 | } | |
| 483 | 535 | from.client = nil | |
| 484 | 536 | ac.setSession(nil) | |
| @@ -491,7 +543,44 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 491 | 543 | targetSess.mu.Unlock() | |
| 492 | 544 | d.touchMRU(targetSess) | |
| 493 | 545 | ac.recordPreviousSession(from) | |
| 546 | + | return targetSess, cleanups, true | |
| 547 | + | } | |
| 548 | + | ||
| 549 | + | func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool { | |
| 550 | + | return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt) | |
| 551 | + | } | |
| 552 | + | ||
| 553 | + | // stealClientForTarget is retained for direct-ID callers and tests. Named | |
| 554 | + | // targets must use switchToTarget so resolution and commit share d.mu. | |
| 555 | + | func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient { | |
| 556 | + | d.mu.Lock() | |
| 557 | + | _, old, cleanups, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target) | |
| 558 | + | d.mu.Unlock() | |
| 559 | + | if !switched { | |
| 560 | + | return nil | |
| 561 | + | } | |
| 562 | + | finishRenderLifecycleCleanups(cleanups) | |
| 563 | + | return old | |
| 564 | + | } | |
| 565 | + | ||
| 566 | + | // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves | |
| 567 | + | // its stopped target and commits creation under one d.mu critical section. | |
| 568 | + | func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool { | |
| 569 | + | d.mu.Lock() | |
| 570 | + | stopped, ok := d.stopped[target.Name] | |
| 571 | + | var ( | |
| 572 | + | targetSess *session | |
| 573 | + | cleanups []renderLifecycleCleanup | |
| 574 | + | switched bool | |
| 575 | + | ) | |
| 576 | + | if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) { | |
| 577 | + | targetSess, cleanups, switched = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped) | |
| 578 | + | } | |
| 494 | 579 | d.mu.Unlock() | |
| 580 | + | if !switched { | |
| 581 | + | d.invalidateRender(from, ac, true, "picker.go") | |
| 582 | + | return false | |
| 583 | + | } | |
| 495 | 584 | finishRenderLifecycleCleanups(cleanups) | |
| 496 | 585 | d.firstPaint(targetSess, ac, ac.size) | |
| 497 | 586 | return true | |
| @@ -49,6 +49,11 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||||
| 49 | for _, s := range sessions { | 49 | for _, s := range sessions { | ||
| 50 | s.mu.Lock() | 50 | s.mu.Lock() | ||
| 51 | view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))} | 51 | view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))} | ||
| 52 | + | if !s.ephemeral { | |||
| 53 | + | createdAt := s.createdAt | |||
| 54 | + | view.TargetName = s.name | |||
| 55 | + | view.ExpectedCreatedAt = &createdAt | |||
| 56 | + | } | |||
| 52 | sessionAttention := false | 57 | sessionAttention := false | ||
| 53 | for i, tb := range s.tabs { | 58 | for i, tb := range s.tabs { | ||
| 54 | name := tabDisplayName(tb, i) | 59 | name := tabDisplayName(tb, i) | ||
| @@ -71,11 +76,14 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||||
| 71 | views = append(views, view) | 76 | views = append(views, view) | ||
| 72 | } | 77 | } | ||
| 73 | for _, s := range stopped { | 78 | for _, s := range stopped { | ||
| 79 | + | createdAt := s.createdAt | |||
| 74 | views = append(views, picker.SessionView{ | 80 | views = append(views, picker.SessionView{ | ||
| 75 | - | ID: domain.SessionID("stopped:" + s.name), | 81 | + | ID: domain.SessionID("stopped:" + s.name), |
| 76 | - | Name: s.name, | 82 | + | Name: s.name, |
| 77 | - | Tabs: []picker.TabEntry{{}}, | 83 | + | TargetName: s.name, |
| 78 | - | Stopped: true, | 84 | + | Tabs: []picker.TabEntry{{}}, |
| 85 | + | Stopped: true, | |||
| 86 | + | ExpectedCreatedAt: &createdAt, | |||
| 79 | }) | 87 | }) | ||
| 80 | } | 88 | } | ||
| 81 | return views, curTab | 89 | return views, curTab | ||
| @@ -383,48 +391,102 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session { | |||||
| 383 | return d.sessions[id] | 391 | return d.sessions[id] | ||
| 384 | } | 392 | } | ||
| 385 | 393 | ||||
| 386 | - | func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) bool { | 394 | + | // switchToTarget resolves named lifecycle targets and commits their transition |
| 387 | - | if target.Stopped { | 395 | + | // while d.mu is held. A named palette result is allowed to cross an |
| 388 | - | return d.resumeStoppedAndSwitch(sess, ac, target) | 396 | + | // active/stopped transition, but it never follows a same-name replacement. |
| 389 | - | } | 397 | + | func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) bool { |
| 390 | - | targetSess := d.sessionByID(target.Session) | 398 | + | d.mu.Lock() |
| 391 | - | if targetSess == nil { | 399 | + | var ( |
| 392 | - | d.invalidateRender(sess, ac, true, "picker.go") | 400 | + | targetSess *session |
| 393 | - | return false | 401 | + | old *attachedClient |
| 394 | - | } | 402 | + | cleanups []renderLifecycleCleanup |
| 395 | - | if targetSess == sess { | 403 | + | switched bool |
| 396 | - | if sess.switchTab(target.TabIndex) { | 404 | + | ) |
| 397 | - | d.activateTab(sess, sess.activeTab()) | 405 | + | if target.Name != "" { |
| 406 | + | active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target) | |||
| 407 | + | if ok { | |||
| 408 | + | if isStopped { | |||
| 409 | + | targetSess, cleanups, switched = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped) | |||
| 410 | + | } else { | |||
| 411 | + | // The snapshot ID may describe the stopped representation. The | |||
| 412 | + | // locked name/lifecycle resolver chose this active incarnation. | |||
| 413 | + | resolvedTarget := target | |||
| 414 | + | resolvedTarget.Session = active.id | |||
| 415 | + | targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget) | |||
| 416 | + | } | |||
| 398 | } | 417 | } | ||
| 399 | - | d.invalidateRender(sess, ac, true, "picker.go") | 418 | + | } else if active := d.sessions[target.Session]; active != nil { |
| 400 | - | return true | 419 | + | targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, target) |
| 401 | } | 420 | } | ||
| 402 | - | old := d.stealClientForTarget(sess, ac, targetSess, target) | 421 | + | d.mu.Unlock() |
| 403 | - | if ac.currentSession() != targetSess { | 422 | + | |
| 404 | - | d.invalidateRender(sess, ac, true, "picker.go") | 423 | + | if !switched { |
| 424 | + | d.invalidateRender(from, ac, true, "picker.go") | |||
| 405 | return false | 425 | return false | ||
| 406 | } | 426 | } | ||
| 427 | + | finishRenderLifecycleCleanups(cleanups) | |||
| 407 | if old != nil && old != ac { | 428 | if old != nil && old != ac { | ||
| 408 | d.unregisterPreview(old) | 429 | d.unregisterPreview(old) | ||
| 409 | old.clearPreviousSession() | 430 | old.clearPreviousSession() | ||
| 410 | old.setSession(nil) | 431 | old.setSession(nil) | ||
| 411 | d.notifyDetachedAsync(old, ports.ReasonDetach) | 432 | d.notifyDetachedAsync(old, ports.ReasonDetach) | ||
| 412 | } | 433 | } | ||
| 413 | - | d.firstPaint(targetSess, ac, ac.size) | 434 | + | if targetSess == from { |
| 435 | + | d.activateTab(from, from.activeTab()) | |||
| 436 | + | d.invalidateRender(from, ac, true, "picker.go") | |||
| 437 | + | } else { | |||
| 438 | + | d.firstPaint(targetSess, ac, ac.size) | |||
| 439 | + | } | |||
| 414 | return true | 440 | return true | ||
| 415 | } | 441 | } | ||
| 416 | 442 | ||||
| 417 | - | func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient { | 443 | + | // resolveNamedLifecycleTargetLocked chooses exactly one current representation |
| 418 | - | d.mu.Lock() | 444 | + | // of a named lifecycle. Caller holds d.mu. Matching the name and lifecycle |
| 445 | + | // identity here closes the lookup-to-handoff window for palette targets. | |||
| 446 | + | func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*session, stoppedSession, bool, bool) { | |||
| 447 | + | if target.Name == "" { | |||
| 448 | + | return nil, stoppedSession{}, false, false | |||
| 449 | + | } | |||
| 450 | + | if active := d.findByNameLocked(target.Name); active != nil { | |||
| 451 | + | active.mu.Lock() | |||
| 452 | + | matches := targetMatchesLifecycle(target, active.name, active.createdAt) | |||
| 453 | + | active.mu.Unlock() | |||
| 454 | + | return active, stoppedSession{}, false, matches | |||
| 455 | + | } | |||
| 456 | + | stopped, ok := d.stopped[target.Name] | |||
| 457 | + | if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt) { | |||
| 458 | + | return nil, stoppedSession{}, false, false | |||
| 459 | + | } | |||
| 460 | + | return nil, stopped, true, true | |||
| 461 | + | } | |||
| 462 | + | ||||
| 463 | + | // switchToActiveTargetLocked commits an active target handoff. Caller holds | |||
| 464 | + | // d.mu and releases it before completing render cleanup or first paint. | |||
| 465 | + | func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target) (*session, *attachedClient, []renderLifecycleCleanup, bool) { | |||
| 419 | if d.sessions[target.Session] != targetSess { | 466 | if d.sessions[target.Session] != targetSess { | ||
| 420 | - | d.mu.Unlock() | 467 | + | return nil, nil, nil, false |
| 421 | - | return nil | 468 | + | } |
| 469 | + | if targetSess == from { | |||
| 470 | + | targetSess.mu.Lock() | |||
| 471 | + | defer targetSess.mu.Unlock() | |||
| 472 | + | if targetSess.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) { | |||
| 473 | + | return nil, nil, nil, false | |||
| 474 | + | } | |||
| 475 | + | targetSess.active = target.TabIndex | |||
| 476 | + | return targetSess, nil, nil, true | |||
| 477 | + | } | |||
| 478 | + | ||||
| 479 | + | targetSess.mu.Lock() | |||
| 480 | + | matches := targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) | |||
| 481 | + | if !matches { | |||
| 482 | + | targetSess.mu.Unlock() | |||
| 483 | + | return nil, nil, nil, false | |||
| 422 | } | 484 | } | ||
| 423 | from.mu.Lock() | 485 | from.mu.Lock() | ||
| 424 | if from.client != ac { | 486 | if from.client != ac { | ||
| 425 | from.mu.Unlock() | 487 | from.mu.Unlock() | ||
| 426 | - | d.mu.Unlock() | 488 | + | targetSess.mu.Unlock() |
| 427 | - | return nil | 489 | + | return nil, nil, nil, false |
| 428 | } | 490 | } | ||
| 429 | term := from.terminal | 491 | term := from.terminal | ||
| 430 | env := copyEnvironment(from.env) | 492 | env := copyEnvironment(from.env) | ||
| @@ -432,7 +494,6 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||||
| 432 | ac.setSession(nil) | 494 | ac.setSession(nil) | ||
| 433 | from.mu.Unlock() | 495 | from.mu.Unlock() | ||
| 434 | 496 | ||||
| 435 | - | targetSess.mu.Lock() | |||
| 436 | old := targetSess.client | 497 | old := targetSess.client | ||
| 437 | targetSess.terminal = term | 498 | targetSess.terminal = term | ||
| 438 | targetSess.env = copyEnvironment(env) | 499 | targetSess.env = copyEnvironment(env) | ||
| @@ -441,8 +502,8 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||||
| 441 | } | 502 | } | ||
| 442 | targetSess.mu.Unlock() | 503 | targetSess.mu.Unlock() | ||
| 443 | 504 | ||||
| 444 | - | // Do not expose targetSess.client until the target coordinator has claimed | 505 | + | // Prepare source invalidation, output rebase, and destination coordinator |
| 445 | - | // this attachment and the output dependency chain has been rebased. | 506 | + | // identity before publishing the destination attachment. |
| 446 | cleanups := d.handoffCoordinator(from, targetSess, old, ac) | 507 | cleanups := d.handoffCoordinator(from, targetSess, old, ac) | ||
| 447 | ac.setSession(targetSess) | 508 | ac.setSession(targetSess) | ||
| 448 | targetSess.mu.Lock() | 509 | targetSess.mu.Lock() | ||
| @@ -450,24 +511,17 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||||
| 450 | targetSess.mu.Unlock() | 511 | targetSess.mu.Unlock() | ||
| 451 | d.touchMRU(targetSess) | 512 | d.touchMRU(targetSess) | ||
| 452 | ac.recordPreviousSession(from) | 513 | ac.recordPreviousSession(from) | ||
| 453 | - | d.mu.Unlock() | 514 | + | return targetSess, old, cleanups, true |
| 454 | - | finishRenderLifecycleCleanups(cleanups) | |||
| 455 | - | return old | |||
| 456 | } | 515 | } | ||
| 457 | 516 | ||||
| 458 | - | func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool { | 517 | + | // resumeStoppedAndSwitchLocked creates the stopped representation and commits |
| 459 | - | d.mu.Lock() | 518 | + | // the handoff while d.mu is held. Creation failure leaves the source client |
| 460 | - | stopped, ok := d.stopped[target.Name] | 519 | + | // and stopped record untouched. |
| 461 | - | if !ok || stopped.purging { | 520 | + | func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool) { |
| 462 | - | d.mu.Unlock() | |||
| 463 | - | d.invalidateRender(from, ac, true, "picker.go") | |||
| 464 | - | return false | |||
| 465 | - | } | |||
| 466 | from.mu.Lock() | 521 | from.mu.Lock() | ||
| 467 | if from.client != ac { | 522 | if from.client != ac { | ||
| 468 | from.mu.Unlock() | 523 | from.mu.Unlock() | ||
| 469 | - | d.mu.Unlock() | 524 | + | return nil, nil, false |
| 470 | - | return false | |||
| 471 | } | 525 | } | ||
| 472 | term := from.terminal | 526 | term := from.terminal | ||
| 473 | env := copyEnvironment(from.env) | 527 | env := copyEnvironment(from.env) | ||
| @@ -475,10 +529,8 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||||
| 475 | targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, stopped.tabNames) | 529 | targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, stopped.tabNames) | ||
| 476 | if err != nil { | 530 | if err != nil { | ||
| 477 | from.mu.Unlock() | 531 | from.mu.Unlock() | ||
| 478 | - | d.mu.Unlock() | |||
| 479 | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | 532 | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | ||
| 480 | - | d.invalidateRender(from, ac, true, "picker.go") | 533 | + | return nil, nil, false |
| 481 | - | return false | |||
| 482 | } | 534 | } | ||
| 483 | from.client = nil | 535 | from.client = nil | ||
| 484 | ac.setSession(nil) | 536 | ac.setSession(nil) | ||
| @@ -491,7 +543,44 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||||
| 491 | targetSess.mu.Unlock() | 543 | targetSess.mu.Unlock() | ||
| 492 | d.touchMRU(targetSess) | 544 | d.touchMRU(targetSess) | ||
| 493 | ac.recordPreviousSession(from) | 545 | ac.recordPreviousSession(from) | ||
| 546 | + | return targetSess, cleanups, true | |||
| 547 | + | } | |||
| 548 | + | ||||
| 549 | + | func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool { | |||
| 550 | + | return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt) | |||
| 551 | + | } | |||
| 552 | + | ||||
| 553 | + | // stealClientForTarget is retained for direct-ID callers and tests. Named | |||
| 554 | + | // targets must use switchToTarget so resolution and commit share d.mu. | |||
| 555 | + | func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient { | |||
| 556 | + | d.mu.Lock() | |||
| 557 | + | _, old, cleanups, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target) | |||
| 558 | + | d.mu.Unlock() | |||
| 559 | + | if !switched { | |||
| 560 | + | return nil | |||
| 561 | + | } | |||
| 562 | + | finishRenderLifecycleCleanups(cleanups) | |||
| 563 | + | return old | |||
| 564 | + | } | |||
| 565 | + | ||||
| 566 | + | // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves | |||
| 567 | + | // its stopped target and commits creation under one d.mu critical section. | |||
| 568 | + | func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool { | |||
| 569 | + | d.mu.Lock() | |||
| 570 | + | stopped, ok := d.stopped[target.Name] | |||
| 571 | + | var ( | |||
| 572 | + | targetSess *session | |||
| 573 | + | cleanups []renderLifecycleCleanup | |||
| 574 | + | switched bool | |||
| 575 | + | ) | |||
| 576 | + | if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) { | |||
| 577 | + | targetSess, cleanups, switched = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped) | |||
| 578 | + | } | |||
| 494 | d.mu.Unlock() | 579 | d.mu.Unlock() | ||
| 580 | + | if !switched { | |||
| 581 | + | d.invalidateRender(from, ac, true, "picker.go") | |||
| 582 | + | return false | |||
| 583 | + | } | |||
| 495 | finishRenderLifecycleCleanups(cleanups) | 584 | finishRenderLifecycleCleanups(cleanups) | ||
| 496 | d.firstPaint(targetSess, ac, ac.size) | 585 | d.firstPaint(targetSess, ac, ac.size) | ||
| 497 | return true | 586 | return true | ||
M internal/usecase/daemon/picker_test.go +82 -14
| @@ -67,6 +67,72 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) { | |||
| 67 | 67 | require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs) | |
| 68 | 68 | require.Equal(t, "beta ", views[1].Name) | |
| 69 | 69 | require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs) | |
| 70 | + | ||
| 71 | + | model := picker.New(views, current.id, 0) | |
| 72 | + | model.Down() | |
| 73 | + | model.Down() | |
| 74 | + | target, ok := model.Selected() | |
| 75 | + | require.True(t, ok) | |
| 76 | + | require.Equal(t, "beta", target.Name) | |
| 77 | + | require.NotNil(t, target.ExpectedCreatedAt) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) { | |
| 81 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 82 | + | ctx, cancel := context.WithCancel(d.serveCtx) | |
| 83 | + | defer cancel() | |
| 84 | + | active := &session{id: "active", name: "active", createdAt: 23, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | |
| 85 | + | d.sessions[active.id] = active | |
| 86 | + | d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 24} | |
| 87 | + | ||
| 88 | + | views, _ := d.pickerViews(active) | |
| 89 | + | ||
| 90 | + | require.Len(t, views, 2) | |
| 91 | + | require.NotNil(t, views[0].ExpectedCreatedAt) | |
| 92 | + | require.Equal(t, int64(23), *views[0].ExpectedCreatedAt) | |
| 93 | + | require.NotNil(t, views[1].ExpectedCreatedAt) | |
| 94 | + | require.Equal(t, int64(24), *views[1].ExpectedCreatedAt) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) { | |
| 98 | + | p, release := newBlockingPTY(t) | |
| 99 | + | defer release() | |
| 100 | + | d, current, ac, _ := newManualSessionWithPTYs(t, p) | |
| 101 | + | ctx, cancel := context.WithCancel(d.serveCtx) | |
| 102 | + | defer cancel() | |
| 103 | + | original := &session{id: "ephemeral-1", name: "2", ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | |
| 104 | + | d.sessions[original.id] = original | |
| 105 | + | ||
| 106 | + | views, _ := d.pickerViews(current) | |
| 107 | + | var originalView picker.SessionView | |
| 108 | + | for _, view := range views { | |
| 109 | + | if view.ID == original.id { | |
| 110 | + | originalView = view | |
| 111 | + | break | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | model := picker.New([]picker.SessionView{originalView}, original.id, 0) | |
| 115 | + | target, ok := model.Selected() | |
| 116 | + | require.True(t, ok) | |
| 117 | + | ||
| 118 | + | delete(d.sessions, original.id) | |
| 119 | + | replacement := &session{id: "ephemeral-2", name: original.name, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | |
| 120 | + | d.sessions[replacement.id] = replacement | |
| 121 | + | ||
| 122 | + | require.False(t, d.switchToTarget(current, ac, target)) | |
| 123 | + | require.Same(t, current, ac.currentSession()) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func TestSameSessionSwitchRejectsReplacedClient(t *testing.T) { | |
| 127 | + | p1, release1 := newBlockingPTY(t) | |
| 128 | + | p2, release2 := newBlockingPTY(t) | |
| 129 | + | defer release1() | |
| 130 | + | defer release2() | |
| 131 | + | d, current, ac, _ := newManualSessionWithPTYs(t, p1, p2) | |
| 132 | + | current.client = &attachedClient{} | |
| 133 | + | ||
| 134 | + | require.False(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1})) | |
| 135 | + | require.Equal(t, 0, current.active) | |
| 70 | 136 | } | |
| 71 | 137 | ||
| 72 | 138 | func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) { | |
| @@ -396,6 +462,10 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) { | |||
| 396 | 462 | tr.EXPECT().Close().Return(nil).Maybe() | |
| 397 | 463 | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 398 | 464 | ac.initOverlays() | |
| 465 | + | handoffAtSendMu := make(chan struct{}) | |
| 466 | + | ac.renderStages = renderStageHooks{ | |
| 467 | + | handoffRebase: func() { close(handoffAtSendMu) }, | |
| 468 | + | } | |
| 399 | 469 | sctx1, cancel1 := context.WithCancel(d.serveCtx) | |
| 400 | 470 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 401 | 471 | defer cancel1() | |
| @@ -403,6 +473,8 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) { | |||
| 403 | 473 | sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac} | |
| 404 | 474 | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | |
| 405 | 475 | ac.setSession(sess1) | |
| 476 | + | d.sessions[sess1.id] = sess1 | |
| 477 | + | d.sessions[sess2.id] = sess2 | |
| 406 | 478 | ||
| 407 | 479 | sess1.tabs[0].focusedPane().screen.Write([]byte("paint while switching")) | |
| 408 | 480 | paintDone := make(chan struct{}) | |
| @@ -414,27 +486,23 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) { | |||
| 414 | 486 | ||
| 415 | 487 | switchDone := make(chan struct{}) | |
| 416 | 488 | go func() { | |
| 417 | - | ac.setSession(sess2) | |
| 489 | + | cleanups := d.handoffCoordinator(sess1, sess2, nil, ac) | |
| 490 | + | finishRenderLifecycleCleanups(cleanups) | |
| 418 | 491 | close(switchDone) | |
| 419 | 492 | }() | |
| 493 | + | awaitTestCompletion(t, handoffAtSendMu, "session switch did not reach the handoff sendMu boundary") | |
| 494 | + | ||
| 495 | + | // The handoff is immediately about to acquire sendMu, which paint still owns | |
| 496 | + | // through its blocked transport send. | |
| 420 | 497 | select { | |
| 421 | 498 | case <-switchDone: | |
| 422 | - | t.Fatal("session switch completed while paint Send was still in flight") | |
| 423 | - | case <-time.After(50 * time.Millisecond): | |
| 499 | + | t.Fatal("session switch completed while paint owned sendMu") | |
| 500 | + | default: | |
| 424 | 501 | } | |
| 425 | 502 | ||
| 426 | 503 | close(releaseSend) | |
| 427 | - | select { | |
| 428 | - | case <-paintDone: | |
| 429 | - | case <-time.After(time.Second): | |
| 430 | - | t.Fatal("paint did not finish after Send was released") | |
| 431 | - | } | |
| 432 | - | select { | |
| 433 | - | case <-switchDone: | |
| 434 | - | case <-time.After(time.Second): | |
| 435 | - | t.Fatal("session switch did not complete after paint finished") | |
| 436 | - | } | |
| 437 | - | require.Same(t, sess2, ac.currentSession()) | |
| 504 | + | awaitTestCompletion(t, paintDone, "paint did not finish after Send was released") | |
| 505 | + | awaitTestCompletion(t, switchDone, "session handoff did not complete after paint finished") | |
| 438 | 506 | } | |
| 439 | 507 | ||
| 440 | 508 | func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) { | |
| @@ -67,6 +67,72 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) { | |||||
| 67 | require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs) | 67 | require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs) | ||
| 68 | require.Equal(t, "beta ", views[1].Name) | 68 | require.Equal(t, "beta ", views[1].Name) | ||
| 69 | require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs) | 69 | require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs) | ||
| 70 | + | ||||
| 71 | + | model := picker.New(views, current.id, 0) | |||
| 72 | + | model.Down() | |||
| 73 | + | model.Down() | |||
| 74 | + | target, ok := model.Selected() | |||
| 75 | + | require.True(t, ok) | |||
| 76 | + | require.Equal(t, "beta", target.Name) | |||
| 77 | + | require.NotNil(t, target.ExpectedCreatedAt) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) { | |||
| 81 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 82 | + | ctx, cancel := context.WithCancel(d.serveCtx) | |||
| 83 | + | defer cancel() | |||
| 84 | + | active := &session{id: "active", name: "active", createdAt: 23, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | |||
| 85 | + | d.sessions[active.id] = active | |||
| 86 | + | d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 24} | |||
| 87 | + | ||||
| 88 | + | views, _ := d.pickerViews(active) | |||
| 89 | + | ||||
| 90 | + | require.Len(t, views, 2) | |||
| 91 | + | require.NotNil(t, views[0].ExpectedCreatedAt) | |||
| 92 | + | require.Equal(t, int64(23), *views[0].ExpectedCreatedAt) | |||
| 93 | + | require.NotNil(t, views[1].ExpectedCreatedAt) | |||
| 94 | + | require.Equal(t, int64(24), *views[1].ExpectedCreatedAt) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) { | |||
| 98 | + | p, release := newBlockingPTY(t) | |||
| 99 | + | defer release() | |||
| 100 | + | d, current, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 101 | + | ctx, cancel := context.WithCancel(d.serveCtx) | |||
| 102 | + | defer cancel() | |||
| 103 | + | original := &session{id: "ephemeral-1", name: "2", ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | |||
| 104 | + | d.sessions[original.id] = original | |||
| 105 | + | ||||
| 106 | + | views, _ := d.pickerViews(current) | |||
| 107 | + | var originalView picker.SessionView | |||
| 108 | + | for _, view := range views { | |||
| 109 | + | if view.ID == original.id { | |||
| 110 | + | originalView = view | |||
| 111 | + | break | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | model := picker.New([]picker.SessionView{originalView}, original.id, 0) | |||
| 115 | + | target, ok := model.Selected() | |||
| 116 | + | require.True(t, ok) | |||
| 117 | + | ||||
| 118 | + | delete(d.sessions, original.id) | |||
| 119 | + | replacement := &session{id: "ephemeral-2", name: original.name, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | |||
| 120 | + | d.sessions[replacement.id] = replacement | |||
| 121 | + | ||||
| 122 | + | require.False(t, d.switchToTarget(current, ac, target)) | |||
| 123 | + | require.Same(t, current, ac.currentSession()) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func TestSameSessionSwitchRejectsReplacedClient(t *testing.T) { | |||
| 127 | + | p1, release1 := newBlockingPTY(t) | |||
| 128 | + | p2, release2 := newBlockingPTY(t) | |||
| 129 | + | defer release1() | |||
| 130 | + | defer release2() | |||
| 131 | + | d, current, ac, _ := newManualSessionWithPTYs(t, p1, p2) | |||
| 132 | + | current.client = &attachedClient{} | |||
| 133 | + | ||||
| 134 | + | require.False(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1})) | |||
| 135 | + | require.Equal(t, 0, current.active) | |||
| 70 | } | 136 | } | ||
| 71 | 137 | ||||
| 72 | func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) { | 138 | func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) { | ||
| @@ -396,6 +462,10 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) { | |||||
| 396 | tr.EXPECT().Close().Return(nil).Maybe() | 462 | tr.EXPECT().Close().Return(nil).Maybe() | ||
| 397 | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | 463 | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | ||
| 398 | ac.initOverlays() | 464 | ac.initOverlays() | ||
| 465 | + | handoffAtSendMu := make(chan struct{}) | |||
| 466 | + | ac.renderStages = renderStageHooks{ | |||
| 467 | + | handoffRebase: func() { close(handoffAtSendMu) }, | |||
| 468 | + | } | |||
| 399 | sctx1, cancel1 := context.WithCancel(d.serveCtx) | 469 | sctx1, cancel1 := context.WithCancel(d.serveCtx) | ||
| 400 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | 470 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | ||
| 401 | defer cancel1() | 471 | defer cancel1() | ||
| @@ -403,6 +473,8 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) { | |||||
| 403 | sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac} | 473 | sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac} | ||
| 404 | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | 474 | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | ||
| 405 | ac.setSession(sess1) | 475 | ac.setSession(sess1) | ||
| 476 | + | d.sessions[sess1.id] = sess1 | |||
| 477 | + | d.sessions[sess2.id] = sess2 | |||
| 406 | 478 | ||||
| 407 | sess1.tabs[0].focusedPane().screen.Write([]byte("paint while switching")) | 479 | sess1.tabs[0].focusedPane().screen.Write([]byte("paint while switching")) | ||
| 408 | paintDone := make(chan struct{}) | 480 | paintDone := make(chan struct{}) | ||
| @@ -414,27 +486,23 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) { | |||||
| 414 | 486 | ||||
| 415 | switchDone := make(chan struct{}) | 487 | switchDone := make(chan struct{}) | ||
| 416 | go func() { | 488 | go func() { | ||
| 417 | - | ac.setSession(sess2) | 489 | + | cleanups := d.handoffCoordinator(sess1, sess2, nil, ac) |
| 490 | + | finishRenderLifecycleCleanups(cleanups) | |||
| 418 | close(switchDone) | 491 | close(switchDone) | ||
| 419 | }() | 492 | }() | ||
| 493 | + | awaitTestCompletion(t, handoffAtSendMu, "session switch did not reach the handoff sendMu boundary") | |||
| 494 | + | ||||
| 495 | + | // The handoff is immediately about to acquire sendMu, which paint still owns | |||
| 496 | + | // through its blocked transport send. | |||
| 420 | select { | 497 | select { | ||
| 421 | case <-switchDone: | 498 | case <-switchDone: | ||
| 422 | - | t.Fatal("session switch completed while paint Send was still in flight") | 499 | + | t.Fatal("session switch completed while paint owned sendMu") |
| 423 | - | case <-time.After(50 * time.Millisecond): | 500 | + | default: |
| 424 | } | 501 | } | ||
| 425 | 502 | ||||
| 426 | close(releaseSend) | 503 | close(releaseSend) | ||
| 427 | - | select { | 504 | + | awaitTestCompletion(t, paintDone, "paint did not finish after Send was released") |
| 428 | - | case <-paintDone: | 505 | + | awaitTestCompletion(t, switchDone, "session handoff did not complete after paint finished") |
| 429 | - | case <-time.After(time.Second): | |||
| 430 | - | t.Fatal("paint did not finish after Send was released") | |||
| 431 | - | } | |||
| 432 | - | select { | |||
| 433 | - | case <-switchDone: | |||
| 434 | - | case <-time.After(time.Second): | |||
| 435 | - | t.Fatal("session switch did not complete after paint finished") | |||
| 436 | - | } | |||
| 437 | - | require.Same(t, sess2, ac.currentSession()) | |||
| 438 | } | 506 | } | ||
| 439 | 507 | ||||
| 440 | func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) { | 508 | func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) { | ||
M internal/usecase/daemon/render_pipeline.go +12 -10
| @@ -16,15 +16,17 @@ import ( | |||
| 16 | 16 | ||
| 17 | 17 | // composeCacheInput is an attachment-local value snapshot. composeFrame never | |
| 18 | 18 | // retains or accesses the attachment that owns it. | |
| 19 | - | // renderStageHooks are optional observability hooks. They are invoked only at | |
| 20 | - | // completed production boundaries: capture and compose after their result is | |
| 21 | - | // available, and emit after prepare plus transport success (including a | |
| 22 | - | // successful no-byte emission). Failed attempts count for capture/compose but | |
| 23 | - | // never for emit. | |
| 19 | + | // renderStageHooks are optional observability hooks. capture and compose run | |
| 20 | + | // after their result is available, and emit after prepare plus transport success | |
| 21 | + | // (including a successful no-byte emission); those hooks run while sendMu is | |
| 22 | + | // held. Failed attempts count for capture/compose but never for emit. | |
| 23 | + | // handoffRebase marks the boundary immediately before a handoff acquires sendMu | |
| 24 | + | // to rebase attachment-owned output state, so it intentionally runs unlocked. | |
| 24 | 25 | type renderStageHooks struct { | |
| 25 | - | capture func() | |
| 26 | - | compose func() | |
| 27 | - | emit func() | |
| 26 | + | capture func() | |
| 27 | + | compose func() | |
| 28 | + | emit func() | |
| 29 | + | handoffRebase func() | |
| 28 | 30 | } | |
| 29 | 31 | ||
| 30 | 32 | type composeCacheInput struct { | |
| @@ -206,8 +208,8 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||
| 206 | 208 | if snap.paletteHints != nil { | |
| 207 | 209 | guidance = snap.paletteHints.Feedback | |
| 208 | 210 | } | |
| 209 | - | o.paletteGuidance = guidance | |
| 210 | - | o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.selection, Description: styles.paletteDesc}, Guidance: guidance}) | |
| 211 | + | o.paletteGuidance = snap.paletteFeedback | |
| 212 | + | o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.selection, Description: styles.paletteDesc}, Guidance: guidance, Feedback: snap.paletteFeedback}) | |
| 211 | 213 | } | |
| 212 | 214 | if snap.promptActive && snap.promptModel != nil { | |
| 213 | 215 | o.prompt.modal = promptModalFor(snap.promptModel.Title()) | |
| @@ -16,15 +16,17 @@ import ( | |||||
| 16 | 16 | ||||
| 17 | // composeCacheInput is an attachment-local value snapshot. composeFrame never | 17 | // composeCacheInput is an attachment-local value snapshot. composeFrame never | ||
| 18 | // retains or accesses the attachment that owns it. | 18 | // retains or accesses the attachment that owns it. | ||
| 19 | - | // renderStageHooks are optional observability hooks. They are invoked only at | 19 | + | // renderStageHooks are optional observability hooks. capture and compose run |
| 20 | - | // completed production boundaries: capture and compose after their result is | 20 | + | // after their result is available, and emit after prepare plus transport success |
| 21 | - | // available, and emit after prepare plus transport success (including a | 21 | + | // (including a successful no-byte emission); those hooks run while sendMu is |
| 22 | - | // successful no-byte emission). Failed attempts count for capture/compose but | 22 | + | // held. Failed attempts count for capture/compose but never for emit. |
| 23 | - | // never for emit. | 23 | + | // handoffRebase marks the boundary immediately before a handoff acquires sendMu |
| 24 | + | // to rebase attachment-owned output state, so it intentionally runs unlocked. | |||
| 24 | type renderStageHooks struct { | 25 | type renderStageHooks struct { | ||
| 25 | - | capture func() | 26 | + | capture func() |
| 26 | - | compose func() | 27 | + | compose func() |
| 27 | - | emit func() | 28 | + | emit func() |
| 29 | + | handoffRebase func() | |||
| 28 | } | 30 | } | ||
| 29 | 31 | ||||
| 30 | type composeCacheInput struct { | 32 | type composeCacheInput struct { | ||
| @@ -206,8 +208,8 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||||
| 206 | if snap.paletteHints != nil { | 208 | if snap.paletteHints != nil { | ||
| 207 | guidance = snap.paletteHints.Feedback | 209 | guidance = snap.paletteHints.Feedback | ||
| 208 | } | 210 | } | ||
| 209 | - | o.paletteGuidance = guidance | 211 | + | o.paletteGuidance = snap.paletteFeedback |
| 210 | - | o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.selection, Description: styles.paletteDesc}, Guidance: guidance}) | 212 | + | o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.selection, Description: styles.paletteDesc}, Guidance: guidance, Feedback: snap.paletteFeedback}) |
| 211 | } | 213 | } | ||
| 212 | if snap.promptActive && snap.promptModel != nil { | 214 | if snap.promptActive && snap.promptModel != nil { | ||
| 213 | o.prompt.modal = promptModalFor(snap.promptModel.Title()) | 215 | o.prompt.modal = promptModalFor(snap.promptModel.Title()) | ||
M internal/usecase/daemon/session.go +30 -13
| @@ -131,6 +131,25 @@ func (d *Daemon) touchMRU(sess *session) { | |||
| 131 | 131 | ||
| 132 | 132 | func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) { | |
| 133 | 133 | env = copyEnvironment(env) | |
| 134 | + | stopped, resuming := d.stopped[name] | |
| 135 | + | var createdAt int64 | |
| 136 | + | if !ephemeral { | |
| 137 | + | if resuming { | |
| 138 | + | // A stopped session is the same lifecycle when resumed; it must retain | |
| 139 | + | // the persisted identity rather than receive a fresh timestamp. | |
| 140 | + | createdAt = stopped.createdAt | |
| 141 | + | if createdAt > d.lastAllocatedCreatedAt { | |
| 142 | + | d.lastAllocatedCreatedAt = createdAt | |
| 143 | + | } | |
| 144 | + | } else { | |
| 145 | + | var err error | |
| 146 | + | createdAt, err = d.allocateLifecycleCreatedAtLocked() | |
| 147 | + | if err != nil { | |
| 148 | + | return nil, err | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 134 | 153 | tbSize := tabSize(sz) | |
| 135 | 154 | var names []string | |
| 136 | 155 | if len(restoredTabNames) > 0 { | |
| @@ -159,17 +178,14 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 159 | 178 | ||
| 160 | 179 | id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | |
| 161 | 180 | d.nextID++ | |
| 162 | - | createdAt := time.Now().UnixNano() | |
| 163 | 181 | ||
| 164 | 182 | sctx, cancel := context.WithCancel(d.serveCtx) | |
| 165 | 183 | for _, tb := range tabs { | |
| 166 | 184 | tb.ctx, tb.cancel = context.WithCancel(sctx) | |
| 167 | 185 | } | |
| 168 | 186 | lastUsedSeq := uint64(0) | |
| 169 | - | if !ephemeral { | |
| 170 | - | if stopped, ok := d.stopped[name]; ok { | |
| 171 | - | lastUsedSeq = stopped.lastUsedSeq | |
| 172 | - | } | |
| 187 | + | if !ephemeral && resuming { | |
| 188 | + | lastUsedSeq = stopped.lastUsedSeq | |
| 173 | 189 | } | |
| 174 | 190 | sess := &session{ | |
| 175 | 191 | id: id, | |
| @@ -495,13 +511,17 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||
| 495 | 511 | oldName := sess.name | |
| 496 | 512 | wasEphemeral := sess.ephemeral | |
| 497 | 513 | createdAt := sess.createdAt | |
| 498 | - | if createdAt == 0 { | |
| 499 | - | createdAt = time.Now().UnixNano() | |
| 500 | - | sess.createdAt = createdAt | |
| 514 | + | if wasEphemeral { | |
| 515 | + | var err error | |
| 516 | + | createdAt, err = d.allocateLifecycleCreatedAtLocked() | |
| 517 | + | if err != nil { | |
| 518 | + | sess.mu.Unlock() | |
| 519 | + | return err | |
| 520 | + | } | |
| 501 | 521 | } | |
| 502 | 522 | lastUsedSeq := sess.mruAt.Load() | |
| 503 | 523 | if wasEphemeral || oldName != name { | |
| 504 | - | record := sess.persistRecordLocked(time.Now().UnixNano()) | |
| 524 | + | record := sess.persistRecordLocked(d.nowUnixNano()) | |
| 505 | 525 | record.Name = name | |
| 506 | 526 | record.CreatedAt = createdAt | |
| 507 | 527 | record.LastUsedSeq = lastUsedSeq | |
| @@ -531,6 +551,7 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||
| 531 | 551 | delete(d.stopped, oldName) | |
| 532 | 552 | delete(d.stopped, name) | |
| 533 | 553 | sess.name = name | |
| 554 | + | sess.createdAt = createdAt | |
| 534 | 555 | sess.ephemeral = false | |
| 535 | 556 | sess.snapEligible.Store(name != "") | |
| 536 | 557 | sess.mu.Unlock() | |
| @@ -572,10 +593,6 @@ func (d *Daemon) renameTab(sess *session, tb *tab, name string) error { | |||
| 572 | 593 | ||
| 573 | 594 | func (s *session) persistRecordLocked(updatedAt int64) persist.Record { | |
| 574 | 595 | createdAt := s.createdAt | |
| 575 | - | if createdAt == 0 { | |
| 576 | - | createdAt = updatedAt | |
| 577 | - | s.createdAt = createdAt | |
| 578 | - | } | |
| 579 | 596 | tabNames := make([]string, len(s.tabs)) | |
| 580 | 597 | lastCustom := -1 | |
| 581 | 598 | for i, tb := range s.tabs { | |
| @@ -131,6 +131,25 @@ func (d *Daemon) touchMRU(sess *session) { | |||||
| 131 | 131 | ||||
| 132 | func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) { | 132 | func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) { | ||
| 133 | env = copyEnvironment(env) | 133 | env = copyEnvironment(env) | ||
| 134 | + | stopped, resuming := d.stopped[name] | |||
| 135 | + | var createdAt int64 | |||
| 136 | + | if !ephemeral { | |||
| 137 | + | if resuming { | |||
| 138 | + | // A stopped session is the same lifecycle when resumed; it must retain | |||
| 139 | + | // the persisted identity rather than receive a fresh timestamp. | |||
| 140 | + | createdAt = stopped.createdAt | |||
| 141 | + | if createdAt > d.lastAllocatedCreatedAt { | |||
| 142 | + | d.lastAllocatedCreatedAt = createdAt | |||
| 143 | + | } | |||
| 144 | + | } else { | |||
| 145 | + | var err error | |||
| 146 | + | createdAt, err = d.allocateLifecycleCreatedAtLocked() | |||
| 147 | + | if err != nil { | |||
| 148 | + | return nil, err | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | ||||
| 134 | tbSize := tabSize(sz) | 153 | tbSize := tabSize(sz) | ||
| 135 | var names []string | 154 | var names []string | ||
| 136 | if len(restoredTabNames) > 0 { | 155 | if len(restoredTabNames) > 0 { | ||
| @@ -159,17 +178,14 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||||
| 159 | 178 | ||||
| 160 | id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | 179 | id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | ||
| 161 | d.nextID++ | 180 | d.nextID++ | ||
| 162 | - | createdAt := time.Now().UnixNano() | |||
| 163 | 181 | ||||
| 164 | sctx, cancel := context.WithCancel(d.serveCtx) | 182 | sctx, cancel := context.WithCancel(d.serveCtx) | ||
| 165 | for _, tb := range tabs { | 183 | for _, tb := range tabs { | ||
| 166 | tb.ctx, tb.cancel = context.WithCancel(sctx) | 184 | tb.ctx, tb.cancel = context.WithCancel(sctx) | ||
| 167 | } | 185 | } | ||
| 168 | lastUsedSeq := uint64(0) | 186 | lastUsedSeq := uint64(0) | ||
| 169 | - | if !ephemeral { | 187 | + | if !ephemeral && resuming { |
| 170 | - | if stopped, ok := d.stopped[name]; ok { | 188 | + | lastUsedSeq = stopped.lastUsedSeq |
| 171 | - | lastUsedSeq = stopped.lastUsedSeq | |||
| 172 | - | } | |||
| 173 | } | 189 | } | ||
| 174 | sess := &session{ | 190 | sess := &session{ | ||
| 175 | id: id, | 191 | id: id, | ||
| @@ -495,13 +511,17 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||||
| 495 | oldName := sess.name | 511 | oldName := sess.name | ||
| 496 | wasEphemeral := sess.ephemeral | 512 | wasEphemeral := sess.ephemeral | ||
| 497 | createdAt := sess.createdAt | 513 | createdAt := sess.createdAt | ||
| 498 | - | if createdAt == 0 { | 514 | + | if wasEphemeral { |
| 499 | - | createdAt = time.Now().UnixNano() | 515 | + | var err error |
| 500 | - | sess.createdAt = createdAt | 516 | + | createdAt, err = d.allocateLifecycleCreatedAtLocked() |
| 517 | + | if err != nil { | |||
| 518 | + | sess.mu.Unlock() | |||
| 519 | + | return err | |||
| 520 | + | } | |||
| 501 | } | 521 | } | ||
| 502 | lastUsedSeq := sess.mruAt.Load() | 522 | lastUsedSeq := sess.mruAt.Load() | ||
| 503 | if wasEphemeral || oldName != name { | 523 | if wasEphemeral || oldName != name { | ||
| 504 | - | record := sess.persistRecordLocked(time.Now().UnixNano()) | 524 | + | record := sess.persistRecordLocked(d.nowUnixNano()) |
| 505 | record.Name = name | 525 | record.Name = name | ||
| 506 | record.CreatedAt = createdAt | 526 | record.CreatedAt = createdAt | ||
| 507 | record.LastUsedSeq = lastUsedSeq | 527 | record.LastUsedSeq = lastUsedSeq | ||
| @@ -531,6 +551,7 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||||
| 531 | delete(d.stopped, oldName) | 551 | delete(d.stopped, oldName) | ||
| 532 | delete(d.stopped, name) | 552 | delete(d.stopped, name) | ||
| 533 | sess.name = name | 553 | sess.name = name | ||
| 554 | + | sess.createdAt = createdAt | |||
| 534 | sess.ephemeral = false | 555 | sess.ephemeral = false | ||
| 535 | sess.snapEligible.Store(name != "") | 556 | sess.snapEligible.Store(name != "") | ||
| 536 | sess.mu.Unlock() | 557 | sess.mu.Unlock() | ||
| @@ -572,10 +593,6 @@ func (d *Daemon) renameTab(sess *session, tb *tab, name string) error { | |||||
| 572 | 593 | ||||
| 573 | func (s *session) persistRecordLocked(updatedAt int64) persist.Record { | 594 | func (s *session) persistRecordLocked(updatedAt int64) persist.Record { | ||
| 574 | createdAt := s.createdAt | 595 | createdAt := s.createdAt | ||
| 575 | - | if createdAt == 0 { | |||
| 576 | - | createdAt = updatedAt | |||
| 577 | - | s.createdAt = createdAt | |||
| 578 | - | } | |||
| 579 | tabNames := make([]string, len(s.tabs)) | 596 | tabNames := make([]string, len(s.tabs)) | ||
| 580 | lastCustom := -1 | 597 | lastCustom := -1 | ||
| 581 | for i, tb := range s.tabs { | 598 | for i, tb := range s.tabs { | ||
M internal/usecase/daemon/session_test.go +248 -0
| @@ -2,8 +2,10 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "io" | |
| 6 | 7 | "log/slog" | |
| 8 | + | "math" | |
| 7 | 9 | "strings" | |
| 8 | 10 | "sync" | |
| 9 | 11 | "sync/atomic" | |
| @@ -1029,6 +1031,111 @@ func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) { | |||
| 1029 | 1031 | require.True(t, state.has("named")) | |
| 1030 | 1032 | } | |
| 1031 | 1033 | ||
| 1034 | + | func TestEphemeralPromotionAssignsAndPersistsLifecycleIdentity(t *testing.T) { | |
| 1035 | + | p, release := newBlockingPTY(t) | |
| 1036 | + | defer release() | |
| 1037 | + | store, state := newMockStore(t) | |
| 1038 | + | clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100)}} | |
| 1039 | + | d := newTestDaemon(t, newFactory(t, p), clock) | |
| 1040 | + | WithStore(store)(d) | |
| 1041 | + | ||
| 1042 | + | sess, err := d.createSessionLocked("0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv) | |
| 1043 | + | require.NoError(t, err) | |
| 1044 | + | require.Zero(t, sess.createdAt, "regression fixture: ephemerals have no lifecycle identity") | |
| 1045 | + | ||
| 1046 | + | require.NoError(t, d.renameSession(sess, "named")) | |
| 1047 | + | ||
| 1048 | + | require.False(t, sess.ephemeral) | |
| 1049 | + | require.Equal(t, "named", sess.name) | |
| 1050 | + | require.Equal(t, int64(100), sess.createdAt) | |
| 1051 | + | require.Equal(t, sess.createdAt, state.record(t, "named").CreatedAt) | |
| 1052 | + | } | |
| 1053 | + | ||
| 1054 | + | func TestEphemeralPromotionLifecyclePreventsStaleSameNamePaletteTarget(t *testing.T) { | |
| 1055 | + | fromPTY, releaseFrom := newBlockingPTY(t) | |
| 1056 | + | firstPTY, releaseFirst := newBlockingPTY(t) | |
| 1057 | + | secondPTY, releaseSecond := newBlockingPTY(t) | |
| 1058 | + | defer releaseFrom() | |
| 1059 | + | defer releaseFirst() | |
| 1060 | + | defer releaseSecond() | |
| 1061 | + | ||
| 1062 | + | d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY) | |
| 1063 | + | store, _ := newMockStore(t) | |
| 1064 | + | clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100)}} | |
| 1065 | + | d.clock = clock | |
| 1066 | + | d.ptys = newFactorySeq(t, firstPTY, secondPTY) | |
| 1067 | + | WithStore(store)(d) | |
| 1068 | + | ||
| 1069 | + | first, err := d.createSessionLocked("0", true, "/tmp", ac.size, terminalEnv{}, d.baseEnv) | |
| 1070 | + | require.NoError(t, err) | |
| 1071 | + | require.NoError(t, d.renameSession(first, "named")) | |
| 1072 | + | staleCreatedAt := first.createdAt | |
| 1073 | + | require.NotZero(t, staleCreatedAt) | |
| 1074 | + | require.NoError(t, d.killSession(first, ports.ReasonSessionKilled, true)) | |
| 1075 | + | d.sessWg.Wait() | |
| 1076 | + | ||
| 1077 | + | second, err := d.createSessionLocked("0", true, "/tmp", ac.size, terminalEnv{}, d.baseEnv) | |
| 1078 | + | require.NoError(t, err) | |
| 1079 | + | require.NoError(t, d.renameSession(second, "named")) | |
| 1080 | + | require.NotEqual(t, staleCreatedAt, second.createdAt) | |
| 1081 | + | ||
| 1082 | + | require.False(t, d.switchToTarget(from, ac, picker.Target{Name: "named", TabIndex: -1, ExpectedCreatedAt: &staleCreatedAt})) | |
| 1083 | + | require.Same(t, from, ac.currentSession()) | |
| 1084 | + | releaseSecond() | |
| 1085 | + | d.sessWg.Wait() | |
| 1086 | + | } | |
| 1087 | + | ||
| 1088 | + | func TestEphemeralPromotionLifecycleFailuresLeaveStateRollbackSafe(t *testing.T) { | |
| 1089 | + | t.Run("allocator exhaustion", func(t *testing.T) { | |
| 1090 | + | p, release := newBlockingPTY(t) | |
| 1091 | + | defer release() | |
| 1092 | + | store, state := newMockStore(t) | |
| 1093 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 1094 | + | WithStore(store)(d) | |
| 1095 | + | d.lastAllocatedCreatedAt = math.MaxInt64 | |
| 1096 | + | ||
| 1097 | + | sess, err := d.createSessionLocked("0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv) | |
| 1098 | + | require.NoError(t, err) | |
| 1099 | + | require.EqualError(t, d.renameSession(sess, "named"), "daemon: lifecycle identities exhausted") | |
| 1100 | + | require.Equal(t, "0", sess.name) | |
| 1101 | + | require.True(t, sess.ephemeral) | |
| 1102 | + | require.Zero(t, sess.createdAt) | |
| 1103 | + | require.Equal(t, int64(math.MaxInt64), d.lastAllocatedCreatedAt) | |
| 1104 | + | require.False(t, state.has("named")) | |
| 1105 | + | }) | |
| 1106 | + | ||
| 1107 | + | t.Run("persistence failure", func(t *testing.T) { | |
| 1108 | + | p, release := newBlockingPTY(t) | |
| 1109 | + | defer release() | |
| 1110 | + | store := portsmocks.NewMockStore(t) | |
| 1111 | + | var attempted map[string][]byte | |
| 1112 | + | store.EXPECT().Set(mock.Anything, mock.Anything).RunAndReturn(func(key, value []byte) error { | |
| 1113 | + | attempted = map[string][]byte{string(key): append([]byte(nil), value...)} | |
| 1114 | + | return errors.New("disk full") | |
| 1115 | + | }).Once() | |
| 1116 | + | clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100)}} | |
| 1117 | + | d := newTestDaemon(t, newFactory(t, p), clock) | |
| 1118 | + | WithStore(store)(d) | |
| 1119 | + | ||
| 1120 | + | sess, err := d.createSessionLocked("0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv) | |
| 1121 | + | require.NoError(t, err) | |
| 1122 | + | require.EqualError(t, d.renameSession(sess, "named"), "disk full") | |
| 1123 | + | require.Equal(t, "0", sess.name) | |
| 1124 | + | require.True(t, sess.ephemeral) | |
| 1125 | + | require.Zero(t, sess.createdAt) | |
| 1126 | + | require.Equal(t, int64(100), d.lastAllocatedCreatedAt, "a failed durable write may be ambiguous, so its lifecycle identity remains reserved") | |
| 1127 | + | records, err := persist.New(newStaticStore(attempted)).LoadAll() | |
| 1128 | + | require.NoError(t, err) | |
| 1129 | + | require.Len(t, records, 1) | |
| 1130 | + | require.Equal(t, int64(100), records[0].CreatedAt, "a promotion must never attempt to persist a zero lifecycle identity") | |
| 1131 | + | ||
| 1132 | + | store.EXPECT().Set(mock.Anything, mock.Anything).Return(nil).Once() | |
| 1133 | + | store.EXPECT().Sync().Return(nil).Once() | |
| 1134 | + | require.NoError(t, d.renameSession(sess, "named")) | |
| 1135 | + | require.Equal(t, int64(101), sess.createdAt, "the high-water mark must remain monotonic after a failed promotion") | |
| 1136 | + | }) | |
| 1137 | + | } | |
| 1138 | + | ||
| 1032 | 1139 | func TestRefreshSessionCwdTouchesOnlyOnChange(t *testing.T) { | |
| 1033 | 1140 | sz := domain.Size{Cols: 80, Rows: 24} | |
| 1034 | 1141 | p, release := newBlockingPTY(t) | |
| @@ -1498,3 +1605,144 @@ func TestShellFromEnvironmentFallsBackOnlyWhenAbsentOrEmpty(t *testing.T) { | |||
| 1498 | 1605 | }) | |
| 1499 | 1606 | } | |
| 1500 | 1607 | } | |
| 1608 | + | ||
| 1609 | + | type lifecycleClock struct { | |
| 1610 | + | nows []time.Time | |
| 1611 | + | next int | |
| 1612 | + | } | |
| 1613 | + | ||
| 1614 | + | func (c *lifecycleClock) Now() time.Time { | |
| 1615 | + | i := min(c.next, len(c.nows)-1) | |
| 1616 | + | c.next++ | |
| 1617 | + | return c.nows[i] | |
| 1618 | + | } | |
| 1619 | + | func (*lifecycleClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} } | |
| 1620 | + | ||
| 1621 | + | func TestNamedSessionLifecycleTimestampsAreMonotonicAcrossClockRegression(t *testing.T) { | |
| 1622 | + | ptys := make([]ports.PTY, 3) | |
| 1623 | + | var releases []func() | |
| 1624 | + | for i := range ptys { | |
| 1625 | + | p, release := newBlockingPTY(t) | |
| 1626 | + | ptys[i] = p | |
| 1627 | + | releases = append(releases, release) | |
| 1628 | + | } | |
| 1629 | + | defer releaseAll(releases) | |
| 1630 | + | clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100), time.Unix(0, 50)}} | |
| 1631 | + | d := newTestDaemon(t, newFactorySeq(t, ptys...), clock) | |
| 1632 | + | sz := domain.Size{Cols: 80, Rows: 24} | |
| 1633 | + | ||
| 1634 | + | var got []int64 | |
| 1635 | + | for _, name := range []string{"one", "two", "three"} { | |
| 1636 | + | sess, err := d.createSessionLocked(name, false, "/tmp", sz, terminalEnv{}, d.baseEnv) | |
| 1637 | + | require.NoError(t, err) | |
| 1638 | + | got = append(got, sess.createdAt) | |
| 1639 | + | } | |
| 1640 | + | require.Equal(t, []int64{100, 101, 102}, got) | |
| 1641 | + | } | |
| 1642 | + | ||
| 1643 | + | func TestNamedSessionLifecycleExhaustionDoesNotMutateSessionState(t *testing.T) { | |
| 1644 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 1645 | + | d.lastAllocatedCreatedAt = math.MaxInt64 | |
| 1646 | + | d.nextID = 17 | |
| 1647 | + | d.stopped["retained"] = stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9} | |
| 1648 | + | ||
| 1649 | + | sess, err := d.createSessionLocked("new", false, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv) | |
| 1650 | + | ||
| 1651 | + | require.ErrorContains(t, err, "lifecycle identities exhausted") | |
| 1652 | + | require.Nil(t, sess) | |
| 1653 | + | require.Empty(t, d.sessions) | |
| 1654 | + | require.Equal(t, uint64(17), d.nextID) | |
| 1655 | + | require.Equal(t, int64(math.MaxInt64), d.lastAllocatedCreatedAt) | |
| 1656 | + | require.Equal(t, stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9}, d.stopped["retained"]) | |
| 1657 | + | ||
| 1658 | + | _, _, err = d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "routed", Size: domain.Size{Cols: 80, Rows: 24}}, nil) | |
| 1659 | + | require.ErrorContains(t, err, "lifecycle identities exhausted") | |
| 1660 | + | require.Empty(t, d.sessions) | |
| 1661 | + | require.Equal(t, uint64(17), d.nextID) | |
| 1662 | + | require.Equal(t, stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9}, d.stopped["retained"]) | |
| 1663 | + | } | |
| 1664 | + | ||
| 1665 | + | func TestNamedSessionLifecycleTimestampStartsAfterPersistedHighWaterMark(t *testing.T) { | |
| 1666 | + | store, _ := newMockStore(t) | |
| 1667 | + | seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store)) | |
| 1668 | + | require.NoError(t, seed.persist.Save(persist.Record{Name: "old", Cwd: "/tmp", CreatedAt: 900, UpdatedAt: 900})) | |
| 1669 | + | ||
| 1670 | + | p, release := newBlockingPTY(t) | |
| 1671 | + | defer release() | |
| 1672 | + | clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100)}} | |
| 1673 | + | // Constructing with persistence must establish the lifecycle high-water mark. | |
| 1674 | + | d := New(newFactory(t, p), clock, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store)) | |
| 1675 | + | d.serveCtx, d.serveCancel = context.WithCancel(context.Background()) | |
| 1676 | + | t.Cleanup(d.serveCancel) | |
| 1677 | + | ||
| 1678 | + | sess, err := d.createSessionLocked("new", false, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv) | |
| 1679 | + | require.NoError(t, err) | |
| 1680 | + | require.Equal(t, int64(901), sess.createdAt) | |
| 1681 | + | } | |
| 1682 | + | ||
| 1683 | + | func TestResumingStoppedSessionPreservesLifecycleIdentityInPersistence(t *testing.T) { | |
| 1684 | + | fromPTY, releaseFrom := newBlockingPTY(t) | |
| 1685 | + | targetPTY, releaseTarget := newBlockingPTY(t) | |
| 1686 | + | defer releaseFrom() | |
| 1687 | + | defer releaseTarget() | |
| 1688 | + | d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY) | |
| 1689 | + | store, state := newMockStore(t) | |
| 1690 | + | WithStore(store)(d) | |
| 1691 | + | d.ptys = newFactory(t, targetPTY) | |
| 1692 | + | d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 77} | |
| 1693 | + | ||
| 1694 | + | require.True(t, d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "stopped", Stopped: true})) | |
| 1695 | + | resumed := ac.currentSession() | |
| 1696 | + | require.Equal(t, int64(77), resumed.createdAt) | |
| 1697 | + | require.Equal(t, int64(77), state.record(t, "stopped").CreatedAt) | |
| 1698 | + | } | |
| 1699 | + | ||
| 1700 | + | func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing.T) { | |
| 1701 | + | t.Run("active replacement is rejected", func(t *testing.T) { | |
| 1702 | + | d, from, ac, _, releases := newRecentNavigationTestSessions(t) | |
| 1703 | + | defer releaseAll(releases) | |
| 1704 | + | target := d.sessions["recent"] | |
| 1705 | + | target.createdAt = 22 | |
| 1706 | + | expected := int64(21) | |
| 1707 | + | ||
| 1708 | + | require.False(t, d.switchToTarget(from, ac, picker.Target{Session: target.id, Name: "recent", TabIndex: 0, ExpectedCreatedAt: &expected})) | |
| 1709 | + | require.Same(t, from, ac.currentSession()) | |
| 1710 | + | }) | |
| 1711 | + | ||
| 1712 | + | t.Run("active target that stopped resumes same lifecycle", func(t *testing.T) { | |
| 1713 | + | fromPTY, releaseFrom := newBlockingPTY(t) | |
| 1714 | + | targetPTY, releaseTarget := newBlockingPTY(t) | |
| 1715 | + | defer releaseFrom() | |
| 1716 | + | defer releaseTarget() | |
| 1717 | + | d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY) | |
| 1718 | + | d.ptys = newFactory(t, targetPTY) | |
| 1719 | + | expected := int64(31) | |
| 1720 | + | d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: expected} | |
| 1721 | + | ||
| 1722 | + | require.True(t, d.switchToTarget(from, ac, picker.Target{Session: "old-active-id", Name: "target", TabIndex: 0, ExpectedCreatedAt: &expected})) | |
| 1723 | + | require.Equal(t, "target", ac.currentSession().name) | |
| 1724 | + | require.Equal(t, expected, ac.currentSession().createdAt) | |
| 1725 | + | }) | |
| 1726 | + | ||
| 1727 | + | t.Run("stopped target that became active switches same lifecycle", func(t *testing.T) { | |
| 1728 | + | d, from, ac, _, releases := newRecentNavigationTestSessions(t) | |
| 1729 | + | defer releaseAll(releases) | |
| 1730 | + | target := d.sessions["recent"] | |
| 1731 | + | target.createdAt = 41 | |
| 1732 | + | expected := int64(41) | |
| 1733 | + | ||
| 1734 | + | require.True(t, d.switchToTarget(from, ac, picker.Target{Session: "stopped:recent", Name: "recent", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected})) | |
| 1735 | + | require.Same(t, target, ac.currentSession()) | |
| 1736 | + | }) | |
| 1737 | + | ||
| 1738 | + | t.Run("stopped deletion and recreation is rejected", func(t *testing.T) { | |
| 1739 | + | fromPTY, releaseFrom := newBlockingPTY(t) | |
| 1740 | + | defer releaseFrom() | |
| 1741 | + | d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY) | |
| 1742 | + | d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: 52} | |
| 1743 | + | expected := int64(51) | |
| 1744 | + | ||
| 1745 | + | require.False(t, d.switchToTarget(from, ac, picker.Target{Name: "target", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected})) | |
| 1746 | + | require.Same(t, from, ac.currentSession()) | |
| 1747 | + | }) | |
| 1748 | + | } | |
| @@ -2,8 +2,10 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "io" | 6 | "io" | ||
| 6 | "log/slog" | 7 | "log/slog" | ||
| 8 | + | "math" | |||
| 7 | "strings" | 9 | "strings" | ||
| 8 | "sync" | 10 | "sync" | ||
| 9 | "sync/atomic" | 11 | "sync/atomic" | ||
| @@ -1029,6 +1031,111 @@ func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) { | |||||
| 1029 | require.True(t, state.has("named")) | 1031 | require.True(t, state.has("named")) | ||
| 1030 | } | 1032 | } | ||
| 1031 | 1033 | ||||
| 1034 | + | func TestEphemeralPromotionAssignsAndPersistsLifecycleIdentity(t *testing.T) { | |||
| 1035 | + | p, release := newBlockingPTY(t) | |||
| 1036 | + | defer release() | |||
| 1037 | + | store, state := newMockStore(t) | |||
| 1038 | + | clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100)}} | |||
| 1039 | + | d := newTestDaemon(t, newFactory(t, p), clock) | |||
| 1040 | + | WithStore(store)(d) | |||
| 1041 | + | ||||
| 1042 | + | sess, err := d.createSessionLocked("0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv) | |||
| 1043 | + | require.NoError(t, err) | |||
| 1044 | + | require.Zero(t, sess.createdAt, "regression fixture: ephemerals have no lifecycle identity") | |||
| 1045 | + | ||||
| 1046 | + | require.NoError(t, d.renameSession(sess, "named")) | |||
| 1047 | + | ||||
| 1048 | + | require.False(t, sess.ephemeral) | |||
| 1049 | + | require.Equal(t, "named", sess.name) | |||
| 1050 | + | require.Equal(t, int64(100), sess.createdAt) | |||
| 1051 | + | require.Equal(t, sess.createdAt, state.record(t, "named").CreatedAt) | |||
| 1052 | + | } | |||
| 1053 | + | ||||
| 1054 | + | func TestEphemeralPromotionLifecyclePreventsStaleSameNamePaletteTarget(t *testing.T) { | |||
| 1055 | + | fromPTY, releaseFrom := newBlockingPTY(t) | |||
| 1056 | + | firstPTY, releaseFirst := newBlockingPTY(t) | |||
| 1057 | + | secondPTY, releaseSecond := newBlockingPTY(t) | |||
| 1058 | + | defer releaseFrom() | |||
| 1059 | + | defer releaseFirst() | |||
| 1060 | + | defer releaseSecond() | |||
| 1061 | + | ||||
| 1062 | + | d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY) | |||
| 1063 | + | store, _ := newMockStore(t) | |||
| 1064 | + | clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100)}} | |||
| 1065 | + | d.clock = clock | |||
| 1066 | + | d.ptys = newFactorySeq(t, firstPTY, secondPTY) | |||
| 1067 | + | WithStore(store)(d) | |||
| 1068 | + | ||||
| 1069 | + | first, err := d.createSessionLocked("0", true, "/tmp", ac.size, terminalEnv{}, d.baseEnv) | |||
| 1070 | + | require.NoError(t, err) | |||
| 1071 | + | require.NoError(t, d.renameSession(first, "named")) | |||
| 1072 | + | staleCreatedAt := first.createdAt | |||
| 1073 | + | require.NotZero(t, staleCreatedAt) | |||
| 1074 | + | require.NoError(t, d.killSession(first, ports.ReasonSessionKilled, true)) | |||
| 1075 | + | d.sessWg.Wait() | |||
| 1076 | + | ||||
| 1077 | + | second, err := d.createSessionLocked("0", true, "/tmp", ac.size, terminalEnv{}, d.baseEnv) | |||
| 1078 | + | require.NoError(t, err) | |||
| 1079 | + | require.NoError(t, d.renameSession(second, "named")) | |||
| 1080 | + | require.NotEqual(t, staleCreatedAt, second.createdAt) | |||
| 1081 | + | ||||
| 1082 | + | require.False(t, d.switchToTarget(from, ac, picker.Target{Name: "named", TabIndex: -1, ExpectedCreatedAt: &staleCreatedAt})) | |||
| 1083 | + | require.Same(t, from, ac.currentSession()) | |||
| 1084 | + | releaseSecond() | |||
| 1085 | + | d.sessWg.Wait() | |||
| 1086 | + | } | |||
| 1087 | + | ||||
| 1088 | + | func TestEphemeralPromotionLifecycleFailuresLeaveStateRollbackSafe(t *testing.T) { | |||
| 1089 | + | t.Run("allocator exhaustion", func(t *testing.T) { | |||
| 1090 | + | p, release := newBlockingPTY(t) | |||
| 1091 | + | defer release() | |||
| 1092 | + | store, state := newMockStore(t) | |||
| 1093 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |||
| 1094 | + | WithStore(store)(d) | |||
| 1095 | + | d.lastAllocatedCreatedAt = math.MaxInt64 | |||
| 1096 | + | ||||
| 1097 | + | sess, err := d.createSessionLocked("0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv) | |||
| 1098 | + | require.NoError(t, err) | |||
| 1099 | + | require.EqualError(t, d.renameSession(sess, "named"), "daemon: lifecycle identities exhausted") | |||
| 1100 | + | require.Equal(t, "0", sess.name) | |||
| 1101 | + | require.True(t, sess.ephemeral) | |||
| 1102 | + | require.Zero(t, sess.createdAt) | |||
| 1103 | + | require.Equal(t, int64(math.MaxInt64), d.lastAllocatedCreatedAt) | |||
| 1104 | + | require.False(t, state.has("named")) | |||
| 1105 | + | }) | |||
| 1106 | + | ||||
| 1107 | + | t.Run("persistence failure", func(t *testing.T) { | |||
| 1108 | + | p, release := newBlockingPTY(t) | |||
| 1109 | + | defer release() | |||
| 1110 | + | store := portsmocks.NewMockStore(t) | |||
| 1111 | + | var attempted map[string][]byte | |||
| 1112 | + | store.EXPECT().Set(mock.Anything, mock.Anything).RunAndReturn(func(key, value []byte) error { | |||
| 1113 | + | attempted = map[string][]byte{string(key): append([]byte(nil), value...)} | |||
| 1114 | + | return errors.New("disk full") | |||
| 1115 | + | }).Once() | |||
| 1116 | + | clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100)}} | |||
| 1117 | + | d := newTestDaemon(t, newFactory(t, p), clock) | |||
| 1118 | + | WithStore(store)(d) | |||
| 1119 | + | ||||
| 1120 | + | sess, err := d.createSessionLocked("0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv) | |||
| 1121 | + | require.NoError(t, err) | |||
| 1122 | + | require.EqualError(t, d.renameSession(sess, "named"), "disk full") | |||
| 1123 | + | require.Equal(t, "0", sess.name) | |||
| 1124 | + | require.True(t, sess.ephemeral) | |||
| 1125 | + | require.Zero(t, sess.createdAt) | |||
| 1126 | + | require.Equal(t, int64(100), d.lastAllocatedCreatedAt, "a failed durable write may be ambiguous, so its lifecycle identity remains reserved") | |||
| 1127 | + | records, err := persist.New(newStaticStore(attempted)).LoadAll() | |||
| 1128 | + | require.NoError(t, err) | |||
| 1129 | + | require.Len(t, records, 1) | |||
| 1130 | + | require.Equal(t, int64(100), records[0].CreatedAt, "a promotion must never attempt to persist a zero lifecycle identity") | |||
| 1131 | + | ||||
| 1132 | + | store.EXPECT().Set(mock.Anything, mock.Anything).Return(nil).Once() | |||
| 1133 | + | store.EXPECT().Sync().Return(nil).Once() | |||
| 1134 | + | require.NoError(t, d.renameSession(sess, "named")) | |||
| 1135 | + | require.Equal(t, int64(101), sess.createdAt, "the high-water mark must remain monotonic after a failed promotion") | |||
| 1136 | + | }) | |||
| 1137 | + | } | |||
| 1138 | + | ||||
| 1032 | func TestRefreshSessionCwdTouchesOnlyOnChange(t *testing.T) { | 1139 | func TestRefreshSessionCwdTouchesOnlyOnChange(t *testing.T) { | ||
| 1033 | sz := domain.Size{Cols: 80, Rows: 24} | 1140 | sz := domain.Size{Cols: 80, Rows: 24} | ||
| 1034 | p, release := newBlockingPTY(t) | 1141 | p, release := newBlockingPTY(t) | ||
| @@ -1498,3 +1605,144 @@ func TestShellFromEnvironmentFallsBackOnlyWhenAbsentOrEmpty(t *testing.T) { | |||||
| 1498 | }) | 1605 | }) | ||
| 1499 | } | 1606 | } | ||
| 1500 | } | 1607 | } | ||
| 1608 | + | ||||
| 1609 | + | type lifecycleClock struct { | |||
| 1610 | + | nows []time.Time | |||
| 1611 | + | next int | |||
| 1612 | + | } | |||
| 1613 | + | ||||
| 1614 | + | func (c *lifecycleClock) Now() time.Time { | |||
| 1615 | + | i := min(c.next, len(c.nows)-1) | |||
| 1616 | + | c.next++ | |||
| 1617 | + | return c.nows[i] | |||
| 1618 | + | } | |||
| 1619 | + | func (*lifecycleClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} } | |||
| 1620 | + | ||||
| 1621 | + | func TestNamedSessionLifecycleTimestampsAreMonotonicAcrossClockRegression(t *testing.T) { | |||
| 1622 | + | ptys := make([]ports.PTY, 3) | |||
| 1623 | + | var releases []func() | |||
| 1624 | + | for i := range ptys { | |||
| 1625 | + | p, release := newBlockingPTY(t) | |||
| 1626 | + | ptys[i] = p | |||
| 1627 | + | releases = append(releases, release) | |||
| 1628 | + | } | |||
| 1629 | + | defer releaseAll(releases) | |||
| 1630 | + | clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100), time.Unix(0, 50)}} | |||
| 1631 | + | d := newTestDaemon(t, newFactorySeq(t, ptys...), clock) | |||
| 1632 | + | sz := domain.Size{Cols: 80, Rows: 24} | |||
| 1633 | + | ||||
| 1634 | + | var got []int64 | |||
| 1635 | + | for _, name := range []string{"one", "two", "three"} { | |||
| 1636 | + | sess, err := d.createSessionLocked(name, false, "/tmp", sz, terminalEnv{}, d.baseEnv) | |||
| 1637 | + | require.NoError(t, err) | |||
| 1638 | + | got = append(got, sess.createdAt) | |||
| 1639 | + | } | |||
| 1640 | + | require.Equal(t, []int64{100, 101, 102}, got) | |||
| 1641 | + | } | |||
| 1642 | + | ||||
| 1643 | + | func TestNamedSessionLifecycleExhaustionDoesNotMutateSessionState(t *testing.T) { | |||
| 1644 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 1645 | + | d.lastAllocatedCreatedAt = math.MaxInt64 | |||
| 1646 | + | d.nextID = 17 | |||
| 1647 | + | d.stopped["retained"] = stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9} | |||
| 1648 | + | ||||
| 1649 | + | sess, err := d.createSessionLocked("new", false, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv) | |||
| 1650 | + | ||||
| 1651 | + | require.ErrorContains(t, err, "lifecycle identities exhausted") | |||
| 1652 | + | require.Nil(t, sess) | |||
| 1653 | + | require.Empty(t, d.sessions) | |||
| 1654 | + | require.Equal(t, uint64(17), d.nextID) | |||
| 1655 | + | require.Equal(t, int64(math.MaxInt64), d.lastAllocatedCreatedAt) | |||
| 1656 | + | require.Equal(t, stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9}, d.stopped["retained"]) | |||
| 1657 | + | ||||
| 1658 | + | _, _, err = d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "routed", Size: domain.Size{Cols: 80, Rows: 24}}, nil) | |||
| 1659 | + | require.ErrorContains(t, err, "lifecycle identities exhausted") | |||
| 1660 | + | require.Empty(t, d.sessions) | |||
| 1661 | + | require.Equal(t, uint64(17), d.nextID) | |||
| 1662 | + | require.Equal(t, stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9}, d.stopped["retained"]) | |||
| 1663 | + | } | |||
| 1664 | + | ||||
| 1665 | + | func TestNamedSessionLifecycleTimestampStartsAfterPersistedHighWaterMark(t *testing.T) { | |||
| 1666 | + | store, _ := newMockStore(t) | |||
| 1667 | + | seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store)) | |||
| 1668 | + | require.NoError(t, seed.persist.Save(persist.Record{Name: "old", Cwd: "/tmp", CreatedAt: 900, UpdatedAt: 900})) | |||
| 1669 | + | ||||
| 1670 | + | p, release := newBlockingPTY(t) | |||
| 1671 | + | defer release() | |||
| 1672 | + | clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100)}} | |||
| 1673 | + | // Constructing with persistence must establish the lifecycle high-water mark. | |||
| 1674 | + | d := New(newFactory(t, p), clock, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store)) | |||
| 1675 | + | d.serveCtx, d.serveCancel = context.WithCancel(context.Background()) | |||
| 1676 | + | t.Cleanup(d.serveCancel) | |||
| 1677 | + | ||||
| 1678 | + | sess, err := d.createSessionLocked("new", false, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv) | |||
| 1679 | + | require.NoError(t, err) | |||
| 1680 | + | require.Equal(t, int64(901), sess.createdAt) | |||
| 1681 | + | } | |||
| 1682 | + | ||||
| 1683 | + | func TestResumingStoppedSessionPreservesLifecycleIdentityInPersistence(t *testing.T) { | |||
| 1684 | + | fromPTY, releaseFrom := newBlockingPTY(t) | |||
| 1685 | + | targetPTY, releaseTarget := newBlockingPTY(t) | |||
| 1686 | + | defer releaseFrom() | |||
| 1687 | + | defer releaseTarget() | |||
| 1688 | + | d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY) | |||
| 1689 | + | store, state := newMockStore(t) | |||
| 1690 | + | WithStore(store)(d) | |||
| 1691 | + | d.ptys = newFactory(t, targetPTY) | |||
| 1692 | + | d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 77} | |||
| 1693 | + | ||||
| 1694 | + | require.True(t, d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "stopped", Stopped: true})) | |||
| 1695 | + | resumed := ac.currentSession() | |||
| 1696 | + | require.Equal(t, int64(77), resumed.createdAt) | |||
| 1697 | + | require.Equal(t, int64(77), state.record(t, "stopped").CreatedAt) | |||
| 1698 | + | } | |||
| 1699 | + | ||||
| 1700 | + | func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing.T) { | |||
| 1701 | + | t.Run("active replacement is rejected", func(t *testing.T) { | |||
| 1702 | + | d, from, ac, _, releases := newRecentNavigationTestSessions(t) | |||
| 1703 | + | defer releaseAll(releases) | |||
| 1704 | + | target := d.sessions["recent"] | |||
| 1705 | + | target.createdAt = 22 | |||
| 1706 | + | expected := int64(21) | |||
| 1707 | + | ||||
| 1708 | + | require.False(t, d.switchToTarget(from, ac, picker.Target{Session: target.id, Name: "recent", TabIndex: 0, ExpectedCreatedAt: &expected})) | |||
| 1709 | + | require.Same(t, from, ac.currentSession()) | |||
| 1710 | + | }) | |||
| 1711 | + | ||||
| 1712 | + | t.Run("active target that stopped resumes same lifecycle", func(t *testing.T) { | |||
| 1713 | + | fromPTY, releaseFrom := newBlockingPTY(t) | |||
| 1714 | + | targetPTY, releaseTarget := newBlockingPTY(t) | |||
| 1715 | + | defer releaseFrom() | |||
| 1716 | + | defer releaseTarget() | |||
| 1717 | + | d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY) | |||
| 1718 | + | d.ptys = newFactory(t, targetPTY) | |||
| 1719 | + | expected := int64(31) | |||
| 1720 | + | d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: expected} | |||
| 1721 | + | ||||
| 1722 | + | require.True(t, d.switchToTarget(from, ac, picker.Target{Session: "old-active-id", Name: "target", TabIndex: 0, ExpectedCreatedAt: &expected})) | |||
| 1723 | + | require.Equal(t, "target", ac.currentSession().name) | |||
| 1724 | + | require.Equal(t, expected, ac.currentSession().createdAt) | |||
| 1725 | + | }) | |||
| 1726 | + | ||||
| 1727 | + | t.Run("stopped target that became active switches same lifecycle", func(t *testing.T) { | |||
| 1728 | + | d, from, ac, _, releases := newRecentNavigationTestSessions(t) | |||
| 1729 | + | defer releaseAll(releases) | |||
| 1730 | + | target := d.sessions["recent"] | |||
| 1731 | + | target.createdAt = 41 | |||
| 1732 | + | expected := int64(41) | |||
| 1733 | + | ||||
| 1734 | + | require.True(t, d.switchToTarget(from, ac, picker.Target{Session: "stopped:recent", Name: "recent", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected})) | |||
| 1735 | + | require.Same(t, target, ac.currentSession()) | |||
| 1736 | + | }) | |||
| 1737 | + | ||||
| 1738 | + | t.Run("stopped deletion and recreation is rejected", func(t *testing.T) { | |||
| 1739 | + | fromPTY, releaseFrom := newBlockingPTY(t) | |||
| 1740 | + | defer releaseFrom() | |||
| 1741 | + | d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY) | |||
| 1742 | + | d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: 52} | |||
| 1743 | + | expected := int64(51) | |||
| 1744 | + | ||||
| 1745 | + | require.False(t, d.switchToTarget(from, ac, picker.Target{Name: "target", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected})) | |||
| 1746 | + | require.Same(t, from, ac.currentSession()) | |||
| 1747 | + | }) | |||
| 1748 | + | } | |||
M internal/usecase/palette/action.go +21 -12
| @@ -13,16 +13,17 @@ type Action struct { | |||
| 13 | 13 | Args []string | |
| 14 | 14 | } | |
| 15 | 15 | ||
| 16 | - | // ParseAction recognizes an exact effective command token. It accepts outer | |
| 17 | - | // and repeated whitespace but never treats a prefix or concatenated token as | |
| 18 | - | // a command. Static commands reject arguments. | |
| 19 | - | func ParseAction(commands []command.Command, input string) (Action, bool) { | |
| 16 | + | // ParseAction recognizes an exact effective command result token. It accepts | |
| 17 | + | // outer and repeated whitespace but never treats a prefix or concatenated token | |
| 18 | + | // as a command. Static commands reject arguments. | |
| 19 | + | func ParseAction(results []Result, input string) (Action, bool) { | |
| 20 | 20 | fields := strings.Fields(input) | |
| 21 | 21 | if len(fields) == 0 { | |
| 22 | 22 | return Action{}, false | |
| 23 | 23 | } | |
| 24 | - | for _, cmd := range commands { | |
| 25 | - | if !strings.EqualFold(fields[0], cmd.Code) { | |
| 24 | + | for _, result := range results { | |
| 25 | + | cmd, ok := result.Command() | |
| 26 | + | if !ok || !strings.EqualFold(fields[0], cmd.Code) { | |
| 26 | 27 | continue | |
| 27 | 28 | } | |
| 28 | 29 | args := append([]string(nil), fields[1:]...) | |
| @@ -43,15 +44,23 @@ func ParseAction(commands []command.Command, input string) (Action, bool) { | |||
| 43 | 44 | ||
| 44 | 45 | // ArgumentCommand returns the exact argument-taking command whose token is | |
| 45 | 46 | // being typed, including the whitespace-before-argument state. | |
| 46 | - | func ArgumentCommand(commands []command.Command, input string) (command.Command, bool) { | |
| 47 | + | func ArgumentCommand(results []Result, input string) (command.Command, bool) { | |
| 48 | + | _, cmd, ok := argumentResult(results, input) | |
| 49 | + | return cmd, ok | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // argumentResult finds the exact argument-taking command and its source row in | |
| 53 | + | // one pass so callers can reuse the immutable result without another scan. | |
| 54 | + | func argumentResult(results []Result, input string) (Result, command.Command, bool) { | |
| 47 | 55 | fields := strings.Fields(input) | |
| 48 | 56 | if len(fields) == 0 { | |
| 49 | - | return command.Command{}, false | |
| 57 | + | return Result{}, command.Command{}, false | |
| 50 | 58 | } | |
| 51 | - | for _, cmd := range commands { | |
| 52 | - | if cmd.Arguments == command.ArgumentsRequired && strings.EqualFold(fields[0], cmd.Code) { | |
| 53 | - | return cmd, true | |
| 59 | + | for _, result := range results { | |
| 60 | + | cmd, ok := result.Command() | |
| 61 | + | if ok && cmd.Arguments == command.ArgumentsRequired && strings.EqualFold(fields[0], cmd.Code) { | |
| 62 | + | return result, cmd, true | |
| 54 | 63 | } | |
| 55 | 64 | } | |
| 56 | - | return command.Command{}, false | |
| 65 | + | return Result{}, command.Command{}, false | |
| 57 | 66 | } | |
| @@ -13,16 +13,17 @@ type Action struct { | |||||
| 13 | Args []string | 13 | Args []string | ||
| 14 | } | 14 | } | ||
| 15 | 15 | ||||
| 16 | - | // ParseAction recognizes an exact effective command token. It accepts outer | 16 | + | // ParseAction recognizes an exact effective command result token. It accepts |
| 17 | - | // and repeated whitespace but never treats a prefix or concatenated token as | 17 | + | // outer and repeated whitespace but never treats a prefix or concatenated token |
| 18 | - | // a command. Static commands reject arguments. | 18 | + | // as a command. Static commands reject arguments. |
| 19 | - | func ParseAction(commands []command.Command, input string) (Action, bool) { | 19 | + | func ParseAction(results []Result, input string) (Action, bool) { |
| 20 | fields := strings.Fields(input) | 20 | fields := strings.Fields(input) | ||
| 21 | if len(fields) == 0 { | 21 | if len(fields) == 0 { | ||
| 22 | return Action{}, false | 22 | return Action{}, false | ||
| 23 | } | 23 | } | ||
| 24 | - | for _, cmd := range commands { | 24 | + | for _, result := range results { |
| 25 | - | if !strings.EqualFold(fields[0], cmd.Code) { | 25 | + | cmd, ok := result.Command() |
| 26 | + | if !ok || !strings.EqualFold(fields[0], cmd.Code) { | |||
| 26 | continue | 27 | continue | ||
| 27 | } | 28 | } | ||
| 28 | args := append([]string(nil), fields[1:]...) | 29 | args := append([]string(nil), fields[1:]...) | ||
| @@ -43,15 +44,23 @@ func ParseAction(commands []command.Command, input string) (Action, bool) { | |||||
| 43 | 44 | ||||
| 44 | // ArgumentCommand returns the exact argument-taking command whose token is | 45 | // ArgumentCommand returns the exact argument-taking command whose token is | ||
| 45 | // being typed, including the whitespace-before-argument state. | 46 | // being typed, including the whitespace-before-argument state. | ||
| 46 | - | func ArgumentCommand(commands []command.Command, input string) (command.Command, bool) { | 47 | + | func ArgumentCommand(results []Result, input string) (command.Command, bool) { |
| 48 | + | _, cmd, ok := argumentResult(results, input) | |||
| 49 | + | return cmd, ok | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // argumentResult finds the exact argument-taking command and its source row in | |||
| 53 | + | // one pass so callers can reuse the immutable result without another scan. | |||
| 54 | + | func argumentResult(results []Result, input string) (Result, command.Command, bool) { | |||
| 47 | fields := strings.Fields(input) | 55 | fields := strings.Fields(input) | ||
| 48 | if len(fields) == 0 { | 56 | if len(fields) == 0 { | ||
| 49 | - | return command.Command{}, false | 57 | + | return Result{}, command.Command{}, false |
| 50 | } | 58 | } | ||
| 51 | - | for _, cmd := range commands { | 59 | + | for _, result := range results { |
| 52 | - | if cmd.Arguments == command.ArgumentsRequired && strings.EqualFold(fields[0], cmd.Code) { | 60 | + | cmd, ok := result.Command() |
| 53 | - | return cmd, true | 61 | + | if ok && cmd.Arguments == command.ArgumentsRequired && strings.EqualFold(fields[0], cmd.Code) { |
| 62 | + | return result, cmd, true | |||
| 54 | } | 63 | } | ||
| 55 | } | 64 | } | ||
| 56 | - | return command.Command{}, false | 65 | + | return Result{}, command.Command{}, false |
| 57 | } | 66 | } | ||
M internal/usecase/palette/action_test.go +16 -4
| @@ -2,15 +2,16 @@ package palette | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | + | "time" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/vev/internal/usecase/command" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | 10 | func TestParseActionExactEffectiveToken(t *testing.T) { | |
| 10 | - | commands := []command.Command{ | |
| 11 | + | results := CommandResults([]command.Command{ | |
| 11 | 12 | {Code: "BSK", Arguments: command.ArgumentsNone}, | |
| 12 | 13 | {Code: "JRS", Arguments: command.ArgumentsRequired, ContextHint: command.ContextHintRecentSessions}, | |
| 13 | - | } | |
| 14 | + | }) | |
| 14 | 15 | tests := []struct { | |
| 15 | 16 | name string | |
| 16 | 17 | input string | |
| @@ -27,7 +28,7 @@ func TestParseActionExactEffectiveToken(t *testing.T) { | |||
| 27 | 28 | } | |
| 28 | 29 | for _, tt := range tests { | |
| 29 | 30 | t.Run(tt.name, func(t *testing.T) { | |
| 30 | - | action, ok := ParseAction(commands, tt.input) | |
| 31 | + | action, ok := ParseAction(results, tt.input) | |
| 31 | 32 | if ok != tt.ok { | |
| 32 | 33 | t.Fatalf("ParseAction(%q) ok = %v, want %v", tt.input, ok, tt.ok) | |
| 33 | 34 | } | |
| @@ -43,11 +44,22 @@ func TestParseActionExactEffectiveToken(t *testing.T) { | |||
| 43 | 44 | ||
| 44 | 45 | func TestArgumentCommandKeepsExactRowVisible(t *testing.T) { | |
| 45 | 46 | cmd := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired} | |
| 46 | - | if got, ok := ArgumentCommand([]command.Command{cmd}, "JRS 1"); !ok || got.Code != "JRS" { | |
| 47 | + | if got, ok := ArgumentCommand(CommandResults([]command.Command{cmd}), "JRS 1"); !ok || got.Code != "JRS" { | |
| 47 | 48 | t.Fatalf("ArgumentCommand() = %#v, %v; want JRS, true", got, ok) | |
| 48 | 49 | } | |
| 49 | 50 | } | |
| 50 | 51 | ||
| 52 | + | func TestArgumentResultReturnsTheMatchingRowAndCommand(t *testing.T) { | |
| 53 | + | jrs := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired} | |
| 54 | + | results := []Result{NewStoppedSessionResult("JRS", time.Time{}), NewCommandResult(jrs)} | |
| 55 | + | ||
| 56 | + | result, got, ok := argumentResult(results, "jrs 1") | |
| 57 | + | ||
| 58 | + | if !ok || result.Kind() != ResultKindCommand || got.Code != jrs.Code { | |
| 59 | + | t.Fatalf("argumentResult() = %#v, %#v, %v; want matching JRS row and command", result, got, ok) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 51 | 63 | func sameStrings(a, b []string) bool { | |
| 52 | 64 | if len(a) != len(b) { | |
| 53 | 65 | return false | |
| @@ -2,15 +2,16 @@ package palette | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | + | "time" | |||
| 5 | 6 | ||||
| 6 | "github.com/bnema/vev/internal/usecase/command" | 7 | "github.com/bnema/vev/internal/usecase/command" | ||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| 9 | func TestParseActionExactEffectiveToken(t *testing.T) { | 10 | func TestParseActionExactEffectiveToken(t *testing.T) { | ||
| 10 | - | commands := []command.Command{ | 11 | + | results := CommandResults([]command.Command{ |
| 11 | {Code: "BSK", Arguments: command.ArgumentsNone}, | 12 | {Code: "BSK", Arguments: command.ArgumentsNone}, | ||
| 12 | {Code: "JRS", Arguments: command.ArgumentsRequired, ContextHint: command.ContextHintRecentSessions}, | 13 | {Code: "JRS", Arguments: command.ArgumentsRequired, ContextHint: command.ContextHintRecentSessions}, | ||
| 13 | - | } | 14 | + | }) |
| 14 | tests := []struct { | 15 | tests := []struct { | ||
| 15 | name string | 16 | name string | ||
| 16 | input string | 17 | input string | ||
| @@ -27,7 +28,7 @@ func TestParseActionExactEffectiveToken(t *testing.T) { | |||||
| 27 | } | 28 | } | ||
| 28 | for _, tt := range tests { | 29 | for _, tt := range tests { | ||
| 29 | t.Run(tt.name, func(t *testing.T) { | 30 | t.Run(tt.name, func(t *testing.T) { | ||
| 30 | - | action, ok := ParseAction(commands, tt.input) | 31 | + | action, ok := ParseAction(results, tt.input) |
| 31 | if ok != tt.ok { | 32 | if ok != tt.ok { | ||
| 32 | t.Fatalf("ParseAction(%q) ok = %v, want %v", tt.input, ok, tt.ok) | 33 | t.Fatalf("ParseAction(%q) ok = %v, want %v", tt.input, ok, tt.ok) | ||
| 33 | } | 34 | } | ||
| @@ -43,11 +44,22 @@ func TestParseActionExactEffectiveToken(t *testing.T) { | |||||
| 43 | 44 | ||||
| 44 | func TestArgumentCommandKeepsExactRowVisible(t *testing.T) { | 45 | func TestArgumentCommandKeepsExactRowVisible(t *testing.T) { | ||
| 45 | cmd := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired} | 46 | cmd := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired} | ||
| 46 | - | if got, ok := ArgumentCommand([]command.Command{cmd}, "JRS 1"); !ok || got.Code != "JRS" { | 47 | + | if got, ok := ArgumentCommand(CommandResults([]command.Command{cmd}), "JRS 1"); !ok || got.Code != "JRS" { |
| 47 | t.Fatalf("ArgumentCommand() = %#v, %v; want JRS, true", got, ok) | 48 | t.Fatalf("ArgumentCommand() = %#v, %v; want JRS, true", got, ok) | ||
| 48 | } | 49 | } | ||
| 49 | } | 50 | } | ||
| 50 | 51 | ||||
| 52 | + | func TestArgumentResultReturnsTheMatchingRowAndCommand(t *testing.T) { | |||
| 53 | + | jrs := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired} | |||
| 54 | + | results := []Result{NewStoppedSessionResult("JRS", time.Time{}), NewCommandResult(jrs)} | |||
| 55 | + | ||||
| 56 | + | result, got, ok := argumentResult(results, "jrs 1") | |||
| 57 | + | ||||
| 58 | + | if !ok || result.Kind() != ResultKindCommand || got.Code != jrs.Code { | |||
| 59 | + | t.Fatalf("argumentResult() = %#v, %#v, %v; want matching JRS row and command", result, got, ok) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 51 | func sameStrings(a, b []string) bool { | 63 | func sameStrings(a, b []string) bool { | ||
| 52 | if len(a) != len(b) { | 64 | if len(a) != len(b) { | ||
| 53 | return false | 65 | return false | ||
M internal/usecase/palette/fuzzy.go +56 -30
| @@ -3,31 +3,36 @@ package palette | |||
| 3 | 3 | import ( | |
| 4 | 4 | "sort" | |
| 5 | 5 | "strings" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/vev/internal/usecase/command" | |
| 6 | + | "unicode/utf8" | |
| 8 | 7 | ) | |
| 9 | 8 | ||
| 9 | + | // Match is a fuzzy-matched immutable palette result. | |
| 10 | 10 | type Match struct { | |
| 11 | - | Command command.Command | |
| 11 | + | Result Result | |
| 12 | 12 | Positions []int | |
| 13 | 13 | rank int | |
| 14 | 14 | span int | |
| 15 | 15 | first int | |
| 16 | 16 | order int | |
| 17 | + | search string | |
| 17 | 18 | } | |
| 18 | 19 | ||
| 19 | - | func Fuzzy(commands []command.Command, query string) []Match { | |
| 20 | + | // Fuzzy searches immutable palette results. | |
| 21 | + | func Fuzzy(results []Result, query string) []Match { | |
| 22 | + | needle := strings.ToLower(query) | |
| 20 | 23 | if query == "" { | |
| 21 | - | out := make([]Match, len(commands)) | |
| 22 | - | for i, cmd := range commands { | |
| 23 | - | out[i] = Match{Command: cmd, order: i} | |
| 24 | + | out := make([]Match, 0, len(results)) | |
| 25 | + | for i, result := range results { | |
| 26 | + | out = append(out, newMatch(result, i)) | |
| 24 | 27 | } | |
| 25 | 28 | return out | |
| 26 | 29 | } | |
| 30 | + | ||
| 31 | + | needleRunes := []rune(needle) | |
| 27 | 32 | var out []Match | |
| 28 | - | for i, cmd := range commands { | |
| 29 | - | if m, ok := score(cmd, query, i); ok { | |
| 30 | - | out = append(out, m) | |
| 33 | + | for i, result := range results { | |
| 34 | + | if match, ok := score(result, needle, needleRunes, i); ok { | |
| 35 | + | out = append(out, match) | |
| 31 | 36 | } | |
| 32 | 37 | } | |
| 33 | 38 | sort.SliceStable(out, func(i, j int) bool { | |
| @@ -41,41 +46,62 @@ func Fuzzy(commands []command.Command, query string) []Match { | |||
| 41 | 46 | if a.first != b.first { | |
| 42 | 47 | return a.first < b.first | |
| 43 | 48 | } | |
| 44 | - | if a.Command.Code != b.Command.Code { | |
| 45 | - | return a.Command.Code < b.Command.Code | |
| 49 | + | if a.Result.Kind() != b.Result.Kind() { | |
| 50 | + | return a.Result.Kind() < b.Result.Kind() | |
| 51 | + | } | |
| 52 | + | if a.search != b.search { | |
| 53 | + | return a.search < b.search | |
| 46 | 54 | } | |
| 47 | 55 | return a.order < b.order | |
| 48 | 56 | }) | |
| 49 | 57 | return out | |
| 50 | 58 | } | |
| 51 | 59 | ||
| 52 | - | func score(cmd command.Command, query string, order int) (Match, bool) { | |
| 53 | - | code := []rune(cmd.Code) | |
| 54 | - | q := []rune(query) | |
| 55 | - | codeLower := strings.ToLower(cmd.Code) | |
| 56 | - | qLower := strings.ToLower(query) | |
| 57 | - | if codeLower == qLower { | |
| 58 | - | return Match{Command: cmd, Positions: rangePositions(len(code)), rank: 0, span: len(code), first: 0, order: order}, true | |
| 60 | + | func score(result Result, needle string, needleRunes []rune, order int) (Match, bool) { | |
| 61 | + | match := newMatch(result, order) | |
| 62 | + | text := match.search | |
| 63 | + | ||
| 64 | + | // An exact command shortcode always wins over every other match. The | |
| 65 | + | // remaining results then share exact/prefix/subsequence scoring. | |
| 66 | + | if result.Kind() == ResultKindCommand && text == needle { | |
| 67 | + | match.Positions = rangePositions(utf8.RuneCountInString(text)) | |
| 68 | + | match.rank, match.span, match.first = 0, len(match.Positions), 0 | |
| 69 | + | return match, true | |
| 59 | 70 | } | |
| 60 | - | if strings.HasPrefix(codeLower, qLower) { | |
| 61 | - | return Match{Command: cmd, Positions: rangePositions(len(q)), rank: 1, span: len(q), first: 0, order: order}, true | |
| 71 | + | if text == needle { | |
| 72 | + | match.Positions = rangePositions(utf8.RuneCountInString(text)) | |
| 73 | + | match.rank, match.span, match.first = 1, len(match.Positions), 0 | |
| 74 | + | return match, true | |
| 62 | 75 | } | |
| 63 | - | if positions, ok := subsequencePositions([]rune(codeLower), []rune(qLower)); ok { | |
| 64 | - | return Match{Command: cmd, Positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true | |
| 76 | + | if strings.HasPrefix(text, needle) { | |
| 77 | + | match.Positions = rangePositions(utf8.RuneCountInString(needle)) | |
| 78 | + | match.rank, match.span, match.first = 2, len(match.Positions), 0 | |
| 79 | + | return match, true | |
| 65 | 80 | } | |
| 66 | - | text := strings.ToLower(cmd.Desc) | |
| 67 | - | if positions, ok := subsequencePositions([]rune(text), []rune(qLower)); ok { | |
| 68 | - | return Match{Command: cmd, rank: 3, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true | |
| 81 | + | if positions, ok := subsequencePositions([]rune(text), needleRunes); ok { | |
| 82 | + | match.Positions = positions | |
| 83 | + | match.rank, match.span, match.first = 3, positions[len(positions)-1]-positions[0]+1, positions[0] | |
| 84 | + | return match, true | |
| 85 | + | } | |
| 86 | + | if cmd, ok := result.Command(); ok { | |
| 87 | + | if positions, ok := subsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok { | |
| 88 | + | match.rank, match.span, match.first = 4, positions[len(positions)-1]-positions[0]+1, positions[0] | |
| 89 | + | return match, true | |
| 90 | + | } | |
| 69 | 91 | } | |
| 70 | 92 | return Match{}, false | |
| 71 | 93 | } | |
| 72 | 94 | ||
| 95 | + | func newMatch(result Result, order int) Match { | |
| 96 | + | return Match{Result: result, order: order, search: strings.ToLower(result.SearchText())} | |
| 97 | + | } | |
| 98 | + | ||
| 73 | 99 | func rangePositions(n int) []int { | |
| 74 | - | p := make([]int, n) | |
| 75 | - | for i := range p { | |
| 76 | - | p[i] = i | |
| 100 | + | positions := make([]int, n) | |
| 101 | + | for i := range positions { | |
| 102 | + | positions[i] = i | |
| 77 | 103 | } | |
| 78 | - | return p | |
| 104 | + | return positions | |
| 79 | 105 | } | |
| 80 | 106 | ||
| 81 | 107 | func subsequencePositions(haystack, needle []rune) ([]int, bool) { | |
| @@ -3,31 +3,36 @@ package palette | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "sort" | 4 | "sort" | ||
| 5 | "strings" | 5 | "strings" | ||
| 6 | - | 6 | + | "unicode/utf8" | |
| 7 | - | "github.com/bnema/vev/internal/usecase/command" | |||
| 8 | ) | 7 | ) | ||
| 9 | 8 | ||||
| 9 | + | // Match is a fuzzy-matched immutable palette result. | |||
| 10 | type Match struct { | 10 | type Match struct { | ||
| 11 | - | Command command.Command | 11 | + | Result Result |
| 12 | Positions []int | 12 | Positions []int | ||
| 13 | rank int | 13 | rank int | ||
| 14 | span int | 14 | span int | ||
| 15 | first int | 15 | first int | ||
| 16 | order int | 16 | order int | ||
| 17 | + | search string | |||
| 17 | } | 18 | } | ||
| 18 | 19 | ||||
| 19 | - | func Fuzzy(commands []command.Command, query string) []Match { | 20 | + | // Fuzzy searches immutable palette results. |
| 21 | + | func Fuzzy(results []Result, query string) []Match { | |||
| 22 | + | needle := strings.ToLower(query) | |||
| 20 | if query == "" { | 23 | if query == "" { | ||
| 21 | - | out := make([]Match, len(commands)) | 24 | + | out := make([]Match, 0, len(results)) |
| 22 | - | for i, cmd := range commands { | 25 | + | for i, result := range results { |
| 23 | - | out[i] = Match{Command: cmd, order: i} | 26 | + | out = append(out, newMatch(result, i)) |
| 24 | } | 27 | } | ||
| 25 | return out | 28 | return out | ||
| 26 | } | 29 | } | ||
| 30 | + | ||||
| 31 | + | needleRunes := []rune(needle) | |||
| 27 | var out []Match | 32 | var out []Match | ||
| 28 | - | for i, cmd := range commands { | 33 | + | for i, result := range results { |
| 29 | - | if m, ok := score(cmd, query, i); ok { | 34 | + | if match, ok := score(result, needle, needleRunes, i); ok { |
| 30 | - | out = append(out, m) | 35 | + | out = append(out, match) |
| 31 | } | 36 | } | ||
| 32 | } | 37 | } | ||
| 33 | sort.SliceStable(out, func(i, j int) bool { | 38 | sort.SliceStable(out, func(i, j int) bool { | ||
| @@ -41,41 +46,62 @@ func Fuzzy(commands []command.Command, query string) []Match { | |||||
| 41 | if a.first != b.first { | 46 | if a.first != b.first { | ||
| 42 | return a.first < b.first | 47 | return a.first < b.first | ||
| 43 | } | 48 | } | ||
| 44 | - | if a.Command.Code != b.Command.Code { | 49 | + | if a.Result.Kind() != b.Result.Kind() { |
| 45 | - | return a.Command.Code < b.Command.Code | 50 | + | return a.Result.Kind() < b.Result.Kind() |
| 51 | + | } | |||
| 52 | + | if a.search != b.search { | |||
| 53 | + | return a.search < b.search | |||
| 46 | } | 54 | } | ||
| 47 | return a.order < b.order | 55 | return a.order < b.order | ||
| 48 | }) | 56 | }) | ||
| 49 | return out | 57 | return out | ||
| 50 | } | 58 | } | ||
| 51 | 59 | ||||
| 52 | - | func score(cmd command.Command, query string, order int) (Match, bool) { | 60 | + | func score(result Result, needle string, needleRunes []rune, order int) (Match, bool) { |
| 53 | - | code := []rune(cmd.Code) | 61 | + | match := newMatch(result, order) |
| 54 | - | q := []rune(query) | 62 | + | text := match.search |
| 55 | - | codeLower := strings.ToLower(cmd.Code) | 63 | + | |
| 56 | - | qLower := strings.ToLower(query) | 64 | + | // An exact command shortcode always wins over every other match. The |
| 57 | - | if codeLower == qLower { | 65 | + | // remaining results then share exact/prefix/subsequence scoring. |
| 58 | - | return Match{Command: cmd, Positions: rangePositions(len(code)), rank: 0, span: len(code), first: 0, order: order}, true | 66 | + | if result.Kind() == ResultKindCommand && text == needle { |
| 67 | + | match.Positions = rangePositions(utf8.RuneCountInString(text)) | |||
| 68 | + | match.rank, match.span, match.first = 0, len(match.Positions), 0 | |||
| 69 | + | return match, true | |||
| 59 | } | 70 | } | ||
| 60 | - | if strings.HasPrefix(codeLower, qLower) { | 71 | + | if text == needle { |
| 61 | - | return Match{Command: cmd, Positions: rangePositions(len(q)), rank: 1, span: len(q), first: 0, order: order}, true | 72 | + | match.Positions = rangePositions(utf8.RuneCountInString(text)) |
| 73 | + | match.rank, match.span, match.first = 1, len(match.Positions), 0 | |||
| 74 | + | return match, true | |||
| 62 | } | 75 | } | ||
| 63 | - | if positions, ok := subsequencePositions([]rune(codeLower), []rune(qLower)); ok { | 76 | + | if strings.HasPrefix(text, needle) { |
| 64 | - | return Match{Command: cmd, Positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true | 77 | + | match.Positions = rangePositions(utf8.RuneCountInString(needle)) |
| 78 | + | match.rank, match.span, match.first = 2, len(match.Positions), 0 | |||
| 79 | + | return match, true | |||
| 65 | } | 80 | } | ||
| 66 | - | text := strings.ToLower(cmd.Desc) | 81 | + | if positions, ok := subsequencePositions([]rune(text), needleRunes); ok { |
| 67 | - | if positions, ok := subsequencePositions([]rune(text), []rune(qLower)); ok { | 82 | + | match.Positions = positions |
| 68 | - | return Match{Command: cmd, rank: 3, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true | 83 | + | match.rank, match.span, match.first = 3, positions[len(positions)-1]-positions[0]+1, positions[0] |
| 84 | + | return match, true | |||
| 85 | + | } | |||
| 86 | + | if cmd, ok := result.Command(); ok { | |||
| 87 | + | if positions, ok := subsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok { | |||
| 88 | + | match.rank, match.span, match.first = 4, positions[len(positions)-1]-positions[0]+1, positions[0] | |||
| 89 | + | return match, true | |||
| 90 | + | } | |||
| 69 | } | 91 | } | ||
| 70 | return Match{}, false | 92 | return Match{}, false | ||
| 71 | } | 93 | } | ||
| 72 | 94 | ||||
| 95 | + | func newMatch(result Result, order int) Match { | |||
| 96 | + | return Match{Result: result, order: order, search: strings.ToLower(result.SearchText())} | |||
| 97 | + | } | |||
| 98 | + | ||||
| 73 | func rangePositions(n int) []int { | 99 | func rangePositions(n int) []int { | ||
| 74 | - | p := make([]int, n) | 100 | + | positions := make([]int, n) |
| 75 | - | for i := range p { | 101 | + | for i := range positions { |
| 76 | - | p[i] = i | 102 | + | positions[i] = i |
| 77 | } | 103 | } | ||
| 78 | - | return p | 104 | + | return positions |
| 79 | } | 105 | } | ||
| 80 | 106 | ||||
| 81 | func subsequencePositions(haystack, needle []rune) ([]int, bool) { | 107 | func subsequencePositions(haystack, needle []rune) ([]int, bool) { | ||
M internal/usecase/palette/fuzzy_test.go +105 -10
| @@ -2,7 +2,9 @@ package palette | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | + | "time" | |
| 5 | 6 | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 6 | 8 | "github.com/bnema/vev/internal/usecase/command" | |
| 7 | 9 | "github.com/stretchr/testify/require" | |
| 8 | 10 | ) | |
| @@ -14,52 +16,56 @@ func cmd(code, name, desc string) command.Command { | |||
| 14 | 16 | func codes(matches []Match) []string { | |
| 15 | 17 | out := make([]string, len(matches)) | |
| 16 | 18 | for i, match := range matches { | |
| 17 | - | out[i] = match.Command.Code | |
| 19 | + | cmd, ok := match.Result.Command() | |
| 20 | + | if !ok { | |
| 21 | + | continue | |
| 22 | + | } | |
| 23 | + | out[i] = cmd.Code | |
| 18 | 24 | } | |
| 19 | 25 | return out | |
| 20 | 26 | } | |
| 21 | 27 | ||
| 22 | 28 | func TestFuzzyOrdersVisibleFieldScoringTiers(t *testing.T) { | |
| 23 | - | matches := Fuzzy([]command.Command{ | |
| 29 | + | matches := Fuzzy(CommandResults([]command.Command{ | |
| 24 | 30 | cmd("ZQQ", "unused", "alpha beta"), | |
| 25 | 31 | cmd("XAB", "unused", "subsequence code"), | |
| 26 | 32 | cmd("ABX", "unused", "prefix code"), | |
| 27 | 33 | cmd("AB", "unused", "exact code"), | |
| 28 | - | }, "ab") | |
| 34 | + | }), "ab") | |
| 29 | 35 | ||
| 30 | 36 | require.Equal(t, []string{"AB", "ABX", "XAB", "ZQQ"}, codes(matches)) | |
| 31 | 37 | require.Empty(t, matches[3].Positions, "description matches do not expose code highlight positions") | |
| 32 | 38 | } | |
| 33 | 39 | ||
| 34 | 40 | func TestFuzzyDoesNotMatchCommandNames(t *testing.T) { | |
| 35 | - | matches := Fuzzy([]command.Command{ | |
| 41 | + | matches := Fuzzy(CommandResults([]command.Command{ | |
| 36 | 42 | cmd("CPY", "Create Alpha Beta", "Enter copy mode"), | |
| 37 | 43 | cmd("ZQQ", "unused", "Alpha Beta tools"), | |
| 38 | - | }, "ab") | |
| 44 | + | }), "ab") | |
| 39 | 45 | ||
| 40 | 46 | require.Equal(t, []string{"ZQQ"}, codes(matches)) | |
| 41 | 47 | } | |
| 42 | 48 | ||
| 43 | 49 | func TestFuzzyDescriptionMatchingIsCaseInsensitiveWithoutCodeHighlights(t *testing.T) { | |
| 44 | - | matches := Fuzzy([]command.Command{cmd("CPY", "unused", "Enter COPY mode")}, "copy") | |
| 50 | + | matches := Fuzzy(CommandResults([]command.Command{cmd("CPY", "unused", "Enter COPY mode")}), "copy") | |
| 45 | 51 | ||
| 46 | 52 | require.Len(t, matches, 1) | |
| 47 | 53 | require.Empty(t, matches[0].Positions) | |
| 48 | 54 | } | |
| 49 | 55 | ||
| 50 | 56 | func TestFuzzyTieBreaksBySpanFirstThenCode(t *testing.T) { | |
| 51 | - | matches := Fuzzy([]command.Command{ | |
| 57 | + | matches := Fuzzy(CommandResults([]command.Command{ | |
| 52 | 58 | cmd("ZAXQB", "", ""), // span 3, first 1 | |
| 53 | 59 | cmd("BAXQ", "", ""), // span 3, first 1, code sorts first | |
| 54 | 60 | cmd("PXAQ", "", ""), // span 2, first 2 | |
| 55 | 61 | cmd("BAQ", "", ""), // span 2, first 0 | |
| 56 | - | }, "aq") | |
| 62 | + | }), "aq") | |
| 57 | 63 | ||
| 58 | 64 | require.Equal(t, []string{"BAQ", "PXAQ", "BAXQ", "ZAXQB"}, codes(matches)) | |
| 59 | 65 | } | |
| 60 | 66 | ||
| 61 | 67 | func TestFuzzyIsCaseInsensitiveAndHighlightsCodeRunePositions(t *testing.T) { | |
| 62 | - | matches := Fuzzy([]command.Command{cmd("CpY", "Copy mode", "Enter copy mode")}, "cy") | |
| 68 | + | matches := Fuzzy(CommandResults([]command.Command{cmd("CpY", "Copy mode", "Enter copy mode")}), "cy") | |
| 63 | 69 | ||
| 64 | 70 | require.Len(t, matches, 1) | |
| 65 | 71 | require.Equal(t, []int{0, 2}, matches[0].Positions) | |
| @@ -67,7 +73,96 @@ func TestFuzzyIsCaseInsensitiveAndHighlightsCodeRunePositions(t *testing.T) { | |||
| 67 | 73 | ||
| 68 | 74 | func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) { | |
| 69 | 75 | commands := []command.Command{cmd("B", "", ""), cmd("A", "", "")} | |
| 70 | - | matches := Fuzzy(commands, "") | |
| 76 | + | matches := Fuzzy(CommandResults(commands), "") | |
| 71 | 77 | ||
| 72 | 78 | require.Equal(t, []string{"B", "A"}, codes(matches)) | |
| 73 | 79 | } | |
| 80 | + | ||
| 81 | + | func TestFuzzyOrdersMixedResults(t *testing.T) { | |
| 82 | + | created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC) | |
| 83 | + | tests := []struct { | |
| 84 | + | name string | |
| 85 | + | results []Result | |
| 86 | + | query string | |
| 87 | + | wantText []string | |
| 88 | + | wantKinds []ResultKind | |
| 89 | + | wantPositions [][]int | |
| 90 | + | wantActiveIDs map[int]domain.SessionID | |
| 91 | + | }{ | |
| 92 | + | { | |
| 93 | + | name: "command shortcode precedes sessions and description matches", | |
| 94 | + | results: []Result{ | |
| 95 | + | NewStoppedSessionResult("work", created), | |
| 96 | + | NewCommandResult(cmd("WORK", "", "Create a workspace")), | |
| 97 | + | NewActiveSessionResult("work", created, domain.SessionID("work-id")), | |
| 98 | + | NewCommandResult(cmd("WQORRK", "", "")), | |
| 99 | + | NewCommandResult(cmd("ZZZ", "", "work tools")), | |
| 100 | + | }, | |
| 101 | + | query: "work", | |
| 102 | + | wantText: []string{"WORK", "work", "work", "WQORRK", "ZZZ"}, | |
| 103 | + | wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindStoppedSession, ResultKindCommand, ResultKindCommand}, | |
| 104 | + | wantPositions: [][]int{{0, 1, 2, 3}, {0, 1, 2, 3}, {0, 1, 2, 3}, {0, 2, 3, 5}, nil}, | |
| 105 | + | }, | |
| 106 | + | { | |
| 107 | + | name: "equivalent sessions sort by normalized text", | |
| 108 | + | results: []Result{ | |
| 109 | + | NewStoppedSessionResult("aBravo", time.Time{}), | |
| 110 | + | NewStoppedSessionResult("aAlpha", time.Time{}), | |
| 111 | + | }, | |
| 112 | + | query: "a", | |
| 113 | + | wantText: []string{"aAlpha", "aBravo"}, | |
| 114 | + | wantKinds: []ResultKind{ResultKindStoppedSession, ResultKindStoppedSession}, | |
| 115 | + | wantPositions: [][]int{{0}, {0}}, | |
| 116 | + | }, | |
| 117 | + | { | |
| 118 | + | name: "kind then normalized text then stable order", | |
| 119 | + | results: []Result{ | |
| 120 | + | NewStoppedSessionResult("aBravo", time.Time{}), | |
| 121 | + | NewStoppedSessionResult("aAlpha", time.Time{}), | |
| 122 | + | NewActiveSessionResult("aZulu", time.Time{}, "z"), | |
| 123 | + | NewActiveSessionResult("aEcho", time.Time{}, "e"), | |
| 124 | + | NewActiveSessionResult("aEcho", time.Time{}, "e2"), | |
| 125 | + | NewCommandResult(cmd("AX", "", "")), | |
| 126 | + | }, | |
| 127 | + | query: "a", | |
| 128 | + | wantText: []string{"AX", "aEcho", "aEcho", "aZulu", "aAlpha", "aBravo"}, | |
| 129 | + | wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindStoppedSession, ResultKindStoppedSession}, | |
| 130 | + | wantPositions: [][]int{{0}, {0}, {0}, {0}, {0}, {0}}, | |
| 131 | + | wantActiveIDs: map[int]domain.SessionID{1: "e", 2: "e2", 3: "z"}, | |
| 132 | + | }, | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | for _, tt := range tests { | |
| 136 | + | t.Run(tt.name, func(t *testing.T) { | |
| 137 | + | matches := Fuzzy(tt.results, tt.query) | |
| 138 | + | require.Equal(t, tt.wantText, matchSearchText(matches)) | |
| 139 | + | require.Equal(t, tt.wantKinds, matchKinds(matches)) | |
| 140 | + | positions := make([][]int, len(matches)) | |
| 141 | + | for i := range matches { | |
| 142 | + | positions[i] = matches[i].Positions | |
| 143 | + | } | |
| 144 | + | require.Equal(t, tt.wantPositions, positions) | |
| 145 | + | for index, wantID := range tt.wantActiveIDs { | |
| 146 | + | gotID, ok := matches[index].Result.SessionID() | |
| 147 | + | require.True(t, ok) | |
| 148 | + | require.Equal(t, wantID, gotID) | |
| 149 | + | } | |
| 150 | + | }) | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func matchSearchText(matches []Match) []string { | |
| 155 | + | out := make([]string, len(matches)) | |
| 156 | + | for i, match := range matches { | |
| 157 | + | out[i] = match.Result.SearchText() | |
| 158 | + | } | |
| 159 | + | return out | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func matchKinds(matches []Match) []ResultKind { | |
| 163 | + | out := make([]ResultKind, len(matches)) | |
| 164 | + | for i, match := range matches { | |
| 165 | + | out[i] = match.Result.Kind() | |
| 166 | + | } | |
| 167 | + | return out | |
| 168 | + | } | |
| @@ -2,7 +2,9 @@ package palette | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | + | "time" | |||
| 5 | 6 | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 6 | "github.com/bnema/vev/internal/usecase/command" | 8 | "github.com/bnema/vev/internal/usecase/command" | ||
| 7 | "github.com/stretchr/testify/require" | 9 | "github.com/stretchr/testify/require" | ||
| 8 | ) | 10 | ) | ||
| @@ -14,52 +16,56 @@ func cmd(code, name, desc string) command.Command { | |||||
| 14 | func codes(matches []Match) []string { | 16 | func codes(matches []Match) []string { | ||
| 15 | out := make([]string, len(matches)) | 17 | out := make([]string, len(matches)) | ||
| 16 | for i, match := range matches { | 18 | for i, match := range matches { | ||
| 17 | - | out[i] = match.Command.Code | 19 | + | cmd, ok := match.Result.Command() |
| 20 | + | if !ok { | |||
| 21 | + | continue | |||
| 22 | + | } | |||
| 23 | + | out[i] = cmd.Code | |||
| 18 | } | 24 | } | ||
| 19 | return out | 25 | return out | ||
| 20 | } | 26 | } | ||
| 21 | 27 | ||||
| 22 | func TestFuzzyOrdersVisibleFieldScoringTiers(t *testing.T) { | 28 | func TestFuzzyOrdersVisibleFieldScoringTiers(t *testing.T) { | ||
| 23 | - | matches := Fuzzy([]command.Command{ | 29 | + | matches := Fuzzy(CommandResults([]command.Command{ |
| 24 | cmd("ZQQ", "unused", "alpha beta"), | 30 | cmd("ZQQ", "unused", "alpha beta"), | ||
| 25 | cmd("XAB", "unused", "subsequence code"), | 31 | cmd("XAB", "unused", "subsequence code"), | ||
| 26 | cmd("ABX", "unused", "prefix code"), | 32 | cmd("ABX", "unused", "prefix code"), | ||
| 27 | cmd("AB", "unused", "exact code"), | 33 | cmd("AB", "unused", "exact code"), | ||
| 28 | - | }, "ab") | 34 | + | }), "ab") |
| 29 | 35 | ||||
| 30 | require.Equal(t, []string{"AB", "ABX", "XAB", "ZQQ"}, codes(matches)) | 36 | require.Equal(t, []string{"AB", "ABX", "XAB", "ZQQ"}, codes(matches)) | ||
| 31 | require.Empty(t, matches[3].Positions, "description matches do not expose code highlight positions") | 37 | require.Empty(t, matches[3].Positions, "description matches do not expose code highlight positions") | ||
| 32 | } | 38 | } | ||
| 33 | 39 | ||||
| 34 | func TestFuzzyDoesNotMatchCommandNames(t *testing.T) { | 40 | func TestFuzzyDoesNotMatchCommandNames(t *testing.T) { | ||
| 35 | - | matches := Fuzzy([]command.Command{ | 41 | + | matches := Fuzzy(CommandResults([]command.Command{ |
| 36 | cmd("CPY", "Create Alpha Beta", "Enter copy mode"), | 42 | cmd("CPY", "Create Alpha Beta", "Enter copy mode"), | ||
| 37 | cmd("ZQQ", "unused", "Alpha Beta tools"), | 43 | cmd("ZQQ", "unused", "Alpha Beta tools"), | ||
| 38 | - | }, "ab") | 44 | + | }), "ab") |
| 39 | 45 | ||||
| 40 | require.Equal(t, []string{"ZQQ"}, codes(matches)) | 46 | require.Equal(t, []string{"ZQQ"}, codes(matches)) | ||
| 41 | } | 47 | } | ||
| 42 | 48 | ||||
| 43 | func TestFuzzyDescriptionMatchingIsCaseInsensitiveWithoutCodeHighlights(t *testing.T) { | 49 | func TestFuzzyDescriptionMatchingIsCaseInsensitiveWithoutCodeHighlights(t *testing.T) { | ||
| 44 | - | matches := Fuzzy([]command.Command{cmd("CPY", "unused", "Enter COPY mode")}, "copy") | 50 | + | matches := Fuzzy(CommandResults([]command.Command{cmd("CPY", "unused", "Enter COPY mode")}), "copy") |
| 45 | 51 | ||||
| 46 | require.Len(t, matches, 1) | 52 | require.Len(t, matches, 1) | ||
| 47 | require.Empty(t, matches[0].Positions) | 53 | require.Empty(t, matches[0].Positions) | ||
| 48 | } | 54 | } | ||
| 49 | 55 | ||||
| 50 | func TestFuzzyTieBreaksBySpanFirstThenCode(t *testing.T) { | 56 | func TestFuzzyTieBreaksBySpanFirstThenCode(t *testing.T) { | ||
| 51 | - | matches := Fuzzy([]command.Command{ | 57 | + | matches := Fuzzy(CommandResults([]command.Command{ |
| 52 | cmd("ZAXQB", "", ""), // span 3, first 1 | 58 | cmd("ZAXQB", "", ""), // span 3, first 1 | ||
| 53 | cmd("BAXQ", "", ""), // span 3, first 1, code sorts first | 59 | cmd("BAXQ", "", ""), // span 3, first 1, code sorts first | ||
| 54 | cmd("PXAQ", "", ""), // span 2, first 2 | 60 | cmd("PXAQ", "", ""), // span 2, first 2 | ||
| 55 | cmd("BAQ", "", ""), // span 2, first 0 | 61 | cmd("BAQ", "", ""), // span 2, first 0 | ||
| 56 | - | }, "aq") | 62 | + | }), "aq") |
| 57 | 63 | ||||
| 58 | require.Equal(t, []string{"BAQ", "PXAQ", "BAXQ", "ZAXQB"}, codes(matches)) | 64 | require.Equal(t, []string{"BAQ", "PXAQ", "BAXQ", "ZAXQB"}, codes(matches)) | ||
| 59 | } | 65 | } | ||
| 60 | 66 | ||||
| 61 | func TestFuzzyIsCaseInsensitiveAndHighlightsCodeRunePositions(t *testing.T) { | 67 | func TestFuzzyIsCaseInsensitiveAndHighlightsCodeRunePositions(t *testing.T) { | ||
| 62 | - | matches := Fuzzy([]command.Command{cmd("CpY", "Copy mode", "Enter copy mode")}, "cy") | 68 | + | matches := Fuzzy(CommandResults([]command.Command{cmd("CpY", "Copy mode", "Enter copy mode")}), "cy") |
| 63 | 69 | ||||
| 64 | require.Len(t, matches, 1) | 70 | require.Len(t, matches, 1) | ||
| 65 | require.Equal(t, []int{0, 2}, matches[0].Positions) | 71 | require.Equal(t, []int{0, 2}, matches[0].Positions) | ||
| @@ -67,7 +73,96 @@ func TestFuzzyIsCaseInsensitiveAndHighlightsCodeRunePositions(t *testing.T) { | |||||
| 67 | 73 | ||||
| 68 | func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) { | 74 | func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) { | ||
| 69 | commands := []command.Command{cmd("B", "", ""), cmd("A", "", "")} | 75 | commands := []command.Command{cmd("B", "", ""), cmd("A", "", "")} | ||
| 70 | - | matches := Fuzzy(commands, "") | 76 | + | matches := Fuzzy(CommandResults(commands), "") |
| 71 | 77 | ||||
| 72 | require.Equal(t, []string{"B", "A"}, codes(matches)) | 78 | require.Equal(t, []string{"B", "A"}, codes(matches)) | ||
| 73 | } | 79 | } | ||
| 80 | + | ||||
| 81 | + | func TestFuzzyOrdersMixedResults(t *testing.T) { | |||
| 82 | + | created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC) | |||
| 83 | + | tests := []struct { | |||
| 84 | + | name string | |||
| 85 | + | results []Result | |||
| 86 | + | query string | |||
| 87 | + | wantText []string | |||
| 88 | + | wantKinds []ResultKind | |||
| 89 | + | wantPositions [][]int | |||
| 90 | + | wantActiveIDs map[int]domain.SessionID | |||
| 91 | + | }{ | |||
| 92 | + | { | |||
| 93 | + | name: "command shortcode precedes sessions and description matches", | |||
| 94 | + | results: []Result{ | |||
| 95 | + | NewStoppedSessionResult("work", created), | |||
| 96 | + | NewCommandResult(cmd("WORK", "", "Create a workspace")), | |||
| 97 | + | NewActiveSessionResult("work", created, domain.SessionID("work-id")), | |||
| 98 | + | NewCommandResult(cmd("WQORRK", "", "")), | |||
| 99 | + | NewCommandResult(cmd("ZZZ", "", "work tools")), | |||
| 100 | + | }, | |||
| 101 | + | query: "work", | |||
| 102 | + | wantText: []string{"WORK", "work", "work", "WQORRK", "ZZZ"}, | |||
| 103 | + | wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindStoppedSession, ResultKindCommand, ResultKindCommand}, | |||
| 104 | + | wantPositions: [][]int{{0, 1, 2, 3}, {0, 1, 2, 3}, {0, 1, 2, 3}, {0, 2, 3, 5}, nil}, | |||
| 105 | + | }, | |||
| 106 | + | { | |||
| 107 | + | name: "equivalent sessions sort by normalized text", | |||
| 108 | + | results: []Result{ | |||
| 109 | + | NewStoppedSessionResult("aBravo", time.Time{}), | |||
| 110 | + | NewStoppedSessionResult("aAlpha", time.Time{}), | |||
| 111 | + | }, | |||
| 112 | + | query: "a", | |||
| 113 | + | wantText: []string{"aAlpha", "aBravo"}, | |||
| 114 | + | wantKinds: []ResultKind{ResultKindStoppedSession, ResultKindStoppedSession}, | |||
| 115 | + | wantPositions: [][]int{{0}, {0}}, | |||
| 116 | + | }, | |||
| 117 | + | { | |||
| 118 | + | name: "kind then normalized text then stable order", | |||
| 119 | + | results: []Result{ | |||
| 120 | + | NewStoppedSessionResult("aBravo", time.Time{}), | |||
| 121 | + | NewStoppedSessionResult("aAlpha", time.Time{}), | |||
| 122 | + | NewActiveSessionResult("aZulu", time.Time{}, "z"), | |||
| 123 | + | NewActiveSessionResult("aEcho", time.Time{}, "e"), | |||
| 124 | + | NewActiveSessionResult("aEcho", time.Time{}, "e2"), | |||
| 125 | + | NewCommandResult(cmd("AX", "", "")), | |||
| 126 | + | }, | |||
| 127 | + | query: "a", | |||
| 128 | + | wantText: []string{"AX", "aEcho", "aEcho", "aZulu", "aAlpha", "aBravo"}, | |||
| 129 | + | wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindStoppedSession, ResultKindStoppedSession}, | |||
| 130 | + | wantPositions: [][]int{{0}, {0}, {0}, {0}, {0}, {0}}, | |||
| 131 | + | wantActiveIDs: map[int]domain.SessionID{1: "e", 2: "e2", 3: "z"}, | |||
| 132 | + | }, | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | for _, tt := range tests { | |||
| 136 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 137 | + | matches := Fuzzy(tt.results, tt.query) | |||
| 138 | + | require.Equal(t, tt.wantText, matchSearchText(matches)) | |||
| 139 | + | require.Equal(t, tt.wantKinds, matchKinds(matches)) | |||
| 140 | + | positions := make([][]int, len(matches)) | |||
| 141 | + | for i := range matches { | |||
| 142 | + | positions[i] = matches[i].Positions | |||
| 143 | + | } | |||
| 144 | + | require.Equal(t, tt.wantPositions, positions) | |||
| 145 | + | for index, wantID := range tt.wantActiveIDs { | |||
| 146 | + | gotID, ok := matches[index].Result.SessionID() | |||
| 147 | + | require.True(t, ok) | |||
| 148 | + | require.Equal(t, wantID, gotID) | |||
| 149 | + | } | |||
| 150 | + | }) | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func matchSearchText(matches []Match) []string { | |||
| 155 | + | out := make([]string, len(matches)) | |||
| 156 | + | for i, match := range matches { | |||
| 157 | + | out[i] = match.Result.SearchText() | |||
| 158 | + | } | |||
| 159 | + | return out | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func matchKinds(matches []Match) []ResultKind { | |||
| 163 | + | out := make([]ResultKind, len(matches)) | |||
| 164 | + | for i, match := range matches { | |||
| 165 | + | out[i] = match.Result.Kind() | |||
| 166 | + | } | |||
| 167 | + | return out | |||
| 168 | + | } | |||
M internal/usecase/palette/model.go +123 -71
| @@ -2,6 +2,7 @@ package palette | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "unicode" | |
| 5 | + | "unicode/utf8" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/vev/internal/domain" | |
| 7 | 8 | "github.com/bnema/vev/internal/usecase/command" | |
| @@ -20,23 +21,32 @@ type RenderStyles struct { | |||
| 20 | 21 | type RenderOptions struct { | |
| 21 | 22 | Styles RenderStyles | |
| 22 | 23 | Guidance string | |
| 24 | + | // Feedback is interaction-scoped daemon feedback. It replaces the selected | |
| 25 | + | // row's contextual detail without adding a searchable result or row. | |
| 26 | + | Feedback string | |
| 23 | 27 | } | |
| 24 | 28 | ||
| 25 | 29 | type Model struct { | |
| 26 | - | commands []command.Command | |
| 27 | - | input ui.TextInput | |
| 28 | - | matches []Match | |
| 29 | - | selected int | |
| 30 | - | scroll int | |
| 30 | + | results []Result | |
| 31 | + | argumentResults []Result | |
| 32 | + | input ui.TextInput | |
| 33 | + | matches []Match | |
| 34 | + | selected int | |
| 35 | + | scroll int | |
| 31 | 36 | } | |
| 32 | 37 | ||
| 33 | - | func New(commands []command.Command) *Model { | |
| 34 | - | m := &Model{commands: append([]command.Command(nil), commands...)} | |
| 38 | + | // New accepts typed immutable palette results. | |
| 39 | + | func New(results []Result) *Model { | |
| 40 | + | copied := append([]Result(nil), results...) | |
| 41 | + | m := &Model{ | |
| 42 | + | results: copied, | |
| 43 | + | argumentResults: requiredArgumentResults(copied), | |
| 44 | + | } | |
| 35 | 45 | m.refresh() | |
| 36 | 46 | return m | |
| 37 | 47 | } | |
| 38 | 48 | ||
| 39 | - | func NewRegistry() *Model { return New(command.Registry()) } | |
| 49 | + | func NewRegistry() *Model { return New(CommandResults(command.Registry())) } | |
| 40 | 50 | ||
| 41 | 51 | func DefaultRenderStyles() RenderStyles { | |
| 42 | 52 | selection := renderer.DefaultStyle() | |
| @@ -49,16 +59,14 @@ func DefaultRenderStyles() RenderStyles { | |||
| 49 | 59 | func (m *Model) Insert(r rune) { | |
| 50 | 60 | if m != nil { | |
| 51 | 61 | m.input.Insert(r) | |
| 52 | - | m.selected = 0 | |
| 53 | - | m.scroll = 0 | |
| 62 | + | m.selected, m.scroll = 0, 0 | |
| 54 | 63 | m.refresh() | |
| 55 | 64 | } | |
| 56 | 65 | } | |
| 57 | 66 | func (m *Model) Backspace() { | |
| 58 | 67 | if m != nil && m.input.Value() != "" { | |
| 59 | 68 | m.input.Backspace() | |
| 60 | - | m.selected = 0 | |
| 61 | - | m.scroll = 0 | |
| 69 | + | m.selected, m.scroll = 0, 0 | |
| 62 | 70 | m.refresh() | |
| 63 | 71 | } | |
| 64 | 72 | } | |
| @@ -81,26 +89,32 @@ func (m *Model) Down() { | |||
| 81 | 89 | m.clamp() | |
| 82 | 90 | } | |
| 83 | 91 | } | |
| 84 | - | func (m *Model) Selected() (command.Command, bool) { | |
| 92 | + | ||
| 93 | + | // Selected returns the typed immutable target, not an executable command. | |
| 94 | + | func (m *Model) Selected() (Result, bool) { | |
| 85 | 95 | if m == nil || m.selected < 0 || m.selected >= len(m.matches) { | |
| 86 | - | return command.Command{}, false | |
| 96 | + | return Result{}, false | |
| 87 | 97 | } | |
| 88 | - | return m.matches[m.selected].Command, true | |
| 98 | + | return m.matches[m.selected].Result, true | |
| 89 | 99 | } | |
| 90 | 100 | ||
| 91 | - | // CompleteSelected replaces the query with the selected command's effective | |
| 92 | - | // code. Required-argument commands retain the typed argument bytes. | |
| 101 | + | // CompleteSelected only completes static command results. Session targets can | |
| 102 | + | // never be parsed, completed, or otherwise treated as commands. | |
| 93 | 103 | func (m *Model) CompleteSelected() bool { | |
| 94 | 104 | selected, ok := m.Selected() | |
| 95 | 105 | if !ok { | |
| 96 | 106 | return false | |
| 97 | 107 | } | |
| 108 | + | cmd, ok := selected.Command() | |
| 109 | + | if !ok { | |
| 110 | + | return false | |
| 111 | + | } | |
| 98 | 112 | ||
| 99 | 113 | query := m.Query() | |
| 100 | - | completed := selected.Code | |
| 101 | - | if selected.Arguments == command.ArgumentsRequired { | |
| 114 | + | completed := cmd.Code | |
| 115 | + | if cmd.Arguments == command.ArgumentsRequired { | |
| 102 | 116 | token, args, hasSeparator := completionParts(query) | |
| 103 | - | if token == selected.Code && hasSeparator && args != "" { | |
| 117 | + | if token == cmd.Code && hasSeparator && args != "" { | |
| 104 | 118 | return false | |
| 105 | 119 | } | |
| 106 | 120 | completed += " " | |
| @@ -113,8 +127,7 @@ func (m *Model) CompleteSelected() bool { | |||
| 113 | 127 | } | |
| 114 | 128 | ||
| 115 | 129 | m.input.SetValue(completed) | |
| 116 | - | m.selected = 0 | |
| 117 | - | m.scroll = 0 | |
| 130 | + | m.selected, m.scroll = 0, 0 | |
| 118 | 131 | m.refresh() | |
| 119 | 132 | return true | |
| 120 | 133 | } | |
| @@ -136,12 +149,12 @@ func completionParts(query string) (token, args string, hasSeparator bool) { | |||
| 136 | 149 | return query, "", false | |
| 137 | 150 | } | |
| 138 | 151 | ||
| 139 | - | // ArgumentCommand returns the exact argument-taking command being entered. | |
| 152 | + | // ArgumentCommand returns the exact argument-taking static command being entered. | |
| 140 | 153 | func (m *Model) ArgumentCommand() (command.Command, bool) { | |
| 141 | 154 | if m == nil { | |
| 142 | 155 | return command.Command{}, false | |
| 143 | 156 | } | |
| 144 | - | return ArgumentCommand(m.commands, m.Query()) | |
| 157 | + | return ArgumentCommand(m.results, m.Query()) | |
| 145 | 158 | } | |
| 146 | 159 | ||
| 147 | 160 | func (m *Model) Matches() []Match { | |
| @@ -158,30 +171,39 @@ func (m *Model) Matches() []Match { | |||
| 158 | 171 | ||
| 159 | 172 | func (m *Model) refresh() { | |
| 160 | 173 | query := m.input.Value() | |
| 161 | - | m.matches = Fuzzy(m.commands, query) | |
| 162 | - | // Once an argument-taking token is exact, retain its row while its | |
| 174 | + | m.matches = Fuzzy(m.results, query) | |
| 175 | + | // Once an argument-taking static token is exact, retain its row while its | |
| 163 | 176 | // arguments make ordinary fuzzy matching inapplicable. | |
| 164 | - | if cmd, ok := ArgumentCommand(m.commands, query); ok { | |
| 165 | - | m.prependMatch(cmd) | |
| 177 | + | if result, _, ok := argumentResult(m.results, query); ok { | |
| 178 | + | m.prependMatch(result) | |
| 166 | 179 | } else if len(m.matches) == 0 { | |
| 167 | 180 | // Arguments are not part of static command matching. When they would | |
| 168 | 181 | // otherwise clear the list, keep required-argument candidates matched by | |
| 169 | 182 | // the partial first token so Tab can complete them. | |
| 170 | 183 | token, _, hasSeparator := completionParts(query) | |
| 171 | 184 | if hasSeparator { | |
| 172 | - | for _, match := range Fuzzy(m.commands, token) { | |
| 173 | - | if match.Command.Arguments == command.ArgumentsRequired { | |
| 174 | - | m.matches = append(m.matches, match) | |
| 175 | - | } | |
| 176 | - | } | |
| 185 | + | m.matches = Fuzzy(m.argumentResults, token) | |
| 177 | 186 | } | |
| 178 | 187 | } | |
| 179 | 188 | m.clamp() | |
| 180 | 189 | } | |
| 181 | 190 | ||
| 182 | - | func (m *Model) prependMatch(cmd command.Command) { | |
| 191 | + | func requiredArgumentResults(results []Result) []Result { | |
| 192 | + | commands := make([]Result, 0, len(results)) | |
| 193 | + | for _, result := range results { | |
| 194 | + | cmd, ok := result.Command() | |
| 195 | + | if ok && cmd.Arguments == command.ArgumentsRequired { | |
| 196 | + | commands = append(commands, result) | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | return commands | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func (m *Model) prependMatch(result Result) { | |
| 203 | + | cmd, _ := result.Command() | |
| 183 | 204 | for i, match := range m.matches { | |
| 184 | - | if match.Command.Code != cmd.Code { | |
| 205 | + | candidate, ok := match.Result.Command() | |
| 206 | + | if !ok || candidate.Code != cmd.Code { | |
| 185 | 207 | continue | |
| 186 | 208 | } | |
| 187 | 209 | if i > 0 { | |
| @@ -190,12 +212,11 @@ func (m *Model) prependMatch(cmd command.Command) { | |||
| 190 | 212 | } | |
| 191 | 213 | return | |
| 192 | 214 | } | |
| 193 | - | m.matches = append([]Match{{Command: cmd}}, m.matches...) | |
| 215 | + | m.matches = append([]Match{newMatch(result, 0)}, m.matches...) | |
| 194 | 216 | } | |
| 195 | 217 | func (m *Model) clamp() { | |
| 196 | 218 | if len(m.matches) == 0 { | |
| 197 | - | m.selected = -1 | |
| 198 | - | m.scroll = 0 | |
| 219 | + | m.selected, m.scroll = -1, 0 | |
| 199 | 220 | return | |
| 200 | 221 | } | |
| 201 | 222 | if m.selected < 0 { | |
| @@ -218,21 +239,15 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame { | |||
| 218 | 239 | if frame.Width == 0 || frame.Height == 0 { | |
| 219 | 240 | return frame | |
| 220 | 241 | } | |
| 221 | - | base := renderer.DefaultStyle() | |
| 222 | - | selection := styles.Selection | |
| 223 | - | desc := styles.Description | |
| 242 | + | base, selection, desc := renderer.DefaultStyle(), styles.Selection, styles.Description | |
| 224 | 243 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | |
| 225 | 244 | if m == nil { | |
| 226 | 245 | ui.DrawInputLine(frame, 0, "> ", "", base, selection) | |
| 227 | 246 | return frame | |
| 228 | 247 | } | |
| 229 | 248 | ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection) | |
| 230 | - | start := 1 | |
| 231 | - | visible := frame.Height - start | |
| 232 | - | if visible <= 0 { | |
| 233 | - | return frame | |
| 234 | - | } | |
| 235 | - | if len(m.matches) == 0 { | |
| 249 | + | start, visible := 1, frame.Height-1 | |
| 250 | + | if visible <= 0 || len(m.matches) == 0 { | |
| 236 | 251 | return frame | |
| 237 | 252 | } | |
| 238 | 253 | if m.selected < m.scroll { | |
| @@ -243,8 +258,8 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame { | |||
| 243 | 258 | } | |
| 244 | 259 | codeWidth := 0 | |
| 245 | 260 | for _, match := range m.matches { | |
| 246 | - | if len([]rune(match.Command.Code)) > codeWidth { | |
| 247 | - | codeWidth = len([]rune(match.Command.Code)) | |
| 261 | + | if cmd, ok := match.Result.Command(); ok && utf8.RuneCountInString(cmd.Code) > codeWidth { | |
| 262 | + | codeWidth = utf8.RuneCountInString(cmd.Code) | |
| 248 | 263 | } | |
| 249 | 264 | } | |
| 250 | 265 | activeCmd, activeOK := m.ArgumentCommand() | |
| @@ -259,34 +274,71 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame { | |||
| 259 | 274 | style = selection | |
| 260 | 275 | } | |
| 261 | 276 | ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) | |
| 262 | - | x := 0 | |
| 263 | - | highlight := map[int]bool{} | |
| 264 | - | for _, p := range match.Positions { | |
| 265 | - | highlight[p] = true | |
| 277 | + | if cmd, ok := match.Result.Command(); ok { | |
| 278 | + | m.renderCommand(frame, y+start, style, selection, desc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Feedback, idx == m.selected) | |
| 279 | + | continue | |
| 266 | 280 | } | |
| 267 | - | for i, r := range []rune(match.Command.Code) { | |
| 268 | - | cellStyle := style | |
| 281 | + | m.renderSession(frame, y+start, style, selection, match, opts.Feedback, idx == m.selected) | |
| 282 | + | } | |
| 283 | + | return frame | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, desc renderer.Style, codeWidth int, cmd command.Command, positions []int, activeCmd command.Command, activeOK bool, guidance, feedback string, selected bool) { | |
| 287 | + | x, nextHighlight := 0, 0 | |
| 288 | + | for _, r := range cmd.Code { | |
| 289 | + | cellStyle := style | |
| 290 | + | cellStyle.Bold = true | |
| 291 | + | if nextHighlight < len(positions) && positions[nextHighlight] == x { | |
| 292 | + | cellStyle = selection | |
| 269 | 293 | cellStyle.Bold = true | |
| 270 | - | if highlight[i] { | |
| 271 | - | cellStyle = selection | |
| 272 | - | cellStyle.Bold = true | |
| 273 | - | } | |
| 274 | - | if x < frame.Width { | |
| 275 | - | frame.Set(x, y+start, renderer.Cell{Rune: r, Style: cellStyle}) | |
| 276 | - | } | |
| 277 | - | x++ | |
| 294 | + | nextHighlight++ | |
| 278 | 295 | } | |
| 279 | - | for x < codeWidth+1 && x < frame.Width { | |
| 280 | - | frame.Set(x, y+start, renderer.Cell{Rune: ' ', Style: style}) | |
| 281 | - | x++ | |
| 296 | + | if x < frame.Width { | |
| 297 | + | frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle}) | |
| 282 | 298 | } | |
| 283 | - | description := match.Command.Desc | |
| 284 | - | if opts.Guidance != "" && activeOK && match.Command.ContextHint != command.ContextHintNone && activeCmd.Code == match.Command.Code { | |
| 285 | - | description = opts.Guidance | |
| 299 | + | x++ | |
| 300 | + | } | |
| 301 | + | for x < codeWidth+1 && x < frame.Width { | |
| 302 | + | frame.Set(x, y, renderer.Cell{Rune: ' ', Style: style}) | |
| 303 | + | x++ | |
| 304 | + | } | |
| 305 | + | description := cmd.Desc | |
| 306 | + | if feedback != "" && selected { | |
| 307 | + | description = feedback | |
| 308 | + | } else if guidance != "" && activeOK && cmd.ContextHint != command.ContextHintNone && activeCmd.Code == cmd.Code { | |
| 309 | + | description = guidance | |
| 310 | + | } | |
| 311 | + | ui.DrawText(frame, x, y, frame.Width, description, mergePaletteDescStyle(style, desc)) | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) { | |
| 315 | + | x, nextHighlight := 0, 0 | |
| 316 | + | prefixWidth := utf8.RuneCountInString(match.Result.sessionDisplayPrefix()) | |
| 317 | + | for _, r := range match.Result.DisplayText() { | |
| 318 | + | cellStyle := style | |
| 319 | + | if nextHighlight < len(match.Positions) && prefixWidth+match.Positions[nextHighlight] == x { | |
| 320 | + | cellStyle = selection | |
| 321 | + | cellStyle.Bold = true | |
| 322 | + | nextHighlight++ | |
| 323 | + | } | |
| 324 | + | if x < frame.Width { | |
| 325 | + | frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle}) | |
| 286 | 326 | } | |
| 287 | - | ui.DrawText(frame, x, y+start, frame.Width, description, mergePaletteDescStyle(style, desc)) | |
| 327 | + | x++ | |
| 328 | + | } | |
| 329 | + | if feedback == "" || !selected { | |
| 330 | + | return | |
| 331 | + | } | |
| 332 | + | if x < frame.Width { | |
| 333 | + | frame.Set(x, y, renderer.Cell{Rune: ' ', Style: style}) | |
| 334 | + | } | |
| 335 | + | x++ | |
| 336 | + | for _, r := range feedback { | |
| 337 | + | if x < frame.Width { | |
| 338 | + | frame.Set(x, y, renderer.Cell{Rune: r, Style: style}) | |
| 339 | + | } | |
| 340 | + | x++ | |
| 288 | 341 | } | |
| 289 | - | return frame | |
| 290 | 342 | } | |
| 291 | 343 | ||
| 292 | 344 | func mergePaletteDescStyle(line, desc renderer.Style) renderer.Style { | |
| @@ -2,6 +2,7 @@ package palette | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "unicode" | 4 | "unicode" | ||
| 5 | + | "unicode/utf8" | |||
| 5 | 6 | ||||
| 6 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 7 | "github.com/bnema/vev/internal/usecase/command" | 8 | "github.com/bnema/vev/internal/usecase/command" | ||
| @@ -20,23 +21,32 @@ type RenderStyles struct { | |||||
| 20 | type RenderOptions struct { | 21 | type RenderOptions struct { | ||
| 21 | Styles RenderStyles | 22 | Styles RenderStyles | ||
| 22 | Guidance string | 23 | Guidance string | ||
| 24 | + | // Feedback is interaction-scoped daemon feedback. It replaces the selected | |||
| 25 | + | // row's contextual detail without adding a searchable result or row. | |||
| 26 | + | Feedback string | |||
| 23 | } | 27 | } | ||
| 24 | 28 | ||||
| 25 | type Model struct { | 29 | type Model struct { | ||
| 26 | - | commands []command.Command | 30 | + | results []Result |
| 27 | - | input ui.TextInput | 31 | + | argumentResults []Result |
| 28 | - | matches []Match | 32 | + | input ui.TextInput |
| 29 | - | selected int | 33 | + | matches []Match |
| 30 | - | scroll int | 34 | + | selected int |
| 35 | + | scroll int | |||
| 31 | } | 36 | } | ||
| 32 | 37 | ||||
| 33 | - | func New(commands []command.Command) *Model { | 38 | + | // New accepts typed immutable palette results. |
| 34 | - | m := &Model{commands: append([]command.Command(nil), commands...)} | 39 | + | func New(results []Result) *Model { |
| 40 | + | copied := append([]Result(nil), results...) | |||
| 41 | + | m := &Model{ | |||
| 42 | + | results: copied, | |||
| 43 | + | argumentResults: requiredArgumentResults(copied), | |||
| 44 | + | } | |||
| 35 | m.refresh() | 45 | m.refresh() | ||
| 36 | return m | 46 | return m | ||
| 37 | } | 47 | } | ||
| 38 | 48 | ||||
| 39 | - | func NewRegistry() *Model { return New(command.Registry()) } | 49 | + | func NewRegistry() *Model { return New(CommandResults(command.Registry())) } |
| 40 | 50 | ||||
| 41 | func DefaultRenderStyles() RenderStyles { | 51 | func DefaultRenderStyles() RenderStyles { | ||
| 42 | selection := renderer.DefaultStyle() | 52 | selection := renderer.DefaultStyle() | ||
| @@ -49,16 +59,14 @@ func DefaultRenderStyles() RenderStyles { | |||||
| 49 | func (m *Model) Insert(r rune) { | 59 | func (m *Model) Insert(r rune) { | ||
| 50 | if m != nil { | 60 | if m != nil { | ||
| 51 | m.input.Insert(r) | 61 | m.input.Insert(r) | ||
| 52 | - | m.selected = 0 | 62 | + | m.selected, m.scroll = 0, 0 |
| 53 | - | m.scroll = 0 | |||
| 54 | m.refresh() | 63 | m.refresh() | ||
| 55 | } | 64 | } | ||
| 56 | } | 65 | } | ||
| 57 | func (m *Model) Backspace() { | 66 | func (m *Model) Backspace() { | ||
| 58 | if m != nil && m.input.Value() != "" { | 67 | if m != nil && m.input.Value() != "" { | ||
| 59 | m.input.Backspace() | 68 | m.input.Backspace() | ||
| 60 | - | m.selected = 0 | 69 | + | m.selected, m.scroll = 0, 0 |
| 61 | - | m.scroll = 0 | |||
| 62 | m.refresh() | 70 | m.refresh() | ||
| 63 | } | 71 | } | ||
| 64 | } | 72 | } | ||
| @@ -81,26 +89,32 @@ func (m *Model) Down() { | |||||
| 81 | m.clamp() | 89 | m.clamp() | ||
| 82 | } | 90 | } | ||
| 83 | } | 91 | } | ||
| 84 | - | func (m *Model) Selected() (command.Command, bool) { | 92 | + | |
| 93 | + | // Selected returns the typed immutable target, not an executable command. | |||
| 94 | + | func (m *Model) Selected() (Result, bool) { | |||
| 85 | if m == nil || m.selected < 0 || m.selected >= len(m.matches) { | 95 | if m == nil || m.selected < 0 || m.selected >= len(m.matches) { | ||
| 86 | - | return command.Command{}, false | 96 | + | return Result{}, false |
| 87 | } | 97 | } | ||
| 88 | - | return m.matches[m.selected].Command, true | 98 | + | return m.matches[m.selected].Result, true |
| 89 | } | 99 | } | ||
| 90 | 100 | ||||
| 91 | - | // CompleteSelected replaces the query with the selected command's effective | 101 | + | // CompleteSelected only completes static command results. Session targets can |
| 92 | - | // code. Required-argument commands retain the typed argument bytes. | 102 | + | // never be parsed, completed, or otherwise treated as commands. |
| 93 | func (m *Model) CompleteSelected() bool { | 103 | func (m *Model) CompleteSelected() bool { | ||
| 94 | selected, ok := m.Selected() | 104 | selected, ok := m.Selected() | ||
| 95 | if !ok { | 105 | if !ok { | ||
| 96 | return false | 106 | return false | ||
| 97 | } | 107 | } | ||
| 108 | + | cmd, ok := selected.Command() | |||
| 109 | + | if !ok { | |||
| 110 | + | return false | |||
| 111 | + | } | |||
| 98 | 112 | ||||
| 99 | query := m.Query() | 113 | query := m.Query() | ||
| 100 | - | completed := selected.Code | 114 | + | completed := cmd.Code |
| 101 | - | if selected.Arguments == command.ArgumentsRequired { | 115 | + | if cmd.Arguments == command.ArgumentsRequired { |
| 102 | token, args, hasSeparator := completionParts(query) | 116 | token, args, hasSeparator := completionParts(query) | ||
| 103 | - | if token == selected.Code && hasSeparator && args != "" { | 117 | + | if token == cmd.Code && hasSeparator && args != "" { |
| 104 | return false | 118 | return false | ||
| 105 | } | 119 | } | ||
| 106 | completed += " " | 120 | completed += " " | ||
| @@ -113,8 +127,7 @@ func (m *Model) CompleteSelected() bool { | |||||
| 113 | } | 127 | } | ||
| 114 | 128 | ||||
| 115 | m.input.SetValue(completed) | 129 | m.input.SetValue(completed) | ||
| 116 | - | m.selected = 0 | 130 | + | m.selected, m.scroll = 0, 0 |
| 117 | - | m.scroll = 0 | |||
| 118 | m.refresh() | 131 | m.refresh() | ||
| 119 | return true | 132 | return true | ||
| 120 | } | 133 | } | ||
| @@ -136,12 +149,12 @@ func completionParts(query string) (token, args string, hasSeparator bool) { | |||||
| 136 | return query, "", false | 149 | return query, "", false | ||
| 137 | } | 150 | } | ||
| 138 | 151 | ||||
| 139 | - | // ArgumentCommand returns the exact argument-taking command being entered. | 152 | + | // ArgumentCommand returns the exact argument-taking static command being entered. |
| 140 | func (m *Model) ArgumentCommand() (command.Command, bool) { | 153 | func (m *Model) ArgumentCommand() (command.Command, bool) { | ||
| 141 | if m == nil { | 154 | if m == nil { | ||
| 142 | return command.Command{}, false | 155 | return command.Command{}, false | ||
| 143 | } | 156 | } | ||
| 144 | - | return ArgumentCommand(m.commands, m.Query()) | 157 | + | return ArgumentCommand(m.results, m.Query()) |
| 145 | } | 158 | } | ||
| 146 | 159 | ||||
| 147 | func (m *Model) Matches() []Match { | 160 | func (m *Model) Matches() []Match { | ||
| @@ -158,30 +171,39 @@ func (m *Model) Matches() []Match { | |||||
| 158 | 171 | ||||
| 159 | func (m *Model) refresh() { | 172 | func (m *Model) refresh() { | ||
| 160 | query := m.input.Value() | 173 | query := m.input.Value() | ||
| 161 | - | m.matches = Fuzzy(m.commands, query) | 174 | + | m.matches = Fuzzy(m.results, query) |
| 162 | - | // Once an argument-taking token is exact, retain its row while its | 175 | + | // Once an argument-taking static token is exact, retain its row while its |
| 163 | // arguments make ordinary fuzzy matching inapplicable. | 176 | // arguments make ordinary fuzzy matching inapplicable. | ||
| 164 | - | if cmd, ok := ArgumentCommand(m.commands, query); ok { | 177 | + | if result, _, ok := argumentResult(m.results, query); ok { |
| 165 | - | m.prependMatch(cmd) | 178 | + | m.prependMatch(result) |
| 166 | } else if len(m.matches) == 0 { | 179 | } else if len(m.matches) == 0 { | ||
| 167 | // Arguments are not part of static command matching. When they would | 180 | // Arguments are not part of static command matching. When they would | ||
| 168 | // otherwise clear the list, keep required-argument candidates matched by | 181 | // otherwise clear the list, keep required-argument candidates matched by | ||
| 169 | // the partial first token so Tab can complete them. | 182 | // the partial first token so Tab can complete them. | ||
| 170 | token, _, hasSeparator := completionParts(query) | 183 | token, _, hasSeparator := completionParts(query) | ||
| 171 | if hasSeparator { | 184 | if hasSeparator { | ||
| 172 | - | for _, match := range Fuzzy(m.commands, token) { | 185 | + | m.matches = Fuzzy(m.argumentResults, token) |
| 173 | - | if match.Command.Arguments == command.ArgumentsRequired { | |||
| 174 | - | m.matches = append(m.matches, match) | |||
| 175 | - | } | |||
| 176 | - | } | |||
| 177 | } | 186 | } | ||
| 178 | } | 187 | } | ||
| 179 | m.clamp() | 188 | m.clamp() | ||
| 180 | } | 189 | } | ||
| 181 | 190 | ||||
| 182 | - | func (m *Model) prependMatch(cmd command.Command) { | 191 | + | func requiredArgumentResults(results []Result) []Result { |
| 192 | + | commands := make([]Result, 0, len(results)) | |||
| 193 | + | for _, result := range results { | |||
| 194 | + | cmd, ok := result.Command() | |||
| 195 | + | if ok && cmd.Arguments == command.ArgumentsRequired { | |||
| 196 | + | commands = append(commands, result) | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | return commands | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func (m *Model) prependMatch(result Result) { | |||
| 203 | + | cmd, _ := result.Command() | |||
| 183 | for i, match := range m.matches { | 204 | for i, match := range m.matches { | ||
| 184 | - | if match.Command.Code != cmd.Code { | 205 | + | candidate, ok := match.Result.Command() |
| 206 | + | if !ok || candidate.Code != cmd.Code { | |||
| 185 | continue | 207 | continue | ||
| 186 | } | 208 | } | ||
| 187 | if i > 0 { | 209 | if i > 0 { | ||
| @@ -190,12 +212,11 @@ func (m *Model) prependMatch(cmd command.Command) { | |||||
| 190 | } | 212 | } | ||
| 191 | return | 213 | return | ||
| 192 | } | 214 | } | ||
| 193 | - | m.matches = append([]Match{{Command: cmd}}, m.matches...) | 215 | + | m.matches = append([]Match{newMatch(result, 0)}, m.matches...) |
| 194 | } | 216 | } | ||
| 195 | func (m *Model) clamp() { | 217 | func (m *Model) clamp() { | ||
| 196 | if len(m.matches) == 0 { | 218 | if len(m.matches) == 0 { | ||
| 197 | - | m.selected = -1 | 219 | + | m.selected, m.scroll = -1, 0 |
| 198 | - | m.scroll = 0 | |||
| 199 | return | 220 | return | ||
| 200 | } | 221 | } | ||
| 201 | if m.selected < 0 { | 222 | if m.selected < 0 { | ||
| @@ -218,21 +239,15 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame { | |||||
| 218 | if frame.Width == 0 || frame.Height == 0 { | 239 | if frame.Width == 0 || frame.Height == 0 { | ||
| 219 | return frame | 240 | return frame | ||
| 220 | } | 241 | } | ||
| 221 | - | base := renderer.DefaultStyle() | 242 | + | base, selection, desc := renderer.DefaultStyle(), styles.Selection, styles.Description |
| 222 | - | selection := styles.Selection | |||
| 223 | - | desc := styles.Description | |||
| 224 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | 243 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | ||
| 225 | if m == nil { | 244 | if m == nil { | ||
| 226 | ui.DrawInputLine(frame, 0, "> ", "", base, selection) | 245 | ui.DrawInputLine(frame, 0, "> ", "", base, selection) | ||
| 227 | return frame | 246 | return frame | ||
| 228 | } | 247 | } | ||
| 229 | ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection) | 248 | ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection) | ||
| 230 | - | start := 1 | 249 | + | start, visible := 1, frame.Height-1 |
| 231 | - | visible := frame.Height - start | 250 | + | if visible <= 0 || len(m.matches) == 0 { |
| 232 | - | if visible <= 0 { | |||
| 233 | - | return frame | |||
| 234 | - | } | |||
| 235 | - | if len(m.matches) == 0 { | |||
| 236 | return frame | 251 | return frame | ||
| 237 | } | 252 | } | ||
| 238 | if m.selected < m.scroll { | 253 | if m.selected < m.scroll { | ||
| @@ -243,8 +258,8 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame { | |||||
| 243 | } | 258 | } | ||
| 244 | codeWidth := 0 | 259 | codeWidth := 0 | ||
| 245 | for _, match := range m.matches { | 260 | for _, match := range m.matches { | ||
| 246 | - | if len([]rune(match.Command.Code)) > codeWidth { | 261 | + | if cmd, ok := match.Result.Command(); ok && utf8.RuneCountInString(cmd.Code) > codeWidth { |
| 247 | - | codeWidth = len([]rune(match.Command.Code)) | 262 | + | codeWidth = utf8.RuneCountInString(cmd.Code) |
| 248 | } | 263 | } | ||
| 249 | } | 264 | } | ||
| 250 | activeCmd, activeOK := m.ArgumentCommand() | 265 | activeCmd, activeOK := m.ArgumentCommand() | ||
| @@ -259,34 +274,71 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame { | |||||
| 259 | style = selection | 274 | style = selection | ||
| 260 | } | 275 | } | ||
| 261 | ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) | 276 | ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) | ||
| 262 | - | x := 0 | 277 | + | if cmd, ok := match.Result.Command(); ok { |
| 263 | - | highlight := map[int]bool{} | 278 | + | m.renderCommand(frame, y+start, style, selection, desc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Feedback, idx == m.selected) |
| 264 | - | for _, p := range match.Positions { | 279 | + | continue |
| 265 | - | highlight[p] = true | |||
| 266 | } | 280 | } | ||
| 267 | - | for i, r := range []rune(match.Command.Code) { | 281 | + | m.renderSession(frame, y+start, style, selection, match, opts.Feedback, idx == m.selected) |
| 268 | - | cellStyle := style | 282 | + | } |
| 283 | + | return frame | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, desc renderer.Style, codeWidth int, cmd command.Command, positions []int, activeCmd command.Command, activeOK bool, guidance, feedback string, selected bool) { | |||
| 287 | + | x, nextHighlight := 0, 0 | |||
| 288 | + | for _, r := range cmd.Code { | |||
| 289 | + | cellStyle := style | |||
| 290 | + | cellStyle.Bold = true | |||
| 291 | + | if nextHighlight < len(positions) && positions[nextHighlight] == x { | |||
| 292 | + | cellStyle = selection | |||
| 269 | cellStyle.Bold = true | 293 | cellStyle.Bold = true | ||
| 270 | - | if highlight[i] { | 294 | + | nextHighlight++ |
| 271 | - | cellStyle = selection | |||
| 272 | - | cellStyle.Bold = true | |||
| 273 | - | } | |||
| 274 | - | if x < frame.Width { | |||
| 275 | - | frame.Set(x, y+start, renderer.Cell{Rune: r, Style: cellStyle}) | |||
| 276 | - | } | |||
| 277 | - | x++ | |||
| 278 | } | 295 | } | ||
| 279 | - | for x < codeWidth+1 && x < frame.Width { | 296 | + | if x < frame.Width { |
| 280 | - | frame.Set(x, y+start, renderer.Cell{Rune: ' ', Style: style}) | 297 | + | frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle}) |
| 281 | - | x++ | |||
| 282 | } | 298 | } | ||
| 283 | - | description := match.Command.Desc | 299 | + | x++ |
| 284 | - | if opts.Guidance != "" && activeOK && match.Command.ContextHint != command.ContextHintNone && activeCmd.Code == match.Command.Code { | 300 | + | } |
| 285 | - | description = opts.Guidance | 301 | + | for x < codeWidth+1 && x < frame.Width { |
| 302 | + | frame.Set(x, y, renderer.Cell{Rune: ' ', Style: style}) | |||
| 303 | + | x++ | |||
| 304 | + | } | |||
| 305 | + | description := cmd.Desc | |||
| 306 | + | if feedback != "" && selected { | |||
| 307 | + | description = feedback | |||
| 308 | + | } else if guidance != "" && activeOK && cmd.ContextHint != command.ContextHintNone && activeCmd.Code == cmd.Code { | |||
| 309 | + | description = guidance | |||
| 310 | + | } | |||
| 311 | + | ui.DrawText(frame, x, y, frame.Width, description, mergePaletteDescStyle(style, desc)) | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) { | |||
| 315 | + | x, nextHighlight := 0, 0 | |||
| 316 | + | prefixWidth := utf8.RuneCountInString(match.Result.sessionDisplayPrefix()) | |||
| 317 | + | for _, r := range match.Result.DisplayText() { | |||
| 318 | + | cellStyle := style | |||
| 319 | + | if nextHighlight < len(match.Positions) && prefixWidth+match.Positions[nextHighlight] == x { | |||
| 320 | + | cellStyle = selection | |||
| 321 | + | cellStyle.Bold = true | |||
| 322 | + | nextHighlight++ | |||
| 323 | + | } | |||
| 324 | + | if x < frame.Width { | |||
| 325 | + | frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle}) | |||
| 286 | } | 326 | } | ||
| 287 | - | ui.DrawText(frame, x, y+start, frame.Width, description, mergePaletteDescStyle(style, desc)) | 327 | + | x++ |
| 328 | + | } | |||
| 329 | + | if feedback == "" || !selected { | |||
| 330 | + | return | |||
| 331 | + | } | |||
| 332 | + | if x < frame.Width { | |||
| 333 | + | frame.Set(x, y, renderer.Cell{Rune: ' ', Style: style}) | |||
| 334 | + | } | |||
| 335 | + | x++ | |||
| 336 | + | for _, r := range feedback { | |||
| 337 | + | if x < frame.Width { | |||
| 338 | + | frame.Set(x, y, renderer.Cell{Rune: r, Style: style}) | |||
| 339 | + | } | |||
| 340 | + | x++ | |||
| 288 | } | 341 | } | ||
| 289 | - | return frame | |||
| 290 | } | 342 | } | ||
| 291 | 343 | ||||
| 292 | func mergePaletteDescStyle(line, desc renderer.Style) renderer.Style { | 344 | func mergePaletteDescStyle(line, desc renderer.Style) renderer.Style { | ||
M internal/usecase/palette/model_test.go +89 -22
| @@ -3,6 +3,7 @@ package palette | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "testing" | |
| 6 | + | "time" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| 8 | 9 | "github.com/bnema/vev/internal/usecase/command" | |
| @@ -11,33 +12,33 @@ import ( | |||
| 11 | 12 | ) | |
| 12 | 13 | ||
| 13 | 14 | func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) { | |
| 14 | - | m := New([]command.Command{ | |
| 15 | + | m := New(CommandResults([]command.Command{ | |
| 15 | 16 | cmd("ABC", "Alpha", "first"), | |
| 16 | 17 | cmd("DEF", "Delta", "second"), | |
| 17 | 18 | cmd("AXY", "Other", "third"), | |
| 18 | - | }) | |
| 19 | + | })) | |
| 19 | 20 | ||
| 20 | 21 | require.Equal(t, "", m.Query()) | |
| 21 | 22 | selected, ok := m.Selected() | |
| 22 | 23 | require.True(t, ok) | |
| 23 | - | require.Equal(t, "ABC", selected.Code) | |
| 24 | + | require.Equal(t, "ABC", selectedCommandCode(t, selected)) | |
| 24 | 25 | ||
| 25 | 26 | m.Down() | |
| 26 | 27 | m.Down() | |
| 27 | 28 | selected, ok = m.Selected() | |
| 28 | 29 | require.True(t, ok) | |
| 29 | - | require.Equal(t, "AXY", selected.Code) | |
| 30 | + | require.Equal(t, "AXY", selectedCommandCode(t, selected)) | |
| 30 | 31 | ||
| 31 | 32 | m.Insert('d') | |
| 32 | 33 | require.Equal(t, "d", m.Query()) | |
| 33 | 34 | selected, ok = m.Selected() | |
| 34 | 35 | require.True(t, ok) | |
| 35 | - | require.Equal(t, "DEF", selected.Code, "selection clamps to only match after query changes") | |
| 36 | + | require.Equal(t, "DEF", selectedCommandCode(t, selected), "selection clamps to only match after query changes") | |
| 36 | 37 | ||
| 37 | 38 | m.Up() | |
| 38 | 39 | selected, ok = m.Selected() | |
| 39 | 40 | require.True(t, ok) | |
| 40 | - | require.Equal(t, "DEF", selected.Code, "up at first match clamps") | |
| 41 | + | require.Equal(t, "DEF", selectedCommandCode(t, selected), "up at first match clamps") | |
| 41 | 42 | ||
| 42 | 43 | m.Backspace() | |
| 43 | 44 | require.Equal(t, "", m.Query()) | |
| @@ -45,7 +46,7 @@ func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) { | |||
| 45 | 46 | } | |
| 46 | 47 | ||
| 47 | 48 | func TestModelNoMatchesClearsSelection(t *testing.T) { | |
| 48 | - | m := New([]command.Command{cmd("ABC", "Alpha", "first")}) | |
| 49 | + | m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")})) | |
| 49 | 50 | m.Insert('z') | |
| 50 | 51 | ||
| 51 | 52 | _, ok := m.Selected() | |
| @@ -54,7 +55,7 @@ func TestModelNoMatchesClearsSelection(t *testing.T) { | |||
| 54 | 55 | } | |
| 55 | 56 | ||
| 56 | 57 | func TestModelMatchesDeepCopiesPositions(t *testing.T) { | |
| 57 | - | m := New([]command.Command{cmd("ABC", "Alpha", "first")}) | |
| 58 | + | m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")})) | |
| 58 | 59 | m.Insert('a') | |
| 59 | 60 | ||
| 60 | 61 | matches := m.Matches() | |
| @@ -74,8 +75,8 @@ func TestModelExactArgumentMatchMovesExistingFuzzyMatchToFront(t *testing.T) { | |||
| 74 | 75 | cmd("AAA", "", "ZZZ 1"), | |
| 75 | 76 | zzz, | |
| 76 | 77 | } | |
| 77 | - | want := Fuzzy(commands, "ZZZ 1")[1] | |
| 78 | - | m := New(commands) | |
| 78 | + | want := Fuzzy(CommandResults(commands), "ZZZ 1")[1] | |
| 79 | + | m := New(CommandResults(commands)) | |
| 79 | 80 | ||
| 80 | 81 | for _, r := range "ZZZ 1" { | |
| 81 | 82 | m.Insert(r) | |
| @@ -83,11 +84,11 @@ func TestModelExactArgumentMatchMovesExistingFuzzyMatchToFront(t *testing.T) { | |||
| 83 | 84 | ||
| 84 | 85 | matches := m.Matches() | |
| 85 | 86 | require.Len(t, matches, 2) | |
| 86 | - | require.Equal(t, "ZZZ", matches[0].Command.Code) | |
| 87 | + | require.Equal(t, "ZZZ", matchCommandCode(t, matches[0])) | |
| 87 | 88 | require.Equal(t, want, matches[0], "the existing fuzzy match must retain its metadata") | |
| 88 | 89 | selected, ok := m.Selected() | |
| 89 | 90 | require.True(t, ok) | |
| 90 | - | require.Equal(t, "ZZZ", selected.Code) | |
| 91 | + | require.Equal(t, "ZZZ", selectedCommandCode(t, selected)) | |
| 91 | 92 | } | |
| 92 | 93 | ||
| 93 | 94 | func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) { | |
| @@ -102,11 +103,11 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) { | |||
| 102 | 103 | commands: func(zzz command.Command) []command.Command { | |
| 103 | 104 | return []command.Command{cmd("AAA", "", "ZZZ 1"), zzz} | |
| 104 | 105 | }, | |
| 105 | - | want: Match{Command: command.Command{ | |
| 106 | + | want: newMatch(NewCommandResult(command.Command{ | |
| 106 | 107 | Code: "ZZZ", | |
| 107 | 108 | Desc: "unmatched", | |
| 108 | 109 | Arguments: command.ArgumentsRequired, | |
| 109 | - | }}, | |
| 110 | + | }), 0), | |
| 110 | 111 | wantLen: 2, | |
| 111 | 112 | }, | |
| 112 | 113 | { | |
| @@ -118,7 +119,7 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) { | |||
| 118 | 119 | want: func() Match { | |
| 119 | 120 | zzz := cmd("ZZZ", "", "ZZZ 1") | |
| 120 | 121 | zzz.Arguments = command.ArgumentsRequired | |
| 121 | - | return Fuzzy([]command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")}, "ZZZ 1")[0] | |
| 122 | + | return Fuzzy(CommandResults([]command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")}), "ZZZ 1")[0] | |
| 122 | 123 | }(), | |
| 123 | 124 | wantLen: 2, | |
| 124 | 125 | }, | |
| @@ -126,7 +127,7 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) { | |||
| 126 | 127 | t.Run(tt.name, func(t *testing.T) { | |
| 127 | 128 | zzz := cmd("ZZZ", "", "unmatched") | |
| 128 | 129 | zzz.Arguments = command.ArgumentsRequired | |
| 129 | - | m := New(tt.commands(zzz)) | |
| 130 | + | m := New(CommandResults(tt.commands(zzz))) | |
| 130 | 131 | for _, r := range "ZZZ 1" { | |
| 131 | 132 | m.Insert(r) | |
| 132 | 133 | } | |
| @@ -139,10 +140,10 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) { | |||
| 139 | 140 | } | |
| 140 | 141 | ||
| 141 | 142 | func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) { | |
| 142 | - | m := New([]command.Command{ | |
| 143 | + | m := New(CommandResults([]command.Command{ | |
| 143 | 144 | cmd("CPY", "Copy", "Enter copy mode"), | |
| 144 | 145 | cmd("CNT", "New", "Create tab"), | |
| 145 | - | }) | |
| 146 | + | })) | |
| 146 | 147 | m.Insert('c') | |
| 147 | 148 | m.Insert('y') | |
| 148 | 149 | frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles()}) | |
| @@ -165,7 +166,7 @@ func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) { | |||
| 165 | 166 | } | |
| 166 | 167 | ||
| 167 | 168 | func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) { | |
| 168 | - | m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}) | |
| 169 | + | m := New(CommandResults([]command.Command{cmd("CPY", "Copy", "Enter copy mode")})) | |
| 169 | 170 | want := []rune("CPY Enter copy mode") | |
| 170 | 171 | ||
| 171 | 172 | for _, cols := range []int{0, 1, 3, 4, 7} { | |
| @@ -179,6 +180,18 @@ func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) { | |||
| 179 | 180 | } | |
| 180 | 181 | } | |
| 181 | 182 | ||
| 183 | + | func selectedCommandCode(t *testing.T, result Result) string { | |
| 184 | + | t.Helper() | |
| 185 | + | cmd, ok := result.Command() | |
| 186 | + | require.True(t, ok, "selected result is a command") | |
| 187 | + | return cmd.Code | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func matchCommandCode(t *testing.T, match Match) string { | |
| 191 | + | t.Helper() | |
| 192 | + | return selectedCommandCode(t, match.Result) | |
| 193 | + | } | |
| 194 | + | ||
| 182 | 195 | func frameRow(frame renderer.Frame, y int) string { | |
| 183 | 196 | row := make([]rune, frame.Width) | |
| 184 | 197 | for x := range frame.Width { | |
| @@ -247,7 +260,7 @@ func TestRenderUsesConfiguredStyles(t *testing.T) { | |||
| 247 | 260 | ||
| 248 | 261 | for _, tt := range tests { | |
| 249 | 262 | t.Run(tt.name, func(t *testing.T) { | |
| 250 | - | m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}) | |
| 263 | + | m := New(CommandResults([]command.Command{cmd("CPY", "Copy", "Enter copy mode")})) | |
| 251 | 264 | m.Insert('c') | |
| 252 | 265 | m.Insert('y') | |
| 253 | 266 | frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: tt.styles()}) | |
| @@ -294,7 +307,7 @@ func TestModelCompleteSelected(t *testing.T) { | |||
| 294 | 307 | if tt.registry { | |
| 295 | 308 | m = NewRegistry() | |
| 296 | 309 | } else { | |
| 297 | - | m = New(tt.commands) | |
| 310 | + | m = New(CommandResults(tt.commands)) | |
| 298 | 311 | } | |
| 299 | 312 | for _, r := range tt.query { | |
| 300 | 313 | m.Insert(r) | |
| @@ -319,11 +332,65 @@ func TestModelCompleteSelected(t *testing.T) { | |||
| 319 | 332 | } | |
| 320 | 333 | } | |
| 321 | 334 | ||
| 335 | + | func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T) { | |
| 336 | + | created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC) | |
| 337 | + | results := []Result{ | |
| 338 | + | NewCommandResult(cmd("JRS", "Jump", "Jump to recent session")), | |
| 339 | + | NewActiveSessionResult("work", created, "work-id"), | |
| 340 | + | NewStoppedSessionResult("work", created), | |
| 341 | + | } | |
| 342 | + | m := New(results) | |
| 343 | + | results[1] = NewActiveSessionResult("changed", created, "changed-id") | |
| 344 | + | ||
| 345 | + | for _, r := range "work" { | |
| 346 | + | m.Insert(r) | |
| 347 | + | } | |
| 348 | + | matches := m.Matches() | |
| 349 | + | require.Len(t, matches, 2) | |
| 350 | + | require.Equal(t, ResultKindActiveSession, matches[0].Result.Kind()) | |
| 351 | + | matches[0].Result = NewCommandResult(cmd("BAD", "", "")) | |
| 352 | + | require.Equal(t, ResultKindActiveSession, m.Matches()[0].Result.Kind()) | |
| 353 | + | ||
| 354 | + | selected, ok := m.Selected() | |
| 355 | + | require.True(t, ok) | |
| 356 | + | require.Equal(t, ResultKindActiveSession, selected.Kind()) | |
| 357 | + | id, active := selected.SessionID() | |
| 358 | + | require.True(t, active) | |
| 359 | + | require.Equal(t, domain.SessionID("work-id"), id) | |
| 360 | + | require.False(t, m.CompleteSelected(), "sessions never participate in tab completion") | |
| 361 | + | require.Equal(t, "work", m.Query()) | |
| 362 | + | _, argument := m.ArgumentCommand() | |
| 363 | + | require.False(t, argument) | |
| 364 | + | ||
| 365 | + | frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()}) | |
| 366 | + | require.Equal(t, "Switch to session work ", frameRow(frame, 1)) | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) { | |
| 370 | + | m := New([]Result{NewStoppedSessionResult("work", time.Unix(0, 1))}) | |
| 371 | + | m.Insert('w') | |
| 372 | + | m.Insert('k') | |
| 373 | + | ||
| 374 | + | frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()}) | |
| 375 | + | ||
| 376 | + | require.Equal(t, "Resume session work ", frameRow(frame, 1)) | |
| 377 | + | require.False(t, frame.At(14, 1).Style.Bold, "resume prefix is not highlighted") | |
| 378 | + | require.True(t, frame.At(15, 1).Style.Bold, "first matched session rune is highlighted") | |
| 379 | + | require.True(t, frame.At(18, 1).Style.Bold, "last matched session rune is highlighted") | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) { | |
| 383 | + | m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), "work")}) | |
| 384 | + | frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"}) | |
| 385 | + | require.Len(t, m.Matches(), 1) | |
| 386 | + | require.Contains(t, frameRow(frame, 1), "requested session is unavailable") | |
| 387 | + | } | |
| 388 | + | ||
| 322 | 389 | func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) { | |
| 323 | 390 | jrs := cmd("JRS", "Jump", "Jump to recent session") | |
| 324 | 391 | jrs.Arguments = command.ArgumentsRequired | |
| 325 | 392 | jrs.ContextHint = command.ContextHintRecentSessions | |
| 326 | - | m := New([]command.Command{jrs}) | |
| 393 | + | m := New(CommandResults([]command.Command{jrs})) | |
| 327 | 394 | for _, r := range "JRS 1" { | |
| 328 | 395 | m.Insert(r) | |
| 329 | 396 | } | |
| @@ -3,6 +3,7 @@ package palette | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | + | "time" | |||
| 6 | 7 | ||||
| 7 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 8 | "github.com/bnema/vev/internal/usecase/command" | 9 | "github.com/bnema/vev/internal/usecase/command" | ||
| @@ -11,33 +12,33 @@ import ( | |||||
| 11 | ) | 12 | ) | ||
| 12 | 13 | ||||
| 13 | func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) { | 14 | func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) { | ||
| 14 | - | m := New([]command.Command{ | 15 | + | m := New(CommandResults([]command.Command{ |
| 15 | cmd("ABC", "Alpha", "first"), | 16 | cmd("ABC", "Alpha", "first"), | ||
| 16 | cmd("DEF", "Delta", "second"), | 17 | cmd("DEF", "Delta", "second"), | ||
| 17 | cmd("AXY", "Other", "third"), | 18 | cmd("AXY", "Other", "third"), | ||
| 18 | - | }) | 19 | + | })) |
| 19 | 20 | ||||
| 20 | require.Equal(t, "", m.Query()) | 21 | require.Equal(t, "", m.Query()) | ||
| 21 | selected, ok := m.Selected() | 22 | selected, ok := m.Selected() | ||
| 22 | require.True(t, ok) | 23 | require.True(t, ok) | ||
| 23 | - | require.Equal(t, "ABC", selected.Code) | 24 | + | require.Equal(t, "ABC", selectedCommandCode(t, selected)) |
| 24 | 25 | ||||
| 25 | m.Down() | 26 | m.Down() | ||
| 26 | m.Down() | 27 | m.Down() | ||
| 27 | selected, ok = m.Selected() | 28 | selected, ok = m.Selected() | ||
| 28 | require.True(t, ok) | 29 | require.True(t, ok) | ||
| 29 | - | require.Equal(t, "AXY", selected.Code) | 30 | + | require.Equal(t, "AXY", selectedCommandCode(t, selected)) |
| 30 | 31 | ||||
| 31 | m.Insert('d') | 32 | m.Insert('d') | ||
| 32 | require.Equal(t, "d", m.Query()) | 33 | require.Equal(t, "d", m.Query()) | ||
| 33 | selected, ok = m.Selected() | 34 | selected, ok = m.Selected() | ||
| 34 | require.True(t, ok) | 35 | require.True(t, ok) | ||
| 35 | - | require.Equal(t, "DEF", selected.Code, "selection clamps to only match after query changes") | 36 | + | require.Equal(t, "DEF", selectedCommandCode(t, selected), "selection clamps to only match after query changes") |
| 36 | 37 | ||||
| 37 | m.Up() | 38 | m.Up() | ||
| 38 | selected, ok = m.Selected() | 39 | selected, ok = m.Selected() | ||
| 39 | require.True(t, ok) | 40 | require.True(t, ok) | ||
| 40 | - | require.Equal(t, "DEF", selected.Code, "up at first match clamps") | 41 | + | require.Equal(t, "DEF", selectedCommandCode(t, selected), "up at first match clamps") |
| 41 | 42 | ||||
| 42 | m.Backspace() | 43 | m.Backspace() | ||
| 43 | require.Equal(t, "", m.Query()) | 44 | require.Equal(t, "", m.Query()) | ||
| @@ -45,7 +46,7 @@ func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) { | |||||
| 45 | } | 46 | } | ||
| 46 | 47 | ||||
| 47 | func TestModelNoMatchesClearsSelection(t *testing.T) { | 48 | func TestModelNoMatchesClearsSelection(t *testing.T) { | ||
| 48 | - | m := New([]command.Command{cmd("ABC", "Alpha", "first")}) | 49 | + | m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")})) |
| 49 | m.Insert('z') | 50 | m.Insert('z') | ||
| 50 | 51 | ||||
| 51 | _, ok := m.Selected() | 52 | _, ok := m.Selected() | ||
| @@ -54,7 +55,7 @@ func TestModelNoMatchesClearsSelection(t *testing.T) { | |||||
| 54 | } | 55 | } | ||
| 55 | 56 | ||||
| 56 | func TestModelMatchesDeepCopiesPositions(t *testing.T) { | 57 | func TestModelMatchesDeepCopiesPositions(t *testing.T) { | ||
| 57 | - | m := New([]command.Command{cmd("ABC", "Alpha", "first")}) | 58 | + | m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")})) |
| 58 | m.Insert('a') | 59 | m.Insert('a') | ||
| 59 | 60 | ||||
| 60 | matches := m.Matches() | 61 | matches := m.Matches() | ||
| @@ -74,8 +75,8 @@ func TestModelExactArgumentMatchMovesExistingFuzzyMatchToFront(t *testing.T) { | |||||
| 74 | cmd("AAA", "", "ZZZ 1"), | 75 | cmd("AAA", "", "ZZZ 1"), | ||
| 75 | zzz, | 76 | zzz, | ||
| 76 | } | 77 | } | ||
| 77 | - | want := Fuzzy(commands, "ZZZ 1")[1] | 78 | + | want := Fuzzy(CommandResults(commands), "ZZZ 1")[1] |
| 78 | - | m := New(commands) | 79 | + | m := New(CommandResults(commands)) |
| 79 | 80 | ||||
| 80 | for _, r := range "ZZZ 1" { | 81 | for _, r := range "ZZZ 1" { | ||
| 81 | m.Insert(r) | 82 | m.Insert(r) | ||
| @@ -83,11 +84,11 @@ func TestModelExactArgumentMatchMovesExistingFuzzyMatchToFront(t *testing.T) { | |||||
| 83 | 84 | ||||
| 84 | matches := m.Matches() | 85 | matches := m.Matches() | ||
| 85 | require.Len(t, matches, 2) | 86 | require.Len(t, matches, 2) | ||
| 86 | - | require.Equal(t, "ZZZ", matches[0].Command.Code) | 87 | + | require.Equal(t, "ZZZ", matchCommandCode(t, matches[0])) |
| 87 | require.Equal(t, want, matches[0], "the existing fuzzy match must retain its metadata") | 88 | require.Equal(t, want, matches[0], "the existing fuzzy match must retain its metadata") | ||
| 88 | selected, ok := m.Selected() | 89 | selected, ok := m.Selected() | ||
| 89 | require.True(t, ok) | 90 | require.True(t, ok) | ||
| 90 | - | require.Equal(t, "ZZZ", selected.Code) | 91 | + | require.Equal(t, "ZZZ", selectedCommandCode(t, selected)) |
| 91 | } | 92 | } | ||
| 92 | 93 | ||||
| 93 | func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) { | 94 | func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) { | ||
| @@ -102,11 +103,11 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) { | |||||
| 102 | commands: func(zzz command.Command) []command.Command { | 103 | commands: func(zzz command.Command) []command.Command { | ||
| 103 | return []command.Command{cmd("AAA", "", "ZZZ 1"), zzz} | 104 | return []command.Command{cmd("AAA", "", "ZZZ 1"), zzz} | ||
| 104 | }, | 105 | }, | ||
| 105 | - | want: Match{Command: command.Command{ | 106 | + | want: newMatch(NewCommandResult(command.Command{ |
| 106 | Code: "ZZZ", | 107 | Code: "ZZZ", | ||
| 107 | Desc: "unmatched", | 108 | Desc: "unmatched", | ||
| 108 | Arguments: command.ArgumentsRequired, | 109 | Arguments: command.ArgumentsRequired, | ||
| 109 | - | }}, | 110 | + | }), 0), |
| 110 | wantLen: 2, | 111 | wantLen: 2, | ||
| 111 | }, | 112 | }, | ||
| 112 | { | 113 | { | ||
| @@ -118,7 +119,7 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) { | |||||
| 118 | want: func() Match { | 119 | want: func() Match { | ||
| 119 | zzz := cmd("ZZZ", "", "ZZZ 1") | 120 | zzz := cmd("ZZZ", "", "ZZZ 1") | ||
| 120 | zzz.Arguments = command.ArgumentsRequired | 121 | zzz.Arguments = command.ArgumentsRequired | ||
| 121 | - | return Fuzzy([]command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")}, "ZZZ 1")[0] | 122 | + | return Fuzzy(CommandResults([]command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")}), "ZZZ 1")[0] |
| 122 | }(), | 123 | }(), | ||
| 123 | wantLen: 2, | 124 | wantLen: 2, | ||
| 124 | }, | 125 | }, | ||
| @@ -126,7 +127,7 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) { | |||||
| 126 | t.Run(tt.name, func(t *testing.T) { | 127 | t.Run(tt.name, func(t *testing.T) { | ||
| 127 | zzz := cmd("ZZZ", "", "unmatched") | 128 | zzz := cmd("ZZZ", "", "unmatched") | ||
| 128 | zzz.Arguments = command.ArgumentsRequired | 129 | zzz.Arguments = command.ArgumentsRequired | ||
| 129 | - | m := New(tt.commands(zzz)) | 130 | + | m := New(CommandResults(tt.commands(zzz))) |
| 130 | for _, r := range "ZZZ 1" { | 131 | for _, r := range "ZZZ 1" { | ||
| 131 | m.Insert(r) | 132 | m.Insert(r) | ||
| 132 | } | 133 | } | ||
| @@ -139,10 +140,10 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) { | |||||
| 139 | } | 140 | } | ||
| 140 | 141 | ||||
| 141 | func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) { | 142 | func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) { | ||
| 142 | - | m := New([]command.Command{ | 143 | + | m := New(CommandResults([]command.Command{ |
| 143 | cmd("CPY", "Copy", "Enter copy mode"), | 144 | cmd("CPY", "Copy", "Enter copy mode"), | ||
| 144 | cmd("CNT", "New", "Create tab"), | 145 | cmd("CNT", "New", "Create tab"), | ||
| 145 | - | }) | 146 | + | })) |
| 146 | m.Insert('c') | 147 | m.Insert('c') | ||
| 147 | m.Insert('y') | 148 | m.Insert('y') | ||
| 148 | frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles()}) | 149 | frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles()}) | ||
| @@ -165,7 +166,7 @@ func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) { | |||||
| 165 | } | 166 | } | ||
| 166 | 167 | ||||
| 167 | func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) { | 168 | func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) { | ||
| 168 | - | m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}) | 169 | + | m := New(CommandResults([]command.Command{cmd("CPY", "Copy", "Enter copy mode")})) |
| 169 | want := []rune("CPY Enter copy mode") | 170 | want := []rune("CPY Enter copy mode") | ||
| 170 | 171 | ||||
| 171 | for _, cols := range []int{0, 1, 3, 4, 7} { | 172 | for _, cols := range []int{0, 1, 3, 4, 7} { | ||
| @@ -179,6 +180,18 @@ func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) { | |||||
| 179 | } | 180 | } | ||
| 180 | } | 181 | } | ||
| 181 | 182 | ||||
| 183 | + | func selectedCommandCode(t *testing.T, result Result) string { | |||
| 184 | + | t.Helper() | |||
| 185 | + | cmd, ok := result.Command() | |||
| 186 | + | require.True(t, ok, "selected result is a command") | |||
| 187 | + | return cmd.Code | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func matchCommandCode(t *testing.T, match Match) string { | |||
| 191 | + | t.Helper() | |||
| 192 | + | return selectedCommandCode(t, match.Result) | |||
| 193 | + | } | |||
| 194 | + | ||||
| 182 | func frameRow(frame renderer.Frame, y int) string { | 195 | func frameRow(frame renderer.Frame, y int) string { | ||
| 183 | row := make([]rune, frame.Width) | 196 | row := make([]rune, frame.Width) | ||
| 184 | for x := range frame.Width { | 197 | for x := range frame.Width { | ||
| @@ -247,7 +260,7 @@ func TestRenderUsesConfiguredStyles(t *testing.T) { | |||||
| 247 | 260 | ||||
| 248 | for _, tt := range tests { | 261 | for _, tt := range tests { | ||
| 249 | t.Run(tt.name, func(t *testing.T) { | 262 | t.Run(tt.name, func(t *testing.T) { | ||
| 250 | - | m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}) | 263 | + | m := New(CommandResults([]command.Command{cmd("CPY", "Copy", "Enter copy mode")})) |
| 251 | m.Insert('c') | 264 | m.Insert('c') | ||
| 252 | m.Insert('y') | 265 | m.Insert('y') | ||
| 253 | frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: tt.styles()}) | 266 | frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: tt.styles()}) | ||
| @@ -294,7 +307,7 @@ func TestModelCompleteSelected(t *testing.T) { | |||||
| 294 | if tt.registry { | 307 | if tt.registry { | ||
| 295 | m = NewRegistry() | 308 | m = NewRegistry() | ||
| 296 | } else { | 309 | } else { | ||
| 297 | - | m = New(tt.commands) | 310 | + | m = New(CommandResults(tt.commands)) |
| 298 | } | 311 | } | ||
| 299 | for _, r := range tt.query { | 312 | for _, r := range tt.query { | ||
| 300 | m.Insert(r) | 313 | m.Insert(r) | ||
| @@ -319,11 +332,65 @@ func TestModelCompleteSelected(t *testing.T) { | |||||
| 319 | } | 332 | } | ||
| 320 | } | 333 | } | ||
| 321 | 334 | ||||
| 335 | + | func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T) { | |||
| 336 | + | created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC) | |||
| 337 | + | results := []Result{ | |||
| 338 | + | NewCommandResult(cmd("JRS", "Jump", "Jump to recent session")), | |||
| 339 | + | NewActiveSessionResult("work", created, "work-id"), | |||
| 340 | + | NewStoppedSessionResult("work", created), | |||
| 341 | + | } | |||
| 342 | + | m := New(results) | |||
| 343 | + | results[1] = NewActiveSessionResult("changed", created, "changed-id") | |||
| 344 | + | ||||
| 345 | + | for _, r := range "work" { | |||
| 346 | + | m.Insert(r) | |||
| 347 | + | } | |||
| 348 | + | matches := m.Matches() | |||
| 349 | + | require.Len(t, matches, 2) | |||
| 350 | + | require.Equal(t, ResultKindActiveSession, matches[0].Result.Kind()) | |||
| 351 | + | matches[0].Result = NewCommandResult(cmd("BAD", "", "")) | |||
| 352 | + | require.Equal(t, ResultKindActiveSession, m.Matches()[0].Result.Kind()) | |||
| 353 | + | ||||
| 354 | + | selected, ok := m.Selected() | |||
| 355 | + | require.True(t, ok) | |||
| 356 | + | require.Equal(t, ResultKindActiveSession, selected.Kind()) | |||
| 357 | + | id, active := selected.SessionID() | |||
| 358 | + | require.True(t, active) | |||
| 359 | + | require.Equal(t, domain.SessionID("work-id"), id) | |||
| 360 | + | require.False(t, m.CompleteSelected(), "sessions never participate in tab completion") | |||
| 361 | + | require.Equal(t, "work", m.Query()) | |||
| 362 | + | _, argument := m.ArgumentCommand() | |||
| 363 | + | require.False(t, argument) | |||
| 364 | + | ||||
| 365 | + | frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()}) | |||
| 366 | + | require.Equal(t, "Switch to session work ", frameRow(frame, 1)) | |||
| 367 | + | } | |||
| 368 | + | ||||
| 369 | + | func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) { | |||
| 370 | + | m := New([]Result{NewStoppedSessionResult("work", time.Unix(0, 1))}) | |||
| 371 | + | m.Insert('w') | |||
| 372 | + | m.Insert('k') | |||
| 373 | + | ||||
| 374 | + | frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()}) | |||
| 375 | + | ||||
| 376 | + | require.Equal(t, "Resume session work ", frameRow(frame, 1)) | |||
| 377 | + | require.False(t, frame.At(14, 1).Style.Bold, "resume prefix is not highlighted") | |||
| 378 | + | require.True(t, frame.At(15, 1).Style.Bold, "first matched session rune is highlighted") | |||
| 379 | + | require.True(t, frame.At(18, 1).Style.Bold, "last matched session rune is highlighted") | |||
| 380 | + | } | |||
| 381 | + | ||||
| 382 | + | func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) { | |||
| 383 | + | m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), "work")}) | |||
| 384 | + | frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"}) | |||
| 385 | + | require.Len(t, m.Matches(), 1) | |||
| 386 | + | require.Contains(t, frameRow(frame, 1), "requested session is unavailable") | |||
| 387 | + | } | |||
| 388 | + | ||||
| 322 | func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) { | 389 | func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) { | ||
| 323 | jrs := cmd("JRS", "Jump", "Jump to recent session") | 390 | jrs := cmd("JRS", "Jump", "Jump to recent session") | ||
| 324 | jrs.Arguments = command.ArgumentsRequired | 391 | jrs.Arguments = command.ArgumentsRequired | ||
| 325 | jrs.ContextHint = command.ContextHintRecentSessions | 392 | jrs.ContextHint = command.ContextHintRecentSessions | ||
| 326 | - | m := New([]command.Command{jrs}) | 393 | + | m := New(CommandResults([]command.Command{jrs})) |
| 327 | for _, r := range "JRS 1" { | 394 | for _, r := range "JRS 1" { | ||
| 328 | m.Insert(r) | 395 | m.Insert(r) | ||
| 329 | } | 396 | } | ||
A internal/usecase/palette/result.go +109 -0
| @@ -0,0 +1,109 @@ | |||
| 1 | + | package palette | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "time" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/usecase/command" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // ResultKind identifies the immutable target represented by a palette row. | |
| 11 | + | type ResultKind uint8 | |
| 12 | + | ||
| 13 | + | const ( | |
| 14 | + | ResultKindCommand ResultKind = iota | |
| 15 | + | ResultKindActiveSession | |
| 16 | + | ResultKindStoppedSession | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // Result is an immutable palette target. Its kind is the sole discriminator | |
| 20 | + | // for its private command or session payload. | |
| 21 | + | type Result struct { | |
| 22 | + | kind ResultKind | |
| 23 | + | command command.Command | |
| 24 | + | session sessionPayload | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | type sessionPayload struct { | |
| 28 | + | name string | |
| 29 | + | createdAt time.Time | |
| 30 | + | sessionID domain.SessionID | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // NewCommandResult creates a static command palette target. | |
| 34 | + | func NewCommandResult(cmd command.Command) Result { | |
| 35 | + | return Result{kind: ResultKindCommand, command: cmd} | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // NewActiveSessionResult creates an immutable active named-session target. | |
| 39 | + | func NewActiveSessionResult(name string, createdAt time.Time, sessionID domain.SessionID) Result { | |
| 40 | + | return Result{ | |
| 41 | + | kind: ResultKindActiveSession, | |
| 42 | + | session: sessionPayload{name: name, createdAt: createdAt, sessionID: sessionID}, | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // NewStoppedSessionResult creates an immutable stopped named-session target. | |
| 47 | + | func NewStoppedSessionResult(name string, createdAt time.Time) Result { | |
| 48 | + | return Result{ | |
| 49 | + | kind: ResultKindStoppedSession, | |
| 50 | + | session: sessionPayload{name: name, createdAt: createdAt}, | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func (r Result) Kind() ResultKind { return r.kind } | |
| 55 | + | ||
| 56 | + | func (r Result) DisplayText() string { | |
| 57 | + | if r.kind == ResultKindCommand { | |
| 58 | + | return r.command.Code | |
| 59 | + | } | |
| 60 | + | return r.sessionDisplayPrefix() + r.session.name | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func (r Result) sessionDisplayPrefix() string { | |
| 64 | + | if r.kind == ResultKindStoppedSession { | |
| 65 | + | return stoppedSessionDisplayPrefix | |
| 66 | + | } | |
| 67 | + | return activeSessionDisplayPrefix | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (r Result) SearchText() string { | |
| 71 | + | if r.kind == ResultKindCommand { | |
| 72 | + | return r.command.Code | |
| 73 | + | } | |
| 74 | + | return r.session.name | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // Command returns the command payload only for command results. | |
| 78 | + | func (r Result) Command() (command.Command, bool) { | |
| 79 | + | return r.command, r.kind == ResultKindCommand | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // SessionName returns the session name only for session results. | |
| 83 | + | func (r Result) SessionName() (string, bool) { | |
| 84 | + | return r.session.name, r.kind != ResultKindCommand | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // SessionCreatedAt returns the session creation time only for session results. | |
| 88 | + | func (r Result) SessionCreatedAt() (time.Time, bool) { | |
| 89 | + | return r.session.createdAt, r.kind != ResultKindCommand | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // SessionID returns the lifecycle identity only for active session results. | |
| 93 | + | func (r Result) SessionID() (domain.SessionID, bool) { | |
| 94 | + | return r.session.sessionID, r.kind == ResultKindActiveSession | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | const ( | |
| 98 | + | activeSessionDisplayPrefix = "Switch to session " | |
| 99 | + | stoppedSessionDisplayPrefix = "Resume session " | |
| 100 | + | ) | |
| 101 | + | ||
| 102 | + | // CommandResults converts static commands to immutable palette targets. | |
| 103 | + | func CommandResults(commands []command.Command) []Result { | |
| 104 | + | results := make([]Result, len(commands)) | |
| 105 | + | for i, cmd := range commands { | |
| 106 | + | results[i] = NewCommandResult(cmd) | |
| 107 | + | } | |
| 108 | + | return results | |
| 109 | + | } | |
| @@ -0,0 +1,109 @@ | |||||
| 1 | + | package palette | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "time" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/internal/usecase/command" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // ResultKind identifies the immutable target represented by a palette row. | |||
| 11 | + | type ResultKind uint8 | |||
| 12 | + | ||||
| 13 | + | const ( | |||
| 14 | + | ResultKindCommand ResultKind = iota | |||
| 15 | + | ResultKindActiveSession | |||
| 16 | + | ResultKindStoppedSession | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // Result is an immutable palette target. Its kind is the sole discriminator | |||
| 20 | + | // for its private command or session payload. | |||
| 21 | + | type Result struct { | |||
| 22 | + | kind ResultKind | |||
| 23 | + | command command.Command | |||
| 24 | + | session sessionPayload | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | type sessionPayload struct { | |||
| 28 | + | name string | |||
| 29 | + | createdAt time.Time | |||
| 30 | + | sessionID domain.SessionID | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // NewCommandResult creates a static command palette target. | |||
| 34 | + | func NewCommandResult(cmd command.Command) Result { | |||
| 35 | + | return Result{kind: ResultKindCommand, command: cmd} | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // NewActiveSessionResult creates an immutable active named-session target. | |||
| 39 | + | func NewActiveSessionResult(name string, createdAt time.Time, sessionID domain.SessionID) Result { | |||
| 40 | + | return Result{ | |||
| 41 | + | kind: ResultKindActiveSession, | |||
| 42 | + | session: sessionPayload{name: name, createdAt: createdAt, sessionID: sessionID}, | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // NewStoppedSessionResult creates an immutable stopped named-session target. | |||
| 47 | + | func NewStoppedSessionResult(name string, createdAt time.Time) Result { | |||
| 48 | + | return Result{ | |||
| 49 | + | kind: ResultKindStoppedSession, | |||
| 50 | + | session: sessionPayload{name: name, createdAt: createdAt}, | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func (r Result) Kind() ResultKind { return r.kind } | |||
| 55 | + | ||||
| 56 | + | func (r Result) DisplayText() string { | |||
| 57 | + | if r.kind == ResultKindCommand { | |||
| 58 | + | return r.command.Code | |||
| 59 | + | } | |||
| 60 | + | return r.sessionDisplayPrefix() + r.session.name | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func (r Result) sessionDisplayPrefix() string { | |||
| 64 | + | if r.kind == ResultKindStoppedSession { | |||
| 65 | + | return stoppedSessionDisplayPrefix | |||
| 66 | + | } | |||
| 67 | + | return activeSessionDisplayPrefix | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func (r Result) SearchText() string { | |||
| 71 | + | if r.kind == ResultKindCommand { | |||
| 72 | + | return r.command.Code | |||
| 73 | + | } | |||
| 74 | + | return r.session.name | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // Command returns the command payload only for command results. | |||
| 78 | + | func (r Result) Command() (command.Command, bool) { | |||
| 79 | + | return r.command, r.kind == ResultKindCommand | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // SessionName returns the session name only for session results. | |||
| 83 | + | func (r Result) SessionName() (string, bool) { | |||
| 84 | + | return r.session.name, r.kind != ResultKindCommand | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // SessionCreatedAt returns the session creation time only for session results. | |||
| 88 | + | func (r Result) SessionCreatedAt() (time.Time, bool) { | |||
| 89 | + | return r.session.createdAt, r.kind != ResultKindCommand | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // SessionID returns the lifecycle identity only for active session results. | |||
| 93 | + | func (r Result) SessionID() (domain.SessionID, bool) { | |||
| 94 | + | return r.session.sessionID, r.kind == ResultKindActiveSession | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | const ( | |||
| 98 | + | activeSessionDisplayPrefix = "Switch to session " | |||
| 99 | + | stoppedSessionDisplayPrefix = "Resume session " | |||
| 100 | + | ) | |||
| 101 | + | ||||
| 102 | + | // CommandResults converts static commands to immutable palette targets. | |||
| 103 | + | func CommandResults(commands []command.Command) []Result { | |||
| 104 | + | results := make([]Result, len(commands)) | |||
| 105 | + | for i, cmd := range commands { | |||
| 106 | + | results[i] = NewCommandResult(cmd) | |||
| 107 | + | } | |||
| 108 | + | return results | |||
| 109 | + | } | |||
A internal/usecase/palette/result_test.go +48 -0
| @@ -0,0 +1,48 @@ | |||
| 1 | + | package palette | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/usecase/command" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |
| 13 | + | created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC) | |
| 14 | + | commandResult := NewCommandResult(command.Command{Code: "NT", Desc: "Create tab"}) | |
| 15 | + | active := NewActiveSessionResult("work", created, domain.SessionID("session-work")) | |
| 16 | + | stopped := NewStoppedSessionResult("archive", created) | |
| 17 | + | ||
| 18 | + | require.Equal(t, ResultKindCommand, commandResult.Kind()) | |
| 19 | + | require.Equal(t, "NT", commandResult.DisplayText()) | |
| 20 | + | require.Equal(t, "NT", commandResult.SearchText()) | |
| 21 | + | commandInfo, ok := commandResult.Command() | |
| 22 | + | require.True(t, ok) | |
| 23 | + | require.Equal(t, "NT", commandInfo.Code) | |
| 24 | + | ||
| 25 | + | require.Equal(t, ResultKindActiveSession, active.Kind()) | |
| 26 | + | name, ok := active.SessionName() | |
| 27 | + | require.True(t, ok) | |
| 28 | + | require.Equal(t, "work", name) | |
| 29 | + | createdAt, ok := active.SessionCreatedAt() | |
| 30 | + | require.True(t, ok) | |
| 31 | + | require.Equal(t, created, createdAt) | |
| 32 | + | require.Equal(t, "Switch to session work", active.DisplayText()) | |
| 33 | + | require.Equal(t, "work", active.SearchText()) | |
| 34 | + | id, ok := active.SessionID() | |
| 35 | + | require.True(t, ok) | |
| 36 | + | require.Equal(t, domain.SessionID("session-work"), id) | |
| 37 | + | ||
| 38 | + | require.Equal(t, ResultKindStoppedSession, stopped.Kind()) | |
| 39 | + | name, ok = stopped.SessionName() | |
| 40 | + | require.True(t, ok) | |
| 41 | + | require.Equal(t, "archive", name) | |
| 42 | + | createdAt, ok = stopped.SessionCreatedAt() | |
| 43 | + | require.True(t, ok) | |
| 44 | + | require.Equal(t, created, createdAt) | |
| 45 | + | require.Equal(t, "Resume session archive", stopped.DisplayText()) | |
| 46 | + | _, ok = stopped.SessionID() | |
| 47 | + | require.False(t, ok) | |
| 48 | + | } | |
| @@ -0,0 +1,48 @@ | |||||
| 1 | + | package palette | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | "github.com/bnema/vev/internal/usecase/command" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |||
| 13 | + | created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC) | |||
| 14 | + | commandResult := NewCommandResult(command.Command{Code: "NT", Desc: "Create tab"}) | |||
| 15 | + | active := NewActiveSessionResult("work", created, domain.SessionID("session-work")) | |||
| 16 | + | stopped := NewStoppedSessionResult("archive", created) | |||
| 17 | + | ||||
| 18 | + | require.Equal(t, ResultKindCommand, commandResult.Kind()) | |||
| 19 | + | require.Equal(t, "NT", commandResult.DisplayText()) | |||
| 20 | + | require.Equal(t, "NT", commandResult.SearchText()) | |||
| 21 | + | commandInfo, ok := commandResult.Command() | |||
| 22 | + | require.True(t, ok) | |||
| 23 | + | require.Equal(t, "NT", commandInfo.Code) | |||
| 24 | + | ||||
| 25 | + | require.Equal(t, ResultKindActiveSession, active.Kind()) | |||
| 26 | + | name, ok := active.SessionName() | |||
| 27 | + | require.True(t, ok) | |||
| 28 | + | require.Equal(t, "work", name) | |||
| 29 | + | createdAt, ok := active.SessionCreatedAt() | |||
| 30 | + | require.True(t, ok) | |||
| 31 | + | require.Equal(t, created, createdAt) | |||
| 32 | + | require.Equal(t, "Switch to session work", active.DisplayText()) | |||
| 33 | + | require.Equal(t, "work", active.SearchText()) | |||
| 34 | + | id, ok := active.SessionID() | |||
| 35 | + | require.True(t, ok) | |||
| 36 | + | require.Equal(t, domain.SessionID("session-work"), id) | |||
| 37 | + | ||||
| 38 | + | require.Equal(t, ResultKindStoppedSession, stopped.Kind()) | |||
| 39 | + | name, ok = stopped.SessionName() | |||
| 40 | + | require.True(t, ok) | |||
| 41 | + | require.Equal(t, "archive", name) | |||
| 42 | + | createdAt, ok = stopped.SessionCreatedAt() | |||
| 43 | + | require.True(t, ok) | |||
| 44 | + | require.Equal(t, created, createdAt) | |||
| 45 | + | require.Equal(t, "Resume session archive", stopped.DisplayText()) | |||
| 46 | + | _, ok = stopped.SessionID() | |||
| 47 | + | require.False(t, ok) | |||
| 48 | + | } | |||
M internal/usecase/picker/model.go +21 -14
| @@ -16,11 +16,13 @@ const ( | |||
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | 18 | type SessionView struct { | |
| 19 | - | ID domain.SessionID | |
| 20 | - | Name string | |
| 21 | - | Tabs []TabEntry | |
| 22 | - | Active int | |
| 23 | - | Stopped bool | |
| 19 | + | ID domain.SessionID | |
| 20 | + | Name string | |
| 21 | + | TargetName string | |
| 22 | + | Tabs []TabEntry | |
| 23 | + | Active int | |
| 24 | + | Stopped bool | |
| 25 | + | ExpectedCreatedAt *int64 | |
| 24 | 26 | } | |
| 25 | 27 | ||
| 26 | 28 | // TabEntry is one tab row; Name is drawn emphasized, Detail muted. | |
| @@ -58,6 +60,10 @@ type Target struct { | |||
| 58 | 60 | Name string | |
| 59 | 61 | TabIndex int | |
| 60 | 62 | Stopped bool | |
| 63 | + | // ExpectedCreatedAt optionally pins this target to a particular named | |
| 64 | + | // session lifecycle. Callers that obtain a snapshot outside the daemon use | |
| 65 | + | // it to reject a same-name replacement at commit time. | |
| 66 | + | ExpectedCreatedAt *int64 | |
| 61 | 67 | } | |
| 62 | 68 | ||
| 63 | 69 | // Preview is a bounded view of the selected pane's visible frame. | |
| @@ -89,29 +95,30 @@ type row struct { | |||
| 89 | 95 | detail string // " (paneTitle)" segment, muted; "" for headers | |
| 90 | 96 | attention bool // draw the attention marker right after the name, before detail; tab rows only | |
| 91 | 97 | session domain.SessionID | |
| 92 | - | // targetName is the stopped-session lookup name threaded into Target; | |
| 98 | + | // targetName is the named-session lookup name threaded into Target; | |
| 93 | 99 | // distinct from dispName, which is what gets drawn. | |
| 94 | - | targetName string | |
| 95 | - | tabIndex int | |
| 96 | - | stopped bool | |
| 100 | + | targetName string | |
| 101 | + | tabIndex int | |
| 102 | + | stopped bool | |
| 103 | + | expectedCreatedAt *int64 | |
| 97 | 104 | } | |
| 98 | 105 | ||
| 99 | 106 | func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model { | |
| 100 | 107 | m := &Model{selected: -1} | |
| 101 | 108 | activeSelection := -1 | |
| 102 | 109 | for _, session := range sessions { | |
| 103 | - | targetName := "" | |
| 104 | - | if session.Stopped { | |
| 110 | + | targetName := session.TargetName | |
| 111 | + | if targetName == "" && (session.Stopped || session.ExpectedCreatedAt != nil) { | |
| 105 | 112 | targetName = session.Name | |
| 106 | 113 | } | |
| 107 | - | m.rows = append(m.rows, row{header: true, dispName: session.Name, session: session.ID, targetName: targetName, tabIndex: -1, stopped: session.Stopped}) | |
| 114 | + | m.rows = append(m.rows, row{header: true, dispName: session.Name, session: session.ID, targetName: targetName, tabIndex: -1, stopped: session.Stopped, expectedCreatedAt: session.ExpectedCreatedAt}) | |
| 108 | 115 | active := session.Active | |
| 109 | 116 | if active < 0 || active >= len(session.Tabs) { | |
| 110 | 117 | active = 0 | |
| 111 | 118 | } | |
| 112 | 119 | for i, tab := range session.Tabs { | |
| 113 | 120 | idx := len(m.rows) | |
| 114 | - | m.rows = append(m.rows, row{dispName: tab.Name, detail: tab.Detail, attention: tab.Attention, session: session.ID, targetName: targetName, tabIndex: i, stopped: session.Stopped}) | |
| 121 | + | m.rows = append(m.rows, row{dispName: tab.Name, detail: tab.Detail, attention: tab.Attention, session: session.ID, targetName: targetName, tabIndex: i, stopped: session.Stopped, expectedCreatedAt: session.ExpectedCreatedAt}) | |
| 115 | 122 | if session.ID == cur && i == curTab { | |
| 116 | 123 | m.selected = idx | |
| 117 | 124 | } | |
| @@ -177,7 +184,7 @@ func (m *Model) Selected() (Target, bool) { | |||
| 177 | 184 | if r.header { | |
| 178 | 185 | return Target{}, false | |
| 179 | 186 | } | |
| 180 | - | return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped}, true | |
| 187 | + | return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAt}, true | |
| 181 | 188 | } | |
| 182 | 189 | ||
| 183 | 190 | func (m *Model) Clone() *Model { | |
| @@ -16,11 +16,13 @@ const ( | |||||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | type SessionView struct { | 18 | type SessionView struct { | ||
| 19 | - | ID domain.SessionID | 19 | + | ID domain.SessionID |
| 20 | - | Name string | 20 | + | Name string |
| 21 | - | Tabs []TabEntry | 21 | + | TargetName string |
| 22 | - | Active int | 22 | + | Tabs []TabEntry |
| 23 | - | Stopped bool | 23 | + | Active int |
| 24 | + | Stopped bool | |||
| 25 | + | ExpectedCreatedAt *int64 | |||
| 24 | } | 26 | } | ||
| 25 | 27 | ||||
| 26 | // TabEntry is one tab row; Name is drawn emphasized, Detail muted. | 28 | // TabEntry is one tab row; Name is drawn emphasized, Detail muted. | ||
| @@ -58,6 +60,10 @@ type Target struct { | |||||
| 58 | Name string | 60 | Name string | ||
| 59 | TabIndex int | 61 | TabIndex int | ||
| 60 | Stopped bool | 62 | Stopped bool | ||
| 63 | + | // ExpectedCreatedAt optionally pins this target to a particular named | |||
| 64 | + | // session lifecycle. Callers that obtain a snapshot outside the daemon use | |||
| 65 | + | // it to reject a same-name replacement at commit time. | |||
| 66 | + | ExpectedCreatedAt *int64 | |||
| 61 | } | 67 | } | ||
| 62 | 68 | ||||
| 63 | // Preview is a bounded view of the selected pane's visible frame. | 69 | // Preview is a bounded view of the selected pane's visible frame. | ||
| @@ -89,29 +95,30 @@ type row struct { | |||||
| 89 | detail string // " (paneTitle)" segment, muted; "" for headers | 95 | detail string // " (paneTitle)" segment, muted; "" for headers | ||
| 90 | attention bool // draw the attention marker right after the name, before detail; tab rows only | 96 | attention bool // draw the attention marker right after the name, before detail; tab rows only | ||
| 91 | session domain.SessionID | 97 | session domain.SessionID | ||
| 92 | - | // targetName is the stopped-session lookup name threaded into Target; | 98 | + | // targetName is the named-session lookup name threaded into Target; |
| 93 | // distinct from dispName, which is what gets drawn. | 99 | // distinct from dispName, which is what gets drawn. | ||
| 94 | - | targetName string | 100 | + | targetName string |
| 95 | - | tabIndex int | 101 | + | tabIndex int |
| 96 | - | stopped bool | 102 | + | stopped bool |
| 103 | + | expectedCreatedAt *int64 | |||
| 97 | } | 104 | } | ||
| 98 | 105 | ||||
| 99 | func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model { | 106 | func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model { | ||
| 100 | m := &Model{selected: -1} | 107 | m := &Model{selected: -1} | ||
| 101 | activeSelection := -1 | 108 | activeSelection := -1 | ||
| 102 | for _, session := range sessions { | 109 | for _, session := range sessions { | ||
| 103 | - | targetName := "" | 110 | + | targetName := session.TargetName |
| 104 | - | if session.Stopped { | 111 | + | if targetName == "" && (session.Stopped || session.ExpectedCreatedAt != nil) { |
| 105 | targetName = session.Name | 112 | targetName = session.Name | ||
| 106 | } | 113 | } | ||
| 107 | - | m.rows = append(m.rows, row{header: true, dispName: session.Name, session: session.ID, targetName: targetName, tabIndex: -1, stopped: session.Stopped}) | 114 | + | m.rows = append(m.rows, row{header: true, dispName: session.Name, session: session.ID, targetName: targetName, tabIndex: -1, stopped: session.Stopped, expectedCreatedAt: session.ExpectedCreatedAt}) |
| 108 | active := session.Active | 115 | active := session.Active | ||
| 109 | if active < 0 || active >= len(session.Tabs) { | 116 | if active < 0 || active >= len(session.Tabs) { | ||
| 110 | active = 0 | 117 | active = 0 | ||
| 111 | } | 118 | } | ||
| 112 | for i, tab := range session.Tabs { | 119 | for i, tab := range session.Tabs { | ||
| 113 | idx := len(m.rows) | 120 | idx := len(m.rows) | ||
| 114 | - | m.rows = append(m.rows, row{dispName: tab.Name, detail: tab.Detail, attention: tab.Attention, session: session.ID, targetName: targetName, tabIndex: i, stopped: session.Stopped}) | 121 | + | m.rows = append(m.rows, row{dispName: tab.Name, detail: tab.Detail, attention: tab.Attention, session: session.ID, targetName: targetName, tabIndex: i, stopped: session.Stopped, expectedCreatedAt: session.ExpectedCreatedAt}) |
| 115 | if session.ID == cur && i == curTab { | 122 | if session.ID == cur && i == curTab { | ||
| 116 | m.selected = idx | 123 | m.selected = idx | ||
| 117 | } | 124 | } | ||
| @@ -177,7 +184,7 @@ func (m *Model) Selected() (Target, bool) { | |||||
| 177 | if r.header { | 184 | if r.header { | ||
| 178 | return Target{}, false | 185 | return Target{}, false | ||
| 179 | } | 186 | } | ||
| 180 | - | return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped}, true | 187 | + | return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAt}, true |
| 181 | } | 188 | } | ||
| 182 | 189 | ||||
| 183 | func (m *Model) Clone() *Model { | 190 | func (m *Model) Clone() *Model { | ||