feat(picker): add compact searchable session UI (#240)
* feat(picker): add compact searchable session UI * fix(picker): harden search and refresh state * fix(picker): preserve stable refresh identity * fix(picker): bound compact title geometry * fix(picker): address PR review findings
- commit
- d73dd26f6842c23fbb93855f22dfbed4acb28f02
- author
- bnema · · 2026-08-30 02:48 UTC
- committer
- GitHub · · 2026-08-30 02:48 UTC
- parent
- c8de770
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
14 files · +1467 -135
M README.md +2 -0
| @@ -65,6 +65,8 @@ Type a code or fuzzy-search the palette. Common default codes: | |||
| 65 | 65 | | `MFP` / `MAT` | move pane / tab | `SSP` | session picker | | |
| 66 | 66 | | `DET` | detach | `NTC` | notifications | | |
| 67 | 67 | ||
| 68 | + | In the session picker, press `/` to fuzzy-search session names, hosts, tab names, and tab details; use Arrow keys or Ctrl+n/Ctrl+p to move between matches. Escape clears the query, and a second Escape leaves search. | |
| 69 | + | ||
| 68 | 70 | When a client is attached to both a local and a remote daemon, `CNS` and `CNS <name>` list eligible daemon destinations in the existing palette. If two or more destinations exist, choose one explicitly with Up or Down before pressing Enter. The unqualified choice appears only when the client has an exact local route; remote choices remain host-qualified. A direct remote start without a proven local route never labels its home route as local. | |
| 69 | 71 | ||
| 70 | 72 | Scroll up to enter copy mode; use vim motions, `v` to select, and `y` to copy. All codes and bindings are configurable in [configuration](docs/configuration.md). | |
| @@ -65,6 +65,8 @@ Type a code or fuzzy-search the palette. Common default codes: | |||||
| 65 | | `MFP` / `MAT` | move pane / tab | `SSP` | session picker | | 65 | | `MFP` / `MAT` | move pane / tab | `SSP` | session picker | | ||
| 66 | | `DET` | detach | `NTC` | notifications | | 66 | | `DET` | detach | `NTC` | notifications | | ||
| 67 | 67 | ||||
| 68 | + | In the session picker, press `/` to fuzzy-search session names, hosts, tab names, and tab details; use Arrow keys or Ctrl+n/Ctrl+p to move between matches. Escape clears the query, and a second Escape leaves search. | |||
| 69 | + | ||||
| 68 | When a client is attached to both a local and a remote daemon, `CNS` and `CNS <name>` list eligible daemon destinations in the existing palette. If two or more destinations exist, choose one explicitly with Up or Down before pressing Enter. The unqualified choice appears only when the client has an exact local route; remote choices remain host-qualified. A direct remote start without a proven local route never labels its home route as local. | 70 | When a client is attached to both a local and a remote daemon, `CNS` and `CNS <name>` list eligible daemon destinations in the existing palette. If two or more destinations exist, choose one explicitly with Up or Down before pressing Enter. The unqualified choice appears only when the client has an exact local route; remote choices remain host-qualified. A direct remote start without a proven local route never labels its home route as local. | ||
| 69 | 71 | ||||
| 70 | Scroll up to enter copy mode; use vim motions, `v` to select, and `y` to copy. All codes and bindings are configurable in [configuration](docs/configuration.md). | 72 | Scroll up to enter copy mode; use vim motions, `v` to select, and `y` to copy. All codes and bindings are configurable in [configuration](docs/configuration.md). | ||
M internal/usecase/daemon/move_picker.go +8 -8
| @@ -173,6 +173,8 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions | |||
| 173 | 173 | } | |
| 174 | 174 | intent, source := rt.pickerIntent, rt.pickerSource | |
| 175 | 175 | observedModel, observedGeneration := rt.picker, rt.pickerGeneration | |
| 176 | + | rt.pickerRefreshSequence++ | |
| 177 | + | refreshSequence := rt.pickerRefreshSequence | |
| 176 | 178 | current := picker.SourceFilter{} | |
| 177 | 179 | if intent != pickerNavigate || opts.preserveSelection { | |
| 178 | 180 | cursor, _ := rt.picker.Cursor() | |
| @@ -187,19 +189,17 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions | |||
| 187 | 189 | } | |
| 188 | 190 | if intent != pickerNavigate { | |
| 189 | 191 | if _, ok := model.Selected(); !ok { | |
| 190 | - | d.closePickerIfCurrent(ac, observedModel, observedGeneration) | |
| 192 | + | d.closePickerIfCurrentRefresh(ac, observedModel, observedGeneration, refreshSequence) | |
| 191 | 193 | return | |
| 192 | 194 | } | |
| 193 | 195 | } | |
| 194 | - | // Navigate-only: move intents keep the selection-or-close logic above | |
| 195 | - | // authoritative, so a stale row index can never override it. | |
| 196 | - | if intent == pickerNavigate && opts.nearestRow >= 0 { | |
| 197 | - | model.SelectNearestRow(opts.nearestRow) | |
| 198 | - | } | |
| 199 | 196 | rt.pickerMu.Lock() | |
| 200 | - | updated := rt.picker == observedModel && rt.pickerGeneration == observedGeneration && rt.pickerIntent == intent && rt.pickerSource == source | |
| 197 | + | updated := rt.picker == observedModel && rt.pickerGeneration == observedGeneration && rt.pickerRefreshSequence == refreshSequence && rt.pickerIntent == intent && rt.pickerSource == source | |
| 201 | 198 | if updated { | |
| 202 | - | rt.picker = model | |
| 199 | + | rt.picker.ReplaceFrom(model) | |
| 200 | + | if intent == pickerNavigate && opts.nearestRow >= 0 { | |
| 201 | + | rt.picker.SelectNearestRow(opts.nearestRow) | |
| 202 | + | } | |
| 203 | 203 | rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load())) | |
| 204 | 204 | } | |
| 205 | 205 | rt.pickerMu.Unlock() | |
| @@ -173,6 +173,8 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions | |||||
| 173 | } | 173 | } | ||
| 174 | intent, source := rt.pickerIntent, rt.pickerSource | 174 | intent, source := rt.pickerIntent, rt.pickerSource | ||
| 175 | observedModel, observedGeneration := rt.picker, rt.pickerGeneration | 175 | observedModel, observedGeneration := rt.picker, rt.pickerGeneration | ||
| 176 | + | rt.pickerRefreshSequence++ | |||
| 177 | + | refreshSequence := rt.pickerRefreshSequence | |||
| 176 | current := picker.SourceFilter{} | 178 | current := picker.SourceFilter{} | ||
| 177 | if intent != pickerNavigate || opts.preserveSelection { | 179 | if intent != pickerNavigate || opts.preserveSelection { | ||
| 178 | cursor, _ := rt.picker.Cursor() | 180 | cursor, _ := rt.picker.Cursor() | ||
| @@ -187,19 +189,17 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions | |||||
| 187 | } | 189 | } | ||
| 188 | if intent != pickerNavigate { | 190 | if intent != pickerNavigate { | ||
| 189 | if _, ok := model.Selected(); !ok { | 191 | if _, ok := model.Selected(); !ok { | ||
| 190 | - | d.closePickerIfCurrent(ac, observedModel, observedGeneration) | 192 | + | d.closePickerIfCurrentRefresh(ac, observedModel, observedGeneration, refreshSequence) |
| 191 | return | 193 | return | ||
| 192 | } | 194 | } | ||
| 193 | } | 195 | } | ||
| 194 | - | // Navigate-only: move intents keep the selection-or-close logic above | |||
| 195 | - | // authoritative, so a stale row index can never override it. | |||
| 196 | - | if intent == pickerNavigate && opts.nearestRow >= 0 { | |||
| 197 | - | model.SelectNearestRow(opts.nearestRow) | |||
| 198 | - | } | |||
| 199 | rt.pickerMu.Lock() | 196 | rt.pickerMu.Lock() | ||
| 200 | - | updated := rt.picker == observedModel && rt.pickerGeneration == observedGeneration && rt.pickerIntent == intent && rt.pickerSource == source | 197 | + | updated := rt.picker == observedModel && rt.pickerGeneration == observedGeneration && rt.pickerRefreshSequence == refreshSequence && rt.pickerIntent == intent && rt.pickerSource == source |
| 201 | if updated { | 198 | if updated { | ||
| 202 | - | rt.picker = model | 199 | + | rt.picker.ReplaceFrom(model) |
| 200 | + | if intent == pickerNavigate && opts.nearestRow >= 0 { | |||
| 201 | + | rt.picker.SelectNearestRow(opts.nearestRow) | |||
| 202 | + | } | |||
| 203 | rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load())) | 203 | rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load())) | ||
| 204 | } | 204 | } | ||
| 205 | rt.pickerMu.Unlock() | 205 | rt.pickerMu.Unlock() | ||
M internal/usecase/daemon/move_picker_test.go +54 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "sync/atomic" | |
| 4 | 5 | "testing" | |
| 5 | 6 | "time" | |
| 6 | 7 | ||
| @@ -297,6 +298,59 @@ func TestMovePickerRefreshCloseKeepsReplacementPicker(t *testing.T) { | |||
| 297 | 298 | ac.overlays.pickerMu.Unlock() | |
| 298 | 299 | } | |
| 299 | 300 | ||
| 301 | + | func TestMovePickerOlderEmptyRefreshCannotCloseNewerValidRefresh(t *testing.T) { | |
| 302 | + | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | |
| 303 | + | defer releaseAll(releases) | |
| 304 | + | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{ | |
| 305 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |
| 306 | + | TabID: "source-tab", PaneID: "source-pane", | |
| 307 | + | })) | |
| 308 | + | d.mu.Lock() | |
| 309 | + | delete(d.sessions, destination.id) | |
| 310 | + | d.mu.Unlock() | |
| 311 | + | ||
| 312 | + | rebuildReached := make(chan struct{}) | |
| 313 | + | allowOld := make(chan struct{}) | |
| 314 | + | var builds atomic.Int32 | |
| 315 | + | ac.overlays.afterPickerRefreshBuild = func(*picker.Model) { | |
| 316 | + | if builds.Add(1) == 1 { | |
| 317 | + | close(rebuildReached) | |
| 318 | + | select { | |
| 319 | + | case <-allowOld: | |
| 320 | + | case <-time.After(time.Second): | |
| 321 | + | t.Error("timed out waiting to release the older move-picker refresh") | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | oldDone := make(chan struct{}) | |
| 326 | + | go func() { | |
| 327 | + | d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1}) | |
| 328 | + | close(oldDone) | |
| 329 | + | }() | |
| 330 | + | select { | |
| 331 | + | case <-rebuildReached: | |
| 332 | + | case <-time.After(time.Second): | |
| 333 | + | t.Fatal("timed out waiting for the first move-picker refresh rebuild") | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | d.mu.Lock() | |
| 337 | + | d.sessions[destination.id] = destination | |
| 338 | + | d.mu.Unlock() | |
| 339 | + | d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1}) | |
| 340 | + | close(allowOld) | |
| 341 | + | select { | |
| 342 | + | case <-oldDone: | |
| 343 | + | case <-time.After(time.Second): | |
| 344 | + | t.Fatal("timed out waiting for the older move-picker refresh") | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | require.True(t, ac.overlays.pickerActive(), "the older empty refresh must not close the newer in-place publication") | |
| 348 | + | ac.overlays.pickerMu.Lock() | |
| 349 | + | _, ok := ac.overlays.picker.Selected() | |
| 350 | + | ac.overlays.pickerMu.Unlock() | |
| 351 | + | require.True(t, ok) | |
| 352 | + | } | |
| 353 | + | ||
| 300 | 354 | func TestMovePickerDispatchMuNotHeldWhileOpen(t *testing.T) { | |
| 301 | 355 | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | |
| 302 | 356 | defer releaseAll(releases) | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "sync/atomic" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | "time" | 6 | "time" | ||
| 6 | 7 | ||||
| @@ -297,6 +298,59 @@ func TestMovePickerRefreshCloseKeepsReplacementPicker(t *testing.T) { | |||||
| 297 | ac.overlays.pickerMu.Unlock() | 298 | ac.overlays.pickerMu.Unlock() | ||
| 298 | } | 299 | } | ||
| 299 | 300 | ||||
| 301 | + | func TestMovePickerOlderEmptyRefreshCannotCloseNewerValidRefresh(t *testing.T) { | |||
| 302 | + | d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0) | |||
| 303 | + | defer releaseAll(releases) | |||
| 304 | + | require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{ | |||
| 305 | + | Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, | |||
| 306 | + | TabID: "source-tab", PaneID: "source-pane", | |||
| 307 | + | })) | |||
| 308 | + | d.mu.Lock() | |||
| 309 | + | delete(d.sessions, destination.id) | |||
| 310 | + | d.mu.Unlock() | |||
| 311 | + | ||||
| 312 | + | rebuildReached := make(chan struct{}) | |||
| 313 | + | allowOld := make(chan struct{}) | |||
| 314 | + | var builds atomic.Int32 | |||
| 315 | + | ac.overlays.afterPickerRefreshBuild = func(*picker.Model) { | |||
| 316 | + | if builds.Add(1) == 1 { | |||
| 317 | + | close(rebuildReached) | |||
| 318 | + | select { | |||
| 319 | + | case <-allowOld: | |||
| 320 | + | case <-time.After(time.Second): | |||
| 321 | + | t.Error("timed out waiting to release the older move-picker refresh") | |||
| 322 | + | } | |||
| 323 | + | } | |||
| 324 | + | } | |||
| 325 | + | oldDone := make(chan struct{}) | |||
| 326 | + | go func() { | |||
| 327 | + | d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1}) | |||
| 328 | + | close(oldDone) | |||
| 329 | + | }() | |||
| 330 | + | select { | |||
| 331 | + | case <-rebuildReached: | |||
| 332 | + | case <-time.After(time.Second): | |||
| 333 | + | t.Fatal("timed out waiting for the first move-picker refresh rebuild") | |||
| 334 | + | } | |||
| 335 | + | ||||
| 336 | + | d.mu.Lock() | |||
| 337 | + | d.sessions[destination.id] = destination | |||
| 338 | + | d.mu.Unlock() | |||
| 339 | + | d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1}) | |||
| 340 | + | close(allowOld) | |||
| 341 | + | select { | |||
| 342 | + | case <-oldDone: | |||
| 343 | + | case <-time.After(time.Second): | |||
| 344 | + | t.Fatal("timed out waiting for the older move-picker refresh") | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | require.True(t, ac.overlays.pickerActive(), "the older empty refresh must not close the newer in-place publication") | |||
| 348 | + | ac.overlays.pickerMu.Lock() | |||
| 349 | + | _, ok := ac.overlays.picker.Selected() | |||
| 350 | + | ac.overlays.pickerMu.Unlock() | |||
| 351 | + | require.True(t, ok) | |||
| 352 | + | } | |||
| 353 | + | ||||
| 300 | func TestMovePickerDispatchMuNotHeldWhileOpen(t *testing.T) { | 354 | func TestMovePickerDispatchMuNotHeldWhileOpen(t *testing.T) { | ||
| 301 | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | 355 | d, source, ac, _, _, releases := setupMovePickerSessions(t, 0) | ||
| 302 | defer releaseAll(releases) | 356 | defer releaseAll(releases) | ||
M internal/usecase/daemon/overlay_runtime.go +4 -1
| @@ -23,7 +23,10 @@ type overlayRuntime struct { | |||
| 23 | 23 | // pickerGeneration identifies one open lifecycle. It advances only when a | |
| 24 | 24 | // picker is published, so delayed close and registration work can prove it | |
| 25 | 25 | // still owns the exact lifecycle it captured. | |
| 26 | - | pickerGeneration uint64 | |
| 26 | + | pickerGeneration uint64 | |
| 27 | + | // pickerRefreshSequence advances for every rebuild attempt in one open | |
| 28 | + | // lifecycle. A zero close token deliberately disables this extra fence. | |
| 29 | + | pickerRefreshSequence uint64 | |
| 27 | 30 | pickerTitle string | |
| 28 | 31 | pickerIntent pickerIntent | |
| 29 | 32 | pickerSource moveSourceLocator | |
| @@ -23,7 +23,10 @@ type overlayRuntime struct { | |||||
| 23 | // pickerGeneration identifies one open lifecycle. It advances only when a | 23 | // pickerGeneration identifies one open lifecycle. It advances only when a | ||
| 24 | // picker is published, so delayed close and registration work can prove it | 24 | // picker is published, so delayed close and registration work can prove it | ||
| 25 | // still owns the exact lifecycle it captured. | 25 | // still owns the exact lifecycle it captured. | ||
| 26 | - | pickerGeneration uint64 | 26 | + | pickerGeneration uint64 |
| 27 | + | // pickerRefreshSequence advances for every rebuild attempt in one open | |||
| 28 | + | // lifecycle. A zero close token deliberately disables this extra fence. | |||
| 29 | + | pickerRefreshSequence uint64 | |||
| 27 | pickerTitle string | 30 | pickerTitle string | ||
| 28 | pickerIntent pickerIntent | 31 | pickerIntent pickerIntent | ||
| 29 | pickerSource moveSourceLocator | 32 | pickerSource moveSourceLocator | ||
M internal/usecase/daemon/picker.go +134 -29
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "github.com/bnema/vev/internal/domain" | |
| 12 | 12 | "github.com/bnema/vev/internal/protocol" | |
| 13 | 13 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 14 | + | "github.com/bnema/vev/internal/usecase/keys" | |
| 14 | 15 | "github.com/bnema/vev/internal/usecase/layout" | |
| 15 | 16 | "github.com/bnema/vev/internal/usecase/picker" | |
| 16 | 17 | "github.com/bnema/vev/internal/usecase/ui" | |
| @@ -177,32 +178,56 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session | |||
| 177 | 178 | } | |
| 178 | 179 | } | |
| 179 | 180 | views := make([]picker.SessionView, 0, len(live)+len(stopped)+catalogRows) | |
| 180 | - | for _, item := range live { | |
| 181 | - | views = append(views, item.view.pickerView()) | |
| 181 | + | grouped := pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped | |
| 182 | + | for i, item := range live { | |
| 183 | + | view := item.view.pickerView() | |
| 184 | + | if grouped && i == 0 { | |
| 185 | + | view.Section = "LOCAL" | |
| 186 | + | } | |
| 187 | + | views = append(views, view) | |
| 182 | 188 | } | |
| 189 | + | publishedRemote := false | |
| 183 | 190 | for _, host := range catalog { | |
| 191 | + | publishedForHost := 0 | |
| 184 | 192 | for _, session := range host.entry.Sessions { | |
| 185 | 193 | key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name} | |
| 186 | 194 | if key.Validate() != nil { | |
| 187 | 195 | continue | |
| 188 | 196 | } | |
| 189 | - | views = append(views, remotePickerView(key, session, host.status, host.entry.FetchedAt)) | |
| 197 | + | view := remotePickerView(key, session, host.status, host.entry.FetchedAt) | |
| 198 | + | if grouped { | |
| 199 | + | view.HideRemoteOrigin = true | |
| 200 | + | if publishedForHost == 0 { | |
| 201 | + | view.Section = "REMOTE " + host.entry.Host | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | views = append(views, view) | |
| 205 | + | publishedForHost++ | |
| 206 | + | publishedRemote = true | |
| 190 | 207 | } | |
| 191 | 208 | if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch || host.status == remoteHostMalformed) { | |
| 192 | - | views = append(views, remotePickerHostView(host.entry.Host, host.status)) | |
| 209 | + | view := remotePickerHostView(host.entry.Host, host.status) | |
| 210 | + | if grouped { | |
| 211 | + | view.Section = "REMOTE " + host.entry.Host | |
| 212 | + | } | |
| 213 | + | views = append(views, view) | |
| 214 | + | publishedRemote = true | |
| 193 | 215 | } | |
| 194 | 216 | } | |
| 195 | - | for _, s := range stopped { | |
| 217 | + | for i, s := range stopped { | |
| 196 | 218 | createdAt := s.createdAt | |
| 197 | - | views = append(views, picker.SessionView{ | |
| 219 | + | view := picker.SessionView{ | |
| 198 | 220 | ID: domain.SessionID("stopped:" + s.name), | |
| 199 | 221 | Incarnation: s.incarnation, | |
| 200 | 222 | Name: s.name, | |
| 201 | 223 | TargetName: s.name, | |
| 202 | - | Tabs: []picker.TabEntry{{}}, | |
| 203 | 224 | Stopped: true, | |
| 204 | 225 | ExpectedCreatedAt: &createdAt, | |
| 205 | - | }) | |
| 226 | + | } | |
| 227 | + | if grouped && i == 0 && (len(live) == 0 || publishedRemote) { | |
| 228 | + | view.Section = "LOCAL" | |
| 229 | + | } | |
| 230 | + | views = append(views, view) | |
| 206 | 231 | } | |
| 207 | 232 | return views, current | |
| 208 | 233 | } | |
| @@ -263,6 +288,29 @@ func remotePickerReasonText(reason string) string { | |||
| 263 | 288 | } | |
| 264 | 289 | } | |
| 265 | 290 | ||
| 291 | + | func pickerSearchSlashIndex(data, pending []byte) int { | |
| 292 | + | if len(pending) != 0 { | |
| 293 | + | return -1 | |
| 294 | + | } | |
| 295 | + | for offset := 0; offset < len(data); { | |
| 296 | + | if data[offset] == '/' { | |
| 297 | + | return offset | |
| 298 | + | } | |
| 299 | + | if data[offset] != keys.ESC { | |
| 300 | + | offset++ | |
| 301 | + | continue | |
| 302 | + | } | |
| 303 | + | if consumed, _ := routeListEscape(data[offset:]); consumed > 0 { | |
| 304 | + | offset += consumed | |
| 305 | + | continue | |
| 306 | + | } | |
| 307 | + | // An incomplete or unknown escape owns the rest of this input chunk; | |
| 308 | + | // slash bytes inside it cannot transition the picker into search mode. | |
| 309 | + | return -1 | |
| 310 | + | } | |
| 311 | + | return -1 | |
| 312 | + | } | |
| 313 | + | ||
| 266 | 314 | func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | |
| 267 | 315 | rt := ac.overlays | |
| 268 | 316 | var previewGeneration uint64 | |
| @@ -305,22 +353,40 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 305 | 353 | rt.pickerMu.Unlock() | |
| 306 | 354 | return | |
| 307 | 355 | } | |
| 308 | - | result := handleListInputLocked(d.clock, data, d.pickerListInputState(ac), func(b byte) listInputResult { | |
| 356 | + | normalAction := func(b byte) listInputResult { | |
| 309 | 357 | switch b { | |
| 310 | - | case 'x': | |
| 311 | - | return listInputResult{action: b, stop: true} | |
| 312 | - | case 's': | |
| 358 | + | case 'x', 's': | |
| 313 | 359 | return listInputResult{action: b, stop: true} | |
| 314 | 360 | case '\r', '\n': | |
| 315 | 361 | return listInputResult{action: b, exit: true} | |
| 316 | 362 | default: | |
| 317 | 363 | return listInputResult{} | |
| 318 | 364 | } | |
| 319 | - | }) | |
| 365 | + | } | |
| 366 | + | var result listInputResult | |
| 367 | + | if rt.picker.SearchActive() { | |
| 368 | + | result = d.handlePickerSearchInputLocked(ac, data) | |
| 369 | + | } else if slash := pickerSearchSlashIndex(data, rt.pickerPending); slash >= 0 { | |
| 370 | + | result = handleListInputLocked(d.clock, data[:slash], d.pickerListInputState(ac), normalAction) | |
| 371 | + | if result.action == 0 && !result.exit && len(rt.pickerPending) == 0 { | |
| 372 | + | rt.picker.EnterSearch() | |
| 373 | + | result.changed = true | |
| 374 | + | if slash+1 < len(data) { | |
| 375 | + | searchResult := d.handlePickerSearchInputLocked(ac, data[slash+1:]) | |
| 376 | + | searchResult.changed = searchResult.changed || result.changed | |
| 377 | + | result = searchResult | |
| 378 | + | } | |
| 379 | + | } | |
| 380 | + | } else { | |
| 381 | + | result = handleListInputLocked(d.clock, data, d.pickerListInputState(ac), normalAction) | |
| 382 | + | } | |
| 320 | 383 | var target picker.Target | |
| 321 | 384 | var cursor picker.Target | |
| 322 | 385 | var ok bool | |
| 323 | 386 | var cursorOK bool | |
| 387 | + | var rejectedBySearch bool | |
| 388 | + | observedPicker := rt.picker | |
| 389 | + | observedGeneration := rt.pickerGeneration | |
| 324 | 390 | var intent pickerIntent | |
| 325 | 391 | var source moveSourceLocator | |
| 326 | 392 | // prevIdx is the row the victim occupied; -1 means "no post-delete hint". | |
| @@ -328,17 +394,25 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 328 | 394 | if result.action == 'x' || result.action == '\r' || result.action == '\n' { | |
| 329 | 395 | target, ok = rt.picker.Selected() | |
| 330 | 396 | cursor, cursorOK = rt.picker.Cursor() | |
| 397 | + | rejectedBySearch = rt.picker.SelectionRejectedBySearch() | |
| 331 | 398 | prevIdx = rt.picker.SelectedIndex() | |
| 332 | 399 | } | |
| 333 | 400 | intent, source = rt.pickerIntent, rt.pickerSource | |
| 334 | 401 | rt.pickerMu.Unlock() | |
| 335 | 402 | ||
| 403 | + | if !d.pickerInstanceCurrent(ac, observedPicker, observedGeneration) { | |
| 404 | + | return | |
| 405 | + | } | |
| 336 | 406 | if result.action == 's' { | |
| 337 | 407 | d.togglePickerSort() | |
| 338 | 408 | d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1}) | |
| 339 | 409 | d.invalidateRender(sess, ac, true, "picker.go") | |
| 340 | 410 | return | |
| 341 | 411 | } | |
| 412 | + | if (result.action == '\r' || result.action == '\n') && !ok && rejectedBySearch { | |
| 413 | + | d.invalidateRender(sess, ac, true, "picker search no match") | |
| 414 | + | return | |
| 415 | + | } | |
| 342 | 416 | if (result.action == '\r' || result.action == '\n') && !ok && cursorOK && (cursor.RemoteTarget != nil || cursor.RemoteHost != "") { | |
| 343 | 417 | d.notifyRemotePickerUnavailable(sess, cursor) | |
| 344 | 418 | d.invalidateRender(sess, ac, true, "picker.go") | |
| @@ -392,7 +466,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 392 | 466 | } | |
| 393 | 467 | backNavigationSent = true | |
| 394 | 468 | } | |
| 395 | - | d.closePicker(ac) | |
| 469 | + | d.closePickerIfCurrent(ac, observedPicker, observedGeneration) | |
| 396 | 470 | } | |
| 397 | 471 | if committing { | |
| 398 | 472 | if intent == pickerMovePane || intent == pickerMoveTab { | |
| @@ -404,7 +478,9 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 404 | 478 | if len(effects) != 0 && effects[0] != nil { | |
| 405 | 479 | effects[0].End() | |
| 406 | 480 | } | |
| 407 | - | d.closePicker(ac) | |
| 481 | + | if !d.closePickerIfCurrent(ac, observedPicker, observedGeneration) { | |
| 482 | + | return | |
| 483 | + | } | |
| 408 | 484 | err := d.commitMovePickerSelection(intent, source, target) | |
| 409 | 485 | if err != nil { | |
| 410 | 486 | d.reportAttachmentError(sess, movePickerUserError(err)) | |
| @@ -416,9 +492,11 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 416 | 492 | } | |
| 417 | 493 | var err error | |
| 418 | 494 | if len(effects) != 0 && effects[0] != nil { | |
| 419 | - | err = d.switchToTargetForAttachment(effects[0], target, sessionHandoffGuard{closePicker: true, allowSamePeer: true}, "picker-select") | |
| 495 | + | err = d.switchToTargetForAttachment(effects[0], target, sessionHandoffGuard{closePicker: true, allowSamePeer: true, pickerModel: observedPicker, pickerGeneration: observedGeneration}, "picker-select") | |
| 420 | 496 | } else { | |
| 421 | - | d.closePicker(ac) | |
| 497 | + | if !d.closePickerIfCurrent(ac, observedPicker, observedGeneration) { | |
| 498 | + | return | |
| 499 | + | } | |
| 422 | 500 | err = d.switchToTarget(sess, ac, target) | |
| 423 | 501 | } | |
| 424 | 502 | if errors.Is(err, errAttachmentTransition) { | |
| @@ -595,8 +673,9 @@ func (d *Daemon) startRemotePickerPreview(ac *attachedClient, target picker.Targ | |||
| 595 | 673 | } | |
| 596 | 674 | ||
| 597 | 675 | func remotePickerPreviewSize(size domain.Size) (uint16, uint16) { | |
| 598 | - | layout := picker.ChooseLayout(size) | |
| 599 | - | width, height := layout.Preview.Width, layout.Preview.Height | |
| 676 | + | presentation := pickerModal.Resolve(size) | |
| 677 | + | geometry := picker.ChooseGeometry(rectSize(presentation.Inner)) | |
| 678 | + | width, height := geometry.Preview.Width, geometry.Preview.Height | |
| 600 | 679 | if width <= 0 || height <= 0 { | |
| 601 | 680 | return 0, 0 | |
| 602 | 681 | } | |
| @@ -712,15 +791,31 @@ func (d *Daemon) closePicker(ac *attachedClient) { | |||
| 712 | 791 | d.closePickerIfCurrent(ac, nil, 0) | |
| 713 | 792 | } | |
| 714 | 793 | ||
| 794 | + | func (d *Daemon) pickerInstanceCurrent(ac *attachedClient, model *picker.Model, generation uint64) bool { | |
| 795 | + | if ac == nil || ac.overlays == nil { | |
| 796 | + | return false | |
| 797 | + | } | |
| 798 | + | ac.overlays.pickerMu.Lock() | |
| 799 | + | defer ac.overlays.pickerMu.Unlock() | |
| 800 | + | return ac.overlays.picker == model && ac.overlays.pickerGeneration == generation | |
| 801 | + | } | |
| 802 | + | ||
| 715 | 803 | // closePickerIfCurrent closes only the observed picker lifecycle. A nonzero | |
| 716 | 804 | // generation guards terminal parked cleanup even when a refresh replaced the | |
| 717 | 805 | // model within that generation. | |
| 718 | 806 | func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, generation uint64) bool { | |
| 807 | + | return d.closePickerIfCurrentRefresh(ac, model, generation, 0) | |
| 808 | + | } | |
| 809 | + | ||
| 810 | + | func (d *Daemon) closePickerIfCurrentRefresh(ac *attachedClient, model *picker.Model, generation, refreshSequence uint64) bool { | |
| 719 | 811 | if ac == nil || ac.overlays == nil { | |
| 720 | 812 | return false | |
| 721 | 813 | } | |
| 722 | 814 | ac.overlays.pickerMu.Lock() | |
| 723 | - | if model != nil && (ac.overlays.picker != model || ac.overlays.pickerGeneration != generation) || model == nil && generation != 0 && ac.overlays.pickerGeneration != generation { | |
| 815 | + | refreshChanged := refreshSequence != 0 && ac.overlays.pickerRefreshSequence != refreshSequence | |
| 816 | + | modelChanged := model != nil && (ac.overlays.picker != model || ac.overlays.pickerGeneration != generation) | |
| 817 | + | generationChanged := model == nil && generation != 0 && ac.overlays.pickerGeneration != generation | |
| 818 | + | if refreshChanged || modelChanged || generationChanged { | |
| 724 | 819 | ac.overlays.pickerMu.Unlock() | |
| 725 | 820 | return false | |
| 726 | 821 | } | |
| @@ -777,9 +872,19 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session { | |||
| 777 | 872 | // one active-session handoff. Its zero value keeps daemon-owned transitions on | |
| 778 | 873 | // their established path. | |
| 779 | 874 | type sessionHandoffGuard struct { | |
| 780 | - | expectedSource *tab | |
| 781 | - | closePicker bool | |
| 782 | - | allowSamePeer bool | |
| 875 | + | expectedSource *tab | |
| 876 | + | closePicker bool | |
| 877 | + | allowSamePeer bool | |
| 878 | + | pickerModel *picker.Model | |
| 879 | + | pickerGeneration uint64 | |
| 880 | + | } | |
| 881 | + | ||
| 882 | + | func (d *Daemon) closePickerForGuard(ac *attachedClient, guard sessionHandoffGuard) bool { | |
| 883 | + | if guard.pickerModel != nil || guard.pickerGeneration != 0 { | |
| 884 | + | return d.closePickerIfCurrent(ac, guard.pickerModel, guard.pickerGeneration) | |
| 885 | + | } | |
| 886 | + | d.closePicker(ac) | |
| 887 | + | return true | |
| 783 | 888 | } | |
| 784 | 889 | ||
| 785 | 890 | // switchActiveTargetForAttachment hands a frame-bound navigation request to the | |
| @@ -849,7 +954,7 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(effect *attachmentEffect | |||
| 849 | 954 | if guard.closePicker { | |
| 850 | 955 | fresh, admitted := effect.ac.beginAttachmentEffect(transition.published) | |
| 851 | 956 | if admitted { | |
| 852 | - | d.closePicker(effect.ac) | |
| 957 | + | d.closePickerForGuard(effect.ac, guard) | |
| 853 | 958 | fresh.End() | |
| 854 | 959 | } | |
| 855 | 960 | } | |
| @@ -958,7 +1063,7 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(effect *attachmentEffect, ta | |||
| 958 | 1063 | // Keep the source link open until the client receives the ordered cleanup | |
| 959 | 1064 | // and handoff frames, then let the client's close drive ordinary parking. | |
| 960 | 1065 | if guard.closePicker { | |
| 961 | - | d.closePicker(effect.ac) | |
| 1066 | + | d.closePickerForGuard(effect.ac, guard) | |
| 962 | 1067 | } | |
| 963 | 1068 | effect.bindActionEnd(d, "detach") | |
| 964 | 1069 | effect.End() | |
| @@ -999,7 +1104,7 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(effect *attachmentEffect, t | |||
| 999 | 1104 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to remote session", err) | |
| 1000 | 1105 | } | |
| 1001 | 1106 | if guard.closePicker { | |
| 1002 | - | d.closePicker(effect.ac) | |
| 1107 | + | d.closePickerForGuard(effect.ac, guard) | |
| 1003 | 1108 | } | |
| 1004 | 1109 | d.clientGoneForAttachment(effect, false) | |
| 1005 | 1110 | return nil | |
| @@ -1109,11 +1214,11 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC | |||
| 1109 | 1214 | if sourceEffect != nil { | |
| 1110 | 1215 | fresh, admitted := ac.beginAttachmentEffect(transition.published) | |
| 1111 | 1216 | if admitted { | |
| 1112 | - | d.closePicker(ac) | |
| 1217 | + | d.closePickerForGuard(ac, guard) | |
| 1113 | 1218 | fresh.End() | |
| 1114 | 1219 | } | |
| 1115 | 1220 | } else { | |
| 1116 | - | d.closePicker(ac) | |
| 1221 | + | d.closePickerForGuard(ac, guard) | |
| 1117 | 1222 | } | |
| 1118 | 1223 | } | |
| 1119 | 1224 | d.deferAttachmentTransitionCleanups(transition) | |
| @@ -1397,7 +1502,7 @@ func (d *Daemon) killPickerTargetForAttachment(target picker.Target, effect *att | |||
| 1397 | 1502 | ||
| 1398 | 1503 | func (d *Daemon) killPickerTarget(target picker.Target) error { | |
| 1399 | 1504 | if target.Stopped { | |
| 1400 | - | if err := d.retryStoppedPurge(target.Name); err != nil { | |
| 1505 | + | if err := d.retryStoppedPurgeExact(target.Name, target.Incarnation, target.ExpectedCreatedAt); err != nil { | |
| 1401 | 1506 | d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name) | |
| 1402 | 1507 | return domain.UserErr(domain.NoticePersistDelete, "couldn't delete stopped session", err) | |
| 1403 | 1508 | } | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "github.com/bnema/vev/internal/domain" | 11 | "github.com/bnema/vev/internal/domain" | ||
| 12 | "github.com/bnema/vev/internal/protocol" | 12 | "github.com/bnema/vev/internal/protocol" | ||
| 13 | "github.com/bnema/vev/internal/protocol/catalogue" | 13 | "github.com/bnema/vev/internal/protocol/catalogue" | ||
| 14 | + | "github.com/bnema/vev/internal/usecase/keys" | |||
| 14 | "github.com/bnema/vev/internal/usecase/layout" | 15 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 15 | "github.com/bnema/vev/internal/usecase/picker" | 16 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 16 | "github.com/bnema/vev/internal/usecase/ui" | 17 | "github.com/bnema/vev/internal/usecase/ui" | ||
| @@ -177,32 +178,56 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session | |||||
| 177 | } | 178 | } | ||
| 178 | } | 179 | } | ||
| 179 | views := make([]picker.SessionView, 0, len(live)+len(stopped)+catalogRows) | 180 | views := make([]picker.SessionView, 0, len(live)+len(stopped)+catalogRows) | ||
| 180 | - | for _, item := range live { | 181 | + | grouped := pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped |
| 181 | - | views = append(views, item.view.pickerView()) | 182 | + | for i, item := range live { |
| 183 | + | view := item.view.pickerView() | |||
| 184 | + | if grouped && i == 0 { | |||
| 185 | + | view.Section = "LOCAL" | |||
| 186 | + | } | |||
| 187 | + | views = append(views, view) | |||
| 182 | } | 188 | } | ||
| 189 | + | publishedRemote := false | |||
| 183 | for _, host := range catalog { | 190 | for _, host := range catalog { | ||
| 191 | + | publishedForHost := 0 | |||
| 184 | for _, session := range host.entry.Sessions { | 192 | for _, session := range host.entry.Sessions { | ||
| 185 | key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name} | 193 | key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name} | ||
| 186 | if key.Validate() != nil { | 194 | if key.Validate() != nil { | ||
| 187 | continue | 195 | continue | ||
| 188 | } | 196 | } | ||
| 189 | - | views = append(views, remotePickerView(key, session, host.status, host.entry.FetchedAt)) | 197 | + | view := remotePickerView(key, session, host.status, host.entry.FetchedAt) |
| 198 | + | if grouped { | |||
| 199 | + | view.HideRemoteOrigin = true | |||
| 200 | + | if publishedForHost == 0 { | |||
| 201 | + | view.Section = "REMOTE " + host.entry.Host | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | views = append(views, view) | |||
| 205 | + | publishedForHost++ | |||
| 206 | + | publishedRemote = true | |||
| 190 | } | 207 | } | ||
| 191 | if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch || host.status == remoteHostMalformed) { | 208 | if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch || host.status == remoteHostMalformed) { | ||
| 192 | - | views = append(views, remotePickerHostView(host.entry.Host, host.status)) | 209 | + | view := remotePickerHostView(host.entry.Host, host.status) |
| 210 | + | if grouped { | |||
| 211 | + | view.Section = "REMOTE " + host.entry.Host | |||
| 212 | + | } | |||
| 213 | + | views = append(views, view) | |||
| 214 | + | publishedRemote = true | |||
| 193 | } | 215 | } | ||
| 194 | } | 216 | } | ||
| 195 | - | for _, s := range stopped { | 217 | + | for i, s := range stopped { |
| 196 | createdAt := s.createdAt | 218 | createdAt := s.createdAt | ||
| 197 | - | views = append(views, picker.SessionView{ | 219 | + | view := picker.SessionView{ |
| 198 | ID: domain.SessionID("stopped:" + s.name), | 220 | ID: domain.SessionID("stopped:" + s.name), | ||
| 199 | Incarnation: s.incarnation, | 221 | Incarnation: s.incarnation, | ||
| 200 | Name: s.name, | 222 | Name: s.name, | ||
| 201 | TargetName: s.name, | 223 | TargetName: s.name, | ||
| 202 | - | Tabs: []picker.TabEntry{{}}, | |||
| 203 | Stopped: true, | 224 | Stopped: true, | ||
| 204 | ExpectedCreatedAt: &createdAt, | 225 | ExpectedCreatedAt: &createdAt, | ||
| 205 | - | }) | 226 | + | } |
| 227 | + | if grouped && i == 0 && (len(live) == 0 || publishedRemote) { | |||
| 228 | + | view.Section = "LOCAL" | |||
| 229 | + | } | |||
| 230 | + | views = append(views, view) | |||
| 206 | } | 231 | } | ||
| 207 | return views, current | 232 | return views, current | ||
| 208 | } | 233 | } | ||
| @@ -263,6 +288,29 @@ func remotePickerReasonText(reason string) string { | |||||
| 263 | } | 288 | } | ||
| 264 | } | 289 | } | ||
| 265 | 290 | ||||
| 291 | + | func pickerSearchSlashIndex(data, pending []byte) int { | |||
| 292 | + | if len(pending) != 0 { | |||
| 293 | + | return -1 | |||
| 294 | + | } | |||
| 295 | + | for offset := 0; offset < len(data); { | |||
| 296 | + | if data[offset] == '/' { | |||
| 297 | + | return offset | |||
| 298 | + | } | |||
| 299 | + | if data[offset] != keys.ESC { | |||
| 300 | + | offset++ | |||
| 301 | + | continue | |||
| 302 | + | } | |||
| 303 | + | if consumed, _ := routeListEscape(data[offset:]); consumed > 0 { | |||
| 304 | + | offset += consumed | |||
| 305 | + | continue | |||
| 306 | + | } | |||
| 307 | + | // An incomplete or unknown escape owns the rest of this input chunk; | |||
| 308 | + | // slash bytes inside it cannot transition the picker into search mode. | |||
| 309 | + | return -1 | |||
| 310 | + | } | |||
| 311 | + | return -1 | |||
| 312 | + | } | |||
| 313 | + | ||||
| 266 | func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | 314 | func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | ||
| 267 | rt := ac.overlays | 315 | rt := ac.overlays | ||
| 268 | var previewGeneration uint64 | 316 | var previewGeneration uint64 | ||
| @@ -305,22 +353,40 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||||
| 305 | rt.pickerMu.Unlock() | 353 | rt.pickerMu.Unlock() | ||
| 306 | return | 354 | return | ||
| 307 | } | 355 | } | ||
| 308 | - | result := handleListInputLocked(d.clock, data, d.pickerListInputState(ac), func(b byte) listInputResult { | 356 | + | normalAction := func(b byte) listInputResult { |
| 309 | switch b { | 357 | switch b { | ||
| 310 | - | case 'x': | 358 | + | case 'x', 's': |
| 311 | - | return listInputResult{action: b, stop: true} | |||
| 312 | - | case 's': | |||
| 313 | return listInputResult{action: b, stop: true} | 359 | return listInputResult{action: b, stop: true} | ||
| 314 | case '\r', '\n': | 360 | case '\r', '\n': | ||
| 315 | return listInputResult{action: b, exit: true} | 361 | return listInputResult{action: b, exit: true} | ||
| 316 | default: | 362 | default: | ||
| 317 | return listInputResult{} | 363 | return listInputResult{} | ||
| 318 | } | 364 | } | ||
| 319 | - | }) | 365 | + | } |
| 366 | + | var result listInputResult | |||
| 367 | + | if rt.picker.SearchActive() { | |||
| 368 | + | result = d.handlePickerSearchInputLocked(ac, data) | |||
| 369 | + | } else if slash := pickerSearchSlashIndex(data, rt.pickerPending); slash >= 0 { | |||
| 370 | + | result = handleListInputLocked(d.clock, data[:slash], d.pickerListInputState(ac), normalAction) | |||
| 371 | + | if result.action == 0 && !result.exit && len(rt.pickerPending) == 0 { | |||
| 372 | + | rt.picker.EnterSearch() | |||
| 373 | + | result.changed = true | |||
| 374 | + | if slash+1 < len(data) { | |||
| 375 | + | searchResult := d.handlePickerSearchInputLocked(ac, data[slash+1:]) | |||
| 376 | + | searchResult.changed = searchResult.changed || result.changed | |||
| 377 | + | result = searchResult | |||
| 378 | + | } | |||
| 379 | + | } | |||
| 380 | + | } else { | |||
| 381 | + | result = handleListInputLocked(d.clock, data, d.pickerListInputState(ac), normalAction) | |||
| 382 | + | } | |||
| 320 | var target picker.Target | 383 | var target picker.Target | ||
| 321 | var cursor picker.Target | 384 | var cursor picker.Target | ||
| 322 | var ok bool | 385 | var ok bool | ||
| 323 | var cursorOK bool | 386 | var cursorOK bool | ||
| 387 | + | var rejectedBySearch bool | |||
| 388 | + | observedPicker := rt.picker | |||
| 389 | + | observedGeneration := rt.pickerGeneration | |||
| 324 | var intent pickerIntent | 390 | var intent pickerIntent | ||
| 325 | var source moveSourceLocator | 391 | var source moveSourceLocator | ||
| 326 | // prevIdx is the row the victim occupied; -1 means "no post-delete hint". | 392 | // prevIdx is the row the victim occupied; -1 means "no post-delete hint". | ||
| @@ -328,17 +394,25 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||||
| 328 | if result.action == 'x' || result.action == '\r' || result.action == '\n' { | 394 | if result.action == 'x' || result.action == '\r' || result.action == '\n' { | ||
| 329 | target, ok = rt.picker.Selected() | 395 | target, ok = rt.picker.Selected() | ||
| 330 | cursor, cursorOK = rt.picker.Cursor() | 396 | cursor, cursorOK = rt.picker.Cursor() | ||
| 397 | + | rejectedBySearch = rt.picker.SelectionRejectedBySearch() | |||
| 331 | prevIdx = rt.picker.SelectedIndex() | 398 | prevIdx = rt.picker.SelectedIndex() | ||
| 332 | } | 399 | } | ||
| 333 | intent, source = rt.pickerIntent, rt.pickerSource | 400 | intent, source = rt.pickerIntent, rt.pickerSource | ||
| 334 | rt.pickerMu.Unlock() | 401 | rt.pickerMu.Unlock() | ||
| 335 | 402 | ||||
| 403 | + | if !d.pickerInstanceCurrent(ac, observedPicker, observedGeneration) { | |||
| 404 | + | return | |||
| 405 | + | } | |||
| 336 | if result.action == 's' { | 406 | if result.action == 's' { | ||
| 337 | d.togglePickerSort() | 407 | d.togglePickerSort() | ||
| 338 | d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1}) | 408 | d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1}) | ||
| 339 | d.invalidateRender(sess, ac, true, "picker.go") | 409 | d.invalidateRender(sess, ac, true, "picker.go") | ||
| 340 | return | 410 | return | ||
| 341 | } | 411 | } | ||
| 412 | + | if (result.action == '\r' || result.action == '\n') && !ok && rejectedBySearch { | |||
| 413 | + | d.invalidateRender(sess, ac, true, "picker search no match") | |||
| 414 | + | return | |||
| 415 | + | } | |||
| 342 | if (result.action == '\r' || result.action == '\n') && !ok && cursorOK && (cursor.RemoteTarget != nil || cursor.RemoteHost != "") { | 416 | if (result.action == '\r' || result.action == '\n') && !ok && cursorOK && (cursor.RemoteTarget != nil || cursor.RemoteHost != "") { | ||
| 343 | d.notifyRemotePickerUnavailable(sess, cursor) | 417 | d.notifyRemotePickerUnavailable(sess, cursor) | ||
| 344 | d.invalidateRender(sess, ac, true, "picker.go") | 418 | d.invalidateRender(sess, ac, true, "picker.go") | ||
| @@ -392,7 +466,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||||
| 392 | } | 466 | } | ||
| 393 | backNavigationSent = true | 467 | backNavigationSent = true | ||
| 394 | } | 468 | } | ||
| 395 | - | d.closePicker(ac) | 469 | + | d.closePickerIfCurrent(ac, observedPicker, observedGeneration) |
| 396 | } | 470 | } | ||
| 397 | if committing { | 471 | if committing { | ||
| 398 | if intent == pickerMovePane || intent == pickerMoveTab { | 472 | if intent == pickerMovePane || intent == pickerMoveTab { | ||
| @@ -404,7 +478,9 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||||
| 404 | if len(effects) != 0 && effects[0] != nil { | 478 | if len(effects) != 0 && effects[0] != nil { | ||
| 405 | effects[0].End() | 479 | effects[0].End() | ||
| 406 | } | 480 | } | ||
| 407 | - | d.closePicker(ac) | 481 | + | if !d.closePickerIfCurrent(ac, observedPicker, observedGeneration) { |
| 482 | + | return | |||
| 483 | + | } | |||
| 408 | err := d.commitMovePickerSelection(intent, source, target) | 484 | err := d.commitMovePickerSelection(intent, source, target) | ||
| 409 | if err != nil { | 485 | if err != nil { | ||
| 410 | d.reportAttachmentError(sess, movePickerUserError(err)) | 486 | d.reportAttachmentError(sess, movePickerUserError(err)) | ||
| @@ -416,9 +492,11 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||||
| 416 | } | 492 | } | ||
| 417 | var err error | 493 | var err error | ||
| 418 | if len(effects) != 0 && effects[0] != nil { | 494 | if len(effects) != 0 && effects[0] != nil { | ||
| 419 | - | err = d.switchToTargetForAttachment(effects[0], target, sessionHandoffGuard{closePicker: true, allowSamePeer: true}, "picker-select") | 495 | + | err = d.switchToTargetForAttachment(effects[0], target, sessionHandoffGuard{closePicker: true, allowSamePeer: true, pickerModel: observedPicker, pickerGeneration: observedGeneration}, "picker-select") |
| 420 | } else { | 496 | } else { | ||
| 421 | - | d.closePicker(ac) | 497 | + | if !d.closePickerIfCurrent(ac, observedPicker, observedGeneration) { |
| 498 | + | return | |||
| 499 | + | } | |||
| 422 | err = d.switchToTarget(sess, ac, target) | 500 | err = d.switchToTarget(sess, ac, target) | ||
| 423 | } | 501 | } | ||
| 424 | if errors.Is(err, errAttachmentTransition) { | 502 | if errors.Is(err, errAttachmentTransition) { | ||
| @@ -595,8 +673,9 @@ func (d *Daemon) startRemotePickerPreview(ac *attachedClient, target picker.Targ | |||||
| 595 | } | 673 | } | ||
| 596 | 674 | ||||
| 597 | func remotePickerPreviewSize(size domain.Size) (uint16, uint16) { | 675 | func remotePickerPreviewSize(size domain.Size) (uint16, uint16) { | ||
| 598 | - | layout := picker.ChooseLayout(size) | 676 | + | presentation := pickerModal.Resolve(size) |
| 599 | - | width, height := layout.Preview.Width, layout.Preview.Height | 677 | + | geometry := picker.ChooseGeometry(rectSize(presentation.Inner)) |
| 678 | + | width, height := geometry.Preview.Width, geometry.Preview.Height | |||
| 600 | if width <= 0 || height <= 0 { | 679 | if width <= 0 || height <= 0 { | ||
| 601 | return 0, 0 | 680 | return 0, 0 | ||
| 602 | } | 681 | } | ||
| @@ -712,15 +791,31 @@ func (d *Daemon) closePicker(ac *attachedClient) { | |||||
| 712 | d.closePickerIfCurrent(ac, nil, 0) | 791 | d.closePickerIfCurrent(ac, nil, 0) | ||
| 713 | } | 792 | } | ||
| 714 | 793 | ||||
| 794 | + | func (d *Daemon) pickerInstanceCurrent(ac *attachedClient, model *picker.Model, generation uint64) bool { | |||
| 795 | + | if ac == nil || ac.overlays == nil { | |||
| 796 | + | return false | |||
| 797 | + | } | |||
| 798 | + | ac.overlays.pickerMu.Lock() | |||
| 799 | + | defer ac.overlays.pickerMu.Unlock() | |||
| 800 | + | return ac.overlays.picker == model && ac.overlays.pickerGeneration == generation | |||
| 801 | + | } | |||
| 802 | + | ||||
| 715 | // closePickerIfCurrent closes only the observed picker lifecycle. A nonzero | 803 | // closePickerIfCurrent closes only the observed picker lifecycle. A nonzero | ||
| 716 | // generation guards terminal parked cleanup even when a refresh replaced the | 804 | // generation guards terminal parked cleanup even when a refresh replaced the | ||
| 717 | // model within that generation. | 805 | // model within that generation. | ||
| 718 | func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, generation uint64) bool { | 806 | func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, generation uint64) bool { | ||
| 807 | + | return d.closePickerIfCurrentRefresh(ac, model, generation, 0) | |||
| 808 | + | } | |||
| 809 | + | ||||
| 810 | + | func (d *Daemon) closePickerIfCurrentRefresh(ac *attachedClient, model *picker.Model, generation, refreshSequence uint64) bool { | |||
| 719 | if ac == nil || ac.overlays == nil { | 811 | if ac == nil || ac.overlays == nil { | ||
| 720 | return false | 812 | return false | ||
| 721 | } | 813 | } | ||
| 722 | ac.overlays.pickerMu.Lock() | 814 | ac.overlays.pickerMu.Lock() | ||
| 723 | - | if model != nil && (ac.overlays.picker != model || ac.overlays.pickerGeneration != generation) || model == nil && generation != 0 && ac.overlays.pickerGeneration != generation { | 815 | + | refreshChanged := refreshSequence != 0 && ac.overlays.pickerRefreshSequence != refreshSequence |
| 816 | + | modelChanged := model != nil && (ac.overlays.picker != model || ac.overlays.pickerGeneration != generation) | |||
| 817 | + | generationChanged := model == nil && generation != 0 && ac.overlays.pickerGeneration != generation | |||
| 818 | + | if refreshChanged || modelChanged || generationChanged { | |||
| 724 | ac.overlays.pickerMu.Unlock() | 819 | ac.overlays.pickerMu.Unlock() | ||
| 725 | return false | 820 | return false | ||
| 726 | } | 821 | } | ||
| @@ -777,9 +872,19 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session { | |||||
| 777 | // one active-session handoff. Its zero value keeps daemon-owned transitions on | 872 | // one active-session handoff. Its zero value keeps daemon-owned transitions on | ||
| 778 | // their established path. | 873 | // their established path. | ||
| 779 | type sessionHandoffGuard struct { | 874 | type sessionHandoffGuard struct { | ||
| 780 | - | expectedSource *tab | 875 | + | expectedSource *tab |
| 781 | - | closePicker bool | 876 | + | closePicker bool |
| 782 | - | allowSamePeer bool | 877 | + | allowSamePeer bool |
| 878 | + | pickerModel *picker.Model | |||
| 879 | + | pickerGeneration uint64 | |||
| 880 | + | } | |||
| 881 | + | ||||
| 882 | + | func (d *Daemon) closePickerForGuard(ac *attachedClient, guard sessionHandoffGuard) bool { | |||
| 883 | + | if guard.pickerModel != nil || guard.pickerGeneration != 0 { | |||
| 884 | + | return d.closePickerIfCurrent(ac, guard.pickerModel, guard.pickerGeneration) | |||
| 885 | + | } | |||
| 886 | + | d.closePicker(ac) | |||
| 887 | + | return true | |||
| 783 | } | 888 | } | ||
| 784 | 889 | ||||
| 785 | // switchActiveTargetForAttachment hands a frame-bound navigation request to the | 890 | // switchActiveTargetForAttachment hands a frame-bound navigation request to the | ||
| @@ -849,7 +954,7 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(effect *attachmentEffect | |||||
| 849 | if guard.closePicker { | 954 | if guard.closePicker { | ||
| 850 | fresh, admitted := effect.ac.beginAttachmentEffect(transition.published) | 955 | fresh, admitted := effect.ac.beginAttachmentEffect(transition.published) | ||
| 851 | if admitted { | 956 | if admitted { | ||
| 852 | - | d.closePicker(effect.ac) | 957 | + | d.closePickerForGuard(effect.ac, guard) |
| 853 | fresh.End() | 958 | fresh.End() | ||
| 854 | } | 959 | } | ||
| 855 | } | 960 | } | ||
| @@ -958,7 +1063,7 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(effect *attachmentEffect, ta | |||||
| 958 | // Keep the source link open until the client receives the ordered cleanup | 1063 | // Keep the source link open until the client receives the ordered cleanup | ||
| 959 | // and handoff frames, then let the client's close drive ordinary parking. | 1064 | // and handoff frames, then let the client's close drive ordinary parking. | ||
| 960 | if guard.closePicker { | 1065 | if guard.closePicker { | ||
| 961 | - | d.closePicker(effect.ac) | 1066 | + | d.closePickerForGuard(effect.ac, guard) |
| 962 | } | 1067 | } | ||
| 963 | effect.bindActionEnd(d, "detach") | 1068 | effect.bindActionEnd(d, "detach") | ||
| 964 | effect.End() | 1069 | effect.End() | ||
| @@ -999,7 +1104,7 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(effect *attachmentEffect, t | |||||
| 999 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to remote session", err) | 1104 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to remote session", err) | ||
| 1000 | } | 1105 | } | ||
| 1001 | if guard.closePicker { | 1106 | if guard.closePicker { | ||
| 1002 | - | d.closePicker(effect.ac) | 1107 | + | d.closePickerForGuard(effect.ac, guard) |
| 1003 | } | 1108 | } | ||
| 1004 | d.clientGoneForAttachment(effect, false) | 1109 | d.clientGoneForAttachment(effect, false) | ||
| 1005 | return nil | 1110 | return nil | ||
| @@ -1109,11 +1214,11 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC | |||||
| 1109 | if sourceEffect != nil { | 1214 | if sourceEffect != nil { | ||
| 1110 | fresh, admitted := ac.beginAttachmentEffect(transition.published) | 1215 | fresh, admitted := ac.beginAttachmentEffect(transition.published) | ||
| 1111 | if admitted { | 1216 | if admitted { | ||
| 1112 | - | d.closePicker(ac) | 1217 | + | d.closePickerForGuard(ac, guard) |
| 1113 | fresh.End() | 1218 | fresh.End() | ||
| 1114 | } | 1219 | } | ||
| 1115 | } else { | 1220 | } else { | ||
| 1116 | - | d.closePicker(ac) | 1221 | + | d.closePickerForGuard(ac, guard) |
| 1117 | } | 1222 | } | ||
| 1118 | } | 1223 | } | ||
| 1119 | d.deferAttachmentTransitionCleanups(transition) | 1224 | d.deferAttachmentTransitionCleanups(transition) | ||
| @@ -1397,7 +1502,7 @@ func (d *Daemon) killPickerTargetForAttachment(target picker.Target, effect *att | |||||
| 1397 | 1502 | ||||
| 1398 | func (d *Daemon) killPickerTarget(target picker.Target) error { | 1503 | func (d *Daemon) killPickerTarget(target picker.Target) error { | ||
| 1399 | if target.Stopped { | 1504 | if target.Stopped { | ||
| 1400 | - | if err := d.retryStoppedPurge(target.Name); err != nil { | 1505 | + | if err := d.retryStoppedPurgeExact(target.Name, target.Incarnation, target.ExpectedCreatedAt); err != nil { |
| 1401 | d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name) | 1506 | d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name) | ||
| 1402 | return domain.UserErr(domain.NoticePersistDelete, "couldn't delete stopped session", err) | 1507 | return domain.UserErr(domain.NoticePersistDelete, "couldn't delete stopped session", err) | ||
| 1403 | } | 1508 | } | ||
A internal/usecase/daemon/picker_search_input.go +130 -0
| @@ -0,0 +1,130 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "unicode" | |
| 5 | + | "unicode/utf8" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | "github.com/bnema/vev/internal/usecase/keys" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // handlePickerSearchInputLocked decodes picker search input while pickerMu is | |
| 12 | + | // held. Printable bytes belong to the query; navigation is intentionally | |
| 13 | + | // limited to arrows and Ctrl-N/Ctrl-P so names containing j/k/q/x/s remain | |
| 14 | + | // searchable. Incomplete UTF-8 and escape sequences are retained across input | |
| 15 | + | // chunks in the attachment-owned picker pending buffer. | |
| 16 | + | func (d *Daemon) handlePickerSearchInputLocked(ac *attachedClient, data []byte) listInputResult { | |
| 17 | + | rt := ac.overlays | |
| 18 | + | if len(rt.pickerPending) > 0 { | |
| 19 | + | rt.pickerESC.stop() | |
| 20 | + | combined := make([]byte, 0, len(rt.pickerPending)+len(data)) | |
| 21 | + | combined = append(combined, rt.pickerPending...) | |
| 22 | + | combined = append(combined, data...) | |
| 23 | + | data = combined | |
| 24 | + | rt.pickerPending = nil | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | var result listInputResult | |
| 28 | + | for offset := 0; offset < len(data); { | |
| 29 | + | switch data[offset] { | |
| 30 | + | case keys.ESC: | |
| 31 | + | tail := data[offset:] | |
| 32 | + | if len(tail) > 1 && tail[1] == keys.ESC { | |
| 33 | + | d.applyPickerSearchEscapeLocked(ac) | |
| 34 | + | result.changed = true | |
| 35 | + | if !rt.picker.SearchActive() { | |
| 36 | + | result.exit = true | |
| 37 | + | return result | |
| 38 | + | } | |
| 39 | + | offset++ | |
| 40 | + | continue | |
| 41 | + | } | |
| 42 | + | if consumed, move := routeListEscape(tail); consumed > 0 { | |
| 43 | + | switch move { | |
| 44 | + | case 'A': | |
| 45 | + | rt.picker.Up() | |
| 46 | + | result.changed = true | |
| 47 | + | case 'B': | |
| 48 | + | rt.picker.Down() | |
| 49 | + | result.changed = true | |
| 50 | + | } | |
| 51 | + | offset += consumed | |
| 52 | + | continue | |
| 53 | + | } | |
| 54 | + | if len(tail) == 1 { | |
| 55 | + | d.retainPickerSearchESCLocked(ac) | |
| 56 | + | return result | |
| 57 | + | } | |
| 58 | + | if isListEscapePrefix(tail) { | |
| 59 | + | rt.pickerPending = append(rt.pickerPending[:0], tail...) | |
| 60 | + | return result | |
| 61 | + | } | |
| 62 | + | offset++ | |
| 63 | + | case 0x03: // Ctrl-C | |
| 64 | + | result.exit = true | |
| 65 | + | return result | |
| 66 | + | case 0x0e: // Ctrl-N | |
| 67 | + | rt.picker.Down() | |
| 68 | + | result.changed = true | |
| 69 | + | offset++ | |
| 70 | + | case 0x10: // Ctrl-P | |
| 71 | + | rt.picker.Up() | |
| 72 | + | result.changed = true | |
| 73 | + | offset++ | |
| 74 | + | case '\r', '\n': | |
| 75 | + | result.action = data[offset] | |
| 76 | + | result.exit = true | |
| 77 | + | return result | |
| 78 | + | case 0x08, 0x7f: | |
| 79 | + | before := rt.picker.Query() | |
| 80 | + | rt.picker.BackspaceSearch() | |
| 81 | + | result.changed = result.changed || before != rt.picker.Query() | |
| 82 | + | offset++ | |
| 83 | + | default: | |
| 84 | + | if !utf8.FullRune(data[offset:]) { | |
| 85 | + | rt.pickerPending = append(rt.pickerPending[:0], data[offset:]...) | |
| 86 | + | return result | |
| 87 | + | } | |
| 88 | + | r, width := utf8.DecodeRune(data[offset:]) | |
| 89 | + | if r == utf8.RuneError && width == 1 { | |
| 90 | + | offset++ | |
| 91 | + | continue | |
| 92 | + | } | |
| 93 | + | if unicode.IsPrint(r) { | |
| 94 | + | rt.picker.InsertSearch(r) | |
| 95 | + | result.changed = true | |
| 96 | + | } | |
| 97 | + | offset += width | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | return result | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func (d *Daemon) applyPickerSearchEscapeLocked(ac *attachedClient) { | |
| 104 | + | if ac.overlays.picker.Query() != "" { | |
| 105 | + | ac.overlays.picker.ClearSearch() | |
| 106 | + | } else { | |
| 107 | + | ac.overlays.picker.ExitSearch() | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func (d *Daemon) retainPickerSearchESCLocked(ac *attachedClient) { | |
| 112 | + | rt := ac.overlays | |
| 113 | + | rt.pickerPending = append(rt.pickerPending[:0], keys.ESC) | |
| 114 | + | rt.pickerESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) { | |
| 115 | + | rt.pickerMu.Lock() | |
| 116 | + | if rt.pickerESC.timer != timer || len(rt.pickerPending) != 1 || rt.pickerPending[0] != keys.ESC || rt.picker == nil || !rt.picker.SearchActive() { | |
| 117 | + | rt.pickerMu.Unlock() | |
| 118 | + | return | |
| 119 | + | } | |
| 120 | + | rt.pickerPending = nil | |
| 121 | + | rt.pickerESC.timer = nil | |
| 122 | + | rt.pickerESC.done = nil | |
| 123 | + | d.applyPickerSearchEscapeLocked(ac) | |
| 124 | + | rt.pickerMu.Unlock() | |
| 125 | + | if sess := ac.currentAttachmentSession(); sess != nil { | |
| 126 | + | d.registerPreviewForSelection(ac) | |
| 127 | + | d.invalidateRender(sess, ac, true, "picker search escape") | |
| 128 | + | } | |
| 129 | + | }) | |
| 130 | + | } | |
| @@ -0,0 +1,130 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "unicode" | |||
| 5 | + | "unicode/utf8" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/ports" | |||
| 8 | + | "github.com/bnema/vev/internal/usecase/keys" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // handlePickerSearchInputLocked decodes picker search input while pickerMu is | |||
| 12 | + | // held. Printable bytes belong to the query; navigation is intentionally | |||
| 13 | + | // limited to arrows and Ctrl-N/Ctrl-P so names containing j/k/q/x/s remain | |||
| 14 | + | // searchable. Incomplete UTF-8 and escape sequences are retained across input | |||
| 15 | + | // chunks in the attachment-owned picker pending buffer. | |||
| 16 | + | func (d *Daemon) handlePickerSearchInputLocked(ac *attachedClient, data []byte) listInputResult { | |||
| 17 | + | rt := ac.overlays | |||
| 18 | + | if len(rt.pickerPending) > 0 { | |||
| 19 | + | rt.pickerESC.stop() | |||
| 20 | + | combined := make([]byte, 0, len(rt.pickerPending)+len(data)) | |||
| 21 | + | combined = append(combined, rt.pickerPending...) | |||
| 22 | + | combined = append(combined, data...) | |||
| 23 | + | data = combined | |||
| 24 | + | rt.pickerPending = nil | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | var result listInputResult | |||
| 28 | + | for offset := 0; offset < len(data); { | |||
| 29 | + | switch data[offset] { | |||
| 30 | + | case keys.ESC: | |||
| 31 | + | tail := data[offset:] | |||
| 32 | + | if len(tail) > 1 && tail[1] == keys.ESC { | |||
| 33 | + | d.applyPickerSearchEscapeLocked(ac) | |||
| 34 | + | result.changed = true | |||
| 35 | + | if !rt.picker.SearchActive() { | |||
| 36 | + | result.exit = true | |||
| 37 | + | return result | |||
| 38 | + | } | |||
| 39 | + | offset++ | |||
| 40 | + | continue | |||
| 41 | + | } | |||
| 42 | + | if consumed, move := routeListEscape(tail); consumed > 0 { | |||
| 43 | + | switch move { | |||
| 44 | + | case 'A': | |||
| 45 | + | rt.picker.Up() | |||
| 46 | + | result.changed = true | |||
| 47 | + | case 'B': | |||
| 48 | + | rt.picker.Down() | |||
| 49 | + | result.changed = true | |||
| 50 | + | } | |||
| 51 | + | offset += consumed | |||
| 52 | + | continue | |||
| 53 | + | } | |||
| 54 | + | if len(tail) == 1 { | |||
| 55 | + | d.retainPickerSearchESCLocked(ac) | |||
| 56 | + | return result | |||
| 57 | + | } | |||
| 58 | + | if isListEscapePrefix(tail) { | |||
| 59 | + | rt.pickerPending = append(rt.pickerPending[:0], tail...) | |||
| 60 | + | return result | |||
| 61 | + | } | |||
| 62 | + | offset++ | |||
| 63 | + | case 0x03: // Ctrl-C | |||
| 64 | + | result.exit = true | |||
| 65 | + | return result | |||
| 66 | + | case 0x0e: // Ctrl-N | |||
| 67 | + | rt.picker.Down() | |||
| 68 | + | result.changed = true | |||
| 69 | + | offset++ | |||
| 70 | + | case 0x10: // Ctrl-P | |||
| 71 | + | rt.picker.Up() | |||
| 72 | + | result.changed = true | |||
| 73 | + | offset++ | |||
| 74 | + | case '\r', '\n': | |||
| 75 | + | result.action = data[offset] | |||
| 76 | + | result.exit = true | |||
| 77 | + | return result | |||
| 78 | + | case 0x08, 0x7f: | |||
| 79 | + | before := rt.picker.Query() | |||
| 80 | + | rt.picker.BackspaceSearch() | |||
| 81 | + | result.changed = result.changed || before != rt.picker.Query() | |||
| 82 | + | offset++ | |||
| 83 | + | default: | |||
| 84 | + | if !utf8.FullRune(data[offset:]) { | |||
| 85 | + | rt.pickerPending = append(rt.pickerPending[:0], data[offset:]...) | |||
| 86 | + | return result | |||
| 87 | + | } | |||
| 88 | + | r, width := utf8.DecodeRune(data[offset:]) | |||
| 89 | + | if r == utf8.RuneError && width == 1 { | |||
| 90 | + | offset++ | |||
| 91 | + | continue | |||
| 92 | + | } | |||
| 93 | + | if unicode.IsPrint(r) { | |||
| 94 | + | rt.picker.InsertSearch(r) | |||
| 95 | + | result.changed = true | |||
| 96 | + | } | |||
| 97 | + | offset += width | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | return result | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func (d *Daemon) applyPickerSearchEscapeLocked(ac *attachedClient) { | |||
| 104 | + | if ac.overlays.picker.Query() != "" { | |||
| 105 | + | ac.overlays.picker.ClearSearch() | |||
| 106 | + | } else { | |||
| 107 | + | ac.overlays.picker.ExitSearch() | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func (d *Daemon) retainPickerSearchESCLocked(ac *attachedClient) { | |||
| 112 | + | rt := ac.overlays | |||
| 113 | + | rt.pickerPending = append(rt.pickerPending[:0], keys.ESC) | |||
| 114 | + | rt.pickerESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) { | |||
| 115 | + | rt.pickerMu.Lock() | |||
| 116 | + | if rt.pickerESC.timer != timer || len(rt.pickerPending) != 1 || rt.pickerPending[0] != keys.ESC || rt.picker == nil || !rt.picker.SearchActive() { | |||
| 117 | + | rt.pickerMu.Unlock() | |||
| 118 | + | return | |||
| 119 | + | } | |||
| 120 | + | rt.pickerPending = nil | |||
| 121 | + | rt.pickerESC.timer = nil | |||
| 122 | + | rt.pickerESC.done = nil | |||
| 123 | + | d.applyPickerSearchEscapeLocked(ac) | |||
| 124 | + | rt.pickerMu.Unlock() | |||
| 125 | + | if sess := ac.currentAttachmentSession(); sess != nil { | |||
| 126 | + | d.registerPreviewForSelection(ac) | |||
| 127 | + | d.invalidateRender(sess, ac, true, "picker search escape") | |||
| 128 | + | } | |||
| 129 | + | }) | |||
| 130 | + | } | |||
M internal/usecase/daemon/picker_test.go +177 -2
| @@ -190,6 +190,10 @@ func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) { | |||
| 190 | 190 | // Named (MRU-desc) → ephemeral (MRU-desc) → stopped, even though the | |
| 191 | 191 | // ephemeral session has the highest mruAt. | |
| 192 | 192 | require.Equal(t, []string{"notes", "work", "1", "halted"}, names) | |
| 193 | + | require.Equal(t, "LOCAL", views[0].Section) | |
| 194 | + | require.Empty(t, views[1].Section) | |
| 195 | + | require.Empty(t, views[2].Section) | |
| 196 | + | require.Empty(t, views[3].Section, "a contiguous local-only inventory keeps one section header") | |
| 193 | 197 | } | |
| 194 | 198 | ||
| 195 | 199 | func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) { | |
| @@ -888,6 +892,129 @@ func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) { | |||
| 888 | 892 | } | |
| 889 | 893 | } | |
| 890 | 894 | ||
| 895 | + | func TestPickerSearchAcceptsPrintableActionsAndSplitUTF8(t *testing.T) { | |
| 896 | + | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| 897 | + | defer releaseAll(releases) | |
| 898 | + | ||
| 899 | + | d.enterPicker(sess, ac) | |
| 900 | + | awaitFrame(t, sends, wire.MsgOutput) | |
| 901 | + | d.handlePickerInput(ac, []byte("j/jkqxs/")) | |
| 902 | + | d.handlePickerInput(ac, []byte{0xc3}) | |
| 903 | + | d.handlePickerInput(ac, []byte{0xa9}) | |
| 904 | + | ||
| 905 | + | ac.overlays.pickerMu.Lock() | |
| 906 | + | query := ac.overlays.picker.Query() | |
| 907 | + | active := ac.overlays.picker.SearchActive() | |
| 908 | + | ac.overlays.pickerMu.Unlock() | |
| 909 | + | require.True(t, active) | |
| 910 | + | require.Equal(t, "jkqxs/é", query) | |
| 911 | + | require.True(t, ac.overlays.pickerActive(), "a slash after navigation must enter search and keep later action bytes printable") | |
| 912 | + | } | |
| 913 | + | ||
| 914 | + | func TestPickerSlashInsideEscapeSequenceDoesNotEnterSearch(t *testing.T) { | |
| 915 | + | for _, chunks := range [][][]byte{ | |
| 916 | + | {{0x1b, '[', '/', 'A'}}, | |
| 917 | + | {{0x1b, '['}, {'/', 'A'}}, | |
| 918 | + | } { | |
| 919 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 920 | + | d.enterPicker(sess, ac) | |
| 921 | + | awaitFrame(t, sends, wire.MsgOutput) | |
| 922 | + | for _, chunk := range chunks { | |
| 923 | + | d.handlePickerInput(ac, chunk) | |
| 924 | + | } | |
| 925 | + | ||
| 926 | + | ac.overlays.pickerMu.Lock() | |
| 927 | + | searchActive := ac.overlays.picker != nil && ac.overlays.picker.SearchActive() | |
| 928 | + | ac.overlays.pickerMu.Unlock() | |
| 929 | + | require.False(t, searchActive) | |
| 930 | + | releaseAll(releases) | |
| 931 | + | } | |
| 932 | + | } | |
| 933 | + | ||
| 934 | + | func TestPickerSearchZeroMatchEnterKeepsPickerOpen(t *testing.T) { | |
| 935 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 936 | + | defer releaseAll(releases) | |
| 937 | + | ||
| 938 | + | d.enterPicker(sess, ac) | |
| 939 | + | awaitFrame(t, sends, wire.MsgOutput) | |
| 940 | + | d.handlePickerInput(ac, []byte("/absent\r")) | |
| 941 | + | ||
| 942 | + | require.True(t, ac.overlays.pickerActive()) | |
| 943 | + | ac.overlays.pickerMu.Lock() | |
| 944 | + | defer ac.overlays.pickerMu.Unlock() | |
| 945 | + | require.True(t, ac.overlays.picker.SearchActive()) | |
| 946 | + | require.Equal(t, "absent", ac.overlays.picker.Query()) | |
| 947 | + | _, ok := ac.overlays.picker.Selected() | |
| 948 | + | require.False(t, ok) | |
| 949 | + | } | |
| 950 | + | ||
| 951 | + | func TestPickerSearchSplitArrowNavigatesMatches(t *testing.T) { | |
| 952 | + | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| 953 | + | defer releaseAll(releases) | |
| 954 | + | ||
| 955 | + | d.enterPicker(sess, ac) | |
| 956 | + | awaitFrame(t, sends, wire.MsgOutput) | |
| 957 | + | d.handlePickerInput(ac, []byte("/")) | |
| 958 | + | d.handlePickerInput(ac, []byte("\x1b[")) | |
| 959 | + | d.handlePickerInput(ac, []byte("B")) | |
| 960 | + | ||
| 961 | + | ac.overlays.pickerMu.Lock() | |
| 962 | + | selected, ok := ac.overlays.picker.Selected() | |
| 963 | + | ac.overlays.pickerMu.Unlock() | |
| 964 | + | require.True(t, ok) | |
| 965 | + | require.Equal(t, 1, selected.TabIndex) | |
| 966 | + | } | |
| 967 | + | ||
| 968 | + | func TestPickerSearchRepeatedEscapeChunkClearsThenExits(t *testing.T) { | |
| 969 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 970 | + | defer releaseAll(releases) | |
| 971 | + | ||
| 972 | + | d.enterPicker(sess, ac) | |
| 973 | + | awaitFrame(t, sends, wire.MsgOutput) | |
| 974 | + | d.handlePickerInput(ac, []byte("/query")) | |
| 975 | + | d.handlePickerInput(ac, []byte("\x1b\x1b")) | |
| 976 | + | ||
| 977 | + | require.True(t, ac.overlays.pickerActive()) | |
| 978 | + | ac.overlays.pickerMu.Lock() | |
| 979 | + | defer ac.overlays.pickerMu.Unlock() | |
| 980 | + | require.True(t, ac.overlays.picker.SearchActive(), "first Escape clears while the second is retained as the next staged Escape") | |
| 981 | + | require.Empty(t, ac.overlays.picker.Query()) | |
| 982 | + | require.Equal(t, []byte{0x1b}, ac.overlays.pickerPending) | |
| 983 | + | } | |
| 984 | + | ||
| 985 | + | func TestPickerSearchLoneEscapeClearsThenExitsThenCloses(t *testing.T) { | |
| 986 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 987 | + | defer releaseAll(releases) | |
| 988 | + | clk := &signalClock{timers: make(chan *signalTimer, 3)} | |
| 989 | + | d.clock = clk | |
| 990 | + | ||
| 991 | + | d.enterPicker(sess, ac) | |
| 992 | + | awaitFrame(t, sends, wire.MsgOutput) | |
| 993 | + | d.handlePickerInput(ac, []byte("/query")) | |
| 994 | + | ||
| 995 | + | fireEscape := func() { | |
| 996 | + | d.handlePickerInput(ac, []byte("\x1b")) | |
| 997 | + | timer := <-clk.timers | |
| 998 | + | timer.ch <- time.Now() | |
| 999 | + | } | |
| 1000 | + | fireEscape() | |
| 1001 | + | require.Eventually(t, func() bool { | |
| 1002 | + | ac.overlays.pickerMu.Lock() | |
| 1003 | + | defer ac.overlays.pickerMu.Unlock() | |
| 1004 | + | return ac.overlays.picker != nil && ac.overlays.picker.SearchActive() && ac.overlays.picker.Query() == "" | |
| 1005 | + | }, time.Second, 5*time.Millisecond) | |
| 1006 | + | ||
| 1007 | + | fireEscape() | |
| 1008 | + | require.Eventually(t, func() bool { | |
| 1009 | + | ac.overlays.pickerMu.Lock() | |
| 1010 | + | defer ac.overlays.pickerMu.Unlock() | |
| 1011 | + | return ac.overlays.picker != nil && !ac.overlays.picker.SearchActive() | |
| 1012 | + | }, time.Second, 5*time.Millisecond) | |
| 1013 | + | ||
| 1014 | + | fireEscape() | |
| 1015 | + | require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond) | |
| 1016 | + | } | |
| 1017 | + | ||
| 891 | 1018 | func TestPickerNavigationBackSendFailureKeepsPickerOpen(t *testing.T) { | |
| 892 | 1019 | d, sess, ac, _, releases := newManualTabSession(t, 1) | |
| 893 | 1020 | defer releases[0]() | |
| @@ -1212,7 +1339,7 @@ func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *t | |||
| 1212 | 1339 | } | |
| 1213 | 1340 | ||
| 1214 | 1341 | inner := state.overlays.picker.inner | |
| 1215 | - | layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height}) | |
| 1342 | + | layout := picker.ChooseGeometry(domain.Size{Cols: inner.Width, Rows: inner.Height}) | |
| 1216 | 1343 | require.Equal(t, picker.LayoutHorizontal, layout.Mode) | |
| 1217 | 1344 | require.True(t, inner.At(5, 3).Style.Equal(state.styles.PickerDescription), "description text keeps a muted foreground on the terminal background") | |
| 1218 | 1345 | require.True(t, inner.At(layout.List.Width-1, 3).Style.Equal(state.styles.PickerBase), "inactive row filler keeps the terminal background") | |
| @@ -1227,6 +1354,29 @@ func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *t | |||
| 1227 | 1354 | } | |
| 1228 | 1355 | } | |
| 1229 | 1356 | ||
| 1357 | + | func TestCaptureOverlayLayersRendersNarrowSearchTitleAndStatus(t *testing.T) { | |
| 1358 | + | model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{TabID: "tab", Name: "shell"}}}}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |
| 1359 | + | model.EnterSearch() | |
| 1360 | + | for _, r := range "a-very-long-absent-query" { | |
| 1361 | + | model.InsertSearch(r) | |
| 1362 | + | } | |
| 1363 | + | state := capturedRenderState{ | |
| 1364 | + | theme: themeui.BuiltinDark, | |
| 1365 | + | styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles, | |
| 1366 | + | layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 10}}, | |
| 1367 | + | } | |
| 1368 | + | snap := &overlayRenderSnapshot{pickerActive: true, pickerModel: model} | |
| 1369 | + | ||
| 1370 | + | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | |
| 1371 | + | ||
| 1372 | + | require.True(t, strings.HasPrefix(state.overlays.picker.title, " / ")) | |
| 1373 | + | require.True(t, strings.HasSuffix(state.overlays.picker.title, "_ ")) | |
| 1374 | + | inner := state.overlays.picker.inner | |
| 1375 | + | status := rowText(inner.Row(inner.Height - 1)) | |
| 1376 | + | require.Contains(t, status, "0 matches") | |
| 1377 | + | require.NotContains(t, status, "x delete", "search action bytes belong to the query") | |
| 1378 | + | } | |
| 1379 | + | ||
| 1230 | 1380 | func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) { | |
| 1231 | 1381 | model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |
| 1232 | 1382 | state := capturedRenderState{theme: themeui.BuiltinDark, styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles} | |
| @@ -1251,7 +1401,7 @@ func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testin | |||
| 1251 | 1401 | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | |
| 1252 | 1402 | ||
| 1253 | 1403 | inner := state.overlays.picker.inner | |
| 1254 | - | layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height}) | |
| 1404 | + | layout := picker.ChooseGeometry(domain.Size{Cols: inner.Width, Rows: inner.Height}) | |
| 1255 | 1405 | require.Equal(t, tt.wantMode, layout.Mode) | |
| 1256 | 1406 | got := strings.Join(frameRows(inner), "\n") | |
| 1257 | 1407 | if tt.wantMarker { | |
| @@ -1645,6 +1795,31 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t | |||
| 1645 | 1795 | require.False(t, stillActive, "the purge keeps its existing removal semantics despite cleanup failure") | |
| 1646 | 1796 | } | |
| 1647 | 1797 | ||
| 1798 | + | func TestPickerKillStoppedSessionRejectsSameNameReplacement(t *testing.T) { | |
| 1799 | + | p, release := newBlockingPTY(t) | |
| 1800 | + | defer release() | |
| 1801 | + | d, _, _, _ := newManualSessionWithPTYs(t, p) | |
| 1802 | + | ||
| 1803 | + | oldCreatedAt := int64(7) | |
| 1804 | + | replacement := inactiveSession{ | |
| 1805 | + | name: "stopped", createdAt: 8, incarnation: domain.IncarnationID{2}, | |
| 1806 | + | state: protocol.SessionDown, | |
| 1807 | + | } | |
| 1808 | + | d.inactive[replacement.name] = replacement | |
| 1809 | + | ||
| 1810 | + | err := d.killPickerTarget(picker.Target{ | |
| 1811 | + | Name: "stopped", Stopped: true, Incarnation: domain.IncarnationID{1}, ExpectedCreatedAt: &oldCreatedAt, | |
| 1812 | + | }) | |
| 1813 | + | require.NoError(t, err) | |
| 1814 | + | ||
| 1815 | + | d.mu.Lock() | |
| 1816 | + | got, ok := d.inactive[replacement.name] | |
| 1817 | + | d.mu.Unlock() | |
| 1818 | + | require.True(t, ok, "a stale picker row must not purge a same-name replacement") | |
| 1819 | + | require.Equal(t, replacement.incarnation, got.incarnation) | |
| 1820 | + | require.Equal(t, replacement.createdAt, got.createdAt) | |
| 1821 | + | } | |
| 1822 | + | ||
| 1648 | 1823 | // TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry | |
| 1649 | 1824 | // drives 'x' on a stopped picker entry whose persisted-record delete fails | |
| 1650 | 1825 | // (mock store error). The failure must reach the user as a | |
| @@ -190,6 +190,10 @@ func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) { | |||||
| 190 | // Named (MRU-desc) → ephemeral (MRU-desc) → stopped, even though the | 190 | // Named (MRU-desc) → ephemeral (MRU-desc) → stopped, even though the | ||
| 191 | // ephemeral session has the highest mruAt. | 191 | // ephemeral session has the highest mruAt. | ||
| 192 | require.Equal(t, []string{"notes", "work", "1", "halted"}, names) | 192 | require.Equal(t, []string{"notes", "work", "1", "halted"}, names) | ||
| 193 | + | require.Equal(t, "LOCAL", views[0].Section) | |||
| 194 | + | require.Empty(t, views[1].Section) | |||
| 195 | + | require.Empty(t, views[2].Section) | |||
| 196 | + | require.Empty(t, views[3].Section, "a contiguous local-only inventory keeps one section header") | |||
| 193 | } | 197 | } | ||
| 194 | 198 | ||||
| 195 | func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) { | 199 | func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) { | ||
| @@ -888,6 +892,129 @@ func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) { | |||||
| 888 | } | 892 | } | ||
| 889 | } | 893 | } | ||
| 890 | 894 | ||||
| 895 | + | func TestPickerSearchAcceptsPrintableActionsAndSplitUTF8(t *testing.T) { | |||
| 896 | + | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |||
| 897 | + | defer releaseAll(releases) | |||
| 898 | + | ||||
| 899 | + | d.enterPicker(sess, ac) | |||
| 900 | + | awaitFrame(t, sends, wire.MsgOutput) | |||
| 901 | + | d.handlePickerInput(ac, []byte("j/jkqxs/")) | |||
| 902 | + | d.handlePickerInput(ac, []byte{0xc3}) | |||
| 903 | + | d.handlePickerInput(ac, []byte{0xa9}) | |||
| 904 | + | ||||
| 905 | + | ac.overlays.pickerMu.Lock() | |||
| 906 | + | query := ac.overlays.picker.Query() | |||
| 907 | + | active := ac.overlays.picker.SearchActive() | |||
| 908 | + | ac.overlays.pickerMu.Unlock() | |||
| 909 | + | require.True(t, active) | |||
| 910 | + | require.Equal(t, "jkqxs/é", query) | |||
| 911 | + | require.True(t, ac.overlays.pickerActive(), "a slash after navigation must enter search and keep later action bytes printable") | |||
| 912 | + | } | |||
| 913 | + | ||||
| 914 | + | func TestPickerSlashInsideEscapeSequenceDoesNotEnterSearch(t *testing.T) { | |||
| 915 | + | for _, chunks := range [][][]byte{ | |||
| 916 | + | {{0x1b, '[', '/', 'A'}}, | |||
| 917 | + | {{0x1b, '['}, {'/', 'A'}}, | |||
| 918 | + | } { | |||
| 919 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |||
| 920 | + | d.enterPicker(sess, ac) | |||
| 921 | + | awaitFrame(t, sends, wire.MsgOutput) | |||
| 922 | + | for _, chunk := range chunks { | |||
| 923 | + | d.handlePickerInput(ac, chunk) | |||
| 924 | + | } | |||
| 925 | + | ||||
| 926 | + | ac.overlays.pickerMu.Lock() | |||
| 927 | + | searchActive := ac.overlays.picker != nil && ac.overlays.picker.SearchActive() | |||
| 928 | + | ac.overlays.pickerMu.Unlock() | |||
| 929 | + | require.False(t, searchActive) | |||
| 930 | + | releaseAll(releases) | |||
| 931 | + | } | |||
| 932 | + | } | |||
| 933 | + | ||||
| 934 | + | func TestPickerSearchZeroMatchEnterKeepsPickerOpen(t *testing.T) { | |||
| 935 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |||
| 936 | + | defer releaseAll(releases) | |||
| 937 | + | ||||
| 938 | + | d.enterPicker(sess, ac) | |||
| 939 | + | awaitFrame(t, sends, wire.MsgOutput) | |||
| 940 | + | d.handlePickerInput(ac, []byte("/absent\r")) | |||
| 941 | + | ||||
| 942 | + | require.True(t, ac.overlays.pickerActive()) | |||
| 943 | + | ac.overlays.pickerMu.Lock() | |||
| 944 | + | defer ac.overlays.pickerMu.Unlock() | |||
| 945 | + | require.True(t, ac.overlays.picker.SearchActive()) | |||
| 946 | + | require.Equal(t, "absent", ac.overlays.picker.Query()) | |||
| 947 | + | _, ok := ac.overlays.picker.Selected() | |||
| 948 | + | require.False(t, ok) | |||
| 949 | + | } | |||
| 950 | + | ||||
| 951 | + | func TestPickerSearchSplitArrowNavigatesMatches(t *testing.T) { | |||
| 952 | + | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |||
| 953 | + | defer releaseAll(releases) | |||
| 954 | + | ||||
| 955 | + | d.enterPicker(sess, ac) | |||
| 956 | + | awaitFrame(t, sends, wire.MsgOutput) | |||
| 957 | + | d.handlePickerInput(ac, []byte("/")) | |||
| 958 | + | d.handlePickerInput(ac, []byte("\x1b[")) | |||
| 959 | + | d.handlePickerInput(ac, []byte("B")) | |||
| 960 | + | ||||
| 961 | + | ac.overlays.pickerMu.Lock() | |||
| 962 | + | selected, ok := ac.overlays.picker.Selected() | |||
| 963 | + | ac.overlays.pickerMu.Unlock() | |||
| 964 | + | require.True(t, ok) | |||
| 965 | + | require.Equal(t, 1, selected.TabIndex) | |||
| 966 | + | } | |||
| 967 | + | ||||
| 968 | + | func TestPickerSearchRepeatedEscapeChunkClearsThenExits(t *testing.T) { | |||
| 969 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |||
| 970 | + | defer releaseAll(releases) | |||
| 971 | + | ||||
| 972 | + | d.enterPicker(sess, ac) | |||
| 973 | + | awaitFrame(t, sends, wire.MsgOutput) | |||
| 974 | + | d.handlePickerInput(ac, []byte("/query")) | |||
| 975 | + | d.handlePickerInput(ac, []byte("\x1b\x1b")) | |||
| 976 | + | ||||
| 977 | + | require.True(t, ac.overlays.pickerActive()) | |||
| 978 | + | ac.overlays.pickerMu.Lock() | |||
| 979 | + | defer ac.overlays.pickerMu.Unlock() | |||
| 980 | + | require.True(t, ac.overlays.picker.SearchActive(), "first Escape clears while the second is retained as the next staged Escape") | |||
| 981 | + | require.Empty(t, ac.overlays.picker.Query()) | |||
| 982 | + | require.Equal(t, []byte{0x1b}, ac.overlays.pickerPending) | |||
| 983 | + | } | |||
| 984 | + | ||||
| 985 | + | func TestPickerSearchLoneEscapeClearsThenExitsThenCloses(t *testing.T) { | |||
| 986 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |||
| 987 | + | defer releaseAll(releases) | |||
| 988 | + | clk := &signalClock{timers: make(chan *signalTimer, 3)} | |||
| 989 | + | d.clock = clk | |||
| 990 | + | ||||
| 991 | + | d.enterPicker(sess, ac) | |||
| 992 | + | awaitFrame(t, sends, wire.MsgOutput) | |||
| 993 | + | d.handlePickerInput(ac, []byte("/query")) | |||
| 994 | + | ||||
| 995 | + | fireEscape := func() { | |||
| 996 | + | d.handlePickerInput(ac, []byte("\x1b")) | |||
| 997 | + | timer := <-clk.timers | |||
| 998 | + | timer.ch <- time.Now() | |||
| 999 | + | } | |||
| 1000 | + | fireEscape() | |||
| 1001 | + | require.Eventually(t, func() bool { | |||
| 1002 | + | ac.overlays.pickerMu.Lock() | |||
| 1003 | + | defer ac.overlays.pickerMu.Unlock() | |||
| 1004 | + | return ac.overlays.picker != nil && ac.overlays.picker.SearchActive() && ac.overlays.picker.Query() == "" | |||
| 1005 | + | }, time.Second, 5*time.Millisecond) | |||
| 1006 | + | ||||
| 1007 | + | fireEscape() | |||
| 1008 | + | require.Eventually(t, func() bool { | |||
| 1009 | + | ac.overlays.pickerMu.Lock() | |||
| 1010 | + | defer ac.overlays.pickerMu.Unlock() | |||
| 1011 | + | return ac.overlays.picker != nil && !ac.overlays.picker.SearchActive() | |||
| 1012 | + | }, time.Second, 5*time.Millisecond) | |||
| 1013 | + | ||||
| 1014 | + | fireEscape() | |||
| 1015 | + | require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond) | |||
| 1016 | + | } | |||
| 1017 | + | ||||
| 891 | func TestPickerNavigationBackSendFailureKeepsPickerOpen(t *testing.T) { | 1018 | func TestPickerNavigationBackSendFailureKeepsPickerOpen(t *testing.T) { | ||
| 892 | d, sess, ac, _, releases := newManualTabSession(t, 1) | 1019 | d, sess, ac, _, releases := newManualTabSession(t, 1) | ||
| 893 | defer releases[0]() | 1020 | defer releases[0]() | ||
| @@ -1212,7 +1339,7 @@ func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *t | |||||
| 1212 | } | 1339 | } | ||
| 1213 | 1340 | ||||
| 1214 | inner := state.overlays.picker.inner | 1341 | inner := state.overlays.picker.inner | ||
| 1215 | - | layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height}) | 1342 | + | layout := picker.ChooseGeometry(domain.Size{Cols: inner.Width, Rows: inner.Height}) |
| 1216 | require.Equal(t, picker.LayoutHorizontal, layout.Mode) | 1343 | require.Equal(t, picker.LayoutHorizontal, layout.Mode) | ||
| 1217 | require.True(t, inner.At(5, 3).Style.Equal(state.styles.PickerDescription), "description text keeps a muted foreground on the terminal background") | 1344 | require.True(t, inner.At(5, 3).Style.Equal(state.styles.PickerDescription), "description text keeps a muted foreground on the terminal background") | ||
| 1218 | require.True(t, inner.At(layout.List.Width-1, 3).Style.Equal(state.styles.PickerBase), "inactive row filler keeps the terminal background") | 1345 | require.True(t, inner.At(layout.List.Width-1, 3).Style.Equal(state.styles.PickerBase), "inactive row filler keeps the terminal background") | ||
| @@ -1227,6 +1354,29 @@ func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *t | |||||
| 1227 | } | 1354 | } | ||
| 1228 | } | 1355 | } | ||
| 1229 | 1356 | ||||
| 1357 | + | func TestCaptureOverlayLayersRendersNarrowSearchTitleAndStatus(t *testing.T) { | |||
| 1358 | + | model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{TabID: "tab", Name: "shell"}}}}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |||
| 1359 | + | model.EnterSearch() | |||
| 1360 | + | for _, r := range "a-very-long-absent-query" { | |||
| 1361 | + | model.InsertSearch(r) | |||
| 1362 | + | } | |||
| 1363 | + | state := capturedRenderState{ | |||
| 1364 | + | theme: themeui.BuiltinDark, | |||
| 1365 | + | styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles, | |||
| 1366 | + | layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 10}}, | |||
| 1367 | + | } | |||
| 1368 | + | snap := &overlayRenderSnapshot{pickerActive: true, pickerModel: model} | |||
| 1369 | + | ||||
| 1370 | + | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | |||
| 1371 | + | ||||
| 1372 | + | require.True(t, strings.HasPrefix(state.overlays.picker.title, " / ")) | |||
| 1373 | + | require.True(t, strings.HasSuffix(state.overlays.picker.title, "_ ")) | |||
| 1374 | + | inner := state.overlays.picker.inner | |||
| 1375 | + | status := rowText(inner.Row(inner.Height - 1)) | |||
| 1376 | + | require.Contains(t, status, "0 matches") | |||
| 1377 | + | require.NotContains(t, status, "x delete", "search action bytes belong to the query") | |||
| 1378 | + | } | |||
| 1379 | + | ||||
| 1230 | func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) { | 1380 | func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) { | ||
| 1231 | model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | 1381 | model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | ||
| 1232 | state := capturedRenderState{theme: themeui.BuiltinDark, styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles} | 1382 | state := capturedRenderState{theme: themeui.BuiltinDark, styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles} | ||
| @@ -1251,7 +1401,7 @@ func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testin | |||||
| 1251 | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | 1401 | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | ||
| 1252 | 1402 | ||||
| 1253 | inner := state.overlays.picker.inner | 1403 | inner := state.overlays.picker.inner | ||
| 1254 | - | layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height}) | 1404 | + | layout := picker.ChooseGeometry(domain.Size{Cols: inner.Width, Rows: inner.Height}) |
| 1255 | require.Equal(t, tt.wantMode, layout.Mode) | 1405 | require.Equal(t, tt.wantMode, layout.Mode) | ||
| 1256 | got := strings.Join(frameRows(inner), "\n") | 1406 | got := strings.Join(frameRows(inner), "\n") | ||
| 1257 | if tt.wantMarker { | 1407 | if tt.wantMarker { | ||
| @@ -1645,6 +1795,31 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t | |||||
| 1645 | require.False(t, stillActive, "the purge keeps its existing removal semantics despite cleanup failure") | 1795 | require.False(t, stillActive, "the purge keeps its existing removal semantics despite cleanup failure") | ||
| 1646 | } | 1796 | } | ||
| 1647 | 1797 | ||||
| 1798 | + | func TestPickerKillStoppedSessionRejectsSameNameReplacement(t *testing.T) { | |||
| 1799 | + | p, release := newBlockingPTY(t) | |||
| 1800 | + | defer release() | |||
| 1801 | + | d, _, _, _ := newManualSessionWithPTYs(t, p) | |||
| 1802 | + | ||||
| 1803 | + | oldCreatedAt := int64(7) | |||
| 1804 | + | replacement := inactiveSession{ | |||
| 1805 | + | name: "stopped", createdAt: 8, incarnation: domain.IncarnationID{2}, | |||
| 1806 | + | state: protocol.SessionDown, | |||
| 1807 | + | } | |||
| 1808 | + | d.inactive[replacement.name] = replacement | |||
| 1809 | + | ||||
| 1810 | + | err := d.killPickerTarget(picker.Target{ | |||
| 1811 | + | Name: "stopped", Stopped: true, Incarnation: domain.IncarnationID{1}, ExpectedCreatedAt: &oldCreatedAt, | |||
| 1812 | + | }) | |||
| 1813 | + | require.NoError(t, err) | |||
| 1814 | + | ||||
| 1815 | + | d.mu.Lock() | |||
| 1816 | + | got, ok := d.inactive[replacement.name] | |||
| 1817 | + | d.mu.Unlock() | |||
| 1818 | + | require.True(t, ok, "a stale picker row must not purge a same-name replacement") | |||
| 1819 | + | require.Equal(t, replacement.incarnation, got.incarnation) | |||
| 1820 | + | require.Equal(t, replacement.createdAt, got.createdAt) | |||
| 1821 | + | } | |||
| 1822 | + | ||||
| 1648 | // TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry | 1823 | // TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry | ||
| 1649 | // drives 'x' on a stopped picker entry whose persisted-record delete fails | 1824 | // drives 'x' on a stopped picker entry whose persisted-record delete fails | ||
| 1650 | // (mock store error). The failure must reach the user as a | 1825 | // (mock store error). The failure must reach the user as a | ||
M internal/usecase/daemon/remote_picker.go +0 -6
| @@ -206,12 +206,6 @@ func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatal | |||
| 206 | 206 | // must remain possible, while sendRemoteAttachTargetForAttachment will | |
| 207 | 207 | // fail closed on the invalid target. | |
| 208 | 208 | remoteTarget := &target | |
| 209 | - | if stopped && len(viewTabs) == 0 { | |
| 210 | - | // A healthy durable record may intentionally have no retained tabs, | |
| 211 | - | // for example after a protocol reset. Mirror local stopped rows with | |
| 212 | - | // one default picker target; the owning daemon creates that sole tab. | |
| 213 | - | viewTabs = append(viewTabs, picker.TabEntry{}) | |
| 214 | - | } | |
| 215 | 209 | reason := remoteReasonForStatus(status) | |
| 216 | 210 | targetValid := remoteTarget.Validate() == nil | |
| 217 | 211 | activation := picker.RemoteUnavailable | |
| @@ -206,12 +206,6 @@ func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatal | |||||
| 206 | // must remain possible, while sendRemoteAttachTargetForAttachment will | 206 | // must remain possible, while sendRemoteAttachTargetForAttachment will | ||
| 207 | // fail closed on the invalid target. | 207 | // fail closed on the invalid target. | ||
| 208 | remoteTarget := &target | 208 | remoteTarget := &target | ||
| 209 | - | if stopped && len(viewTabs) == 0 { | |||
| 210 | - | // A healthy durable record may intentionally have no retained tabs, | |||
| 211 | - | // for example after a protocol reset. Mirror local stopped rows with | |||
| 212 | - | // one default picker target; the owning daemon creates that sole tab. | |||
| 213 | - | viewTabs = append(viewTabs, picker.TabEntry{}) | |||
| 214 | - | } | |||
| 215 | reason := remoteReasonForStatus(status) | 209 | reason := remoteReasonForStatus(status) | ||
| 216 | targetValid := remoteTarget.Validate() == nil | 210 | targetValid := remoteTarget.Validate() == nil | ||
| 217 | activation := picker.RemoteUnavailable | 211 | activation := picker.RemoteUnavailable | ||
M internal/usecase/daemon/remote_picker_test.go +31 -1
| @@ -979,7 +979,7 @@ func TestRemoteRefreshUpdatesAllOpenPickersPreservingSelection(t *testing.T) { | |||
| 979 | 979 | updated := owner.overlays.picker | |
| 980 | 980 | selected, ok := updated.Selected() | |
| 981 | 981 | owner.overlays.pickerMu.Unlock() | |
| 982 | - | require.NotSame(t, before[owner], updated, "every open picker must receive the refreshed model") | |
| 982 | + | require.Same(t, before[owner], updated, "refresh updates rows in place so attachment-local editor state remains owned") | |
| 983 | 983 | require.True(t, ok) | |
| 984 | 984 | require.Equal(t, domain.SessionID("second"), selected.Session) | |
| 985 | 985 | } | |
| @@ -1253,6 +1253,36 @@ func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) { | |||
| 1253 | 1253 | ac.overlays.pickerMu.Unlock() | |
| 1254 | 1254 | } | |
| 1255 | 1255 | ||
| 1256 | + | func TestRemotePickerRefreshPreservesQueryEditedDuringBuild(t *testing.T) { | |
| 1257 | + | d := newRemotePickerDaemon(nil) | |
| 1258 | + | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 1259 | + | model := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 1260 | + | d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{}) | |
| 1261 | + | d.handlePickerInput(ac, []byte("/before")) | |
| 1262 | + | ||
| 1263 | + | rebuildReached := make(chan struct{}) | |
| 1264 | + | allowPublication := make(chan struct{}) | |
| 1265 | + | ac.overlays.afterPickerRefreshBuild = func(*picker.Model) { | |
| 1266 | + | close(rebuildReached) | |
| 1267 | + | <-allowPublication | |
| 1268 | + | } | |
| 1269 | + | refreshed := make(chan struct{}) | |
| 1270 | + | go func() { | |
| 1271 | + | d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1}) | |
| 1272 | + | close(refreshed) | |
| 1273 | + | }() | |
| 1274 | + | receiveRemotePickerClose(t, rebuildReached, "picker rebuild") | |
| 1275 | + | ||
| 1276 | + | d.handlePickerInput(ac, []byte("-after")) | |
| 1277 | + | close(allowPublication) | |
| 1278 | + | receiveRemotePickerClose(t, refreshed, "picker refresh") | |
| 1279 | + | ||
| 1280 | + | ac.overlays.pickerMu.Lock() | |
| 1281 | + | defer ac.overlays.pickerMu.Unlock() | |
| 1282 | + | require.Same(t, model, ac.overlays.picker, "refresh applies rows in place so attachment-local editor state remains owned") | |
| 1283 | + | require.Equal(t, "before-after", ac.overlays.picker.Query()) | |
| 1284 | + | } | |
| 1285 | + | ||
| 1256 | 1286 | func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) { | |
| 1257 | 1287 | d := newRemotePickerDaemon(nil) | |
| 1258 | 1288 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| @@ -979,7 +979,7 @@ func TestRemoteRefreshUpdatesAllOpenPickersPreservingSelection(t *testing.T) { | |||||
| 979 | updated := owner.overlays.picker | 979 | updated := owner.overlays.picker | ||
| 980 | selected, ok := updated.Selected() | 980 | selected, ok := updated.Selected() | ||
| 981 | owner.overlays.pickerMu.Unlock() | 981 | owner.overlays.pickerMu.Unlock() | ||
| 982 | - | require.NotSame(t, before[owner], updated, "every open picker must receive the refreshed model") | 982 | + | require.Same(t, before[owner], updated, "refresh updates rows in place so attachment-local editor state remains owned") |
| 983 | require.True(t, ok) | 983 | require.True(t, ok) | ||
| 984 | require.Equal(t, domain.SessionID("second"), selected.Session) | 984 | require.Equal(t, domain.SessionID("second"), selected.Session) | ||
| 985 | } | 985 | } | ||
| @@ -1253,6 +1253,36 @@ func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) { | |||||
| 1253 | ac.overlays.pickerMu.Unlock() | 1253 | ac.overlays.pickerMu.Unlock() | ||
| 1254 | } | 1254 | } | ||
| 1255 | 1255 | ||||
| 1256 | + | func TestRemotePickerRefreshPreservesQueryEditedDuringBuild(t *testing.T) { | |||
| 1257 | + | d := newRemotePickerDaemon(nil) | |||
| 1258 | + | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |||
| 1259 | + | model := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |||
| 1260 | + | d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{}) | |||
| 1261 | + | d.handlePickerInput(ac, []byte("/before")) | |||
| 1262 | + | ||||
| 1263 | + | rebuildReached := make(chan struct{}) | |||
| 1264 | + | allowPublication := make(chan struct{}) | |||
| 1265 | + | ac.overlays.afterPickerRefreshBuild = func(*picker.Model) { | |||
| 1266 | + | close(rebuildReached) | |||
| 1267 | + | <-allowPublication | |||
| 1268 | + | } | |||
| 1269 | + | refreshed := make(chan struct{}) | |||
| 1270 | + | go func() { | |||
| 1271 | + | d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1}) | |||
| 1272 | + | close(refreshed) | |||
| 1273 | + | }() | |||
| 1274 | + | receiveRemotePickerClose(t, rebuildReached, "picker rebuild") | |||
| 1275 | + | ||||
| 1276 | + | d.handlePickerInput(ac, []byte("-after")) | |||
| 1277 | + | close(allowPublication) | |||
| 1278 | + | receiveRemotePickerClose(t, refreshed, "picker refresh") | |||
| 1279 | + | ||||
| 1280 | + | ac.overlays.pickerMu.Lock() | |||
| 1281 | + | defer ac.overlays.pickerMu.Unlock() | |||
| 1282 | + | require.Same(t, model, ac.overlays.picker, "refresh applies rows in place so attachment-local editor state remains owned") | |||
| 1283 | + | require.Equal(t, "before-after", ac.overlays.picker.Query()) | |||
| 1284 | + | } | |||
| 1285 | + | ||||
| 1256 | func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) { | 1286 | func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) { | ||
| 1257 | d := newRemotePickerDaemon(nil) | 1287 | d := newRemotePickerDaemon(nil) | ||
| 1258 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | 1288 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | ||
M internal/usecase/daemon/render_pipeline.go +4 -2
| @@ -242,14 +242,16 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||
| 242 | 242 | if snap.pickerActive && snap.pickerModel != nil { | |
| 243 | 243 | presentation := pickerModal.Resolve(size) | |
| 244 | 244 | title := snap.pickerTitle | |
| 245 | - | if title == "" { | |
| 245 | + | if searchTitle := snap.pickerModel.SearchTitle(presentation.Bounds.Width - 2); searchTitle != "" { | |
| 246 | + | title = searchTitle | |
| 247 | + | } else if title == "" { | |
| 246 | 248 | title = pickerModal.Title | |
| 247 | 249 | } | |
| 248 | 250 | o.picker = capturedModal{active: true, title: title, presentation: presentation, focused: true} | |
| 249 | 251 | stoppedStyle := styles.PickerName | |
| 250 | 252 | stoppedStyle.Attrs |= renderer.AttrDim | |
| 251 | 253 | stoppedStyle.Italic = true | |
| 252 | - | renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator, Stopped: stoppedStyle} | |
| 254 | + | renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator, Stopped: stoppedStyle, Status: styles.PickerDescription, SearchMatch: styles.SearchSelection, SelectionMatch: styles.HintKey} | |
| 253 | 255 | o.picker.inner = snap.pickerModel.Render(rectSize(presentation.Inner), state.preview, renderStyles) | |
| 254 | 256 | } | |
| 255 | 257 | if snap.noticesOverlayActive && snap.noticesOverlayModel != nil { | |
| @@ -242,14 +242,16 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||||
| 242 | if snap.pickerActive && snap.pickerModel != nil { | 242 | if snap.pickerActive && snap.pickerModel != nil { | ||
| 243 | presentation := pickerModal.Resolve(size) | 243 | presentation := pickerModal.Resolve(size) | ||
| 244 | title := snap.pickerTitle | 244 | title := snap.pickerTitle | ||
| 245 | - | if title == "" { | 245 | + | if searchTitle := snap.pickerModel.SearchTitle(presentation.Bounds.Width - 2); searchTitle != "" { |
| 246 | + | title = searchTitle | |||
| 247 | + | } else if title == "" { | |||
| 246 | title = pickerModal.Title | 248 | title = pickerModal.Title | ||
| 247 | } | 249 | } | ||
| 248 | o.picker = capturedModal{active: true, title: title, presentation: presentation, focused: true} | 250 | o.picker = capturedModal{active: true, title: title, presentation: presentation, focused: true} | ||
| 249 | stoppedStyle := styles.PickerName | 251 | stoppedStyle := styles.PickerName | ||
| 250 | stoppedStyle.Attrs |= renderer.AttrDim | 252 | stoppedStyle.Attrs |= renderer.AttrDim | ||
| 251 | stoppedStyle.Italic = true | 253 | stoppedStyle.Italic = true | ||
| 252 | - | renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator, Stopped: stoppedStyle} | 254 | + | renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator, Stopped: stoppedStyle, Status: styles.PickerDescription, SearchMatch: styles.SearchSelection, SelectionMatch: styles.HintKey} |
| 253 | o.picker.inner = snap.pickerModel.Render(rectSize(presentation.Inner), state.preview, renderStyles) | 255 | o.picker.inner = snap.pickerModel.Render(rectSize(presentation.Inner), state.preview, renderStyles) | ||
| 254 | } | 256 | } | ||
| 255 | if snap.noticesOverlayActive && snap.noticesOverlayModel != nil { | 257 | if snap.noticesOverlayActive && snap.noticesOverlayModel != nil { | ||
M internal/usecase/daemon/session.go +14 -0
| @@ -1262,13 +1262,27 @@ func (d *Daemon) retryStoppedPurge(name string) error { | |||
| 1262 | 1262 | return d.retryStoppedPurgeContext(d.serveCtx, name) | |
| 1263 | 1263 | } | |
| 1264 | 1264 | ||
| 1265 | + | func (d *Daemon) retryStoppedPurgeExact(name string, incarnation domain.IncarnationID, expectedCreatedAt *int64) error { | |
| 1266 | + | return d.retryStoppedPurgeContextExact(d.serveCtx, name, incarnation, expectedCreatedAt) | |
| 1267 | + | } | |
| 1268 | + | ||
| 1265 | 1269 | func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) error { | |
| 1270 | + | return d.retryStoppedPurgeContextExact(ctx, name, domain.IncarnationID{}, nil) | |
| 1271 | + | } | |
| 1272 | + | ||
| 1273 | + | func (d *Daemon) retryStoppedPurgeContextExact(ctx context.Context, name string, incarnation domain.IncarnationID, expectedCreatedAt *int64) error { | |
| 1266 | 1274 | d.mu.Lock() | |
| 1267 | 1275 | stopped, ok := d.inactive[name] | |
| 1268 | 1276 | if !ok { | |
| 1269 | 1277 | d.mu.Unlock() | |
| 1270 | 1278 | return nil | |
| 1271 | 1279 | } | |
| 1280 | + | incarnationChanged := incarnation != (domain.IncarnationID{}) && stopped.incarnation != incarnation | |
| 1281 | + | createdAtChanged := expectedCreatedAt != nil && stopped.createdAt != *expectedCreatedAt | |
| 1282 | + | if incarnationChanged || createdAtChanged { | |
| 1283 | + | d.mu.Unlock() | |
| 1284 | + | return nil | |
| 1285 | + | } | |
| 1272 | 1286 | stopped.purging = true | |
| 1273 | 1287 | d.inactive[name] = stopped | |
| 1274 | 1288 | ||
| @@ -1262,13 +1262,27 @@ func (d *Daemon) retryStoppedPurge(name string) error { | |||||
| 1262 | return d.retryStoppedPurgeContext(d.serveCtx, name) | 1262 | return d.retryStoppedPurgeContext(d.serveCtx, name) | ||
| 1263 | } | 1263 | } | ||
| 1264 | 1264 | ||||
| 1265 | + | func (d *Daemon) retryStoppedPurgeExact(name string, incarnation domain.IncarnationID, expectedCreatedAt *int64) error { | |||
| 1266 | + | return d.retryStoppedPurgeContextExact(d.serveCtx, name, incarnation, expectedCreatedAt) | |||
| 1267 | + | } | |||
| 1268 | + | ||||
| 1265 | func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) error { | 1269 | func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) error { | ||
| 1270 | + | return d.retryStoppedPurgeContextExact(ctx, name, domain.IncarnationID{}, nil) | |||
| 1271 | + | } | |||
| 1272 | + | ||||
| 1273 | + | func (d *Daemon) retryStoppedPurgeContextExact(ctx context.Context, name string, incarnation domain.IncarnationID, expectedCreatedAt *int64) error { | |||
| 1266 | d.mu.Lock() | 1274 | d.mu.Lock() | ||
| 1267 | stopped, ok := d.inactive[name] | 1275 | stopped, ok := d.inactive[name] | ||
| 1268 | if !ok { | 1276 | if !ok { | ||
| 1269 | d.mu.Unlock() | 1277 | d.mu.Unlock() | ||
| 1270 | return nil | 1278 | return nil | ||
| 1271 | } | 1279 | } | ||
| 1280 | + | incarnationChanged := incarnation != (domain.IncarnationID{}) && stopped.incarnation != incarnation | |||
| 1281 | + | createdAtChanged := expectedCreatedAt != nil && stopped.createdAt != *expectedCreatedAt | |||
| 1282 | + | if incarnationChanged || createdAtChanged { | |||
| 1283 | + | d.mu.Unlock() | |||
| 1284 | + | return nil | |||
| 1285 | + | } | |||
| 1272 | stopped.purging = true | 1286 | stopped.purging = true | ||
| 1273 | d.inactive[name] = stopped | 1287 | d.inactive[name] = stopped | ||
| 1274 | 1288 | ||||
M internal/usecase/picker/model.go +308 -58
| @@ -1,7 +1,9 @@ | |||
| 1 | 1 | package picker | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "math" | |
| 6 | + | "strings" | |
| 5 | 7 | ||
| 6 | 8 | renderer "github.com/bnema/vev-vt" | |
| 7 | 9 | "github.com/bnema/vev/internal/domain" | |
| @@ -12,7 +14,8 @@ const ( | |||
| 12 | 14 | MinPreviewWidth = 24 | |
| 13 | 15 | MinHorizontalPreviewWidth = 48 | |
| 14 | 16 | MinListWidth = 16 | |
| 15 | - | MaxListWidth = 32 | |
| 17 | + | MinHorizontalListWidth = 20 | |
| 18 | + | MaxListWidth = 44 | |
| 16 | 19 | MinPaneHeight = 4 | |
| 17 | 20 | MinStackHeight = 12 | |
| 18 | 21 | ) | |
| @@ -40,6 +43,7 @@ const ( | |||
| 40 | 43 | ||
| 41 | 44 | type SessionView struct { | |
| 42 | 45 | ID domain.SessionID | |
| 46 | + | Section string | |
| 43 | 47 | Incarnation domain.IncarnationID | |
| 44 | 48 | Name string | |
| 45 | 49 | TargetName string | |
| @@ -48,6 +52,7 @@ type SessionView struct { | |||
| 48 | 52 | Stopped bool | |
| 49 | 53 | ExpectedCreatedAt *int64 | |
| 50 | 54 | RemoteKey *domain.RemoteSessionKey | |
| 55 | + | HideRemoteOrigin bool | |
| 51 | 56 | // RemoteTarget is the structured route/lifecycle identity for picker rows. | |
| 52 | 57 | // It is never reconstructed from Name or a rendered label. | |
| 53 | 58 | RemoteTarget *domain.RemoteSessionTarget | |
| @@ -108,10 +113,13 @@ type RenderStyles struct { | |||
| 108 | 113 | Detail renderer.Style // non-selected detail segment | |
| 109 | 114 | // Background fills otherwise unused interior cells. Base owns ordinary | |
| 110 | 115 | // rows, preserving a distinct inactive surface from modal chrome. | |
| 111 | - | Background renderer.Style | |
| 112 | - | Base renderer.Style // non-selected row fill + suffixes | |
| 113 | - | Stopped renderer.Style // full row style for non-selected stopped rows (fill, name, detail, suffix) | |
| 114 | - | Separator renderer.Style // preview separator | |
| 116 | + | Background renderer.Style | |
| 117 | + | Base renderer.Style // non-selected row fill + suffixes | |
| 118 | + | Stopped renderer.Style // full row style for non-selected stopped rows (fill, name, detail, suffix) | |
| 119 | + | Separator renderer.Style // preview separator | |
| 120 | + | Status renderer.Style // one-row picker-local contextual help | |
| 121 | + | SearchMatch renderer.Style // matched runes on ordinary rows | |
| 122 | + | SelectionMatch renderer.Style // matched runes on the selected row | |
| 115 | 123 | } | |
| 116 | 124 | ||
| 117 | 125 | func defaultRenderStyles() RenderStyles { | |
| @@ -123,7 +131,11 @@ func defaultRenderStyles() RenderStyles { | |||
| 123 | 131 | stopped := renderer.DefaultStyle() | |
| 124 | 132 | stopped.Attrs = renderer.AttrDim | |
| 125 | 133 | stopped.Italic = true | |
| 126 | - | return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Background: base, Base: base, Separator: separator, Stopped: stopped} | |
| 134 | + | searchMatch := base | |
| 135 | + | searchMatch.Bold = true | |
| 136 | + | selectionMatch := selection | |
| 137 | + | selectionMatch.Bold = true | |
| 138 | + | return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Background: base, Base: base, Separator: separator, Stopped: stopped, Status: separator, SearchMatch: searchMatch, SelectionMatch: selectionMatch} | |
| 127 | 139 | } | |
| 128 | 140 | ||
| 129 | 141 | type Target struct { | |
| @@ -161,10 +173,25 @@ type Layout struct { | |||
| 161 | 173 | Preview domain.Rect | |
| 162 | 174 | } | |
| 163 | 175 | ||
| 176 | + | // Geometry is the single picker layout contract consumed by rendering and | |
| 177 | + | // remote preview sizing. Status always reserves the final available inner row. | |
| 178 | + | type Geometry struct { | |
| 179 | + | Content domain.Rect | |
| 180 | + | Status domain.Rect | |
| 181 | + | Mode LayoutMode | |
| 182 | + | List domain.Rect | |
| 183 | + | Separator domain.Rect | |
| 184 | + | Preview domain.Rect | |
| 185 | + | } | |
| 186 | + | ||
| 164 | 187 | type Model struct { | |
| 165 | - | mode SelectionMode | |
| 166 | - | rows []row | |
| 167 | - | selected int | |
| 188 | + | mode SelectionMode | |
| 189 | + | rows []row | |
| 190 | + | selected int | |
| 191 | + | searchActive bool | |
| 192 | + | query ui.TextInput | |
| 193 | + | searchMatches map[int]searchMatch | |
| 194 | + | matchRows []int | |
| 168 | 195 | } | |
| 169 | 196 | ||
| 170 | 197 | type rowKind uint8 | |
| @@ -172,8 +199,40 @@ type rowKind uint8 | |||
| 172 | 199 | const ( | |
| 173 | 200 | rowSession rowKind = iota | |
| 174 | 201 | rowTab | |
| 202 | + | rowSection | |
| 175 | 203 | ) | |
| 176 | 204 | ||
| 205 | + | type rowStatus uint8 | |
| 206 | + | ||
| 207 | + | const ( | |
| 208 | + | rowStatusNone rowStatus = iota | |
| 209 | + | rowStatusUp | |
| 210 | + | rowStatusStopped | |
| 211 | + | rowStatusDown | |
| 212 | + | rowStatusStale | |
| 213 | + | rowStatusVersion | |
| 214 | + | rowStatusError | |
| 215 | + | ) | |
| 216 | + | ||
| 217 | + | func (s rowStatus) badge() string { | |
| 218 | + | switch s { | |
| 219 | + | case rowStatusUp: | |
| 220 | + | return "[up]" | |
| 221 | + | case rowStatusStopped: | |
| 222 | + | return "[stopped]" | |
| 223 | + | case rowStatusDown: | |
| 224 | + | return "[down]" | |
| 225 | + | case rowStatusStale: | |
| 226 | + | return "[stale]" | |
| 227 | + | case rowStatusVersion: | |
| 228 | + | return "[version]" | |
| 229 | + | case rowStatusError: | |
| 230 | + | return "[error]" | |
| 231 | + | default: | |
| 232 | + | return "" | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | ||
| 177 | 236 | func (k rowKind) rendersAsHeader() bool { | |
| 178 | 237 | return k == rowSession | |
| 179 | 238 | } | |
| @@ -199,6 +258,7 @@ type row struct { | |||
| 199 | 258 | // targetName is the named-session lookup name threaded into Target; | |
| 200 | 259 | // distinct from dispName, which is what gets drawn. | |
| 201 | 260 | targetName string | |
| 261 | + | sessionDisplay string | |
| 202 | 262 | tabID domain.TabStableID | |
| 203 | 263 | tabIndex int | |
| 204 | 264 | stopped bool | |
| @@ -214,6 +274,12 @@ type row struct { | |||
| 214 | 274 | selectable bool | |
| 215 | 275 | focusable bool | |
| 216 | 276 | dim bool | |
| 277 | + | status rowStatus | |
| 278 | + | statusDetail string | |
| 279 | + | foldedName string | |
| 280 | + | foldedDetail string | |
| 281 | + | foldedSession string | |
| 282 | + | foldedHost string | |
| 217 | 283 | } | |
| 218 | 284 | ||
| 219 | 285 | func New(sessions []SessionView, config SelectionConfig) *Model { | |
| @@ -221,6 +287,12 @@ func New(sessions []SessionView, config SelectionConfig) *Model { | |||
| 221 | 287 | activeSelection := -1 | |
| 222 | 288 | for _, session := range sessions { | |
| 223 | 289 | sessionRows := rowsForSession(session, config) | |
| 290 | + | if len(sessionRows) == 0 { | |
| 291 | + | continue | |
| 292 | + | } | |
| 293 | + | if session.Section != "" { | |
| 294 | + | m.rows = append(m.rows, row{kind: rowSection, dispName: session.Section, dim: true}) | |
| 295 | + | } | |
| 224 | 296 | for _, pickerRow := range sessionRows { | |
| 225 | 297 | idx := len(m.rows) | |
| 226 | 298 | m.rows = append(m.rows, pickerRow) | |
| @@ -269,8 +341,8 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||
| 269 | 341 | } | |
| 270 | 342 | expectedCreatedAt, hasExpectedCreatedAt := int64Value(session.ExpectedCreatedAt) | |
| 271 | 343 | common := row{ | |
| 272 | - | session: session.ID, incarnation: session.Incarnation, targetName: targetName, | |
| 273 | - | stopped: stopped, expectedCreatedAt: expectedCreatedAt, | |
| 344 | + | session: session.ID, incarnation: session.Incarnation, targetName: targetName, sessionDisplay: session.Name, | |
| 345 | + | stopped: stopped, status: statusForSession(session, stopped), statusDetail: session.RemoteDetail, expectedCreatedAt: expectedCreatedAt, | |
| 274 | 346 | hasExpectedCreatedAt: hasExpectedCreatedAt, | |
| 275 | 347 | } | |
| 276 | 348 | if session.RemoteKey != nil { | |
| @@ -285,21 +357,19 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||
| 285 | 357 | header.kind, header.dispName, header.tabIndex = rowSession, session.Name, -1 | |
| 286 | 358 | if common.hasRemoteKey { | |
| 287 | 359 | header.dispName = common.remoteKey.Name | |
| 288 | - | display := common.remoteKey.Display() | |
| 289 | - | header.detail = display[len(common.remoteKey.Name):] | |
| 360 | + | if !session.HideRemoteOrigin { | |
| 361 | + | display := common.remoteKey.Display() | |
| 362 | + | header.detail = display[len(common.remoteKey.Name):] | |
| 363 | + | } | |
| 290 | 364 | } | |
| 291 | 365 | header.selectable = header.kind.selectable(config.Mode) | |
| 292 | 366 | header.focusable = header.selectable | |
| 367 | + | if config.Mode == SelectNavigationTab && stopped && len(session.Tabs) == 0 { | |
| 368 | + | header.selectable, header.focusable = true, true | |
| 369 | + | } | |
| 293 | 370 | if common.remote { | |
| 294 | - | if session.RemoteDetail != "" { | |
| 295 | - | if header.detail != "" { | |
| 296 | - | header.detail += " (" + session.RemoteDetail + ")" | |
| 297 | - | } else { | |
| 298 | - | header.detail = session.RemoteDetail | |
| 299 | - | } | |
| 300 | - | } | |
| 301 | 371 | if common.hasRemoteTarget { | |
| 302 | - | header.selectable = false | |
| 372 | + | header.selectable = config.Mode == SelectNavigationTab && stopped && len(session.Tabs) == 0 && remoteActivatable(session) && common.remoteTarget.Validate() == nil | |
| 303 | 373 | header.focusable = config.Mode == SelectNavigationTab && len(session.Tabs) == 0 | |
| 304 | 374 | } else { | |
| 305 | 375 | header.selectable = false | |
| @@ -311,6 +381,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||
| 311 | 381 | if config.Mode != SelectNavigationTab && session.CannotAcceptMoves { | |
| 312 | 382 | header.selectable, header.focusable, header.dim = false, false, true | |
| 313 | 383 | } | |
| 384 | + | header.prepareSearch() | |
| 314 | 385 | rows := []row{header} | |
| 315 | 386 | for i, tab := range session.Tabs { | |
| 316 | 387 | if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) { | |
| @@ -355,6 +426,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||
| 355 | 426 | if config.Mode != SelectNavigationTab && session.CannotAcceptMoves { | |
| 356 | 427 | tabRow.selectable, tabRow.focusable, tabRow.dim = false, false, true | |
| 357 | 428 | } | |
| 429 | + | tabRow.prepareSearch() | |
| 358 | 430 | rows = append(rows, tabRow) | |
| 359 | 431 | } | |
| 360 | 432 | if config.Mode == SelectMovePaneTab && len(rows) == 1 && !session.CannotAcceptMoves { | |
| @@ -363,6 +435,13 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||
| 363 | 435 | return rows | |
| 364 | 436 | } | |
| 365 | 437 | ||
| 438 | + | func (r *row) prepareSearch() { | |
| 439 | + | r.foldedName = strings.ToLower(r.dispName) | |
| 440 | + | r.foldedDetail = strings.ToLower(r.detail) | |
| 441 | + | r.foldedSession = strings.ToLower(r.sessionDisplay) | |
| 442 | + | r.foldedHost = strings.ToLower(r.remoteHost) | |
| 443 | + | } | |
| 444 | + | ||
| 366 | 445 | func remoteStoppedOrdinalSelector(index int, rawName string, tabCount int) (domain.TabSelector, bool) { | |
| 367 | 446 | if tabCount > math.MaxUint16 || index < 0 || index >= tabCount { | |
| 368 | 447 | return domain.TabSelector{}, false | |
| @@ -370,6 +449,32 @@ func remoteStoppedOrdinalSelector(index int, rawName string, tabCount int) (doma | |||
| 370 | 449 | return domain.NewOrdinalTabSelector(uint16(index), rawName, uint16(tabCount)), true | |
| 371 | 450 | } | |
| 372 | 451 | ||
| 452 | + | func statusForSession(session SessionView, stopped bool) rowStatus { | |
| 453 | + | remote := session.RemoteKey != nil || session.RemoteTarget != nil || session.RemoteHost != "" | |
| 454 | + | if remote { | |
| 455 | + | switch session.RemoteReason { | |
| 456 | + | case "host_unreachable": | |
| 457 | + | return rowStatusDown | |
| 458 | + | case "version_mismatch": | |
| 459 | + | return rowStatusVersion | |
| 460 | + | case "refreshing", "catalog_stale": | |
| 461 | + | return rowStatusStale | |
| 462 | + | case "malformed", "session_broken", "identity_changed": | |
| 463 | + | return rowStatusError | |
| 464 | + | } | |
| 465 | + | } | |
| 466 | + | if stopped { | |
| 467 | + | return rowStatusStopped | |
| 468 | + | } | |
| 469 | + | if !remote { | |
| 470 | + | return rowStatusNone | |
| 471 | + | } | |
| 472 | + | if session.RemoteActivation == RemoteAttach { | |
| 473 | + | return rowStatusUp | |
| 474 | + | } | |
| 475 | + | return rowStatusError | |
| 476 | + | } | |
| 477 | + | ||
| 373 | 478 | func remoteActivatable(session SessionView) bool { | |
| 374 | 479 | return session.RemoteActivation == RemoteAttach || session.RemoteActivation == RemoteRestart | |
| 375 | 480 | } | |
| @@ -397,21 +502,19 @@ func selectionMatches(pickerRow row, current SourceFilter, mode SelectionMode) b | |||
| 397 | 502 | return false | |
| 398 | 503 | } | |
| 399 | 504 | if pickerRow.hasRemoteKey && pickerRow.kind == rowSession { | |
| 400 | - | return current.RemoteKey == nil || pickerRow.remoteKey == *current.RemoteKey | |
| 505 | + | return pickerRow.focusable && (current.RemoteKey == nil || pickerRow.remoteKey == *current.RemoteKey) | |
| 401 | 506 | } | |
| 402 | 507 | if mode == SelectMoveTabSession { | |
| 403 | 508 | return pickerRow.kind == rowSession | |
| 404 | 509 | } | |
| 510 | + | // A stopped session with no retained tab metadata uses its selectable | |
| 511 | + | // header as the default restart target and matches on exact session identity. | |
| 512 | + | if current.TabID == "" && pickerRow.stopped { | |
| 513 | + | return pickerRow.kind == rowSession && pickerRow.selectable | |
| 514 | + | } | |
| 405 | 515 | if pickerRow.kind != rowTab { | |
| 406 | 516 | return false | |
| 407 | 517 | } | |
| 408 | - | // A stopped session contributes one synthetic tab row carrying no stable tab | |
| 409 | - | // identity, so it matches on session identity alone. Its session ID is | |
| 410 | - | // namespaced ("stopped:<name>"), and move modes drop stopped sessions | |
| 411 | - | // entirely, so no live row can be reached this way. | |
| 412 | - | if current.TabID == "" { | |
| 413 | - | return pickerRow.stopped | |
| 414 | - | } | |
| 415 | 518 | return pickerRow.tabID == current.TabID | |
| 416 | 519 | } | |
| 417 | 520 | ||
| @@ -422,14 +525,30 @@ func int64Value(value *int64) (int64, bool) { | |||
| 422 | 525 | return *value, true | |
| 423 | 526 | } | |
| 424 | 527 | ||
| 528 | + | // ChooseGeometry reserves the final inner row for picker-local status before | |
| 529 | + | // solving the list and preview layout. | |
| 530 | + | func ChooseGeometry(inner domain.Size) Geometry { | |
| 531 | + | if inner.Cols <= 0 || inner.Rows <= 0 { | |
| 532 | + | return Geometry{} | |
| 533 | + | } | |
| 534 | + | contentRows := max(inner.Rows-1, 0) | |
| 535 | + | layout := ChooseLayout(domain.Size{Cols: inner.Cols, Rows: contentRows}) | |
| 536 | + | return Geometry{ | |
| 537 | + | Content: domain.Rect{Width: inner.Cols, Height: contentRows}, | |
| 538 | + | Status: domain.Rect{Y: contentRows, Width: inner.Cols, Height: 1}, | |
| 539 | + | Mode: layout.Mode, List: layout.List, Separator: layout.Separator, Preview: layout.Preview, | |
| 540 | + | } | |
| 541 | + | } | |
| 542 | + | ||
| 425 | 543 | func ChooseLayout(inner domain.Size) Layout { | |
| 426 | 544 | if inner.Cols <= 0 || inner.Rows <= 0 { | |
| 427 | 545 | return Layout{} | |
| 428 | 546 | } | |
| 429 | 547 | if inner.Rows >= MinPaneHeight { | |
| 430 | - | listWidth := clamp(inner.Cols*30/100, MinListWidth, MaxListWidth) | |
| 548 | + | listWidth := clamp(inner.Cols*40/100, MinListWidth, MaxListWidth) | |
| 549 | + | listWidth = min(listWidth, inner.Cols-MinHorizontalPreviewWidth-1) | |
| 431 | 550 | previewWidth := inner.Cols - listWidth - 1 | |
| 432 | - | if previewWidth >= MinHorizontalPreviewWidth { | |
| 551 | + | if listWidth >= MinHorizontalListWidth && previewWidth >= MinHorizontalPreviewWidth { | |
| 433 | 552 | return Layout{ | |
| 434 | 553 | Mode: LayoutHorizontal, | |
| 435 | 554 | List: domain.Rect{Width: listWidth, Height: inner.Rows}, | |
| @@ -452,10 +571,18 @@ func ChooseLayout(inner domain.Size) Layout { | |||
| 452 | 571 | } | |
| 453 | 572 | ||
| 454 | 573 | func (m *Model) Up() { | |
| 574 | + | if m != nil && m.searchActive { | |
| 575 | + | m.moveSearch(-1) | |
| 576 | + | return | |
| 577 | + | } | |
| 455 | 578 | m.move(-1) | |
| 456 | 579 | } | |
| 457 | 580 | ||
| 458 | 581 | func (m *Model) Down() { | |
| 582 | + | if m != nil && m.searchActive { | |
| 583 | + | m.moveSearch(1) | |
| 584 | + | return | |
| 585 | + | } | |
| 459 | 586 | m.move(1) | |
| 460 | 587 | } | |
| 461 | 588 | ||
| @@ -474,7 +601,7 @@ func (m *Model) Selected() (Target, bool) { | |||
| 474 | 601 | return Target{}, false | |
| 475 | 602 | } | |
| 476 | 603 | r := m.rows[m.selected] | |
| 477 | - | if !r.selectable { | |
| 604 | + | if !r.selectable || m.searchActive && m.query.Value() != "" && !m.rowMatches(m.selected) { | |
| 478 | 605 | return Target{}, false | |
| 479 | 606 | } | |
| 480 | 607 | return r.target(), true | |
| @@ -519,25 +646,31 @@ func (m *Model) Clone() *Model { | |||
| 519 | 646 | } | |
| 520 | 647 | clone := *m | |
| 521 | 648 | clone.rows = append([]row(nil), m.rows...) | |
| 649 | + | clone.query.SetValue(m.query.Value()) | |
| 650 | + | // Search results are copy-on-write: every query mutation publishes a new | |
| 651 | + | // map and row slice, so render snapshots can safely share the old values. | |
| 652 | + | clone.matchRows = m.matchRows | |
| 653 | + | clone.searchMatches = m.searchMatches | |
| 522 | 654 | return &clone | |
| 523 | 655 | } | |
| 524 | 656 | ||
| 525 | 657 | func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyles) renderer.Frame { | |
| 526 | 658 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | |
| 527 | - | layout := ChooseLayout(inner) | |
| 659 | + | geometry := ChooseGeometry(inner) | |
| 528 | 660 | styleSet := defaultRenderStyles() | |
| 529 | 661 | if len(styles) > 0 { | |
| 530 | 662 | styleSet = styles[0] | |
| 531 | 663 | } | |
| 532 | 664 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styleSet.Background}) | |
| 533 | - | m.renderList(frame, layout.List, styleSet) | |
| 534 | - | switch layout.Mode { | |
| 665 | + | m.renderList(frame, geometry.List, styleSet) | |
| 666 | + | switch geometry.Mode { | |
| 535 | 667 | case LayoutHorizontal: | |
| 536 | - | ui.DrawSeparator(frame, layout.Separator, ui.SeparatorVertical, styleSet.Separator) | |
| 668 | + | ui.DrawSeparator(frame, geometry.Separator, ui.SeparatorVertical, styleSet.Separator) | |
| 537 | 669 | case LayoutStacked: | |
| 538 | - | ui.DrawSeparator(frame, layout.Separator, ui.SeparatorHorizontal, styleSet.Separator) | |
| 670 | + | ui.DrawSeparator(frame, geometry.Separator, ui.SeparatorHorizontal, styleSet.Separator) | |
| 539 | 671 | } | |
| 540 | - | ui.BlitFrame(frame, layout.Preview, preview, ui.VerticalAnchorBottom) | |
| 672 | + | ui.BlitFrame(frame, geometry.Preview, preview, ui.VerticalAnchorBottom) | |
| 673 | + | m.renderStatus(frame, geometry.Status, styleSet.Status) | |
| 541 | 674 | return frame | |
| 542 | 675 | } | |
| 543 | 676 | ||
| @@ -613,13 +746,16 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||
| 613 | 746 | } | |
| 614 | 747 | r := m.rows[idx] | |
| 615 | 748 | base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail | |
| 749 | + | if r.kind == rowSection { | |
| 750 | + | nameStyle = styles.Detail | |
| 751 | + | } | |
| 616 | 752 | if idx == m.selected { | |
| 617 | 753 | base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted | |
| 618 | 754 | } | |
| 619 | 755 | if r.stopped && idx != m.selected { | |
| 620 | 756 | base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped | |
| 621 | 757 | } | |
| 622 | - | if r.dim { | |
| 758 | + | if r.dim || m.searchActive && m.query.Value() != "" && !m.rowMatches(idx) { | |
| 623 | 759 | base.Attrs |= renderer.AttrDim | |
| 624 | 760 | nameStyle.Attrs |= renderer.AttrDim | |
| 625 | 761 | detailStyle.Attrs |= renderer.AttrDim | |
| @@ -627,29 +763,43 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||
| 627 | 763 | ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base}) | |
| 628 | 764 | ||
| 629 | 765 | name := r.dispName | |
| 630 | - | if !r.kind.rendersAsHeader() { | |
| 766 | + | if r.kind == rowTab { | |
| 631 | 767 | name = " " + name | |
| 632 | 768 | } | |
| 633 | - | suffix := "" | |
| 634 | - | if r.kind.rendersAsHeader() && r.stopped { | |
| 635 | - | suffix = "(down)" | |
| 769 | + | badge := "" | |
| 770 | + | if r.kind.rendersAsHeader() { | |
| 771 | + | badge = r.status.badge() | |
| 772 | + | } | |
| 773 | + | contentClipX := clipX | |
| 774 | + | nameWidth := rect.Width | |
| 775 | + | badgeWidth := textCellWidth(badge) | |
| 776 | + | if badge != "" { | |
| 777 | + | nameWidth = max(rect.Width-badgeWidth-1, 0) | |
| 778 | + | contentClipX = max(rect.X, clipX-badgeWidth-1) | |
| 779 | + | } | |
| 780 | + | originalName := name | |
| 781 | + | name = ui.TruncateText(name, nameWidth) | |
| 782 | + | nameMatchStyle := styles.SearchMatch | |
| 783 | + | if idx == m.selected { | |
| 784 | + | nameMatchStyle = styles.SelectionMatch | |
| 785 | + | } | |
| 786 | + | namePositions := m.matchPositions(idx, matchName) | |
| 787 | + | if r.kind == rowTab { | |
| 788 | + | namePositions = shiftPositions(namePositions, 2) | |
| 636 | 789 | } | |
| 637 | - | name = ui.TruncateText(name, max(rect.Width-len(suffix), 0)) | |
| 638 | - | x := ui.DrawText(frame, rect.X, rect.Y+y, clipX, name, nameStyle) | |
| 790 | + | namePositions = visibleMatchPositions(namePositions, name, name != originalName) | |
| 791 | + | x := drawMatchedText(frame, rect.X, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions) | |
| 639 | 792 | ||
| 793 | + | if r.kind == rowSection { | |
| 794 | + | continue | |
| 795 | + | } | |
| 640 | 796 | if r.kind.rendersAsHeader() { | |
| 641 | - | if r.stopped { | |
| 642 | - | detail := r.detail | |
| 643 | - | if detail == "" { | |
| 644 | - | detail = " " | |
| 645 | - | } else { | |
| 646 | - | detail += " " | |
| 647 | - | } | |
| 648 | - | detail = ui.TruncateText(detail, max(clipX-x-len(suffix), 0)) | |
| 649 | - | x = ui.DrawText(frame, x, rect.Y+y, clipX, detail, base) | |
| 650 | - | ui.DrawText(frame, x, rect.Y+y, clipX, suffix, base) | |
| 651 | - | } else { | |
| 652 | - | ui.DrawText(frame, x, rect.Y+y, clipX, ui.TruncateText(r.detail, clipX-x), detailStyle) | |
| 797 | + | detail := ui.TruncateText(r.detail, contentClipX-x) | |
| 798 | + | detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.detail) | |
| 799 | + | drawMatchedText(frame, x, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions) | |
| 800 | + | if badge != "" { | |
| 801 | + | badgeX := max(rect.X, clipX-badgeWidth) | |
| 802 | + | ui.DrawText(frame, badgeX, rect.Y+y, clipX, badge, base) | |
| 653 | 803 | } | |
| 654 | 804 | continue | |
| 655 | 805 | } | |
| @@ -659,8 +809,108 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||
| 659 | 809 | } | |
| 660 | 810 | ||
| 661 | 811 | detail := ui.TruncateText(r.detail, clipX-x) | |
| 662 | - | ui.DrawText(frame, x, rect.Y+y, clipX, detail, detailStyle) | |
| 812 | + | detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.detail) | |
| 813 | + | drawMatchedText(frame, x, rect.Y+y, clipX, detail, detailStyle, nameMatchStyle, detailPositions) | |
| 814 | + | } | |
| 815 | + | } | |
| 816 | + | ||
| 817 | + | func (m *Model) matchPositions(idx int, field matchField) []int { | |
| 818 | + | if m == nil || !m.searchActive || m.query.Value() == "" { | |
| 819 | + | return nil | |
| 820 | + | } | |
| 821 | + | return m.searchMatches[idx].positions(field) | |
| 822 | + | } | |
| 823 | + | ||
| 824 | + | func visibleMatchPositions(positions []int, rendered string, truncated bool) []int { | |
| 825 | + | if !truncated || len(positions) == 0 { | |
| 826 | + | return positions | |
| 827 | + | } | |
| 828 | + | limit := max(len([]rune(rendered))-1, 0) // the final rune is the ellipsis | |
| 829 | + | visible := make([]int, 0, len(positions)) | |
| 830 | + | for _, position := range positions { | |
| 831 | + | if position < limit { | |
| 832 | + | visible = append(visible, position) | |
| 833 | + | } | |
| 834 | + | } | |
| 835 | + | return visible | |
| 836 | + | } | |
| 837 | + | ||
| 838 | + | func shiftPositions(positions []int, delta int) []int { | |
| 839 | + | if len(positions) == 0 || delta == 0 { | |
| 840 | + | return positions | |
| 841 | + | } | |
| 842 | + | shifted := make([]int, len(positions)) | |
| 843 | + | for i, position := range positions { | |
| 844 | + | shifted[i] = position + delta | |
| 845 | + | } | |
| 846 | + | return shifted | |
| 847 | + | } | |
| 848 | + | ||
| 849 | + | func drawMatchedText(frame renderer.Frame, x, y, clipX int, text string, base, match renderer.Style, positions []int) int { | |
| 850 | + | if len(positions) == 0 { | |
| 851 | + | return ui.DrawText(frame, x, y, clipX, text, base) | |
| 852 | + | } | |
| 853 | + | positionIndex := 0 | |
| 854 | + | runeIndex := 0 | |
| 855 | + | for _, r := range text { | |
| 856 | + | style := base | |
| 857 | + | if positionIndex < len(positions) && positions[positionIndex] == runeIndex { | |
| 858 | + | style = match | |
| 859 | + | positionIndex++ | |
| 860 | + | } | |
| 861 | + | x = ui.DrawText(frame, x, y, clipX, string(r), style) | |
| 862 | + | if x >= clipX { | |
| 863 | + | break | |
| 864 | + | } | |
| 865 | + | runeIndex++ | |
| 866 | + | } | |
| 867 | + | return x | |
| 868 | + | } | |
| 869 | + | ||
| 870 | + | func (m *Model) renderStatus(frame renderer.Frame, rect domain.Rect, style renderer.Style) { | |
| 871 | + | if rect.Width <= 0 || rect.Height <= 0 || rect.Y < 0 || rect.Y >= frame.Height { | |
| 872 | + | return | |
| 873 | + | } | |
| 874 | + | ui.FillRect(frame, rect, renderer.Cell{Rune: ' ', Style: style}) | |
| 875 | + | action := "open" | |
| 876 | + | deletable := false | |
| 877 | + | if m != nil && m.selected >= 0 && m.selected < len(m.rows) { | |
| 878 | + | selected := m.rows[m.selected] | |
| 879 | + | if !selected.selectable { | |
| 880 | + | action = "unavailable" | |
| 881 | + | } else if selected.stopped { | |
| 882 | + | action = "restart" | |
| 883 | + | } | |
| 884 | + | deletable = selected.selectable && !selected.remote | |
| 885 | + | } | |
| 886 | + | var groups []string | |
| 887 | + | if m != nil && m.searchActive { | |
| 888 | + | groups = []string{fmt.Sprintf("%d matches", len(m.matchRows)), "Enter " + action, "arrows next"} | |
| 889 | + | escape := "Esc exit" | |
| 890 | + | if m.query.Value() != "" { | |
| 891 | + | escape = "Esc clear" | |
| 892 | + | } | |
| 893 | + | groups = append(groups, escape) | |
| 894 | + | } else { | |
| 895 | + | if m != nil && m.selected >= 0 && m.selected < len(m.rows) && m.rows[m.selected].dim && m.rows[m.selected].statusDetail != "" { | |
| 896 | + | groups = append(groups, m.rows[m.selected].statusDetail) | |
| 897 | + | } | |
| 898 | + | if rect.Width < 60 { | |
| 899 | + | groups = append(groups, "Enter "+action, "/", "Esc", "j/k") | |
| 900 | + | if deletable { | |
| 901 | + | groups = append(groups, "x") | |
| 902 | + | } | |
| 903 | + | groups = append(groups, "s") | |
| 904 | + | } else { | |
| 905 | + | groups = append(groups, "j/k move", "Enter "+action) | |
| 906 | + | if deletable { | |
| 907 | + | groups = append(groups, "x delete") | |
| 908 | + | } | |
| 909 | + | groups = append(groups, "/ search", "s sort", "Esc close") | |
| 910 | + | } | |
| 663 | 911 | } | |
| 912 | + | text := strings.Join(groups, " ") | |
| 913 | + | ui.DrawText(frame, rect.X, rect.Y, rect.X+rect.Width, ui.TruncateText(text, rect.Width), style) | |
| 664 | 914 | } | |
| 665 | 915 | ||
| 666 | 916 | func (m *Model) scrollOffset(visible int) int { | |
| @@ -1,7 +1,9 @@ | |||||
| 1 | package picker | 1 | package picker | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "fmt" | |||
| 4 | "math" | 5 | "math" | ||
| 6 | + | "strings" | |||
| 5 | 7 | ||||
| 6 | renderer "github.com/bnema/vev-vt" | 8 | renderer "github.com/bnema/vev-vt" | ||
| 7 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| @@ -12,7 +14,8 @@ const ( | |||||
| 12 | MinPreviewWidth = 24 | 14 | MinPreviewWidth = 24 | ||
| 13 | MinHorizontalPreviewWidth = 48 | 15 | MinHorizontalPreviewWidth = 48 | ||
| 14 | MinListWidth = 16 | 16 | MinListWidth = 16 | ||
| 15 | - | MaxListWidth = 32 | 17 | + | MinHorizontalListWidth = 20 |
| 18 | + | MaxListWidth = 44 | |||
| 16 | MinPaneHeight = 4 | 19 | MinPaneHeight = 4 | ||
| 17 | MinStackHeight = 12 | 20 | MinStackHeight = 12 | ||
| 18 | ) | 21 | ) | ||
| @@ -40,6 +43,7 @@ const ( | |||||
| 40 | 43 | ||||
| 41 | type SessionView struct { | 44 | type SessionView struct { | ||
| 42 | ID domain.SessionID | 45 | ID domain.SessionID | ||
| 46 | + | Section string | |||
| 43 | Incarnation domain.IncarnationID | 47 | Incarnation domain.IncarnationID | ||
| 44 | Name string | 48 | Name string | ||
| 45 | TargetName string | 49 | TargetName string | ||
| @@ -48,6 +52,7 @@ type SessionView struct { | |||||
| 48 | Stopped bool | 52 | Stopped bool | ||
| 49 | ExpectedCreatedAt *int64 | 53 | ExpectedCreatedAt *int64 | ||
| 50 | RemoteKey *domain.RemoteSessionKey | 54 | RemoteKey *domain.RemoteSessionKey | ||
| 55 | + | HideRemoteOrigin bool | |||
| 51 | // RemoteTarget is the structured route/lifecycle identity for picker rows. | 56 | // RemoteTarget is the structured route/lifecycle identity for picker rows. | ||
| 52 | // It is never reconstructed from Name or a rendered label. | 57 | // It is never reconstructed from Name or a rendered label. | ||
| 53 | RemoteTarget *domain.RemoteSessionTarget | 58 | RemoteTarget *domain.RemoteSessionTarget | ||
| @@ -108,10 +113,13 @@ type RenderStyles struct { | |||||
| 108 | Detail renderer.Style // non-selected detail segment | 113 | Detail renderer.Style // non-selected detail segment | ||
| 109 | // Background fills otherwise unused interior cells. Base owns ordinary | 114 | // Background fills otherwise unused interior cells. Base owns ordinary | ||
| 110 | // rows, preserving a distinct inactive surface from modal chrome. | 115 | // rows, preserving a distinct inactive surface from modal chrome. | ||
| 111 | - | Background renderer.Style | 116 | + | Background renderer.Style |
| 112 | - | Base renderer.Style // non-selected row fill + suffixes | 117 | + | Base renderer.Style // non-selected row fill + suffixes |
| 113 | - | Stopped renderer.Style // full row style for non-selected stopped rows (fill, name, detail, suffix) | 118 | + | Stopped renderer.Style // full row style for non-selected stopped rows (fill, name, detail, suffix) |
| 114 | - | Separator renderer.Style // preview separator | 119 | + | Separator renderer.Style // preview separator |
| 120 | + | Status renderer.Style // one-row picker-local contextual help | |||
| 121 | + | SearchMatch renderer.Style // matched runes on ordinary rows | |||
| 122 | + | SelectionMatch renderer.Style // matched runes on the selected row | |||
| 115 | } | 123 | } | ||
| 116 | 124 | ||||
| 117 | func defaultRenderStyles() RenderStyles { | 125 | func defaultRenderStyles() RenderStyles { | ||
| @@ -123,7 +131,11 @@ func defaultRenderStyles() RenderStyles { | |||||
| 123 | stopped := renderer.DefaultStyle() | 131 | stopped := renderer.DefaultStyle() | ||
| 124 | stopped.Attrs = renderer.AttrDim | 132 | stopped.Attrs = renderer.AttrDim | ||
| 125 | stopped.Italic = true | 133 | stopped.Italic = true | ||
| 126 | - | return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Background: base, Base: base, Separator: separator, Stopped: stopped} | 134 | + | searchMatch := base |
| 135 | + | searchMatch.Bold = true | |||
| 136 | + | selectionMatch := selection | |||
| 137 | + | selectionMatch.Bold = true | |||
| 138 | + | return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Background: base, Base: base, Separator: separator, Stopped: stopped, Status: separator, SearchMatch: searchMatch, SelectionMatch: selectionMatch} | |||
| 127 | } | 139 | } | ||
| 128 | 140 | ||||
| 129 | type Target struct { | 141 | type Target struct { | ||
| @@ -161,10 +173,25 @@ type Layout struct { | |||||
| 161 | Preview domain.Rect | 173 | Preview domain.Rect | ||
| 162 | } | 174 | } | ||
| 163 | 175 | ||||
| 176 | + | // Geometry is the single picker layout contract consumed by rendering and | |||
| 177 | + | // remote preview sizing. Status always reserves the final available inner row. | |||
| 178 | + | type Geometry struct { | |||
| 179 | + | Content domain.Rect | |||
| 180 | + | Status domain.Rect | |||
| 181 | + | Mode LayoutMode | |||
| 182 | + | List domain.Rect | |||
| 183 | + | Separator domain.Rect | |||
| 184 | + | Preview domain.Rect | |||
| 185 | + | } | |||
| 186 | + | ||||
| 164 | type Model struct { | 187 | type Model struct { | ||
| 165 | - | mode SelectionMode | 188 | + | mode SelectionMode |
| 166 | - | rows []row | 189 | + | rows []row |
| 167 | - | selected int | 190 | + | selected int |
| 191 | + | searchActive bool | |||
| 192 | + | query ui.TextInput | |||
| 193 | + | searchMatches map[int]searchMatch | |||
| 194 | + | matchRows []int | |||
| 168 | } | 195 | } | ||
| 169 | 196 | ||||
| 170 | type rowKind uint8 | 197 | type rowKind uint8 | ||
| @@ -172,8 +199,40 @@ type rowKind uint8 | |||||
| 172 | const ( | 199 | const ( | ||
| 173 | rowSession rowKind = iota | 200 | rowSession rowKind = iota | ||
| 174 | rowTab | 201 | rowTab | ||
| 202 | + | rowSection | |||
| 175 | ) | 203 | ) | ||
| 176 | 204 | ||||
| 205 | + | type rowStatus uint8 | |||
| 206 | + | ||||
| 207 | + | const ( | |||
| 208 | + | rowStatusNone rowStatus = iota | |||
| 209 | + | rowStatusUp | |||
| 210 | + | rowStatusStopped | |||
| 211 | + | rowStatusDown | |||
| 212 | + | rowStatusStale | |||
| 213 | + | rowStatusVersion | |||
| 214 | + | rowStatusError | |||
| 215 | + | ) | |||
| 216 | + | ||||
| 217 | + | func (s rowStatus) badge() string { | |||
| 218 | + | switch s { | |||
| 219 | + | case rowStatusUp: | |||
| 220 | + | return "[up]" | |||
| 221 | + | case rowStatusStopped: | |||
| 222 | + | return "[stopped]" | |||
| 223 | + | case rowStatusDown: | |||
| 224 | + | return "[down]" | |||
| 225 | + | case rowStatusStale: | |||
| 226 | + | return "[stale]" | |||
| 227 | + | case rowStatusVersion: | |||
| 228 | + | return "[version]" | |||
| 229 | + | case rowStatusError: | |||
| 230 | + | return "[error]" | |||
| 231 | + | default: | |||
| 232 | + | return "" | |||
| 233 | + | } | |||
| 234 | + | } | |||
| 235 | + | ||||
| 177 | func (k rowKind) rendersAsHeader() bool { | 236 | func (k rowKind) rendersAsHeader() bool { | ||
| 178 | return k == rowSession | 237 | return k == rowSession | ||
| 179 | } | 238 | } | ||
| @@ -199,6 +258,7 @@ type row struct { | |||||
| 199 | // targetName is the named-session lookup name threaded into Target; | 258 | // targetName is the named-session lookup name threaded into Target; | ||
| 200 | // distinct from dispName, which is what gets drawn. | 259 | // distinct from dispName, which is what gets drawn. | ||
| 201 | targetName string | 260 | targetName string | ||
| 261 | + | sessionDisplay string | |||
| 202 | tabID domain.TabStableID | 262 | tabID domain.TabStableID | ||
| 203 | tabIndex int | 263 | tabIndex int | ||
| 204 | stopped bool | 264 | stopped bool | ||
| @@ -214,6 +274,12 @@ type row struct { | |||||
| 214 | selectable bool | 274 | selectable bool | ||
| 215 | focusable bool | 275 | focusable bool | ||
| 216 | dim bool | 276 | dim bool | ||
| 277 | + | status rowStatus | |||
| 278 | + | statusDetail string | |||
| 279 | + | foldedName string | |||
| 280 | + | foldedDetail string | |||
| 281 | + | foldedSession string | |||
| 282 | + | foldedHost string | |||
| 217 | } | 283 | } | ||
| 218 | 284 | ||||
| 219 | func New(sessions []SessionView, config SelectionConfig) *Model { | 285 | func New(sessions []SessionView, config SelectionConfig) *Model { | ||
| @@ -221,6 +287,12 @@ func New(sessions []SessionView, config SelectionConfig) *Model { | |||||
| 221 | activeSelection := -1 | 287 | activeSelection := -1 | ||
| 222 | for _, session := range sessions { | 288 | for _, session := range sessions { | ||
| 223 | sessionRows := rowsForSession(session, config) | 289 | sessionRows := rowsForSession(session, config) | ||
| 290 | + | if len(sessionRows) == 0 { | |||
| 291 | + | continue | |||
| 292 | + | } | |||
| 293 | + | if session.Section != "" { | |||
| 294 | + | m.rows = append(m.rows, row{kind: rowSection, dispName: session.Section, dim: true}) | |||
| 295 | + | } | |||
| 224 | for _, pickerRow := range sessionRows { | 296 | for _, pickerRow := range sessionRows { | ||
| 225 | idx := len(m.rows) | 297 | idx := len(m.rows) | ||
| 226 | m.rows = append(m.rows, pickerRow) | 298 | m.rows = append(m.rows, pickerRow) | ||
| @@ -269,8 +341,8 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||||
| 269 | } | 341 | } | ||
| 270 | expectedCreatedAt, hasExpectedCreatedAt := int64Value(session.ExpectedCreatedAt) | 342 | expectedCreatedAt, hasExpectedCreatedAt := int64Value(session.ExpectedCreatedAt) | ||
| 271 | common := row{ | 343 | common := row{ | ||
| 272 | - | session: session.ID, incarnation: session.Incarnation, targetName: targetName, | 344 | + | session: session.ID, incarnation: session.Incarnation, targetName: targetName, sessionDisplay: session.Name, |
| 273 | - | stopped: stopped, expectedCreatedAt: expectedCreatedAt, | 345 | + | stopped: stopped, status: statusForSession(session, stopped), statusDetail: session.RemoteDetail, expectedCreatedAt: expectedCreatedAt, |
| 274 | hasExpectedCreatedAt: hasExpectedCreatedAt, | 346 | hasExpectedCreatedAt: hasExpectedCreatedAt, | ||
| 275 | } | 347 | } | ||
| 276 | if session.RemoteKey != nil { | 348 | if session.RemoteKey != nil { | ||
| @@ -285,21 +357,19 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||||
| 285 | header.kind, header.dispName, header.tabIndex = rowSession, session.Name, -1 | 357 | header.kind, header.dispName, header.tabIndex = rowSession, session.Name, -1 | ||
| 286 | if common.hasRemoteKey { | 358 | if common.hasRemoteKey { | ||
| 287 | header.dispName = common.remoteKey.Name | 359 | header.dispName = common.remoteKey.Name | ||
| 288 | - | display := common.remoteKey.Display() | 360 | + | if !session.HideRemoteOrigin { |
| 289 | - | header.detail = display[len(common.remoteKey.Name):] | 361 | + | display := common.remoteKey.Display() |
| 362 | + | header.detail = display[len(common.remoteKey.Name):] | |||
| 363 | + | } | |||
| 290 | } | 364 | } | ||
| 291 | header.selectable = header.kind.selectable(config.Mode) | 365 | header.selectable = header.kind.selectable(config.Mode) | ||
| 292 | header.focusable = header.selectable | 366 | header.focusable = header.selectable | ||
| 367 | + | if config.Mode == SelectNavigationTab && stopped && len(session.Tabs) == 0 { | |||
| 368 | + | header.selectable, header.focusable = true, true | |||
| 369 | + | } | |||
| 293 | if common.remote { | 370 | if common.remote { | ||
| 294 | - | if session.RemoteDetail != "" { | |||
| 295 | - | if header.detail != "" { | |||
| 296 | - | header.detail += " (" + session.RemoteDetail + ")" | |||
| 297 | - | } else { | |||
| 298 | - | header.detail = session.RemoteDetail | |||
| 299 | - | } | |||
| 300 | - | } | |||
| 301 | if common.hasRemoteTarget { | 371 | if common.hasRemoteTarget { | ||
| 302 | - | header.selectable = false | 372 | + | header.selectable = config.Mode == SelectNavigationTab && stopped && len(session.Tabs) == 0 && remoteActivatable(session) && common.remoteTarget.Validate() == nil |
| 303 | header.focusable = config.Mode == SelectNavigationTab && len(session.Tabs) == 0 | 373 | header.focusable = config.Mode == SelectNavigationTab && len(session.Tabs) == 0 | ||
| 304 | } else { | 374 | } else { | ||
| 305 | header.selectable = false | 375 | header.selectable = false | ||
| @@ -311,6 +381,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||||
| 311 | if config.Mode != SelectNavigationTab && session.CannotAcceptMoves { | 381 | if config.Mode != SelectNavigationTab && session.CannotAcceptMoves { | ||
| 312 | header.selectable, header.focusable, header.dim = false, false, true | 382 | header.selectable, header.focusable, header.dim = false, false, true | ||
| 313 | } | 383 | } | ||
| 384 | + | header.prepareSearch() | |||
| 314 | rows := []row{header} | 385 | rows := []row{header} | ||
| 315 | for i, tab := range session.Tabs { | 386 | for i, tab := range session.Tabs { | ||
| 316 | if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) { | 387 | if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) { | ||
| @@ -355,6 +426,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||||
| 355 | if config.Mode != SelectNavigationTab && session.CannotAcceptMoves { | 426 | if config.Mode != SelectNavigationTab && session.CannotAcceptMoves { | ||
| 356 | tabRow.selectable, tabRow.focusable, tabRow.dim = false, false, true | 427 | tabRow.selectable, tabRow.focusable, tabRow.dim = false, false, true | ||
| 357 | } | 428 | } | ||
| 429 | + | tabRow.prepareSearch() | |||
| 358 | rows = append(rows, tabRow) | 430 | rows = append(rows, tabRow) | ||
| 359 | } | 431 | } | ||
| 360 | if config.Mode == SelectMovePaneTab && len(rows) == 1 && !session.CannotAcceptMoves { | 432 | if config.Mode == SelectMovePaneTab && len(rows) == 1 && !session.CannotAcceptMoves { | ||
| @@ -363,6 +435,13 @@ func rowsForSession(session SessionView, config SelectionConfig) []row { | |||||
| 363 | return rows | 435 | return rows | ||
| 364 | } | 436 | } | ||
| 365 | 437 | ||||
| 438 | + | func (r *row) prepareSearch() { | |||
| 439 | + | r.foldedName = strings.ToLower(r.dispName) | |||
| 440 | + | r.foldedDetail = strings.ToLower(r.detail) | |||
| 441 | + | r.foldedSession = strings.ToLower(r.sessionDisplay) | |||
| 442 | + | r.foldedHost = strings.ToLower(r.remoteHost) | |||
| 443 | + | } | |||
| 444 | + | ||||
| 366 | func remoteStoppedOrdinalSelector(index int, rawName string, tabCount int) (domain.TabSelector, bool) { | 445 | func remoteStoppedOrdinalSelector(index int, rawName string, tabCount int) (domain.TabSelector, bool) { | ||
| 367 | if tabCount > math.MaxUint16 || index < 0 || index >= tabCount { | 446 | if tabCount > math.MaxUint16 || index < 0 || index >= tabCount { | ||
| 368 | return domain.TabSelector{}, false | 447 | return domain.TabSelector{}, false | ||
| @@ -370,6 +449,32 @@ func remoteStoppedOrdinalSelector(index int, rawName string, tabCount int) (doma | |||||
| 370 | return domain.NewOrdinalTabSelector(uint16(index), rawName, uint16(tabCount)), true | 449 | return domain.NewOrdinalTabSelector(uint16(index), rawName, uint16(tabCount)), true | ||
| 371 | } | 450 | } | ||
| 372 | 451 | ||||
| 452 | + | func statusForSession(session SessionView, stopped bool) rowStatus { | |||
| 453 | + | remote := session.RemoteKey != nil || session.RemoteTarget != nil || session.RemoteHost != "" | |||
| 454 | + | if remote { | |||
| 455 | + | switch session.RemoteReason { | |||
| 456 | + | case "host_unreachable": | |||
| 457 | + | return rowStatusDown | |||
| 458 | + | case "version_mismatch": | |||
| 459 | + | return rowStatusVersion | |||
| 460 | + | case "refreshing", "catalog_stale": | |||
| 461 | + | return rowStatusStale | |||
| 462 | + | case "malformed", "session_broken", "identity_changed": | |||
| 463 | + | return rowStatusError | |||
| 464 | + | } | |||
| 465 | + | } | |||
| 466 | + | if stopped { | |||
| 467 | + | return rowStatusStopped | |||
| 468 | + | } | |||
| 469 | + | if !remote { | |||
| 470 | + | return rowStatusNone | |||
| 471 | + | } | |||
| 472 | + | if session.RemoteActivation == RemoteAttach { | |||
| 473 | + | return rowStatusUp | |||
| 474 | + | } | |||
| 475 | + | return rowStatusError | |||
| 476 | + | } | |||
| 477 | + | ||||
| 373 | func remoteActivatable(session SessionView) bool { | 478 | func remoteActivatable(session SessionView) bool { | ||
| 374 | return session.RemoteActivation == RemoteAttach || session.RemoteActivation == RemoteRestart | 479 | return session.RemoteActivation == RemoteAttach || session.RemoteActivation == RemoteRestart | ||
| 375 | } | 480 | } | ||
| @@ -397,21 +502,19 @@ func selectionMatches(pickerRow row, current SourceFilter, mode SelectionMode) b | |||||
| 397 | return false | 502 | return false | ||
| 398 | } | 503 | } | ||
| 399 | if pickerRow.hasRemoteKey && pickerRow.kind == rowSession { | 504 | if pickerRow.hasRemoteKey && pickerRow.kind == rowSession { | ||
| 400 | - | return current.RemoteKey == nil || pickerRow.remoteKey == *current.RemoteKey | 505 | + | return pickerRow.focusable && (current.RemoteKey == nil || pickerRow.remoteKey == *current.RemoteKey) |
| 401 | } | 506 | } | ||
| 402 | if mode == SelectMoveTabSession { | 507 | if mode == SelectMoveTabSession { | ||
| 403 | return pickerRow.kind == rowSession | 508 | return pickerRow.kind == rowSession | ||
| 404 | } | 509 | } | ||
| 510 | + | // A stopped session with no retained tab metadata uses its selectable | |||
| 511 | + | // header as the default restart target and matches on exact session identity. | |||
| 512 | + | if current.TabID == "" && pickerRow.stopped { | |||
| 513 | + | return pickerRow.kind == rowSession && pickerRow.selectable | |||
| 514 | + | } | |||
| 405 | if pickerRow.kind != rowTab { | 515 | if pickerRow.kind != rowTab { | ||
| 406 | return false | 516 | return false | ||
| 407 | } | 517 | } | ||
| 408 | - | // A stopped session contributes one synthetic tab row carrying no stable tab | |||
| 409 | - | // identity, so it matches on session identity alone. Its session ID is | |||
| 410 | - | // namespaced ("stopped:<name>"), and move modes drop stopped sessions | |||
| 411 | - | // entirely, so no live row can be reached this way. | |||
| 412 | - | if current.TabID == "" { | |||
| 413 | - | return pickerRow.stopped | |||
| 414 | - | } | |||
| 415 | return pickerRow.tabID == current.TabID | 518 | return pickerRow.tabID == current.TabID | ||
| 416 | } | 519 | } | ||
| 417 | 520 | ||||
| @@ -422,14 +525,30 @@ func int64Value(value *int64) (int64, bool) { | |||||
| 422 | return *value, true | 525 | return *value, true | ||
| 423 | } | 526 | } | ||
| 424 | 527 | ||||
| 528 | + | // ChooseGeometry reserves the final inner row for picker-local status before | |||
| 529 | + | // solving the list and preview layout. | |||
| 530 | + | func ChooseGeometry(inner domain.Size) Geometry { | |||
| 531 | + | if inner.Cols <= 0 || inner.Rows <= 0 { | |||
| 532 | + | return Geometry{} | |||
| 533 | + | } | |||
| 534 | + | contentRows := max(inner.Rows-1, 0) | |||
| 535 | + | layout := ChooseLayout(domain.Size{Cols: inner.Cols, Rows: contentRows}) | |||
| 536 | + | return Geometry{ | |||
| 537 | + | Content: domain.Rect{Width: inner.Cols, Height: contentRows}, | |||
| 538 | + | Status: domain.Rect{Y: contentRows, Width: inner.Cols, Height: 1}, | |||
| 539 | + | Mode: layout.Mode, List: layout.List, Separator: layout.Separator, Preview: layout.Preview, | |||
| 540 | + | } | |||
| 541 | + | } | |||
| 542 | + | ||||
| 425 | func ChooseLayout(inner domain.Size) Layout { | 543 | func ChooseLayout(inner domain.Size) Layout { | ||
| 426 | if inner.Cols <= 0 || inner.Rows <= 0 { | 544 | if inner.Cols <= 0 || inner.Rows <= 0 { | ||
| 427 | return Layout{} | 545 | return Layout{} | ||
| 428 | } | 546 | } | ||
| 429 | if inner.Rows >= MinPaneHeight { | 547 | if inner.Rows >= MinPaneHeight { | ||
| 430 | - | listWidth := clamp(inner.Cols*30/100, MinListWidth, MaxListWidth) | 548 | + | listWidth := clamp(inner.Cols*40/100, MinListWidth, MaxListWidth) |
| 549 | + | listWidth = min(listWidth, inner.Cols-MinHorizontalPreviewWidth-1) | |||
| 431 | previewWidth := inner.Cols - listWidth - 1 | 550 | previewWidth := inner.Cols - listWidth - 1 | ||
| 432 | - | if previewWidth >= MinHorizontalPreviewWidth { | 551 | + | if listWidth >= MinHorizontalListWidth && previewWidth >= MinHorizontalPreviewWidth { |
| 433 | return Layout{ | 552 | return Layout{ | ||
| 434 | Mode: LayoutHorizontal, | 553 | Mode: LayoutHorizontal, | ||
| 435 | List: domain.Rect{Width: listWidth, Height: inner.Rows}, | 554 | List: domain.Rect{Width: listWidth, Height: inner.Rows}, | ||
| @@ -452,10 +571,18 @@ func ChooseLayout(inner domain.Size) Layout { | |||||
| 452 | } | 571 | } | ||
| 453 | 572 | ||||
| 454 | func (m *Model) Up() { | 573 | func (m *Model) Up() { | ||
| 574 | + | if m != nil && m.searchActive { | |||
| 575 | + | m.moveSearch(-1) | |||
| 576 | + | return | |||
| 577 | + | } | |||
| 455 | m.move(-1) | 578 | m.move(-1) | ||
| 456 | } | 579 | } | ||
| 457 | 580 | ||||
| 458 | func (m *Model) Down() { | 581 | func (m *Model) Down() { | ||
| 582 | + | if m != nil && m.searchActive { | |||
| 583 | + | m.moveSearch(1) | |||
| 584 | + | return | |||
| 585 | + | } | |||
| 459 | m.move(1) | 586 | m.move(1) | ||
| 460 | } | 587 | } | ||
| 461 | 588 | ||||
| @@ -474,7 +601,7 @@ func (m *Model) Selected() (Target, bool) { | |||||
| 474 | return Target{}, false | 601 | return Target{}, false | ||
| 475 | } | 602 | } | ||
| 476 | r := m.rows[m.selected] | 603 | r := m.rows[m.selected] | ||
| 477 | - | if !r.selectable { | 604 | + | if !r.selectable || m.searchActive && m.query.Value() != "" && !m.rowMatches(m.selected) { |
| 478 | return Target{}, false | 605 | return Target{}, false | ||
| 479 | } | 606 | } | ||
| 480 | return r.target(), true | 607 | return r.target(), true | ||
| @@ -519,25 +646,31 @@ func (m *Model) Clone() *Model { | |||||
| 519 | } | 646 | } | ||
| 520 | clone := *m | 647 | clone := *m | ||
| 521 | clone.rows = append([]row(nil), m.rows...) | 648 | clone.rows = append([]row(nil), m.rows...) | ||
| 649 | + | clone.query.SetValue(m.query.Value()) | |||
| 650 | + | // Search results are copy-on-write: every query mutation publishes a new | |||
| 651 | + | // map and row slice, so render snapshots can safely share the old values. | |||
| 652 | + | clone.matchRows = m.matchRows | |||
| 653 | + | clone.searchMatches = m.searchMatches | |||
| 522 | return &clone | 654 | return &clone | ||
| 523 | } | 655 | } | ||
| 524 | 656 | ||||
| 525 | func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyles) renderer.Frame { | 657 | func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyles) renderer.Frame { | ||
| 526 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | 658 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | ||
| 527 | - | layout := ChooseLayout(inner) | 659 | + | geometry := ChooseGeometry(inner) |
| 528 | styleSet := defaultRenderStyles() | 660 | styleSet := defaultRenderStyles() | ||
| 529 | if len(styles) > 0 { | 661 | if len(styles) > 0 { | ||
| 530 | styleSet = styles[0] | 662 | styleSet = styles[0] | ||
| 531 | } | 663 | } | ||
| 532 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styleSet.Background}) | 664 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styleSet.Background}) | ||
| 533 | - | m.renderList(frame, layout.List, styleSet) | 665 | + | m.renderList(frame, geometry.List, styleSet) |
| 534 | - | switch layout.Mode { | 666 | + | switch geometry.Mode { |
| 535 | case LayoutHorizontal: | 667 | case LayoutHorizontal: | ||
| 536 | - | ui.DrawSeparator(frame, layout.Separator, ui.SeparatorVertical, styleSet.Separator) | 668 | + | ui.DrawSeparator(frame, geometry.Separator, ui.SeparatorVertical, styleSet.Separator) |
| 537 | case LayoutStacked: | 669 | case LayoutStacked: | ||
| 538 | - | ui.DrawSeparator(frame, layout.Separator, ui.SeparatorHorizontal, styleSet.Separator) | 670 | + | ui.DrawSeparator(frame, geometry.Separator, ui.SeparatorHorizontal, styleSet.Separator) |
| 539 | } | 671 | } | ||
| 540 | - | ui.BlitFrame(frame, layout.Preview, preview, ui.VerticalAnchorBottom) | 672 | + | ui.BlitFrame(frame, geometry.Preview, preview, ui.VerticalAnchorBottom) |
| 673 | + | m.renderStatus(frame, geometry.Status, styleSet.Status) | |||
| 541 | return frame | 674 | return frame | ||
| 542 | } | 675 | } | ||
| 543 | 676 | ||||
| @@ -613,13 +746,16 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||||
| 613 | } | 746 | } | ||
| 614 | r := m.rows[idx] | 747 | r := m.rows[idx] | ||
| 615 | base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail | 748 | base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail | ||
| 749 | + | if r.kind == rowSection { | |||
| 750 | + | nameStyle = styles.Detail | |||
| 751 | + | } | |||
| 616 | if idx == m.selected { | 752 | if idx == m.selected { | ||
| 617 | base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted | 753 | base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted | ||
| 618 | } | 754 | } | ||
| 619 | if r.stopped && idx != m.selected { | 755 | if r.stopped && idx != m.selected { | ||
| 620 | base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped | 756 | base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped | ||
| 621 | } | 757 | } | ||
| 622 | - | if r.dim { | 758 | + | if r.dim || m.searchActive && m.query.Value() != "" && !m.rowMatches(idx) { |
| 623 | base.Attrs |= renderer.AttrDim | 759 | base.Attrs |= renderer.AttrDim | ||
| 624 | nameStyle.Attrs |= renderer.AttrDim | 760 | nameStyle.Attrs |= renderer.AttrDim | ||
| 625 | detailStyle.Attrs |= renderer.AttrDim | 761 | detailStyle.Attrs |= renderer.AttrDim | ||
| @@ -627,29 +763,43 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||||
| 627 | ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base}) | 763 | ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base}) | ||
| 628 | 764 | ||||
| 629 | name := r.dispName | 765 | name := r.dispName | ||
| 630 | - | if !r.kind.rendersAsHeader() { | 766 | + | if r.kind == rowTab { |
| 631 | name = " " + name | 767 | name = " " + name | ||
| 632 | } | 768 | } | ||
| 633 | - | suffix := "" | 769 | + | badge := "" |
| 634 | - | if r.kind.rendersAsHeader() && r.stopped { | 770 | + | if r.kind.rendersAsHeader() { |
| 635 | - | suffix = "(down)" | 771 | + | badge = r.status.badge() |
| 772 | + | } | |||
| 773 | + | contentClipX := clipX | |||
| 774 | + | nameWidth := rect.Width | |||
| 775 | + | badgeWidth := textCellWidth(badge) | |||
| 776 | + | if badge != "" { | |||
| 777 | + | nameWidth = max(rect.Width-badgeWidth-1, 0) | |||
| 778 | + | contentClipX = max(rect.X, clipX-badgeWidth-1) | |||
| 779 | + | } | |||
| 780 | + | originalName := name | |||
| 781 | + | name = ui.TruncateText(name, nameWidth) | |||
| 782 | + | nameMatchStyle := styles.SearchMatch | |||
| 783 | + | if idx == m.selected { | |||
| 784 | + | nameMatchStyle = styles.SelectionMatch | |||
| 785 | + | } | |||
| 786 | + | namePositions := m.matchPositions(idx, matchName) | |||
| 787 | + | if r.kind == rowTab { | |||
| 788 | + | namePositions = shiftPositions(namePositions, 2) | |||
| 636 | } | 789 | } | ||
| 637 | - | name = ui.TruncateText(name, max(rect.Width-len(suffix), 0)) | 790 | + | namePositions = visibleMatchPositions(namePositions, name, name != originalName) |
| 638 | - | x := ui.DrawText(frame, rect.X, rect.Y+y, clipX, name, nameStyle) | 791 | + | x := drawMatchedText(frame, rect.X, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions) |
| 639 | 792 | ||||
| 793 | + | if r.kind == rowSection { | |||
| 794 | + | continue | |||
| 795 | + | } | |||
| 640 | if r.kind.rendersAsHeader() { | 796 | if r.kind.rendersAsHeader() { | ||
| 641 | - | if r.stopped { | 797 | + | detail := ui.TruncateText(r.detail, contentClipX-x) |
| 642 | - | detail := r.detail | 798 | + | detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.detail) |
| 643 | - | if detail == "" { | 799 | + | drawMatchedText(frame, x, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions) |
| 644 | - | detail = " " | 800 | + | if badge != "" { |
| 645 | - | } else { | 801 | + | badgeX := max(rect.X, clipX-badgeWidth) |
| 646 | - | detail += " " | 802 | + | ui.DrawText(frame, badgeX, rect.Y+y, clipX, badge, base) |
| 647 | - | } | |||
| 648 | - | detail = ui.TruncateText(detail, max(clipX-x-len(suffix), 0)) | |||
| 649 | - | x = ui.DrawText(frame, x, rect.Y+y, clipX, detail, base) | |||
| 650 | - | ui.DrawText(frame, x, rect.Y+y, clipX, suffix, base) | |||
| 651 | - | } else { | |||
| 652 | - | ui.DrawText(frame, x, rect.Y+y, clipX, ui.TruncateText(r.detail, clipX-x), detailStyle) | |||
| 653 | } | 803 | } | ||
| 654 | continue | 804 | continue | ||
| 655 | } | 805 | } | ||
| @@ -659,8 +809,108 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||||
| 659 | } | 809 | } | ||
| 660 | 810 | ||||
| 661 | detail := ui.TruncateText(r.detail, clipX-x) | 811 | detail := ui.TruncateText(r.detail, clipX-x) | ||
| 662 | - | ui.DrawText(frame, x, rect.Y+y, clipX, detail, detailStyle) | 812 | + | detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.detail) |
| 813 | + | drawMatchedText(frame, x, rect.Y+y, clipX, detail, detailStyle, nameMatchStyle, detailPositions) | |||
| 814 | + | } | |||
| 815 | + | } | |||
| 816 | + | ||||
| 817 | + | func (m *Model) matchPositions(idx int, field matchField) []int { | |||
| 818 | + | if m == nil || !m.searchActive || m.query.Value() == "" { | |||
| 819 | + | return nil | |||
| 820 | + | } | |||
| 821 | + | return m.searchMatches[idx].positions(field) | |||
| 822 | + | } | |||
| 823 | + | ||||
| 824 | + | func visibleMatchPositions(positions []int, rendered string, truncated bool) []int { | |||
| 825 | + | if !truncated || len(positions) == 0 { | |||
| 826 | + | return positions | |||
| 827 | + | } | |||
| 828 | + | limit := max(len([]rune(rendered))-1, 0) // the final rune is the ellipsis | |||
| 829 | + | visible := make([]int, 0, len(positions)) | |||
| 830 | + | for _, position := range positions { | |||
| 831 | + | if position < limit { | |||
| 832 | + | visible = append(visible, position) | |||
| 833 | + | } | |||
| 834 | + | } | |||
| 835 | + | return visible | |||
| 836 | + | } | |||
| 837 | + | ||||
| 838 | + | func shiftPositions(positions []int, delta int) []int { | |||
| 839 | + | if len(positions) == 0 || delta == 0 { | |||
| 840 | + | return positions | |||
| 841 | + | } | |||
| 842 | + | shifted := make([]int, len(positions)) | |||
| 843 | + | for i, position := range positions { | |||
| 844 | + | shifted[i] = position + delta | |||
| 845 | + | } | |||
| 846 | + | return shifted | |||
| 847 | + | } | |||
| 848 | + | ||||
| 849 | + | func drawMatchedText(frame renderer.Frame, x, y, clipX int, text string, base, match renderer.Style, positions []int) int { | |||
| 850 | + | if len(positions) == 0 { | |||
| 851 | + | return ui.DrawText(frame, x, y, clipX, text, base) | |||
| 852 | + | } | |||
| 853 | + | positionIndex := 0 | |||
| 854 | + | runeIndex := 0 | |||
| 855 | + | for _, r := range text { | |||
| 856 | + | style := base | |||
| 857 | + | if positionIndex < len(positions) && positions[positionIndex] == runeIndex { | |||
| 858 | + | style = match | |||
| 859 | + | positionIndex++ | |||
| 860 | + | } | |||
| 861 | + | x = ui.DrawText(frame, x, y, clipX, string(r), style) | |||
| 862 | + | if x >= clipX { | |||
| 863 | + | break | |||
| 864 | + | } | |||
| 865 | + | runeIndex++ | |||
| 866 | + | } | |||
| 867 | + | return x | |||
| 868 | + | } | |||
| 869 | + | ||||
| 870 | + | func (m *Model) renderStatus(frame renderer.Frame, rect domain.Rect, style renderer.Style) { | |||
| 871 | + | if rect.Width <= 0 || rect.Height <= 0 || rect.Y < 0 || rect.Y >= frame.Height { | |||
| 872 | + | return | |||
| 873 | + | } | |||
| 874 | + | ui.FillRect(frame, rect, renderer.Cell{Rune: ' ', Style: style}) | |||
| 875 | + | action := "open" | |||
| 876 | + | deletable := false | |||
| 877 | + | if m != nil && m.selected >= 0 && m.selected < len(m.rows) { | |||
| 878 | + | selected := m.rows[m.selected] | |||
| 879 | + | if !selected.selectable { | |||
| 880 | + | action = "unavailable" | |||
| 881 | + | } else if selected.stopped { | |||
| 882 | + | action = "restart" | |||
| 883 | + | } | |||
| 884 | + | deletable = selected.selectable && !selected.remote | |||
| 885 | + | } | |||
| 886 | + | var groups []string | |||
| 887 | + | if m != nil && m.searchActive { | |||
| 888 | + | groups = []string{fmt.Sprintf("%d matches", len(m.matchRows)), "Enter " + action, "arrows next"} | |||
| 889 | + | escape := "Esc exit" | |||
| 890 | + | if m.query.Value() != "" { | |||
| 891 | + | escape = "Esc clear" | |||
| 892 | + | } | |||
| 893 | + | groups = append(groups, escape) | |||
| 894 | + | } else { | |||
| 895 | + | if m != nil && m.selected >= 0 && m.selected < len(m.rows) && m.rows[m.selected].dim && m.rows[m.selected].statusDetail != "" { | |||
| 896 | + | groups = append(groups, m.rows[m.selected].statusDetail) | |||
| 897 | + | } | |||
| 898 | + | if rect.Width < 60 { | |||
| 899 | + | groups = append(groups, "Enter "+action, "/", "Esc", "j/k") | |||
| 900 | + | if deletable { | |||
| 901 | + | groups = append(groups, "x") | |||
| 902 | + | } | |||
| 903 | + | groups = append(groups, "s") | |||
| 904 | + | } else { | |||
| 905 | + | groups = append(groups, "j/k move", "Enter "+action) | |||
| 906 | + | if deletable { | |||
| 907 | + | groups = append(groups, "x delete") | |||
| 908 | + | } | |||
| 909 | + | groups = append(groups, "/ search", "s sort", "Esc close") | |||
| 910 | + | } | |||
| 663 | } | 911 | } | ||
| 912 | + | text := strings.Join(groups, " ") | |||
| 913 | + | ui.DrawText(frame, rect.X, rect.Y, rect.X+rect.Width, ui.TruncateText(text, rect.Width), style) | |||
| 664 | } | 914 | } | ||
| 665 | 915 | ||||
| 666 | func (m *Model) scrollOffset(visible int) int { | 916 | func (m *Model) scrollOffset(visible int) int { | ||
M internal/usecase/picker/model_test.go +238 -28
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package picker | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "math" | |
| 5 | 6 | "reflect" | |
| 6 | 7 | "strings" | |
| @@ -335,8 +336,8 @@ func TestRenderDrawsCustomOrientedSeparators(t *testing.T) { | |||
| 335 | 336 | rect domain.Rect | |
| 336 | 337 | rune rune | |
| 337 | 338 | }{ | |
| 338 | - | {"horizontal layout", domain.Size{Cols: 69, Rows: 4}, domain.Rect{X: 20, Width: 1, Height: 4}, '│'}, | |
| 339 | - | {"stacked layout", domain.Size{Cols: 24, Rows: 12}, domain.Rect{Y: 4, Width: 24, Height: 1}, '─'}, | |
| 339 | + | {"horizontal layout", domain.Size{Cols: 69, Rows: 5}, domain.Rect{X: 20, Width: 1, Height: 4}, '│'}, | |
| 340 | + | {"stacked layout", domain.Size{Cols: 24, Rows: 13}, domain.Rect{Y: 4, Width: 24, Height: 1}, '─'}, | |
| 340 | 341 | } | |
| 341 | 342 | for _, tt := range tests { | |
| 342 | 343 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -356,26 +357,28 @@ func TestRenderListOnlyOmitsSeparatorAndPreview(t *testing.T) { | |||
| 356 | 357 | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 357 | 358 | frame := m.Render(domain.Size{Cols: 23, Rows: 11}, Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}, RenderStyles{Separator: renderer.Style{Foreground: 8}}) | |
| 358 | 359 | ||
| 359 | - | require.Equal(t, ' ', frame.At(22, 10).Rune) | |
| 360 | 360 | require.NotEqual(t, 'x', frame.At(22, 10).Rune) | |
| 361 | + | require.Equal(t, 'E', frame.At(0, 10).Rune, "the final inner row is reserved for status") | |
| 361 | 362 | } | |
| 362 | 363 | ||
| 363 | 364 | func TestRenderPreviewAnchorsOversizedSourceToFinalRows(t *testing.T) { | |
| 364 | 365 | m := New(nil, SelectionConfig{Mode: SelectNavigationTab}) | |
| 365 | 366 | preview := Preview{Width: 24, Height: 9, Rows: previewRows(24, "abcdefghi")} | |
| 366 | 367 | ||
| 367 | - | frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview) | |
| 368 | + | frame := m.Render(domain.Size{Cols: 24, Rows: 13}, preview) | |
| 368 | 369 | require.Equal(t, 'c', frame.At(0, 5).Rune) | |
| 369 | 370 | require.Equal(t, 'i', frame.At(0, 11).Rune) | |
| 371 | + | require.Equal(t, 'E', frame.At(0, 12).Rune) | |
| 370 | 372 | } | |
| 371 | 373 | ||
| 372 | 374 | func TestRenderPreviewBottomPlacesShortSource(t *testing.T) { | |
| 373 | 375 | m := New(nil, SelectionConfig{Mode: SelectNavigationTab}) | |
| 374 | 376 | preview := Preview{Width: 24, Height: 1, Rows: previewRows(24, "z")} | |
| 375 | 377 | ||
| 376 | - | frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview) | |
| 378 | + | frame := m.Render(domain.Size{Cols: 24, Rows: 13}, preview) | |
| 377 | 379 | require.Equal(t, ' ', frame.At(0, 5).Rune) | |
| 378 | 380 | require.Equal(t, 'z', frame.At(0, 11).Rune) | |
| 381 | + | require.Equal(t, 'E', frame.At(0, 12).Rune) | |
| 379 | 382 | } | |
| 380 | 383 | ||
| 381 | 384 | func TestRenderAttentionMarkerSmokeWithResponsiveLayout(t *testing.T) { | |
| @@ -492,35 +495,64 @@ func TestSelectedIndexReportsRawSelectedRow(t *testing.T) { | |||
| 492 | 495 | require.Equal(t, 1, m.SelectedIndex()) | |
| 493 | 496 | } | |
| 494 | 497 | ||
| 495 | - | func TestStoppedSessionRendersDownSuffix(t *testing.T) { | |
| 498 | + | func TestStoppedSessionUsesOneSelectableHeaderWithRightAlignedStatus(t *testing.T) { | |
| 496 | 499 | for _, tt := range []struct { | |
| 497 | 500 | name string | |
| 498 | 501 | sessionID domain.SessionID | |
| 499 | 502 | sessionName string | |
| 500 | 503 | size domain.Size | |
| 504 | + | wantRow string | |
| 501 | 505 | }{ | |
| 502 | - | {name: "normal", sessionID: "stopped:work", sessionName: "work", size: domain.Size{Cols: 24, Rows: 4}}, | |
| 503 | - | {name: "constrained", sessionID: "stopped:long", sessionName: "long-session", size: domain.Size{Cols: 8, Rows: 2}}, | |
| 506 | + | {name: "normal", sessionID: "stopped:work", sessionName: "work", size: domain.Size{Cols: 24, Rows: 4}, wantRow: "work [stopped]"}, | |
| 507 | + | {name: "constrained", sessionID: "stopped:long", sessionName: "long-session", size: domain.Size{Cols: 12, Rows: 2}, wantRow: "l… [stopped]"}, | |
| 504 | 508 | } { | |
| 505 | 509 | t.Run(tt.name, func(t *testing.T) { | |
| 506 | - | m := New([]SessionView{{ID: tt.sessionID, Name: tt.sessionName, Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 510 | + | m := New([]SessionView{{ID: tt.sessionID, Name: tt.sessionName, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 507 | 511 | got, ok := m.Selected() | |
| 508 | 512 | require.True(t, ok) | |
| 509 | - | require.Equal(t, Target{Session: tt.sessionID, Name: tt.sessionName, TabIndex: 0, Stopped: true}, got) | |
| 513 | + | require.Equal(t, Target{Session: tt.sessionID, Name: tt.sessionName, TabIndex: -1, Stopped: true}, got) | |
| 514 | + | require.Len(t, m.rows, 1) | |
| 510 | 515 | ||
| 511 | 516 | frame := m.Render(tt.size, Preview{}) | |
| 512 | 517 | var label strings.Builder | |
| 513 | 518 | for _, cell := range frame.Row(0) { | |
| 514 | 519 | label.WriteRune(cell.Rune) | |
| 515 | 520 | } | |
| 516 | - | require.Contains(t, label.String(), "(down)") | |
| 521 | + | require.Equal(t, tt.wantRow, strings.TrimRight(label.String(), " ")) | |
| 517 | 522 | }) | |
| 518 | 523 | } | |
| 519 | 524 | } | |
| 520 | 525 | ||
| 526 | + | func TestRemoteStatusBadgesAlignRightAndStatusOmitsDelete(t *testing.T) { | |
| 527 | + | lifecycle := domain.SessionLifecycleID{1} | |
| 528 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |
| 529 | + | target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab"} | |
| 530 | + | m := New([]SessionView{{ | |
| 531 | + | ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteTarget: &target, RemoteHost: "arch", | |
| 532 | + | RemoteActivation: RemoteAttach, Tabs: []TabEntry{{TabID: "tab", Name: "shell"}}, | |
| 533 | + | }}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 534 | + | ||
| 535 | + | frame := m.Render(domain.Size{Cols: 24, Rows: 4}, Preview{}) | |
| 536 | + | require.Equal(t, "work@arch [up]", strings.TrimRight(rowText(frame.Row(0)), " ")) | |
| 537 | + | status := rowText(frame.Row(3)) | |
| 538 | + | require.Contains(t, status, "Enter open") | |
| 539 | + | require.NotContains(t, status, "x delete") | |
| 540 | + | } | |
| 541 | + | ||
| 542 | + | func rowText(row []renderer.Cell) string { | |
| 543 | + | var text strings.Builder | |
| 544 | + | for _, cell := range row { | |
| 545 | + | if cell.Continuation { | |
| 546 | + | continue | |
| 547 | + | } | |
| 548 | + | text.WriteRune(cell.Rune) | |
| 549 | + | } | |
| 550 | + | return text.String() | |
| 551 | + | } | |
| 552 | + | ||
| 521 | 553 | func TestRenderStopsStoppedRowsDimItalic(t *testing.T) { | |
| 522 | 554 | live := SessionView{ID: "live", Name: "work", Tabs: []TabEntry{{TabID: "t1", Name: "tab"}}} | |
| 523 | - | halted := SessionView{ID: "stopped:old", Name: "old", TargetName: "old", Stopped: true, Tabs: []TabEntry{{}}} | |
| 555 | + | halted := SessionView{ID: "stopped:old", Name: "old", TargetName: "old", Stopped: true} | |
| 524 | 556 | m := New([]SessionView{live, halted}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 525 | 557 | ||
| 526 | 558 | stoppedStyle := renderer.Style{Foreground: -1, Background: -1, Italic: true, Attrs: renderer.AttrDim} | |
| @@ -528,15 +560,14 @@ func TestRenderStopsStoppedRowsDimItalic(t *testing.T) { | |||
| 528 | 560 | require.Equal(t, stoppedStyle, styles.Stopped) | |
| 529 | 561 | ||
| 530 | 562 | frame := m.Render(domain.Size{Cols: 15, Rows: 6}, Preview{}) | |
| 531 | - | // Rows: 0 "work" header, 1 " tab" (selected), 2 "old (down)" header, 3 its tab row. | |
| 563 | + | // Rows: 0 "work" header, 1 " tab" (selected), 2 "old [stopped]" header. | |
| 532 | 564 | require.Equal(t, stoppedStyle, frame.Row(2)[0].Style, "stopped header must be dim italic") | |
| 533 | - | require.Equal(t, stoppedStyle, frame.Row(3)[0].Style, "stopped tab row must be dim italic") | |
| 534 | 565 | require.NotEqual(t, stoppedStyle, frame.Row(0)[0].Style, "live header keeps base style") | |
| 535 | 566 | ||
| 536 | 567 | selected := New([]SessionView{live, halted}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: halted.ID}}) | |
| 537 | 568 | selectedFrame := selected.Render(domain.Size{Cols: 15, Rows: 6}, Preview{}) | |
| 538 | - | require.NotEqual(t, stoppedStyle, selectedFrame.Row(3)[0].Style, "selected stopped row keeps selection style, not Stopped") | |
| 539 | - | require.True(t, selectedFrame.Row(3)[0].Style.Inverse, "selected stopped row still shows selection") | |
| 569 | + | require.NotEqual(t, stoppedStyle, selectedFrame.Row(2)[0].Style, "selected stopped row keeps selection style, not Stopped") | |
| 570 | + | require.True(t, selectedFrame.Row(2)[0].Style.Inverse, "selected stopped row still shows selection") | |
| 540 | 571 | } | |
| 541 | 572 | ||
| 542 | 573 | func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | |
| @@ -551,12 +582,13 @@ func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | |||
| 551 | 582 | }}, | |
| 552 | 583 | } | |
| 553 | 584 | ||
| 554 | - | frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview) | |
| 585 | + | frame := m.Render(domain.Size{Cols: 24, Rows: 13}, preview) | |
| 555 | 586 | require.True(t, frame.At(0, 1).Style.Inverse) | |
| 556 | 587 | require.Equal(t, 'a', frame.At(0, 11).Rune) | |
| 557 | 588 | require.Equal(t, 'w', frame.At(22, 11).Rune) | |
| 558 | 589 | require.Equal(t, ' ', frame.At(23, 11).Rune, "wide rune crossing preview pane is dropped") | |
| 559 | 590 | require.Equal(t, ' ', frame.At(0, 5).Rune, "short preview is bottom anchored") | |
| 591 | + | require.Equal(t, 'E', frame.At(0, 12).Rune) | |
| 560 | 592 | } | |
| 561 | 593 | ||
| 562 | 594 | func TestRenderListScrollsSelectionIntoView(t *testing.T) { | |
| @@ -566,9 +598,10 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) { | |||
| 566 | 598 | } | |
| 567 | 599 | ||
| 568 | 600 | frame := m.Render(domain.Size{Cols: 23, Rows: 4}, Preview{}) | |
| 569 | - | require.Equal(t, ' ', frame.At(0, 3).Rune) | |
| 570 | - | require.Equal(t, 'f', frame.At(2, 3).Rune) | |
| 571 | - | require.True(t, frame.At(0, 3).Style.Inverse) | |
| 601 | + | require.Equal(t, ' ', frame.At(0, 2).Rune) | |
| 602 | + | require.Equal(t, 'f', frame.At(2, 2).Rune) | |
| 603 | + | require.True(t, frame.At(0, 2).Style.Inverse) | |
| 604 | + | require.Equal(t, 'E', frame.At(0, 3).Rune) | |
| 572 | 605 | } | |
| 573 | 606 | ||
| 574 | 607 | func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) { | |
| @@ -576,7 +609,7 @@ func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) { | |||
| 576 | 609 | ||
| 577 | 610 | frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{}) | |
| 578 | 611 | ||
| 579 | - | layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5}) | |
| 612 | + | layout := ChooseGeometry(domain.Size{Cols: 69, Rows: 5}) | |
| 580 | 613 | require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column") | |
| 581 | 614 | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "truncated label should end with an ellipsis at the list edge") | |
| 582 | 615 | require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list") | |
| @@ -640,7 +673,7 @@ func TestRenderListTruncatesDetailBeforeName(t *testing.T) { | |||
| 640 | 673 | }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 641 | 674 | ||
| 642 | 675 | frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{}) | |
| 643 | - | layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5}) | |
| 676 | + | layout := ChooseGeometry(domain.Size{Cols: 69, Rows: 5}) | |
| 644 | 677 | require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column") | |
| 645 | 678 | ||
| 646 | 679 | want := " short-name (a ver…" | |
| @@ -657,13 +690,189 @@ func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) { | |||
| 657 | 690 | }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 658 | 691 | ||
| 659 | 692 | frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{}) | |
| 660 | - | layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5}) | |
| 693 | + | layout := ChooseGeometry(domain.Size{Cols: 69, Rows: 5}) | |
| 661 | 694 | require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column") | |
| 662 | 695 | ||
| 663 | 696 | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "the name segment itself is ellipsized once it alone exceeds the width") | |
| 664 | 697 | require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list") | |
| 665 | 698 | } | |
| 666 | 699 | ||
| 700 | + | func TestSearchSelectsBestMatchAndPreservesExactTarget(t *testing.T) { | |
| 701 | + | m := New([]SessionView{ | |
| 702 | + | {ID: "backend", Name: "backend", Tabs: []TabEntry{{TabID: "api-tab", Name: "api-server"}, {TabID: "logs-tab", Name: "logs"}}}, | |
| 703 | + | {ID: "frontend", Name: "frontend", Tabs: []TabEntry{{TabID: "web-tab", Name: "web"}}}, | |
| 704 | + | }, SelectionConfig{Mode: SelectNavigationTab}) | |
| 705 | + | ||
| 706 | + | m.EnterSearch() | |
| 707 | + | for _, r := range "api" { | |
| 708 | + | m.InsertSearch(r) | |
| 709 | + | } | |
| 710 | + | ||
| 711 | + | require.True(t, m.SearchActive()) | |
| 712 | + | require.Equal(t, "api", m.Query()) | |
| 713 | + | require.Equal(t, 1, m.MatchCount()) | |
| 714 | + | selected, ok := m.Selected() | |
| 715 | + | require.True(t, ok) | |
| 716 | + | require.Equal(t, Target{Session: "backend", TabID: "api-tab", TabIndex: 0}, selected) | |
| 717 | + | require.Equal(t, " Search sessions & tabs: api_ ", m.SearchTitle()) | |
| 718 | + | } | |
| 719 | + | ||
| 720 | + | func TestSearchUsesContextWithoutHighlightingUnrelatedText(t *testing.T) { | |
| 721 | + | m := New([]SessionView{ | |
| 722 | + | {ID: "backend", Name: "backend", Tabs: []TabEntry{{TabID: "logs-tab", Name: "logs"}}}, | |
| 723 | + | {ID: "frontend", Name: "frontend", Tabs: []TabEntry{{TabID: "web-tab", Name: "web"}}}, | |
| 724 | + | }, SelectionConfig{Mode: SelectNavigationTab}) | |
| 725 | + | m.EnterSearch() | |
| 726 | + | for _, r := range "back" { | |
| 727 | + | m.InsertSearch(r) | |
| 728 | + | } | |
| 729 | + | ||
| 730 | + | selected, ok := m.Selected() | |
| 731 | + | require.True(t, ok) | |
| 732 | + | require.Equal(t, domain.TabStableID("logs-tab"), selected.TabID, "a parent-session match keeps its tab target navigable") | |
| 733 | + | ||
| 734 | + | matchStyle := renderer.Style{Bold: true, Foreground: 2} | |
| 735 | + | frame := m.Render(domain.Size{Cols: 40, Rows: 6}, Preview{}, RenderStyles{ | |
| 736 | + | Background: renderer.DefaultStyle(), Base: renderer.DefaultStyle(), Name: renderer.DefaultStyle(), Detail: renderer.DefaultStyle(), | |
| 737 | + | Selection: renderer.Style{Inverse: true}, SelectionName: renderer.Style{Inverse: true}, SelectionMuted: renderer.Style{Inverse: true}, | |
| 738 | + | SearchMatch: matchStyle, SelectionMatch: matchStyle, | |
| 739 | + | }) | |
| 740 | + | for x := 2; x < 6; x++ { | |
| 741 | + | require.False(t, frame.At(x, 1).Style.Equal(matchStyle), "context matches must not highlight unrelated rendered tab text") | |
| 742 | + | } | |
| 743 | + | require.True(t, frame.At(0, 3).Style.Attrs&renderer.AttrDim != 0, "non-matching rows are dimmed") | |
| 744 | + | } | |
| 745 | + | ||
| 746 | + | func TestSearchZeroMatchesSuppressesActivationAndRetainsCursorAnchor(t *testing.T) { | |
| 747 | + | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{TabID: "tab", Name: "shell"}}}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 748 | + | before, ok := m.Cursor() | |
| 749 | + | require.True(t, ok) | |
| 750 | + | ||
| 751 | + | m.EnterSearch() | |
| 752 | + | for _, r := range "absent" { | |
| 753 | + | m.InsertSearch(r) | |
| 754 | + | } | |
| 755 | + | ||
| 756 | + | require.Zero(t, m.MatchCount()) | |
| 757 | + | _, ok = m.Selected() | |
| 758 | + | require.False(t, ok) | |
| 759 | + | after, ok := m.Cursor() | |
| 760 | + | require.True(t, ok) | |
| 761 | + | require.Equal(t, before, after) | |
| 762 | + | } | |
| 763 | + | ||
| 764 | + | func TestSearchTitleKeepsQueryTailAndCaretAtNarrowWidth(t *testing.T) { | |
| 765 | + | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{TabID: "tab", Name: "shell"}}}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 766 | + | m.EnterSearch() | |
| 767 | + | for _, r := range "abcdefghijklmnopqrstuv" { | |
| 768 | + | m.InsertSearch(r) | |
| 769 | + | } | |
| 770 | + | ||
| 771 | + | title := m.SearchTitle(20) | |
| 772 | + | require.LessOrEqual(t, textCellWidth(title), 20) | |
| 773 | + | require.True(t, strings.HasPrefix(title, " / ")) | |
| 774 | + | require.True(t, strings.HasSuffix(title, "v_ "), "the visible title keeps the query tail and caret") | |
| 775 | + | ||
| 776 | + | for width := 0; width < 5; width++ { | |
| 777 | + | t.Run(fmt.Sprintf("degenerate width %d", width), func(t *testing.T) { | |
| 778 | + | got := m.SearchTitle(width) | |
| 779 | + | require.LessOrEqual(t, textCellWidth(got), width) | |
| 780 | + | require.NotContains(t, got, "v", "degenerate titles must not force a query cell") | |
| 781 | + | }) | |
| 782 | + | } | |
| 783 | + | } | |
| 784 | + | ||
| 785 | + | func TestSearchDoesNotHighlightEllipsisForTruncatedWideMatch(t *testing.T) { | |
| 786 | + | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{TabID: "tab", Name: "abcdefghijklmnopq界"}}}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 787 | + | m.EnterSearch() | |
| 788 | + | m.InsertSearch('界') | |
| 789 | + | matchStyle := renderer.Style{Foreground: 2, Bold: true} | |
| 790 | + | frame := m.Render(domain.Size{Cols: 20, Rows: 3}, Preview{}, RenderStyles{ | |
| 791 | + | Background: renderer.DefaultStyle(), Base: renderer.DefaultStyle(), Name: renderer.DefaultStyle(), Detail: renderer.DefaultStyle(), | |
| 792 | + | Selection: renderer.Style{Inverse: true}, SelectionName: renderer.Style{Inverse: true}, SelectionMuted: renderer.Style{Inverse: true}, | |
| 793 | + | SearchMatch: matchStyle, SelectionMatch: matchStyle, | |
| 794 | + | }) | |
| 795 | + | ||
| 796 | + | require.Equal(t, '…', frame.At(19, 1).Rune) | |
| 797 | + | require.False(t, frame.At(19, 1).Style.Equal(matchStyle), "a dropped wide-rune match must not style the truncation ellipsis") | |
| 798 | + | } | |
| 799 | + | ||
| 800 | + | func TestReplaceFromMovesStoppedHeaderCursorToNewFocusableTab(t *testing.T) { | |
| 801 | + | lifecycle := domain.SessionLifecycleID{1} | |
| 802 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |
| 803 | + | emptyTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", Stopped: true} | |
| 804 | + | m := New([]SessionView{{ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteTarget: &emptyTarget, Stopped: true, RemoteActivation: RemoteRestart}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 805 | + | require.Equal(t, 0, m.SelectedIndex()) | |
| 806 | + | ||
| 807 | + | tabTarget := emptyTarget | |
| 808 | + | tabTarget.StoppedTab = domain.NewStableTabSelector("tab") | |
| 809 | + | retained := []SessionView{{ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteTarget: &tabTarget, Stopped: true, RemoteActivation: RemoteRestart, Tabs: []TabEntry{{TabID: "tab", Name: "shell"}}}} | |
| 810 | + | next := New(retained, SelectionConfig{Mode: SelectNavigationTab}) | |
| 811 | + | direct := New(retained, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: key.ID(), RemoteKey: &key}}) | |
| 812 | + | directSelected, ok := direct.Selected() | |
| 813 | + | require.True(t, ok) | |
| 814 | + | require.Equal(t, domain.TabStableID("tab"), directSelected.TabID, "retained tabs must not pin the current cursor to the contextual header") | |
| 815 | + | ||
| 816 | + | m.ReplaceFrom(next) | |
| 817 | + | ||
| 818 | + | selected, ok := m.Selected() | |
| 819 | + | require.True(t, ok) | |
| 820 | + | require.Equal(t, domain.TabStableID("tab"), selected.TabID) | |
| 821 | + | require.Equal(t, 1, m.SelectedIndex(), "the contextual stopped header must not retain focus after real tabs appear") | |
| 822 | + | } | |
| 823 | + | ||
| 824 | + | func TestReplaceFromPreservesStableTabAcrossReordering(t *testing.T) { | |
| 825 | + | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{TabID: "first", Name: "first"}, {TabID: "selected", Name: "selected"}}}}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "s", TabID: "selected"}}) | |
| 826 | + | next := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{TabID: "selected", Name: "selected"}, {TabID: "first", Name: "first"}}}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 827 | + | ||
| 828 | + | m.ReplaceFrom(next) | |
| 829 | + | ||
| 830 | + | selected, ok := m.Selected() | |
| 831 | + | require.True(t, ok) | |
| 832 | + | require.Equal(t, domain.TabStableID("selected"), selected.TabID) | |
| 833 | + | require.Equal(t, 0, selected.TabIndex) | |
| 834 | + | } | |
| 835 | + | ||
| 836 | + | func TestFilteredSessionDoesNotLeaveOrphanSection(t *testing.T) { | |
| 837 | + | m := New([]SessionView{{ID: "stopped", Section: "LOCAL", Name: "stopped", Stopped: true}}, SelectionConfig{Mode: SelectMovePaneTab}) | |
| 838 | + | require.Empty(t, m.rows) | |
| 839 | + | } | |
| 840 | + | ||
| 841 | + | func TestUnavailableStoppedRemoteUsesAvailabilityBadgeAndHint(t *testing.T) { | |
| 842 | + | lifecycle := domain.SessionLifecycleID{1} | |
| 843 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |
| 844 | + | target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", Stopped: true} | |
| 845 | + | m := New([]SessionView{{ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteTarget: &target, Stopped: true, RemoteReason: "catalog_stale", RemoteDetail: "catalog stale", RemoteActivation: RemoteUnavailable}}, SelectionConfig{Mode: SelectNavigationTab}) | |
| 846 | + | ||
| 847 | + | frame := m.Render(domain.Size{Cols: 40, Rows: 3}, Preview{}) | |
| 848 | + | require.Contains(t, rowText(frame.Row(0)), "[stale]") | |
| 849 | + | require.Contains(t, rowText(frame.Row(2)), "Enter unavailable") | |
| 850 | + | require.NotContains(t, rowText(frame.Row(2)), "Enter restart") | |
| 851 | + | } | |
| 852 | + | ||
| 853 | + | func TestSearchNavigationUsesCanonicalVisualOrderAfterBestRank(t *testing.T) { | |
| 854 | + | m := New([]SessionView{ | |
| 855 | + | {ID: "first", Name: "first", Tabs: []TabEntry{{TabID: "zeta", Name: "zeta"}}}, | |
| 856 | + | {ID: "second", Name: "second", Tabs: []TabEntry{{TabID: "a", Name: "a"}}}, | |
| 857 | + | {ID: "third", Name: "third", Tabs: []TabEntry{{TabID: "alpha", Name: "alpha"}}}, | |
| 858 | + | }, SelectionConfig{Mode: SelectNavigationTab}) | |
| 859 | + | m.EnterSearch() | |
| 860 | + | m.InsertSearch('a') | |
| 861 | + | ||
| 862 | + | selected, ok := m.Selected() | |
| 863 | + | require.True(t, ok) | |
| 864 | + | require.Equal(t, domain.TabStableID("a"), selected.TabID, "exact match wins initial ranking") | |
| 865 | + | m.Up() | |
| 866 | + | selected, ok = m.Selected() | |
| 867 | + | require.True(t, ok) | |
| 868 | + | require.Equal(t, domain.TabStableID("zeta"), selected.TabID, "navigation follows visual order rather than rank order") | |
| 869 | + | m.Down() | |
| 870 | + | m.Down() | |
| 871 | + | selected, ok = m.Selected() | |
| 872 | + | require.True(t, ok) | |
| 873 | + | require.Equal(t, domain.TabStableID("alpha"), selected.TabID) | |
| 874 | + | } | |
| 875 | + | ||
| 667 | 876 | func cell(r rune) renderer.Cell { | |
| 668 | 877 | return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()} | |
| 669 | 878 | } | |
| @@ -676,10 +885,11 @@ func TestRenderStylesFillBackgroundRowsAndSelection(t *testing.T) { | |||
| 676 | 885 | frame := m.Render(domain.Size{Cols: 20, Rows: 5}, Preview{}, RenderStyles{ | |
| 677 | 886 | Background: background, Base: base, Name: base, Detail: base, | |
| 678 | 887 | Selection: selection, SelectionName: selection, SelectionMuted: selection, | |
| 679 | - | Separator: base, | |
| 888 | + | Separator: base, Status: base, | |
| 680 | 889 | }) | |
| 681 | 890 | ||
| 682 | - | require.True(t, frame.At(19, 4).Style.Equal(background), "unused interior keeps modal base") | |
| 891 | + | require.True(t, frame.At(19, 3).Style.Equal(background), "unused content keeps modal base") | |
| 892 | + | require.True(t, frame.At(19, 4).Style.Equal(base), "status owns the final inner row") | |
| 683 | 893 | require.True(t, frame.At(19, 0).Style.Equal(base), "ordinary row owns inactive surface") | |
| 684 | 894 | require.True(t, frame.At(19, 1).Style.Equal(selection), "selected row owns active surface") | |
| 685 | 895 | } | |
| @@ -733,10 +943,10 @@ func TestPickerRowsKeepTerminalBackgroundAcrossAccentFallbacks(t *testing.T) { | |||
| 733 | 943 | Background: styles.PickerBase, Base: styles.PickerBase, Name: styles.PickerName, | |
| 734 | 944 | Detail: styles.PickerDescription, Selection: styles.PickerSelection, | |
| 735 | 945 | SelectionName: styles.PickerSelection, SelectionMuted: styles.PickerSelection, | |
| 736 | - | Separator: styles.PickerSeparator, | |
| 946 | + | Separator: styles.PickerSeparator, Status: styles.PickerDescription, | |
| 737 | 947 | }) | |
| 738 | - | require.True(t, frame.At(5, 3).Style.Equal(styles.PickerDescription), "description text keeps a contrast-derived foreground without a background tint") | |
| 739 | - | require.True(t, frame.At(31, 3).Style.Equal(styles.PickerBase), "unused row cells retain the terminal background") | |
| 948 | + | require.True(t, frame.At(5, 3).Style.Equal(styles.PickerDescription), "status text keeps a contrast-derived foreground without a background tint") | |
| 949 | + | require.True(t, frame.At(31, 3).Style.Equal(styles.PickerDescription), "status filler owns the final row") | |
| 740 | 950 | }) | |
| 741 | 951 | } | |
| 742 | 952 | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package picker | 1 | package picker | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "fmt" | |||
| 4 | "math" | 5 | "math" | ||
| 5 | "reflect" | 6 | "reflect" | ||
| 6 | "strings" | 7 | "strings" | ||
| @@ -335,8 +336,8 @@ func TestRenderDrawsCustomOrientedSeparators(t *testing.T) { | |||||
| 335 | rect domain.Rect | 336 | rect domain.Rect | ||
| 336 | rune rune | 337 | rune rune | ||
| 337 | }{ | 338 | }{ | ||
| 338 | - | {"horizontal layout", domain.Size{Cols: 69, Rows: 4}, domain.Rect{X: 20, Width: 1, Height: 4}, '│'}, | 339 | + | {"horizontal layout", domain.Size{Cols: 69, Rows: 5}, domain.Rect{X: 20, Width: 1, Height: 4}, '│'}, |
| 339 | - | {"stacked layout", domain.Size{Cols: 24, Rows: 12}, domain.Rect{Y: 4, Width: 24, Height: 1}, '─'}, | 340 | + | {"stacked layout", domain.Size{Cols: 24, Rows: 13}, domain.Rect{Y: 4, Width: 24, Height: 1}, '─'}, |
| 340 | } | 341 | } | ||
| 341 | for _, tt := range tests { | 342 | for _, tt := range tests { | ||
| 342 | t.Run(tt.name, func(t *testing.T) { | 343 | t.Run(tt.name, func(t *testing.T) { | ||
| @@ -356,26 +357,28 @@ func TestRenderListOnlyOmitsSeparatorAndPreview(t *testing.T) { | |||||
| 356 | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) | 357 | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) | ||
| 357 | frame := m.Render(domain.Size{Cols: 23, Rows: 11}, Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}, RenderStyles{Separator: renderer.Style{Foreground: 8}}) | 358 | frame := m.Render(domain.Size{Cols: 23, Rows: 11}, Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}, RenderStyles{Separator: renderer.Style{Foreground: 8}}) | ||
| 358 | 359 | ||||
| 359 | - | require.Equal(t, ' ', frame.At(22, 10).Rune) | |||
| 360 | require.NotEqual(t, 'x', frame.At(22, 10).Rune) | 360 | require.NotEqual(t, 'x', frame.At(22, 10).Rune) | ||
| 361 | + | require.Equal(t, 'E', frame.At(0, 10).Rune, "the final inner row is reserved for status") | |||
| 361 | } | 362 | } | ||
| 362 | 363 | ||||
| 363 | func TestRenderPreviewAnchorsOversizedSourceToFinalRows(t *testing.T) { | 364 | func TestRenderPreviewAnchorsOversizedSourceToFinalRows(t *testing.T) { | ||
| 364 | m := New(nil, SelectionConfig{Mode: SelectNavigationTab}) | 365 | m := New(nil, SelectionConfig{Mode: SelectNavigationTab}) | ||
| 365 | preview := Preview{Width: 24, Height: 9, Rows: previewRows(24, "abcdefghi")} | 366 | preview := Preview{Width: 24, Height: 9, Rows: previewRows(24, "abcdefghi")} | ||
| 366 | 367 | ||||
| 367 | - | frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview) | 368 | + | frame := m.Render(domain.Size{Cols: 24, Rows: 13}, preview) |
| 368 | require.Equal(t, 'c', frame.At(0, 5).Rune) | 369 | require.Equal(t, 'c', frame.At(0, 5).Rune) | ||
| 369 | require.Equal(t, 'i', frame.At(0, 11).Rune) | 370 | require.Equal(t, 'i', frame.At(0, 11).Rune) | ||
| 371 | + | require.Equal(t, 'E', frame.At(0, 12).Rune) | |||
| 370 | } | 372 | } | ||
| 371 | 373 | ||||
| 372 | func TestRenderPreviewBottomPlacesShortSource(t *testing.T) { | 374 | func TestRenderPreviewBottomPlacesShortSource(t *testing.T) { | ||
| 373 | m := New(nil, SelectionConfig{Mode: SelectNavigationTab}) | 375 | m := New(nil, SelectionConfig{Mode: SelectNavigationTab}) | ||
| 374 | preview := Preview{Width: 24, Height: 1, Rows: previewRows(24, "z")} | 376 | preview := Preview{Width: 24, Height: 1, Rows: previewRows(24, "z")} | ||
| 375 | 377 | ||||
| 376 | - | frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview) | 378 | + | frame := m.Render(domain.Size{Cols: 24, Rows: 13}, preview) |
| 377 | require.Equal(t, ' ', frame.At(0, 5).Rune) | 379 | require.Equal(t, ' ', frame.At(0, 5).Rune) | ||
| 378 | require.Equal(t, 'z', frame.At(0, 11).Rune) | 380 | require.Equal(t, 'z', frame.At(0, 11).Rune) | ||
| 381 | + | require.Equal(t, 'E', frame.At(0, 12).Rune) | |||
| 379 | } | 382 | } | ||
| 380 | 383 | ||||
| 381 | func TestRenderAttentionMarkerSmokeWithResponsiveLayout(t *testing.T) { | 384 | func TestRenderAttentionMarkerSmokeWithResponsiveLayout(t *testing.T) { | ||
| @@ -492,35 +495,64 @@ func TestSelectedIndexReportsRawSelectedRow(t *testing.T) { | |||||
| 492 | require.Equal(t, 1, m.SelectedIndex()) | 495 | require.Equal(t, 1, m.SelectedIndex()) | ||
| 493 | } | 496 | } | ||
| 494 | 497 | ||||
| 495 | - | func TestStoppedSessionRendersDownSuffix(t *testing.T) { | 498 | + | func TestStoppedSessionUsesOneSelectableHeaderWithRightAlignedStatus(t *testing.T) { |
| 496 | for _, tt := range []struct { | 499 | for _, tt := range []struct { | ||
| 497 | name string | 500 | name string | ||
| 498 | sessionID domain.SessionID | 501 | sessionID domain.SessionID | ||
| 499 | sessionName string | 502 | sessionName string | ||
| 500 | size domain.Size | 503 | size domain.Size | ||
| 504 | + | wantRow string | |||
| 501 | }{ | 505 | }{ | ||
| 502 | - | {name: "normal", sessionID: "stopped:work", sessionName: "work", size: domain.Size{Cols: 24, Rows: 4}}, | 506 | + | {name: "normal", sessionID: "stopped:work", sessionName: "work", size: domain.Size{Cols: 24, Rows: 4}, wantRow: "work [stopped]"}, |
| 503 | - | {name: "constrained", sessionID: "stopped:long", sessionName: "long-session", size: domain.Size{Cols: 8, Rows: 2}}, | 507 | + | {name: "constrained", sessionID: "stopped:long", sessionName: "long-session", size: domain.Size{Cols: 12, Rows: 2}, wantRow: "l… [stopped]"}, |
| 504 | } { | 508 | } { | ||
| 505 | t.Run(tt.name, func(t *testing.T) { | 509 | t.Run(tt.name, func(t *testing.T) { | ||
| 506 | - | m := New([]SessionView{{ID: tt.sessionID, Name: tt.sessionName, Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab}) | 510 | + | m := New([]SessionView{{ID: tt.sessionID, Name: tt.sessionName, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab}) |
| 507 | got, ok := m.Selected() | 511 | got, ok := m.Selected() | ||
| 508 | require.True(t, ok) | 512 | require.True(t, ok) | ||
| 509 | - | require.Equal(t, Target{Session: tt.sessionID, Name: tt.sessionName, TabIndex: 0, Stopped: true}, got) | 513 | + | require.Equal(t, Target{Session: tt.sessionID, Name: tt.sessionName, TabIndex: -1, Stopped: true}, got) |
| 514 | + | require.Len(t, m.rows, 1) | |||
| 510 | 515 | ||||
| 511 | frame := m.Render(tt.size, Preview{}) | 516 | frame := m.Render(tt.size, Preview{}) | ||
| 512 | var label strings.Builder | 517 | var label strings.Builder | ||
| 513 | for _, cell := range frame.Row(0) { | 518 | for _, cell := range frame.Row(0) { | ||
| 514 | label.WriteRune(cell.Rune) | 519 | label.WriteRune(cell.Rune) | ||
| 515 | } | 520 | } | ||
| 516 | - | require.Contains(t, label.String(), "(down)") | 521 | + | require.Equal(t, tt.wantRow, strings.TrimRight(label.String(), " ")) |
| 517 | }) | 522 | }) | ||
| 518 | } | 523 | } | ||
| 519 | } | 524 | } | ||
| 520 | 525 | ||||
| 526 | + | func TestRemoteStatusBadgesAlignRightAndStatusOmitsDelete(t *testing.T) { | |||
| 527 | + | lifecycle := domain.SessionLifecycleID{1} | |||
| 528 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |||
| 529 | + | target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab"} | |||
| 530 | + | m := New([]SessionView{{ | |||
| 531 | + | ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteTarget: &target, RemoteHost: "arch", | |||
| 532 | + | RemoteActivation: RemoteAttach, Tabs: []TabEntry{{TabID: "tab", Name: "shell"}}, | |||
| 533 | + | }}, SelectionConfig{Mode: SelectNavigationTab}) | |||
| 534 | + | ||||
| 535 | + | frame := m.Render(domain.Size{Cols: 24, Rows: 4}, Preview{}) | |||
| 536 | + | require.Equal(t, "work@arch [up]", strings.TrimRight(rowText(frame.Row(0)), " ")) | |||
| 537 | + | status := rowText(frame.Row(3)) | |||
| 538 | + | require.Contains(t, status, "Enter open") | |||
| 539 | + | require.NotContains(t, status, "x delete") | |||
| 540 | + | } | |||
| 541 | + | ||||
| 542 | + | func rowText(row []renderer.Cell) string { | |||
| 543 | + | var text strings.Builder | |||
| 544 | + | for _, cell := range row { | |||
| 545 | + | if cell.Continuation { | |||
| 546 | + | continue | |||
| 547 | + | } | |||
| 548 | + | text.WriteRune(cell.Rune) | |||
| 549 | + | } | |||
| 550 | + | return text.String() | |||
| 551 | + | } | |||
| 552 | + | ||||
| 521 | func TestRenderStopsStoppedRowsDimItalic(t *testing.T) { | 553 | func TestRenderStopsStoppedRowsDimItalic(t *testing.T) { | ||
| 522 | live := SessionView{ID: "live", Name: "work", Tabs: []TabEntry{{TabID: "t1", Name: "tab"}}} | 554 | live := SessionView{ID: "live", Name: "work", Tabs: []TabEntry{{TabID: "t1", Name: "tab"}}} | ||
| 523 | - | halted := SessionView{ID: "stopped:old", Name: "old", TargetName: "old", Stopped: true, Tabs: []TabEntry{{}}} | 555 | + | halted := SessionView{ID: "stopped:old", Name: "old", TargetName: "old", Stopped: true} |
| 524 | m := New([]SessionView{live, halted}, SelectionConfig{Mode: SelectNavigationTab}) | 556 | m := New([]SessionView{live, halted}, SelectionConfig{Mode: SelectNavigationTab}) | ||
| 525 | 557 | ||||
| 526 | stoppedStyle := renderer.Style{Foreground: -1, Background: -1, Italic: true, Attrs: renderer.AttrDim} | 558 | stoppedStyle := renderer.Style{Foreground: -1, Background: -1, Italic: true, Attrs: renderer.AttrDim} | ||
| @@ -528,15 +560,14 @@ func TestRenderStopsStoppedRowsDimItalic(t *testing.T) { | |||||
| 528 | require.Equal(t, stoppedStyle, styles.Stopped) | 560 | require.Equal(t, stoppedStyle, styles.Stopped) | ||
| 529 | 561 | ||||
| 530 | frame := m.Render(domain.Size{Cols: 15, Rows: 6}, Preview{}) | 562 | frame := m.Render(domain.Size{Cols: 15, Rows: 6}, Preview{}) | ||
| 531 | - | // Rows: 0 "work" header, 1 " tab" (selected), 2 "old (down)" header, 3 its tab row. | 563 | + | // Rows: 0 "work" header, 1 " tab" (selected), 2 "old [stopped]" header. |
| 532 | require.Equal(t, stoppedStyle, frame.Row(2)[0].Style, "stopped header must be dim italic") | 564 | require.Equal(t, stoppedStyle, frame.Row(2)[0].Style, "stopped header must be dim italic") | ||
| 533 | - | require.Equal(t, stoppedStyle, frame.Row(3)[0].Style, "stopped tab row must be dim italic") | |||
| 534 | require.NotEqual(t, stoppedStyle, frame.Row(0)[0].Style, "live header keeps base style") | 565 | require.NotEqual(t, stoppedStyle, frame.Row(0)[0].Style, "live header keeps base style") | ||
| 535 | 566 | ||||
| 536 | selected := New([]SessionView{live, halted}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: halted.ID}}) | 567 | selected := New([]SessionView{live, halted}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: halted.ID}}) | ||
| 537 | selectedFrame := selected.Render(domain.Size{Cols: 15, Rows: 6}, Preview{}) | 568 | selectedFrame := selected.Render(domain.Size{Cols: 15, Rows: 6}, Preview{}) | ||
| 538 | - | require.NotEqual(t, stoppedStyle, selectedFrame.Row(3)[0].Style, "selected stopped row keeps selection style, not Stopped") | 569 | + | require.NotEqual(t, stoppedStyle, selectedFrame.Row(2)[0].Style, "selected stopped row keeps selection style, not Stopped") |
| 539 | - | require.True(t, selectedFrame.Row(3)[0].Style.Inverse, "selected stopped row still shows selection") | 570 | + | require.True(t, selectedFrame.Row(2)[0].Style.Inverse, "selected stopped row still shows selection") |
| 540 | } | 571 | } | ||
| 541 | 572 | ||||
| 542 | func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | 573 | func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | ||
| @@ -551,12 +582,13 @@ func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | |||||
| 551 | }}, | 582 | }}, | ||
| 552 | } | 583 | } | ||
| 553 | 584 | ||||
| 554 | - | frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview) | 585 | + | frame := m.Render(domain.Size{Cols: 24, Rows: 13}, preview) |
| 555 | require.True(t, frame.At(0, 1).Style.Inverse) | 586 | require.True(t, frame.At(0, 1).Style.Inverse) | ||
| 556 | require.Equal(t, 'a', frame.At(0, 11).Rune) | 587 | require.Equal(t, 'a', frame.At(0, 11).Rune) | ||
| 557 | require.Equal(t, 'w', frame.At(22, 11).Rune) | 588 | require.Equal(t, 'w', frame.At(22, 11).Rune) | ||
| 558 | require.Equal(t, ' ', frame.At(23, 11).Rune, "wide rune crossing preview pane is dropped") | 589 | require.Equal(t, ' ', frame.At(23, 11).Rune, "wide rune crossing preview pane is dropped") | ||
| 559 | require.Equal(t, ' ', frame.At(0, 5).Rune, "short preview is bottom anchored") | 590 | require.Equal(t, ' ', frame.At(0, 5).Rune, "short preview is bottom anchored") | ||
| 591 | + | require.Equal(t, 'E', frame.At(0, 12).Rune) | |||
| 560 | } | 592 | } | ||
| 561 | 593 | ||||
| 562 | func TestRenderListScrollsSelectionIntoView(t *testing.T) { | 594 | func TestRenderListScrollsSelectionIntoView(t *testing.T) { | ||
| @@ -566,9 +598,10 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) { | |||||
| 566 | } | 598 | } | ||
| 567 | 599 | ||||
| 568 | frame := m.Render(domain.Size{Cols: 23, Rows: 4}, Preview{}) | 600 | frame := m.Render(domain.Size{Cols: 23, Rows: 4}, Preview{}) | ||
| 569 | - | require.Equal(t, ' ', frame.At(0, 3).Rune) | 601 | + | require.Equal(t, ' ', frame.At(0, 2).Rune) |
| 570 | - | require.Equal(t, 'f', frame.At(2, 3).Rune) | 602 | + | require.Equal(t, 'f', frame.At(2, 2).Rune) |
| 571 | - | require.True(t, frame.At(0, 3).Style.Inverse) | 603 | + | require.True(t, frame.At(0, 2).Style.Inverse) |
| 604 | + | require.Equal(t, 'E', frame.At(0, 3).Rune) | |||
| 572 | } | 605 | } | ||
| 573 | 606 | ||||
| 574 | func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) { | 607 | func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) { | ||
| @@ -576,7 +609,7 @@ func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) { | |||||
| 576 | 609 | ||||
| 577 | frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{}) | 610 | frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{}) | ||
| 578 | 611 | ||||
| 579 | - | layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5}) | 612 | + | layout := ChooseGeometry(domain.Size{Cols: 69, Rows: 5}) |
| 580 | require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column") | 613 | require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column") | ||
| 581 | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "truncated label should end with an ellipsis at the list edge") | 614 | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "truncated label should end with an ellipsis at the list edge") | ||
| 582 | require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list") | 615 | require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list") | ||
| @@ -640,7 +673,7 @@ func TestRenderListTruncatesDetailBeforeName(t *testing.T) { | |||||
| 640 | }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) | 673 | }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) | ||
| 641 | 674 | ||||
| 642 | frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{}) | 675 | frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{}) | ||
| 643 | - | layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5}) | 676 | + | layout := ChooseGeometry(domain.Size{Cols: 69, Rows: 5}) |
| 644 | require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column") | 677 | require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column") | ||
| 645 | 678 | ||||
| 646 | want := " short-name (a ver…" | 679 | want := " short-name (a ver…" | ||
| @@ -657,13 +690,189 @@ func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) { | |||||
| 657 | }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) | 690 | }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab}) | ||
| 658 | 691 | ||||
| 659 | frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{}) | 692 | frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{}) | ||
| 660 | - | layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5}) | 693 | + | layout := ChooseGeometry(domain.Size{Cols: 69, Rows: 5}) |
| 661 | require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column") | 694 | require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column") | ||
| 662 | 695 | ||||
| 663 | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "the name segment itself is ellipsized once it alone exceeds the width") | 696 | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "the name segment itself is ellipsized once it alone exceeds the width") | ||
| 664 | require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list") | 697 | require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list") | ||
| 665 | } | 698 | } | ||
| 666 | 699 | ||||
| 700 | + | func TestSearchSelectsBestMatchAndPreservesExactTarget(t *testing.T) { | |||
| 701 | + | m := New([]SessionView{ | |||
| 702 | + | {ID: "backend", Name: "backend", Tabs: []TabEntry{{TabID: "api-tab", Name: "api-server"}, {TabID: "logs-tab", Name: "logs"}}}, | |||
| 703 | + | {ID: "frontend", Name: "frontend", Tabs: []TabEntry{{TabID: "web-tab", Name: "web"}}}, | |||
| 704 | + | }, SelectionConfig{Mode: SelectNavigationTab}) | |||
| 705 | + | ||||
| 706 | + | m.EnterSearch() | |||
| 707 | + | for _, r := range "api" { | |||
| 708 | + | m.InsertSearch(r) | |||
| 709 | + | } | |||
| 710 | + | ||||
| 711 | + | require.True(t, m.SearchActive()) | |||
| 712 | + | require.Equal(t, "api", m.Query()) | |||
| 713 | + | require.Equal(t, 1, m.MatchCount()) | |||
| 714 | + | selected, ok := m.Selected() | |||
| 715 | + | require.True(t, ok) | |||
| 716 | + | require.Equal(t, Target{Session: "backend", TabID: "api-tab", TabIndex: 0}, selected) | |||
| 717 | + | require.Equal(t, " Search sessions & tabs: api_ ", m.SearchTitle()) | |||
| 718 | + | } | |||
| 719 | + | ||||
| 720 | + | func TestSearchUsesContextWithoutHighlightingUnrelatedText(t *testing.T) { | |||
| 721 | + | m := New([]SessionView{ | |||
| 722 | + | {ID: "backend", Name: "backend", Tabs: []TabEntry{{TabID: "logs-tab", Name: "logs"}}}, | |||
| 723 | + | {ID: "frontend", Name: "frontend", Tabs: []TabEntry{{TabID: "web-tab", Name: "web"}}}, | |||
| 724 | + | }, SelectionConfig{Mode: SelectNavigationTab}) | |||
| 725 | + | m.EnterSearch() | |||
| 726 | + | for _, r := range "back" { | |||
| 727 | + | m.InsertSearch(r) | |||
| 728 | + | } | |||
| 729 | + | ||||
| 730 | + | selected, ok := m.Selected() | |||
| 731 | + | require.True(t, ok) | |||
| 732 | + | require.Equal(t, domain.TabStableID("logs-tab"), selected.TabID, "a parent-session match keeps its tab target navigable") | |||
| 733 | + | ||||
| 734 | + | matchStyle := renderer.Style{Bold: true, Foreground: 2} | |||
| 735 | + | frame := m.Render(domain.Size{Cols: 40, Rows: 6}, Preview{}, RenderStyles{ | |||
| 736 | + | Background: renderer.DefaultStyle(), Base: renderer.DefaultStyle(), Name: renderer.DefaultStyle(), Detail: renderer.DefaultStyle(), | |||
| 737 | + | Selection: renderer.Style{Inverse: true}, SelectionName: renderer.Style{Inverse: true}, SelectionMuted: renderer.Style{Inverse: true}, | |||
| 738 | + | SearchMatch: matchStyle, SelectionMatch: matchStyle, | |||
| 739 | + | }) | |||
| 740 | + | for x := 2; x < 6; x++ { | |||
| 741 | + | require.False(t, frame.At(x, 1).Style.Equal(matchStyle), "context matches must not highlight unrelated rendered tab text") | |||
| 742 | + | } | |||
| 743 | + | require.True(t, frame.At(0, 3).Style.Attrs&renderer.AttrDim != 0, "non-matching rows are dimmed") | |||
| 744 | + | } | |||
| 745 | + | ||||
| 746 | + | func TestSearchZeroMatchesSuppressesActivationAndRetainsCursorAnchor(t *testing.T) { | |||
| 747 | + | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{TabID: "tab", Name: "shell"}}}}, SelectionConfig{Mode: SelectNavigationTab}) | |||
| 748 | + | before, ok := m.Cursor() | |||
| 749 | + | require.True(t, ok) | |||
| 750 | + | ||||
| 751 | + | m.EnterSearch() | |||
| 752 | + | for _, r := range "absent" { | |||
| 753 | + | m.InsertSearch(r) | |||
| 754 | + | } | |||
| 755 | + | ||||
| 756 | + | require.Zero(t, m.MatchCount()) | |||
| 757 | + | _, ok = m.Selected() | |||
| 758 | + | require.False(t, ok) | |||
| 759 | + | after, ok := m.Cursor() | |||
| 760 | + | require.True(t, ok) | |||
| 761 | + | require.Equal(t, before, after) | |||
| 762 | + | } | |||
| 763 | + | ||||
| 764 | + | func TestSearchTitleKeepsQueryTailAndCaretAtNarrowWidth(t *testing.T) { | |||
| 765 | + | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{TabID: "tab", Name: "shell"}}}}, SelectionConfig{Mode: SelectNavigationTab}) | |||
| 766 | + | m.EnterSearch() | |||
| 767 | + | for _, r := range "abcdefghijklmnopqrstuv" { | |||
| 768 | + | m.InsertSearch(r) | |||
| 769 | + | } | |||
| 770 | + | ||||
| 771 | + | title := m.SearchTitle(20) | |||
| 772 | + | require.LessOrEqual(t, textCellWidth(title), 20) | |||
| 773 | + | require.True(t, strings.HasPrefix(title, " / ")) | |||
| 774 | + | require.True(t, strings.HasSuffix(title, "v_ "), "the visible title keeps the query tail and caret") | |||
| 775 | + | ||||
| 776 | + | for width := 0; width < 5; width++ { | |||
| 777 | + | t.Run(fmt.Sprintf("degenerate width %d", width), func(t *testing.T) { | |||
| 778 | + | got := m.SearchTitle(width) | |||
| 779 | + | require.LessOrEqual(t, textCellWidth(got), width) | |||
| 780 | + | require.NotContains(t, got, "v", "degenerate titles must not force a query cell") | |||
| 781 | + | }) | |||
| 782 | + | } | |||
| 783 | + | } | |||
| 784 | + | ||||
| 785 | + | func TestSearchDoesNotHighlightEllipsisForTruncatedWideMatch(t *testing.T) { | |||
| 786 | + | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{TabID: "tab", Name: "abcdefghijklmnopq界"}}}}, SelectionConfig{Mode: SelectNavigationTab}) | |||
| 787 | + | m.EnterSearch() | |||
| 788 | + | m.InsertSearch('界') | |||
| 789 | + | matchStyle := renderer.Style{Foreground: 2, Bold: true} | |||
| 790 | + | frame := m.Render(domain.Size{Cols: 20, Rows: 3}, Preview{}, RenderStyles{ | |||
| 791 | + | Background: renderer.DefaultStyle(), Base: renderer.DefaultStyle(), Name: renderer.DefaultStyle(), Detail: renderer.DefaultStyle(), | |||
| 792 | + | Selection: renderer.Style{Inverse: true}, SelectionName: renderer.Style{Inverse: true}, SelectionMuted: renderer.Style{Inverse: true}, | |||
| 793 | + | SearchMatch: matchStyle, SelectionMatch: matchStyle, | |||
| 794 | + | }) | |||
| 795 | + | ||||
| 796 | + | require.Equal(t, '…', frame.At(19, 1).Rune) | |||
| 797 | + | require.False(t, frame.At(19, 1).Style.Equal(matchStyle), "a dropped wide-rune match must not style the truncation ellipsis") | |||
| 798 | + | } | |||
| 799 | + | ||||
| 800 | + | func TestReplaceFromMovesStoppedHeaderCursorToNewFocusableTab(t *testing.T) { | |||
| 801 | + | lifecycle := domain.SessionLifecycleID{1} | |||
| 802 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |||
| 803 | + | emptyTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", Stopped: true} | |||
| 804 | + | m := New([]SessionView{{ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteTarget: &emptyTarget, Stopped: true, RemoteActivation: RemoteRestart}}, SelectionConfig{Mode: SelectNavigationTab}) | |||
| 805 | + | require.Equal(t, 0, m.SelectedIndex()) | |||
| 806 | + | ||||
| 807 | + | tabTarget := emptyTarget | |||
| 808 | + | tabTarget.StoppedTab = domain.NewStableTabSelector("tab") | |||
| 809 | + | retained := []SessionView{{ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteTarget: &tabTarget, Stopped: true, RemoteActivation: RemoteRestart, Tabs: []TabEntry{{TabID: "tab", Name: "shell"}}}} | |||
| 810 | + | next := New(retained, SelectionConfig{Mode: SelectNavigationTab}) | |||
| 811 | + | direct := New(retained, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: key.ID(), RemoteKey: &key}}) | |||
| 812 | + | directSelected, ok := direct.Selected() | |||
| 813 | + | require.True(t, ok) | |||
| 814 | + | require.Equal(t, domain.TabStableID("tab"), directSelected.TabID, "retained tabs must not pin the current cursor to the contextual header") | |||
| 815 | + | ||||
| 816 | + | m.ReplaceFrom(next) | |||
| 817 | + | ||||
| 818 | + | selected, ok := m.Selected() | |||
| 819 | + | require.True(t, ok) | |||
| 820 | + | require.Equal(t, domain.TabStableID("tab"), selected.TabID) | |||
| 821 | + | require.Equal(t, 1, m.SelectedIndex(), "the contextual stopped header must not retain focus after real tabs appear") | |||
| 822 | + | } | |||
| 823 | + | ||||
| 824 | + | func TestReplaceFromPreservesStableTabAcrossReordering(t *testing.T) { | |||
| 825 | + | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{TabID: "first", Name: "first"}, {TabID: "selected", Name: "selected"}}}}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "s", TabID: "selected"}}) | |||
| 826 | + | next := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{TabID: "selected", Name: "selected"}, {TabID: "first", Name: "first"}}}}, SelectionConfig{Mode: SelectNavigationTab}) | |||
| 827 | + | ||||
| 828 | + | m.ReplaceFrom(next) | |||
| 829 | + | ||||
| 830 | + | selected, ok := m.Selected() | |||
| 831 | + | require.True(t, ok) | |||
| 832 | + | require.Equal(t, domain.TabStableID("selected"), selected.TabID) | |||
| 833 | + | require.Equal(t, 0, selected.TabIndex) | |||
| 834 | + | } | |||
| 835 | + | ||||
| 836 | + | func TestFilteredSessionDoesNotLeaveOrphanSection(t *testing.T) { | |||
| 837 | + | m := New([]SessionView{{ID: "stopped", Section: "LOCAL", Name: "stopped", Stopped: true}}, SelectionConfig{Mode: SelectMovePaneTab}) | |||
| 838 | + | require.Empty(t, m.rows) | |||
| 839 | + | } | |||
| 840 | + | ||||
| 841 | + | func TestUnavailableStoppedRemoteUsesAvailabilityBadgeAndHint(t *testing.T) { | |||
| 842 | + | lifecycle := domain.SessionLifecycleID{1} | |||
| 843 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |||
| 844 | + | target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", Stopped: true} | |||
| 845 | + | m := New([]SessionView{{ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteTarget: &target, Stopped: true, RemoteReason: "catalog_stale", RemoteDetail: "catalog stale", RemoteActivation: RemoteUnavailable}}, SelectionConfig{Mode: SelectNavigationTab}) | |||
| 846 | + | ||||
| 847 | + | frame := m.Render(domain.Size{Cols: 40, Rows: 3}, Preview{}) | |||
| 848 | + | require.Contains(t, rowText(frame.Row(0)), "[stale]") | |||
| 849 | + | require.Contains(t, rowText(frame.Row(2)), "Enter unavailable") | |||
| 850 | + | require.NotContains(t, rowText(frame.Row(2)), "Enter restart") | |||
| 851 | + | } | |||
| 852 | + | ||||
| 853 | + | func TestSearchNavigationUsesCanonicalVisualOrderAfterBestRank(t *testing.T) { | |||
| 854 | + | m := New([]SessionView{ | |||
| 855 | + | {ID: "first", Name: "first", Tabs: []TabEntry{{TabID: "zeta", Name: "zeta"}}}, | |||
| 856 | + | {ID: "second", Name: "second", Tabs: []TabEntry{{TabID: "a", Name: "a"}}}, | |||
| 857 | + | {ID: "third", Name: "third", Tabs: []TabEntry{{TabID: "alpha", Name: "alpha"}}}, | |||
| 858 | + | }, SelectionConfig{Mode: SelectNavigationTab}) | |||
| 859 | + | m.EnterSearch() | |||
| 860 | + | m.InsertSearch('a') | |||
| 861 | + | ||||
| 862 | + | selected, ok := m.Selected() | |||
| 863 | + | require.True(t, ok) | |||
| 864 | + | require.Equal(t, domain.TabStableID("a"), selected.TabID, "exact match wins initial ranking") | |||
| 865 | + | m.Up() | |||
| 866 | + | selected, ok = m.Selected() | |||
| 867 | + | require.True(t, ok) | |||
| 868 | + | require.Equal(t, domain.TabStableID("zeta"), selected.TabID, "navigation follows visual order rather than rank order") | |||
| 869 | + | m.Down() | |||
| 870 | + | m.Down() | |||
| 871 | + | selected, ok = m.Selected() | |||
| 872 | + | require.True(t, ok) | |||
| 873 | + | require.Equal(t, domain.TabStableID("alpha"), selected.TabID) | |||
| 874 | + | } | |||
| 875 | + | ||||
| 667 | func cell(r rune) renderer.Cell { | 876 | func cell(r rune) renderer.Cell { | ||
| 668 | return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()} | 877 | return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()} | ||
| 669 | } | 878 | } | ||
| @@ -676,10 +885,11 @@ func TestRenderStylesFillBackgroundRowsAndSelection(t *testing.T) { | |||||
| 676 | frame := m.Render(domain.Size{Cols: 20, Rows: 5}, Preview{}, RenderStyles{ | 885 | frame := m.Render(domain.Size{Cols: 20, Rows: 5}, Preview{}, RenderStyles{ | ||
| 677 | Background: background, Base: base, Name: base, Detail: base, | 886 | Background: background, Base: base, Name: base, Detail: base, | ||
| 678 | Selection: selection, SelectionName: selection, SelectionMuted: selection, | 887 | Selection: selection, SelectionName: selection, SelectionMuted: selection, | ||
| 679 | - | Separator: base, | 888 | + | Separator: base, Status: base, |
| 680 | }) | 889 | }) | ||
| 681 | 890 | ||||
| 682 | - | require.True(t, frame.At(19, 4).Style.Equal(background), "unused interior keeps modal base") | 891 | + | require.True(t, frame.At(19, 3).Style.Equal(background), "unused content keeps modal base") |
| 892 | + | require.True(t, frame.At(19, 4).Style.Equal(base), "status owns the final inner row") | |||
| 683 | require.True(t, frame.At(19, 0).Style.Equal(base), "ordinary row owns inactive surface") | 893 | require.True(t, frame.At(19, 0).Style.Equal(base), "ordinary row owns inactive surface") | ||
| 684 | require.True(t, frame.At(19, 1).Style.Equal(selection), "selected row owns active surface") | 894 | require.True(t, frame.At(19, 1).Style.Equal(selection), "selected row owns active surface") | ||
| 685 | } | 895 | } | ||
| @@ -733,10 +943,10 @@ func TestPickerRowsKeepTerminalBackgroundAcrossAccentFallbacks(t *testing.T) { | |||||
| 733 | Background: styles.PickerBase, Base: styles.PickerBase, Name: styles.PickerName, | 943 | Background: styles.PickerBase, Base: styles.PickerBase, Name: styles.PickerName, | ||
| 734 | Detail: styles.PickerDescription, Selection: styles.PickerSelection, | 944 | Detail: styles.PickerDescription, Selection: styles.PickerSelection, | ||
| 735 | SelectionName: styles.PickerSelection, SelectionMuted: styles.PickerSelection, | 945 | SelectionName: styles.PickerSelection, SelectionMuted: styles.PickerSelection, | ||
| 736 | - | Separator: styles.PickerSeparator, | 946 | + | Separator: styles.PickerSeparator, Status: styles.PickerDescription, |
| 737 | }) | 947 | }) | ||
| 738 | - | require.True(t, frame.At(5, 3).Style.Equal(styles.PickerDescription), "description text keeps a contrast-derived foreground without a background tint") | 948 | + | require.True(t, frame.At(5, 3).Style.Equal(styles.PickerDescription), "status text keeps a contrast-derived foreground without a background tint") |
| 739 | - | require.True(t, frame.At(31, 3).Style.Equal(styles.PickerBase), "unused row cells retain the terminal background") | 949 | + | require.True(t, frame.At(31, 3).Style.Equal(styles.PickerDescription), "status filler owns the final row") |
| 740 | }) | 950 | }) | ||
| 741 | } | 951 | } | ||
| 742 | } | 952 | } | ||
A internal/usecase/picker/search.go +363 -0
| @@ -0,0 +1,363 @@ | |||
| 1 | + | package picker | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "slices" | |
| 5 | + | "strings" | |
| 6 | + | "unicode/utf8" | |
| 7 | + | ||
| 8 | + | renderer "github.com/bnema/vev-vt" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | const MaxSearchQueryRunes = 256 | |
| 12 | + | ||
| 13 | + | type matchField uint8 | |
| 14 | + | ||
| 15 | + | const ( | |
| 16 | + | matchName matchField = iota | |
| 17 | + | matchDetail | |
| 18 | + | matchContext | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | type fieldMatch struct { | |
| 22 | + | field matchField | |
| 23 | + | positions []int | |
| 24 | + | rank int | |
| 25 | + | span int | |
| 26 | + | first int | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | type searchMatch struct { | |
| 30 | + | fieldMatches []fieldMatch | |
| 31 | + | best fieldMatch | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (m searchMatch) positions(field matchField) []int { | |
| 35 | + | for _, candidate := range m.fieldMatches { | |
| 36 | + | if candidate.field == field { | |
| 37 | + | return candidate.positions | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | return nil | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (m *Model) EnterSearch() { | |
| 44 | + | if m == nil || m.searchActive { | |
| 45 | + | return | |
| 46 | + | } | |
| 47 | + | m.searchActive = true | |
| 48 | + | m.query.SetValue("") | |
| 49 | + | m.refreshSearch(false) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func (m *Model) ExitSearch() { | |
| 53 | + | if m == nil { | |
| 54 | + | return | |
| 55 | + | } | |
| 56 | + | m.searchActive = false | |
| 57 | + | m.query.SetValue("") | |
| 58 | + | m.searchMatches = nil | |
| 59 | + | m.matchRows = nil | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func (m *Model) SearchActive() bool { return m != nil && m.searchActive } | |
| 63 | + | ||
| 64 | + | func (m *Model) Query() string { | |
| 65 | + | if m == nil { | |
| 66 | + | return "" | |
| 67 | + | } | |
| 68 | + | return m.query.Value() | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func (m *Model) SearchTitle(width ...int) string { | |
| 72 | + | if m == nil || !m.searchActive { | |
| 73 | + | return "" | |
| 74 | + | } | |
| 75 | + | full := " Search sessions & tabs: " + m.query.Value() + "_ " | |
| 76 | + | if len(width) == 0 || textCellWidth(full) <= width[0] { | |
| 77 | + | return full | |
| 78 | + | } | |
| 79 | + | const ( | |
| 80 | + | prefix = " / " | |
| 81 | + | suffix = "_ " | |
| 82 | + | ) | |
| 83 | + | fixedWidth := textCellWidth(prefix) + textCellWidth(suffix) | |
| 84 | + | if width[0] < fixedWidth { | |
| 85 | + | return tailCells("_", width[0]) | |
| 86 | + | } | |
| 87 | + | available := width[0] - fixedWidth | |
| 88 | + | return prefix + tailCells(m.query.Value(), available) + suffix | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func tailCells(text string, width int) string { | |
| 92 | + | runes := []rune(text) | |
| 93 | + | used := 0 | |
| 94 | + | start := len(runes) | |
| 95 | + | for start > 0 { | |
| 96 | + | cellWidth := renderer.RuneWidth(runes[start-1]) | |
| 97 | + | if used+cellWidth > width { | |
| 98 | + | break | |
| 99 | + | } | |
| 100 | + | used += cellWidth | |
| 101 | + | start-- | |
| 102 | + | } | |
| 103 | + | return string(runes[start:]) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func textCellWidth(text string) int { | |
| 107 | + | width := 0 | |
| 108 | + | for _, r := range text { | |
| 109 | + | width += renderer.RuneWidth(r) | |
| 110 | + | } | |
| 111 | + | return width | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func (m *Model) InsertSearch(r rune) { | |
| 115 | + | if m == nil || !m.searchActive || utf8.RuneCountInString(m.query.Value()) >= MaxSearchQueryRunes { | |
| 116 | + | return | |
| 117 | + | } | |
| 118 | + | m.query.Insert(r) | |
| 119 | + | m.refreshSearch(true) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func (m *Model) BackspaceSearch() { | |
| 123 | + | if m == nil || !m.searchActive || m.query.Value() == "" { | |
| 124 | + | return | |
| 125 | + | } | |
| 126 | + | m.query.Backspace() | |
| 127 | + | m.refreshSearch(true) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func (m *Model) ClearSearch() { | |
| 131 | + | if m == nil || !m.searchActive || m.query.Value() == "" { | |
| 132 | + | return | |
| 133 | + | } | |
| 134 | + | m.query.SetValue("") | |
| 135 | + | m.refreshSearch(false) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func (m *Model) MatchCount() int { | |
| 139 | + | if m == nil || !m.searchActive { | |
| 140 | + | return 0 | |
| 141 | + | } | |
| 142 | + | return len(m.matchRows) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // ReplaceFrom applies a fresh canonical row snapshot while retaining the | |
| 146 | + | // attachment-local search editor and exact cursor when that target still | |
| 147 | + | // exists. Callers serialize this mutation with their picker ownership lock. | |
| 148 | + | func (m *Model) ReplaceFrom(next *Model) { | |
| 149 | + | if m == nil || next == nil { | |
| 150 | + | return | |
| 151 | + | } | |
| 152 | + | cursor, hadCursor := m.Cursor() | |
| 153 | + | searchActive, query := m.searchActive, m.query.Value() | |
| 154 | + | m.mode = next.mode | |
| 155 | + | m.rows = append(m.rows[:0], next.rows...) | |
| 156 | + | m.selected = next.selected | |
| 157 | + | if hadCursor { | |
| 158 | + | for idx, candidate := range m.rows { | |
| 159 | + | if candidate.focusable && sameTarget(candidate.target(), cursor) { | |
| 160 | + | m.selected = idx | |
| 161 | + | break | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | m.searchActive = searchActive | |
| 166 | + | m.query.SetValue(query) | |
| 167 | + | m.searchMatches = nil | |
| 168 | + | m.matchRows = nil | |
| 169 | + | if !searchActive { | |
| 170 | + | return | |
| 171 | + | } | |
| 172 | + | best := m.refreshSearch(false) | |
| 173 | + | if query != "" && !m.rowMatches(m.selected) && best >= 0 { | |
| 174 | + | m.selected = best | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func sameTarget(left, right Target) bool { | |
| 179 | + | if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name || left.RemoteHost != right.RemoteHost || left.TabID != right.TabID || left.Stopped != right.Stopped || !sameOptionalInt64(left.ExpectedCreatedAt, right.ExpectedCreatedAt) { | |
| 180 | + | return false | |
| 181 | + | } | |
| 182 | + | if left.RemoteKey == nil || right.RemoteKey == nil { | |
| 183 | + | if left.RemoteKey != nil || right.RemoteKey != nil { | |
| 184 | + | return false | |
| 185 | + | } | |
| 186 | + | } else if *left.RemoteKey != *right.RemoteKey { | |
| 187 | + | return false | |
| 188 | + | } | |
| 189 | + | if left.RemoteTarget == nil || right.RemoteTarget == nil { | |
| 190 | + | return left.RemoteTarget == nil && right.RemoteTarget == nil | |
| 191 | + | } | |
| 192 | + | return *left.RemoteTarget == *right.RemoteTarget | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func sameOptionalInt64(left, right *int64) bool { | |
| 196 | + | if left == nil || right == nil { | |
| 197 | + | return left == nil && right == nil | |
| 198 | + | } | |
| 199 | + | return *left == *right | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // SelectionRejectedBySearch reports that the retained cursor is only a hidden | |
| 203 | + | // anchor and must not be interpreted as an unavailable activation target. | |
| 204 | + | func (m *Model) SelectionRejectedBySearch() bool { | |
| 205 | + | return m != nil && m.searchActive && m.query.Value() != "" && !m.rowMatches(m.selected) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func (m *Model) rowMatches(idx int) bool { | |
| 209 | + | if m == nil || !m.searchActive || m.query.Value() == "" { | |
| 210 | + | return true | |
| 211 | + | } | |
| 212 | + | _, ok := m.searchMatches[idx] | |
| 213 | + | return ok | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func (m *Model) moveSearch(delta int) { | |
| 217 | + | if m == nil || len(m.matchRows) == 0 { | |
| 218 | + | return | |
| 219 | + | } | |
| 220 | + | position := slices.Index(m.matchRows, m.selected) | |
| 221 | + | if position < 0 { | |
| 222 | + | if delta > 0 { | |
| 223 | + | m.selected = m.matchRows[0] | |
| 224 | + | } else { | |
| 225 | + | m.selected = m.matchRows[len(m.matchRows)-1] | |
| 226 | + | } | |
| 227 | + | return | |
| 228 | + | } | |
| 229 | + | next := position + delta | |
| 230 | + | if next >= 0 && next < len(m.matchRows) { | |
| 231 | + | m.selected = m.matchRows[next] | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func (m *Model) refreshSearch(selectBest bool) int { | |
| 236 | + | m.searchMatches = make(map[int]searchMatch) | |
| 237 | + | m.matchRows = make([]int, 0, len(m.rows)) | |
| 238 | + | query := strings.ToLower(m.query.Value()) | |
| 239 | + | if query == "" { | |
| 240 | + | for idx, row := range m.rows { | |
| 241 | + | if row.focusable { | |
| 242 | + | m.matchRows = append(m.matchRows, idx) | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | return -1 | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | needleRunes := []rune(query) | |
| 249 | + | bestIdx := -1 | |
| 250 | + | var best fieldMatch | |
| 251 | + | for idx, row := range m.rows { | |
| 252 | + | if !row.focusable { | |
| 253 | + | continue | |
| 254 | + | } | |
| 255 | + | matched, ok := matchRow(row, query, needleRunes) | |
| 256 | + | if !ok { | |
| 257 | + | continue | |
| 258 | + | } | |
| 259 | + | m.searchMatches[idx] = matched | |
| 260 | + | m.matchRows = append(m.matchRows, idx) | |
| 261 | + | if bestIdx < 0 || lessFieldMatch(matched.best, best) { | |
| 262 | + | bestIdx, best = idx, matched.best | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | if selectBest && bestIdx >= 0 { | |
| 266 | + | m.selected = bestIdx | |
| 267 | + | } | |
| 268 | + | return bestIdx | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func matchRow(row row, query string, needleRunes []rune) (searchMatch, bool) { | |
| 272 | + | fields := []struct { | |
| 273 | + | kind matchField | |
| 274 | + | text string | |
| 275 | + | }{ | |
| 276 | + | {matchName, row.foldedName}, | |
| 277 | + | {matchDetail, row.foldedDetail}, | |
| 278 | + | } | |
| 279 | + | if row.kind == rowTab { | |
| 280 | + | fields = append(fields, struct { | |
| 281 | + | kind matchField | |
| 282 | + | text string | |
| 283 | + | }{matchContext, row.foldedSession}) | |
| 284 | + | } | |
| 285 | + | if row.foldedHost != "" { | |
| 286 | + | fields = append(fields, struct { | |
| 287 | + | kind matchField | |
| 288 | + | text string | |
| 289 | + | }{matchContext, row.foldedHost}) | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | var result searchMatch | |
| 293 | + | for _, field := range fields { | |
| 294 | + | match, ok := scoreField(field.kind, field.text, query, needleRunes) | |
| 295 | + | if !ok { | |
| 296 | + | continue | |
| 297 | + | } | |
| 298 | + | result.fieldMatches = append(result.fieldMatches, match) | |
| 299 | + | if len(result.fieldMatches) == 1 || lessFieldMatch(match, result.best) { | |
| 300 | + | result.best = match | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | return result, len(result.fieldMatches) != 0 | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | func scoreField(field matchField, text, query string, needle []rune) (fieldMatch, bool) { | |
| 307 | + | if len(needle) == 0 { | |
| 308 | + | return fieldMatch{field: field}, true | |
| 309 | + | } | |
| 310 | + | if text == query { | |
| 311 | + | positions := rangeRunePositions(len(needle)) | |
| 312 | + | return fieldMatch{field: field, positions: positions, rank: 0, span: len(positions)}, true | |
| 313 | + | } | |
| 314 | + | if strings.HasPrefix(text, query) { | |
| 315 | + | positions := rangeRunePositions(len(needle)) | |
| 316 | + | return fieldMatch{field: field, positions: positions, rank: 1, span: len(positions)}, true | |
| 317 | + | } | |
| 318 | + | positions, ok := subsequenceRunePositions([]rune(text), needle) | |
| 319 | + | if !ok { | |
| 320 | + | return fieldMatch{}, false | |
| 321 | + | } | |
| 322 | + | return fieldMatch{field: field, positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0]}, true | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | func lessFieldMatch(left, right fieldMatch) bool { | |
| 326 | + | if left.rank != right.rank { | |
| 327 | + | return left.rank < right.rank | |
| 328 | + | } | |
| 329 | + | if left.span != right.span { | |
| 330 | + | return left.span < right.span | |
| 331 | + | } | |
| 332 | + | if left.first != right.first { | |
| 333 | + | return left.first < right.first | |
| 334 | + | } | |
| 335 | + | return left.field < right.field | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | func rangeRunePositions(length int) []int { | |
| 339 | + | positions := make([]int, length) | |
| 340 | + | for i := range positions { | |
| 341 | + | positions[i] = i | |
| 342 | + | } | |
| 343 | + | return positions | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | func subsequenceRunePositions(haystack, needle []rune) ([]int, bool) { | |
| 347 | + | if len(needle) == 0 { | |
| 348 | + | return nil, true | |
| 349 | + | } | |
| 350 | + | positions := make([]int, 0, len(needle)) | |
| 351 | + | next := 0 | |
| 352 | + | for idx, r := range haystack { | |
| 353 | + | if r != needle[next] { | |
| 354 | + | continue | |
| 355 | + | } | |
| 356 | + | positions = append(positions, idx) | |
| 357 | + | next++ | |
| 358 | + | if next == len(needle) { | |
| 359 | + | return positions, true | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | return nil, false | |
| 363 | + | } | |
| @@ -0,0 +1,363 @@ | |||||
| 1 | + | package picker | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "slices" | |||
| 5 | + | "strings" | |||
| 6 | + | "unicode/utf8" | |||
| 7 | + | ||||
| 8 | + | renderer "github.com/bnema/vev-vt" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | const MaxSearchQueryRunes = 256 | |||
| 12 | + | ||||
| 13 | + | type matchField uint8 | |||
| 14 | + | ||||
| 15 | + | const ( | |||
| 16 | + | matchName matchField = iota | |||
| 17 | + | matchDetail | |||
| 18 | + | matchContext | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | type fieldMatch struct { | |||
| 22 | + | field matchField | |||
| 23 | + | positions []int | |||
| 24 | + | rank int | |||
| 25 | + | span int | |||
| 26 | + | first int | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | type searchMatch struct { | |||
| 30 | + | fieldMatches []fieldMatch | |||
| 31 | + | best fieldMatch | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (m searchMatch) positions(field matchField) []int { | |||
| 35 | + | for _, candidate := range m.fieldMatches { | |||
| 36 | + | if candidate.field == field { | |||
| 37 | + | return candidate.positions | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | return nil | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func (m *Model) EnterSearch() { | |||
| 44 | + | if m == nil || m.searchActive { | |||
| 45 | + | return | |||
| 46 | + | } | |||
| 47 | + | m.searchActive = true | |||
| 48 | + | m.query.SetValue("") | |||
| 49 | + | m.refreshSearch(false) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func (m *Model) ExitSearch() { | |||
| 53 | + | if m == nil { | |||
| 54 | + | return | |||
| 55 | + | } | |||
| 56 | + | m.searchActive = false | |||
| 57 | + | m.query.SetValue("") | |||
| 58 | + | m.searchMatches = nil | |||
| 59 | + | m.matchRows = nil | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func (m *Model) SearchActive() bool { return m != nil && m.searchActive } | |||
| 63 | + | ||||
| 64 | + | func (m *Model) Query() string { | |||
| 65 | + | if m == nil { | |||
| 66 | + | return "" | |||
| 67 | + | } | |||
| 68 | + | return m.query.Value() | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func (m *Model) SearchTitle(width ...int) string { | |||
| 72 | + | if m == nil || !m.searchActive { | |||
| 73 | + | return "" | |||
| 74 | + | } | |||
| 75 | + | full := " Search sessions & tabs: " + m.query.Value() + "_ " | |||
| 76 | + | if len(width) == 0 || textCellWidth(full) <= width[0] { | |||
| 77 | + | return full | |||
| 78 | + | } | |||
| 79 | + | const ( | |||
| 80 | + | prefix = " / " | |||
| 81 | + | suffix = "_ " | |||
| 82 | + | ) | |||
| 83 | + | fixedWidth := textCellWidth(prefix) + textCellWidth(suffix) | |||
| 84 | + | if width[0] < fixedWidth { | |||
| 85 | + | return tailCells("_", width[0]) | |||
| 86 | + | } | |||
| 87 | + | available := width[0] - fixedWidth | |||
| 88 | + | return prefix + tailCells(m.query.Value(), available) + suffix | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func tailCells(text string, width int) string { | |||
| 92 | + | runes := []rune(text) | |||
| 93 | + | used := 0 | |||
| 94 | + | start := len(runes) | |||
| 95 | + | for start > 0 { | |||
| 96 | + | cellWidth := renderer.RuneWidth(runes[start-1]) | |||
| 97 | + | if used+cellWidth > width { | |||
| 98 | + | break | |||
| 99 | + | } | |||
| 100 | + | used += cellWidth | |||
| 101 | + | start-- | |||
| 102 | + | } | |||
| 103 | + | return string(runes[start:]) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func textCellWidth(text string) int { | |||
| 107 | + | width := 0 | |||
| 108 | + | for _, r := range text { | |||
| 109 | + | width += renderer.RuneWidth(r) | |||
| 110 | + | } | |||
| 111 | + | return width | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func (m *Model) InsertSearch(r rune) { | |||
| 115 | + | if m == nil || !m.searchActive || utf8.RuneCountInString(m.query.Value()) >= MaxSearchQueryRunes { | |||
| 116 | + | return | |||
| 117 | + | } | |||
| 118 | + | m.query.Insert(r) | |||
| 119 | + | m.refreshSearch(true) | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func (m *Model) BackspaceSearch() { | |||
| 123 | + | if m == nil || !m.searchActive || m.query.Value() == "" { | |||
| 124 | + | return | |||
| 125 | + | } | |||
| 126 | + | m.query.Backspace() | |||
| 127 | + | m.refreshSearch(true) | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func (m *Model) ClearSearch() { | |||
| 131 | + | if m == nil || !m.searchActive || m.query.Value() == "" { | |||
| 132 | + | return | |||
| 133 | + | } | |||
| 134 | + | m.query.SetValue("") | |||
| 135 | + | m.refreshSearch(false) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func (m *Model) MatchCount() int { | |||
| 139 | + | if m == nil || !m.searchActive { | |||
| 140 | + | return 0 | |||
| 141 | + | } | |||
| 142 | + | return len(m.matchRows) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // ReplaceFrom applies a fresh canonical row snapshot while retaining the | |||
| 146 | + | // attachment-local search editor and exact cursor when that target still | |||
| 147 | + | // exists. Callers serialize this mutation with their picker ownership lock. | |||
| 148 | + | func (m *Model) ReplaceFrom(next *Model) { | |||
| 149 | + | if m == nil || next == nil { | |||
| 150 | + | return | |||
| 151 | + | } | |||
| 152 | + | cursor, hadCursor := m.Cursor() | |||
| 153 | + | searchActive, query := m.searchActive, m.query.Value() | |||
| 154 | + | m.mode = next.mode | |||
| 155 | + | m.rows = append(m.rows[:0], next.rows...) | |||
| 156 | + | m.selected = next.selected | |||
| 157 | + | if hadCursor { | |||
| 158 | + | for idx, candidate := range m.rows { | |||
| 159 | + | if candidate.focusable && sameTarget(candidate.target(), cursor) { | |||
| 160 | + | m.selected = idx | |||
| 161 | + | break | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | m.searchActive = searchActive | |||
| 166 | + | m.query.SetValue(query) | |||
| 167 | + | m.searchMatches = nil | |||
| 168 | + | m.matchRows = nil | |||
| 169 | + | if !searchActive { | |||
| 170 | + | return | |||
| 171 | + | } | |||
| 172 | + | best := m.refreshSearch(false) | |||
| 173 | + | if query != "" && !m.rowMatches(m.selected) && best >= 0 { | |||
| 174 | + | m.selected = best | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func sameTarget(left, right Target) bool { | |||
| 179 | + | if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name || left.RemoteHost != right.RemoteHost || left.TabID != right.TabID || left.Stopped != right.Stopped || !sameOptionalInt64(left.ExpectedCreatedAt, right.ExpectedCreatedAt) { | |||
| 180 | + | return false | |||
| 181 | + | } | |||
| 182 | + | if left.RemoteKey == nil || right.RemoteKey == nil { | |||
| 183 | + | if left.RemoteKey != nil || right.RemoteKey != nil { | |||
| 184 | + | return false | |||
| 185 | + | } | |||
| 186 | + | } else if *left.RemoteKey != *right.RemoteKey { | |||
| 187 | + | return false | |||
| 188 | + | } | |||
| 189 | + | if left.RemoteTarget == nil || right.RemoteTarget == nil { | |||
| 190 | + | return left.RemoteTarget == nil && right.RemoteTarget == nil | |||
| 191 | + | } | |||
| 192 | + | return *left.RemoteTarget == *right.RemoteTarget | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | func sameOptionalInt64(left, right *int64) bool { | |||
| 196 | + | if left == nil || right == nil { | |||
| 197 | + | return left == nil && right == nil | |||
| 198 | + | } | |||
| 199 | + | return *left == *right | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | // SelectionRejectedBySearch reports that the retained cursor is only a hidden | |||
| 203 | + | // anchor and must not be interpreted as an unavailable activation target. | |||
| 204 | + | func (m *Model) SelectionRejectedBySearch() bool { | |||
| 205 | + | return m != nil && m.searchActive && m.query.Value() != "" && !m.rowMatches(m.selected) | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func (m *Model) rowMatches(idx int) bool { | |||
| 209 | + | if m == nil || !m.searchActive || m.query.Value() == "" { | |||
| 210 | + | return true | |||
| 211 | + | } | |||
| 212 | + | _, ok := m.searchMatches[idx] | |||
| 213 | + | return ok | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | func (m *Model) moveSearch(delta int) { | |||
| 217 | + | if m == nil || len(m.matchRows) == 0 { | |||
| 218 | + | return | |||
| 219 | + | } | |||
| 220 | + | position := slices.Index(m.matchRows, m.selected) | |||
| 221 | + | if position < 0 { | |||
| 222 | + | if delta > 0 { | |||
| 223 | + | m.selected = m.matchRows[0] | |||
| 224 | + | } else { | |||
| 225 | + | m.selected = m.matchRows[len(m.matchRows)-1] | |||
| 226 | + | } | |||
| 227 | + | return | |||
| 228 | + | } | |||
| 229 | + | next := position + delta | |||
| 230 | + | if next >= 0 && next < len(m.matchRows) { | |||
| 231 | + | m.selected = m.matchRows[next] | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func (m *Model) refreshSearch(selectBest bool) int { | |||
| 236 | + | m.searchMatches = make(map[int]searchMatch) | |||
| 237 | + | m.matchRows = make([]int, 0, len(m.rows)) | |||
| 238 | + | query := strings.ToLower(m.query.Value()) | |||
| 239 | + | if query == "" { | |||
| 240 | + | for idx, row := range m.rows { | |||
| 241 | + | if row.focusable { | |||
| 242 | + | m.matchRows = append(m.matchRows, idx) | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | return -1 | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | needleRunes := []rune(query) | |||
| 249 | + | bestIdx := -1 | |||
| 250 | + | var best fieldMatch | |||
| 251 | + | for idx, row := range m.rows { | |||
| 252 | + | if !row.focusable { | |||
| 253 | + | continue | |||
| 254 | + | } | |||
| 255 | + | matched, ok := matchRow(row, query, needleRunes) | |||
| 256 | + | if !ok { | |||
| 257 | + | continue | |||
| 258 | + | } | |||
| 259 | + | m.searchMatches[idx] = matched | |||
| 260 | + | m.matchRows = append(m.matchRows, idx) | |||
| 261 | + | if bestIdx < 0 || lessFieldMatch(matched.best, best) { | |||
| 262 | + | bestIdx, best = idx, matched.best | |||
| 263 | + | } | |||
| 264 | + | } | |||
| 265 | + | if selectBest && bestIdx >= 0 { | |||
| 266 | + | m.selected = bestIdx | |||
| 267 | + | } | |||
| 268 | + | return bestIdx | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func matchRow(row row, query string, needleRunes []rune) (searchMatch, bool) { | |||
| 272 | + | fields := []struct { | |||
| 273 | + | kind matchField | |||
| 274 | + | text string | |||
| 275 | + | }{ | |||
| 276 | + | {matchName, row.foldedName}, | |||
| 277 | + | {matchDetail, row.foldedDetail}, | |||
| 278 | + | } | |||
| 279 | + | if row.kind == rowTab { | |||
| 280 | + | fields = append(fields, struct { | |||
| 281 | + | kind matchField | |||
| 282 | + | text string | |||
| 283 | + | }{matchContext, row.foldedSession}) | |||
| 284 | + | } | |||
| 285 | + | if row.foldedHost != "" { | |||
| 286 | + | fields = append(fields, struct { | |||
| 287 | + | kind matchField | |||
| 288 | + | text string | |||
| 289 | + | }{matchContext, row.foldedHost}) | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | var result searchMatch | |||
| 293 | + | for _, field := range fields { | |||
| 294 | + | match, ok := scoreField(field.kind, field.text, query, needleRunes) | |||
| 295 | + | if !ok { | |||
| 296 | + | continue | |||
| 297 | + | } | |||
| 298 | + | result.fieldMatches = append(result.fieldMatches, match) | |||
| 299 | + | if len(result.fieldMatches) == 1 || lessFieldMatch(match, result.best) { | |||
| 300 | + | result.best = match | |||
| 301 | + | } | |||
| 302 | + | } | |||
| 303 | + | return result, len(result.fieldMatches) != 0 | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | func scoreField(field matchField, text, query string, needle []rune) (fieldMatch, bool) { | |||
| 307 | + | if len(needle) == 0 { | |||
| 308 | + | return fieldMatch{field: field}, true | |||
| 309 | + | } | |||
| 310 | + | if text == query { | |||
| 311 | + | positions := rangeRunePositions(len(needle)) | |||
| 312 | + | return fieldMatch{field: field, positions: positions, rank: 0, span: len(positions)}, true | |||
| 313 | + | } | |||
| 314 | + | if strings.HasPrefix(text, query) { | |||
| 315 | + | positions := rangeRunePositions(len(needle)) | |||
| 316 | + | return fieldMatch{field: field, positions: positions, rank: 1, span: len(positions)}, true | |||
| 317 | + | } | |||
| 318 | + | positions, ok := subsequenceRunePositions([]rune(text), needle) | |||
| 319 | + | if !ok { | |||
| 320 | + | return fieldMatch{}, false | |||
| 321 | + | } | |||
| 322 | + | return fieldMatch{field: field, positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0]}, true | |||
| 323 | + | } | |||
| 324 | + | ||||
| 325 | + | func lessFieldMatch(left, right fieldMatch) bool { | |||
| 326 | + | if left.rank != right.rank { | |||
| 327 | + | return left.rank < right.rank | |||
| 328 | + | } | |||
| 329 | + | if left.span != right.span { | |||
| 330 | + | return left.span < right.span | |||
| 331 | + | } | |||
| 332 | + | if left.first != right.first { | |||
| 333 | + | return left.first < right.first | |||
| 334 | + | } | |||
| 335 | + | return left.field < right.field | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | func rangeRunePositions(length int) []int { | |||
| 339 | + | positions := make([]int, length) | |||
| 340 | + | for i := range positions { | |||
| 341 | + | positions[i] = i | |||
| 342 | + | } | |||
| 343 | + | return positions | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | func subsequenceRunePositions(haystack, needle []rune) ([]int, bool) { | |||
| 347 | + | if len(needle) == 0 { | |||
| 348 | + | return nil, true | |||
| 349 | + | } | |||
| 350 | + | positions := make([]int, 0, len(needle)) | |||
| 351 | + | next := 0 | |||
| 352 | + | for idx, r := range haystack { | |||
| 353 | + | if r != needle[next] { | |||
| 354 | + | continue | |||
| 355 | + | } | |||
| 356 | + | positions = append(positions, idx) | |||
| 357 | + | next++ | |||
| 358 | + | if next == len(needle) { | |||
| 359 | + | return positions, true | |||
| 360 | + | } | |||
| 361 | + | } | |||
| 362 | + | return nil, false | |||
| 363 | + | } | |||