pi-odd-worktree 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +234 -2
- package/extensions/odd-worktree-tools.ts +7593 -0
- package/lib/checkout-facts.js +598 -0
- package/lib/clipboard.js +226 -0
- package/lib/gh-issues.js +1555 -0
- package/lib/issue-preview.js +273 -0
- package/lib/issues-view.js +2274 -0
- package/lib/odd-docs.js +505 -0
- package/lib/odd-markdown.js +413 -0
- package/lib/odd-view.js +990 -0
- package/lib/open-external.js +106 -0
- package/lib/preview-scroll-keys.js +192 -0
- package/lib/session-auto-name.js +292 -0
- package/lib/session-worktree-register.js +62 -0
- package/lib/tui-paint.js +273 -0
- package/lib/view-layout.js +368 -0
- package/lib/warp-create-panel.js +722 -0
- package/lib/warp-create-picker.js +906 -0
- package/lib/warp-delete-confirm.js +465 -0
- package/lib/warp-selector-view.js +1265 -0
- package/lib/worktree-card.js +1117 -0
- package/lib/worktree-changes.js +1395 -0
- package/lib/worktree-close-herdr.js +73 -0
- package/lib/worktree-git.js +1048 -0
- package/lib/worktree-hooks.js +738 -0
- package/lib/worktree-model-result.js +291 -0
- package/lib/worktree-registry.js +886 -0
- package/lib/worktree-session-guard.js +595 -0
- package/lib/worktree-symlinks.js +336 -0
- package/lib/worktree-warp-sessions.js +566 -0
- package/package.json +36 -4
- package/tools/commands.test.js +176 -0
- package/tools/fixtures/gh-api-user.json +1 -0
- package/tools/fixtures/gh-issue-list.json +1 -0
- package/tools/fixtures/gh-issue-view.json +1 -0
- package/tools/fixtures/gh-label-list.json +1 -0
- package/tools/fixtures/gh-repo-view.json +1 -0
- package/tools/fixtures/load-extension.js +66 -0
- package/tools/gh-issues-feedback.test.js +359 -0
- package/tools/gh-issues.test.js +1502 -0
- package/tools/issue-preview.test.js +216 -0
- package/tools/issues-command.test.js +1217 -0
- package/tools/issues-view.test.js +4401 -0
- package/tools/odd-docs.test.js +803 -0
- package/tools/odd-markdown.test.js +236 -0
- package/tools/odd-view.test.js +2328 -0
- package/tools/package-surface.test.js +138 -0
- package/tools/t5-session-identity.test.js +306 -0
- package/tools/warp-create-panel.test.js +185 -0
- package/tools/warp-create-picker.test.js +1357 -0
- package/tools/warp-delete-confirm.test.js +616 -0
- package/tools/warp-selector-view.test.js +1463 -0
- package/tools/worktree-changes.test.js +951 -0
- package/tools/worktree-close-herdr-async.test.js +204 -0
- package/tools/worktree-odd-migration.test.js +224 -0
- package/tools/worktree-registry-cache.test.js +163 -0
- package/tools/worktree-registry-odd.test.js +231 -0
- package/tools/worktree-registry.test.js +244 -0
- package/tools/worktree-session-guard.test.js +507 -0
- package/tools/worktree-tools.test.js +434 -0
|
@@ -0,0 +1,1265 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Warp selector, redesigned as ONE component with TWO views (warp-popup
|
|
3
|
+
// S1,S2,S5,S6,S9): the warp PANEL and the extended list. The panel is the
|
|
4
|
+
// geometry the host mounts at the bottom of the terminal (S3, L6), so its
|
|
5
|
+
// height is decided HERE, from its own content, and `rowsAvailable` bounds
|
|
6
|
+
// it. PURE: no fs, no git, no registry — every external read arrives as an
|
|
7
|
+
// injected option, so the wiring unit only has to hand over data and a
|
|
8
|
+
// `done`.
|
|
9
|
+
//
|
|
10
|
+
// Host component contract (the shape the customize panel and the pickers
|
|
11
|
+
// already use): the factory returns
|
|
12
|
+
// `{ render(width), handleInput(data), invalidate(), state }`.
|
|
13
|
+
//
|
|
14
|
+
// Options
|
|
15
|
+
// checkouts array of `{ name, path, branch, isPrincipal }`; `name`
|
|
16
|
+
// defaults to the path's last segment, `branch` may be
|
|
17
|
+
// omitted, and an entry without a `path` is dropped. An
|
|
18
|
+
// entry may also carry `lastActivity` — an epoch-ms number
|
|
19
|
+
// or an ISO string, or the `{available, value, reason}`
|
|
20
|
+
// envelope `checkout-facts` returns — plus
|
|
21
|
+
// `lastActivityReason`. Rows are ordered by that value
|
|
22
|
+
// descending, and a checkout whose value is unreadable
|
|
23
|
+
// (`null`, missing, `'unavailable'`, unparsable) sorts last
|
|
24
|
+
// with its reason kept for the facts line (S12).
|
|
25
|
+
// current `{ name, path }` (or just the path string) of the
|
|
26
|
+
// checkout this session is in: it owns the panel's header
|
|
27
|
+
// and is never listed again, in EITHER view (S4b) — warping
|
|
28
|
+
// to where the session already is moves it nowhere.
|
|
29
|
+
// canonicalPath(path)
|
|
30
|
+
// OPTIONAL identity reader. Two spellings of one checkout
|
|
31
|
+
// (a symlink, a `..` segment) are the SAME checkout when
|
|
32
|
+
// this resolves them to the same string, so neither view
|
|
33
|
+
// offers the current one under a second name. Compared for
|
|
34
|
+
// equality, never as a prefix: `feat-x` is not `feat-x2`.
|
|
35
|
+
// Read when the list is normalized (mount, reload,
|
|
36
|
+
// enrichment), never inside render(), so a frame stays free
|
|
37
|
+
// of I/O. Defaults to the path itself, for a caller that
|
|
38
|
+
// already hands over canonical paths.
|
|
39
|
+
// facts(path) reader for one checkout — the CURRENT one (its header
|
|
40
|
+
// data line, S4a) and the FOCUSED one (S4d) — called at
|
|
41
|
+
// most once per checkout until `r`. Resolves (or returns)
|
|
42
|
+
// either
|
|
43
|
+
// `null`/`undefined` or an object
|
|
44
|
+
// {
|
|
45
|
+
// branch: string,
|
|
46
|
+
// changes: the change size as `{ added, deleted }`
|
|
47
|
+
// line counts (S16); a caller that can only
|
|
48
|
+
// supply `{ files }` proves a zero when it
|
|
49
|
+
// is 0 and renders `unavailable` otherwise,
|
|
50
|
+
// ahead: commits ahead of the base (integer),
|
|
51
|
+
// behind: commits behind the base (integer),
|
|
52
|
+
// createdAt: a timestamp (epoch-ms number or ISO
|
|
53
|
+
// string) or the `{available, value, reason}`
|
|
54
|
+
// envelope; rendered as `created <age>`,
|
|
55
|
+
// lastActivity: the last-activity timestamp in the same
|
|
56
|
+
// forms; rendered as `modified <age>`,
|
|
57
|
+
// createdAtReason / lastActivityReason: optional
|
|
58
|
+
// diagnostic strings; S15 keeps them in the
|
|
59
|
+
// row's DATA and never prints them (an
|
|
60
|
+
// unreadable age renders the brief `—`),
|
|
61
|
+
// }
|
|
62
|
+
// Each field is independently optional: a missing, `null`
|
|
63
|
+
// or `'unavailable'` value renders `unavailable` (S9) and
|
|
64
|
+
// never `0`, and a missing age renders the brief `—` (S15).
|
|
65
|
+
// S1: a worktree carries no issue or ODD association any
|
|
66
|
+
// more, so an `issues`/`odd`/`assignedOdd` the reader still
|
|
67
|
+
// returns (legacy registry data is preserved, never
|
|
68
|
+
// migrated) is IGNORED: no association reaches a rendered
|
|
69
|
+
// line, a row or a legend. A measured zero for the
|
|
70
|
+
// change size or for one of `↑`/`↓` still disappears from the
|
|
71
|
+
// line entirely instead of printing `0`. A rejected read
|
|
72
|
+
// degrades to every fact `unavailable`. The read is
|
|
73
|
+
// asynchronous and never blocks a frame: a pending read
|
|
74
|
+
// renders `reading …`.
|
|
75
|
+
// reload() called on `r`; may return a new checkouts array (or a
|
|
76
|
+
// promise of one). Every cached fact is dropped either way.
|
|
77
|
+
// onToggle(path) OPTIONAL. When supplied the extended list has a toggle,
|
|
78
|
+
// so `space` toggles the focused checkout while the filter
|
|
79
|
+
// is closed (S6). Without it `space` is an ordinary
|
|
80
|
+
// printable and opens the filter.
|
|
81
|
+
// helpers `{ visibleWidth, truncateToWidth, matchesKey, isKeyRelease }`.
|
|
82
|
+
// theme `{ fg(role, text), bg(role, text), bold(text) }`; S11:
|
|
83
|
+
// every frame rule, title, key, description, count, label
|
|
84
|
+
// and value paints through lib/tui-paint.js, so a shortcut KEY
|
|
85
|
+
// is accent+bold while its words are muted and no colour is
|
|
86
|
+
// named here. A missing, partial or throwing theme keeps plain
|
|
87
|
+
// text, and the focus cue stays the monochrome `▸` mark.
|
|
88
|
+
// rowsAvailable the MAXIMUM number of rows the host offers (number or
|
|
89
|
+
// function): the panel renders exactly its content and
|
|
90
|
+
// never more than this.
|
|
91
|
+
// requestRender repaint request, called after every state change.
|
|
92
|
+
// done(value) the ONLY way out, called at most once:
|
|
93
|
+
// `null` closed with `esc`/`ctrl+c` (panel)
|
|
94
|
+
// `{ warp: path }` warp to that checkout (`enter`)
|
|
95
|
+
// `{ create: true }` open the create panel (`c`, S6)
|
|
96
|
+
// `{ delete: path }` ask to DELETE that checkout
|
|
97
|
+
// (`ctrl+d`, both views)
|
|
98
|
+
// S1/S4: there is NO edit marker — the worktree edit panel
|
|
99
|
+
// was removed together with the issue/ODD association — but
|
|
100
|
+
// `ctrl+d` DOES name the focused checkout's own path. The
|
|
101
|
+
// component still decides nothing: the path is a request,
|
|
102
|
+
// and the extension owns the confirmation, the evidence it
|
|
103
|
+
// discloses, the refusals and the removal. So a keypress
|
|
104
|
+
// can never remove anything on its own, and this view needs
|
|
105
|
+
// no permission mode.
|
|
106
|
+
// `view all worktrees` (the row or `v`) never resolves: it
|
|
107
|
+
// opens the extended list of the same component, and `esc`
|
|
108
|
+
// there steps back one level to the panel.
|
|
109
|
+
//
|
|
110
|
+
// Keys, exactly (S5 + docs/keys.md): `↑`/`↓` and `j`/`k` move, `enter`
|
|
111
|
+
// chooses (and opens the extended list on `view all worktrees`), `c` opens
|
|
112
|
+
// the create flow, `ctrl+d` asks to delete the focused checkout, `v` opens the
|
|
113
|
+
// extended
|
|
114
|
+
// list, `r` reloads, `esc`/`ctrl+c` close from the panel
|
|
115
|
+
// or step back from the list. S1/S4: `e` (edit) is
|
|
116
|
+
// deliberately NOT a binding and no legend names it; `ctrl+d` is offered on
|
|
117
|
+
// the focused checkout row and never on the offer row (which is not a
|
|
118
|
+
// checkout). S9 keeps the footer
|
|
119
|
+
// shortcuts and binds no
|
|
120
|
+
// `?` help overlay: the footer already names every key. `↑`/`↓` are
|
|
121
|
+
// never repurposed for ahead/behind: both numbers ride the facts line. In the
|
|
122
|
+
// extended list the filter is always active (S6): `↑`/`↓`, `enter`, `esc`
|
|
123
|
+
// and `pgUp`/`pgDn` stay bindings, `T` or `/` open an empty filter, any
|
|
124
|
+
// other printable opens it with that character, and once open every
|
|
125
|
+
// printable — `space` included — goes to the text, `backspace` deletes one
|
|
126
|
+
// character and `delete` clears the whole query (S4/S12, and the footer names
|
|
127
|
+
// it while the search is active). `esc` closes the filter
|
|
128
|
+
// keeping the query; the next `esc` steps back to the panel. `ctrl+d` asks to
|
|
129
|
+
// delete only while the filter is closed: while the human is typing, the
|
|
130
|
+
// search footer owns `delete` and no destructive key fires.
|
|
131
|
+
//
|
|
132
|
+
// S5: a selector row is the checkout NAME alone, taking the width the host
|
|
133
|
+
// offers; the branch is not repeated there because the focused checkout's
|
|
134
|
+
// facts line already names it. S4b: the principal is the first row whenever
|
|
135
|
+
// it is not the current one, then the rows follow most-recently-modified
|
|
136
|
+
// first. S1: the facts line names no association at all — worktrees stopped
|
|
137
|
+
// receiving issues and ODD documents, so neither an id, a name nor a count of
|
|
138
|
+
// them is rendered anywhere in this component.
|
|
139
|
+
//
|
|
140
|
+
// Layout (S3/S4, pinned top to bottom):
|
|
141
|
+
// panel 7 fixed rows — top rule, the CURRENT worktree's name, its data
|
|
142
|
+
// line, a blank separator, the FOCUSED worktree's data line, the
|
|
143
|
+
// one-row key legend, the bottom rule — plus up to five worktree
|
|
144
|
+
// rows (the current is never one of them: it owns the header) and a
|
|
145
|
+
// final `view all worktrees` row only when at least one worktree is
|
|
146
|
+
// left out (S4c, reconciled in L2). With nothing to list, one
|
|
147
|
+
// `(no other worktrees)` row takes that place. The height is the
|
|
148
|
+
// content's own height, clamped by `rowsAvailable`, so the frame
|
|
149
|
+
// always fills it exactly.
|
|
150
|
+
// list 5 fixed rows — top rule, the filter row (never selectable), a
|
|
151
|
+
// page of every OTHER checkout (the current one is never a
|
|
152
|
+
// destination, so it is not a row here either), the page indicator
|
|
153
|
+
// and the key footer, the bottom rule. When nothing is left to
|
|
154
|
+
// list, the page says `(no other worktrees)` instead of offering a
|
|
155
|
+
// checkout the session is already in.
|
|
156
|
+
|
|
157
|
+
const { stripAnsi } = require('./odd-markdown.js');
|
|
158
|
+
// S11: the ONE palette the readers share decides which theme role paints each
|
|
159
|
+
// kind of token (key, title, description, count, label, value, border, error),
|
|
160
|
+
// so a footer KEY is always distinguishable from the words beside it and this
|
|
161
|
+
// view keeps no second copy of the hostile-theme guards.
|
|
162
|
+
const palette = require('./tui-paint.js');
|
|
163
|
+
// Reused, not re-implemented: the pickers' page clamps and their
|
|
164
|
+
// independent-fact row shape are the invariants this view shares.
|
|
165
|
+
const { paginate } = require('./warp-create-picker.js');
|
|
166
|
+
|
|
167
|
+
// The role per token kind lives in lib/tui-paint.js (S11); this reads it
|
|
168
|
+
// instead of restating it. `key` and `title` resolve to the palette's own
|
|
169
|
+
// emphasizing painter, everything else to its colour-only one.
|
|
170
|
+
function paintKind(theme, kind, text) {
|
|
171
|
+
const painter = palette[kind];
|
|
172
|
+
if (typeof painter === 'function') return painter(theme, text);
|
|
173
|
+
const role = palette.ROLES[kind];
|
|
174
|
+
return typeof role === 'string' ? palette.paint(theme, role, text) : String(text);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const MIN_WIDTH = 24;
|
|
178
|
+
// S4b: at most five worktree rows; S4d adds the focused data line and the
|
|
179
|
+
// legend below them.
|
|
180
|
+
const MAX_LIST_ROWS = 5;
|
|
181
|
+
// top rule + current name + current data + separator + focused data +
|
|
182
|
+
// legend + bottom rule (S4a/S4d). The list rows and the optional
|
|
183
|
+
// `view all worktrees` row come on top of these.
|
|
184
|
+
const POPUP_FIXED_ROWS = 7;
|
|
185
|
+
// The panel needs its fixed rows plus one list row to be readable at all.
|
|
186
|
+
const POPUP_MIN_HEIGHT = POPUP_FIXED_ROWS + 1;
|
|
187
|
+
const LIST_MIN_HEIGHT = 8;
|
|
188
|
+
// top rule + filter row + page indicator + footer + bottom rule.
|
|
189
|
+
const LIST_FIXED_ROWS = 5;
|
|
190
|
+
const UNAVAILABLE = 'unavailable';
|
|
191
|
+
// S2: ONE legend, ONE terminal row. `text` is the full token, `short` the
|
|
192
|
+
// compact one used when the full form does not fit, and `priority` decides
|
|
193
|
+
// what a still-too-wide legend drops first (the lowest goes first, ties drop
|
|
194
|
+
// the later token), so `esc` outlives every other hint. S11: `key` is the
|
|
195
|
+
// exact key spelling the token starts with, so lib/tui-paint.js paints that
|
|
196
|
+
// prefix with the key role (accent+bold) and the rest as its description —
|
|
197
|
+
// the compact spelling keeps the same prefix, so a key never loses its role
|
|
198
|
+
// when the row compacts. S1/S4: the tokens are the surviving bindings —
|
|
199
|
+
// `e edit` is gone, `ctrl+d delete` is back on both views (the extension owns
|
|
200
|
+
// the confirmation, so the token promises a request, never a removal).
|
|
201
|
+
const POPUP_KEYS = [
|
|
202
|
+
{ key: '↑↓', text: '↑↓ move', short: '↑↓', priority: 70 },
|
|
203
|
+
{ key: 'enter', text: 'enter choose', short: 'enter', priority: 60 },
|
|
204
|
+
{ key: 'c', text: 'c create', short: 'c create', priority: 50 },
|
|
205
|
+
{ key: 'ctrl+d', text: 'ctrl+d delete', short: 'ctrl+d', priority: 40 },
|
|
206
|
+
{ key: 'v', text: 'v all', short: 'v all', priority: 30 },
|
|
207
|
+
{ key: 'r', text: 'r reload', short: 'r reload', priority: 20 },
|
|
208
|
+
{ key: 'esc', text: 'esc close', short: 'esc close', priority: 80 },
|
|
209
|
+
];
|
|
210
|
+
const LIST_KEYS = [
|
|
211
|
+
{ key: '↑↓', text: '↑↓ move', short: '↑↓', priority: 60 },
|
|
212
|
+
{ key: 'pgUp/pgDn', text: 'pgUp/pgDn page', short: 'pgUp/pgDn', priority: 40 },
|
|
213
|
+
{ key: 'enter', text: 'enter warp', short: 'enter', priority: 50 },
|
|
214
|
+
{ key: 'ctrl+d', text: 'ctrl+d delete', short: 'ctrl+d', priority: 45 },
|
|
215
|
+
{ key: 'r', text: 'r reload', short: 'r reload', priority: 20 },
|
|
216
|
+
{ key: 'esc', text: 'esc back', short: 'esc back', priority: 70 },
|
|
217
|
+
];
|
|
218
|
+
// S12/S4: while the search field is active, Delete clears the WHOLE query and
|
|
219
|
+
// the footer says so (typing it out character by character was the reported
|
|
220
|
+
// annoyance); Backspace keeps its one-character meaning. The hints stay on
|
|
221
|
+
// the one footer row like every other legend.
|
|
222
|
+
const LIST_FILTER_KEYS = [
|
|
223
|
+
{ key: '↑↓', text: '↑↓ move', short: '↑↓', priority: 60 },
|
|
224
|
+
{ key: 'enter', text: 'enter warp', short: 'enter', priority: 50 },
|
|
225
|
+
{ key: 'backspace', text: 'backspace del char', short: 'backspace', priority: 45 },
|
|
226
|
+
{ key: 'delete', text: 'delete clear', short: 'delete clear', priority: 65 },
|
|
227
|
+
{ key: 'esc', text: 'esc keep filter', short: 'esc keep', priority: 100 },
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
function measure(helpers, text) {
|
|
231
|
+
try {
|
|
232
|
+
if (helpers && typeof helpers.visibleWidth === 'function') {
|
|
233
|
+
const size = Number(helpers.visibleWidth(text));
|
|
234
|
+
if (Number.isFinite(size)) return size;
|
|
235
|
+
}
|
|
236
|
+
} catch {
|
|
237
|
+
// Fall through to the ANSI-stripped codepoint count.
|
|
238
|
+
}
|
|
239
|
+
return [...stripAnsi(text)].length;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function fit(helpers, text, width) {
|
|
243
|
+
const clean = String(text).split('\n')[0] ?? '';
|
|
244
|
+
if (measure(helpers, clean) <= width) return clean;
|
|
245
|
+
try {
|
|
246
|
+
if (helpers && typeof helpers.truncateToWidth === 'function') {
|
|
247
|
+
const cut = helpers.truncateToWidth(clean, width, '…');
|
|
248
|
+
if (typeof cut === 'string') return cut;
|
|
249
|
+
}
|
|
250
|
+
} catch {
|
|
251
|
+
// Fall through to the plain slice.
|
|
252
|
+
}
|
|
253
|
+
const chars = [...clean];
|
|
254
|
+
return chars.length > width ? chars.slice(0, Math.max(0, width - 1)).join('') + '…' : clean;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function pad(helpers, text, width) {
|
|
258
|
+
const clipped = fit(helpers, text, width);
|
|
259
|
+
return clipped + ' '.repeat(Math.max(0, width - measure(helpers, clipped)));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// S2/S11: a key legend stays on ONE row and is measured on its PLAIN text, so
|
|
263
|
+
// painting can never change what fits. The compaction (full token, then the
|
|
264
|
+
// compact one, then the lowest-priority tokens dropped) and the per-token role
|
|
265
|
+
// painting are lib/tui-paint.js `legendRow` — one implementation shared with
|
|
266
|
+
// the other readers instead of a second copy here.
|
|
267
|
+
|
|
268
|
+
// Raw fallbacks for the keys this view binds, mirroring the sequence the
|
|
269
|
+
// installed matcher accepts in legacy mode, so the panel stays drivable
|
|
270
|
+
// when the helper module failed to load (a panel that ignores Esc reads as
|
|
271
|
+
// frozen).
|
|
272
|
+
const RAW_KEY_BYTES = {
|
|
273
|
+
escape: ['\x1b'],
|
|
274
|
+
'ctrl+c': ['\x03'],
|
|
275
|
+
enter: ['\r', '\n'],
|
|
276
|
+
return: ['\r', '\n'],
|
|
277
|
+
space: [' '],
|
|
278
|
+
backspace: ['\x7f', '\b'],
|
|
279
|
+
delete: ['\x1b[3~', '\x7f'],
|
|
280
|
+
'ctrl+d': ['\x04'],
|
|
281
|
+
up: ['\x1b[A'],
|
|
282
|
+
down: ['\x1b[B'],
|
|
283
|
+
right: ['\x1b[C'],
|
|
284
|
+
left: ['\x1b[D'],
|
|
285
|
+
pageup: ['\x1b[5~'],
|
|
286
|
+
pagedown: ['\x1b[6~'],
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
function keyText(helpers, data, names) {
|
|
290
|
+
for (const name of names) {
|
|
291
|
+
try {
|
|
292
|
+
if (helpers && typeof helpers.matchesKey === 'function' && helpers.matchesKey(data, name)) return true;
|
|
293
|
+
} catch {
|
|
294
|
+
// A throwing matcher never matches; the fallbacks below decide.
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (typeof data === 'string' && data.length > 0) {
|
|
298
|
+
for (const name of names) {
|
|
299
|
+
const raw = RAW_KEY_BYTES[String(name).toLowerCase()];
|
|
300
|
+
if (raw && raw.includes(data)) return true;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const text = typeof data === 'string' ? data : (data && (data.name || data.key)) || '';
|
|
304
|
+
const lower = String(text).toLowerCase();
|
|
305
|
+
return names.some((name) => lower === String(name).toLowerCase());
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function isKeyRelease(helpers, data) {
|
|
309
|
+
try {
|
|
310
|
+
if (helpers && typeof helpers.isKeyRelease === 'function') return helpers.isKeyRelease(data) === true;
|
|
311
|
+
} catch {
|
|
312
|
+
// A throwing classifier never releases.
|
|
313
|
+
}
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// The single printable codepoint a keystroke carries, or null. Space is
|
|
318
|
+
// printable: it only reaches this helper where no toggle claims it.
|
|
319
|
+
function printableChar(data) {
|
|
320
|
+
if (typeof data !== 'string' || data.length === 0 || data === 'space') return null;
|
|
321
|
+
const chars = [...data];
|
|
322
|
+
if (chars.length !== 1) return null;
|
|
323
|
+
const code = chars[0].codePointAt(0);
|
|
324
|
+
if (!Number.isFinite(code) || code < 0x20 || code === 0x7f) return null;
|
|
325
|
+
return chars[0];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function isSpaceKey(helpers, data) {
|
|
329
|
+
// `space` (the kitty name) and the raw byte both mean space.
|
|
330
|
+
if (data === ' ') return true;
|
|
331
|
+
return keyText(helpers, data, ['space']);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function basename(target) {
|
|
335
|
+
const parts = String(target).split(/[\\/]/).filter((part) => part.length > 0);
|
|
336
|
+
return parts.length > 0 ? parts[parts.length - 1] : String(target);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Epoch ms for a timestamp in any of the forms a caller may supply: a finite
|
|
340
|
+
// number, a Date, or an ISO string. Null when it is not a readable timestamp
|
|
341
|
+
// (S9: an unreadable value is never a zero).
|
|
342
|
+
function toTimestampMs(value) {
|
|
343
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
344
|
+
if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.getTime() : null;
|
|
345
|
+
if (typeof value === 'string') {
|
|
346
|
+
const text = value.trim();
|
|
347
|
+
if (text === '' || text === UNAVAILABLE) return null;
|
|
348
|
+
const parsed = Date.parse(text);
|
|
349
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// The ordering timestamp a checkout entry reports, in the forms the options
|
|
355
|
+
// doc names. `at: null` means unreadable, and `reason` keeps what the caller
|
|
356
|
+
// said about it so the facts line can show that instead of a fake date.
|
|
357
|
+
function lastActivityOf(entry) {
|
|
358
|
+
let value = entry.lastActivity;
|
|
359
|
+
let reason = typeof entry.lastActivityReason === 'string' && entry.lastActivityReason.length > 0
|
|
360
|
+
? entry.lastActivityReason
|
|
361
|
+
: null;
|
|
362
|
+
if (value && typeof value === 'object' && !(value instanceof Date)) {
|
|
363
|
+
if (value.available === false) {
|
|
364
|
+
return {
|
|
365
|
+
at: null,
|
|
366
|
+
reason: typeof value.reason === 'string' && value.reason.length > 0 ? value.reason : reason,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
if (typeof value.reason === 'string' && value.reason.length > 0) reason = value.reason;
|
|
370
|
+
value = value.value;
|
|
371
|
+
}
|
|
372
|
+
const at = toTimestampMs(value);
|
|
373
|
+
return { at, reason: at === null ? reason : null };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function normalizeCheckouts(list) {
|
|
377
|
+
const out = [];
|
|
378
|
+
for (const entry of Array.isArray(list) ? list : []) {
|
|
379
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
380
|
+
if (typeof entry.path !== 'string' || entry.path.length === 0) continue;
|
|
381
|
+
const name = typeof entry.name === 'string' && entry.name.length > 0 ? entry.name : basename(entry.path);
|
|
382
|
+
const branch = typeof entry.branch === 'string' && entry.branch.length > 0 ? entry.branch : null;
|
|
383
|
+
const activity = lastActivityOf(entry);
|
|
384
|
+
out.push({
|
|
385
|
+
name,
|
|
386
|
+
path: entry.path,
|
|
387
|
+
branch,
|
|
388
|
+
isPrincipal: entry.isPrincipal === true,
|
|
389
|
+
lastActivity: activity.at,
|
|
390
|
+
lastActivityReason: activity.reason,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
// S6: the principal leads whenever it is present and not the current
|
|
394
|
+
// checkout, whatever its activity: the comparator below puts a principal
|
|
395
|
+
// row ahead of every other row regardless of timestamps. Every other row
|
|
396
|
+
// follows most-recent-first; an unreadable value sorts last, and a stable
|
|
397
|
+
// sort keeps the caller's order among equals (S12).
|
|
398
|
+
out.sort((a, b) => {
|
|
399
|
+
if (a.isPrincipal !== b.isPrincipal) return a.isPrincipal ? -1 : 1;
|
|
400
|
+
if (a.lastActivity === null && b.lastActivity === null) return 0;
|
|
401
|
+
if (a.lastActivity === null) return 1;
|
|
402
|
+
if (b.lastActivity === null) return -1;
|
|
403
|
+
return b.lastActivity - a.lastActivity;
|
|
404
|
+
});
|
|
405
|
+
return out;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function normalizeCurrent(current, checkouts) {
|
|
409
|
+
if (typeof current === 'string' && current.length > 0) {
|
|
410
|
+
const hit = checkouts.find((entry) => entry.path === current);
|
|
411
|
+
return hit ? { name: hit.name, path: hit.path } : { name: basename(current), path: current };
|
|
412
|
+
}
|
|
413
|
+
if (current && typeof current === 'object' && typeof current.path === 'string' && current.path.length > 0) {
|
|
414
|
+
const name = typeof current.name === 'string' && current.name.length > 0 ? current.name : basename(current.path);
|
|
415
|
+
return { name, path: current.path };
|
|
416
|
+
}
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function missing(value) {
|
|
421
|
+
if (value === null || value === undefined) return true;
|
|
422
|
+
return typeof value === 'string' && (value.trim() === '' || value.trim() === UNAVAILABLE);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function textFact(value) {
|
|
426
|
+
return missing(value) ? UNAVAILABLE : String(value);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// S1: a worktree no longer carries an issue or ODD association, so the facts
|
|
430
|
+
// line has no association fact at all — not even a placeholder for one. A
|
|
431
|
+
// legacy `issues`/`odd`/`assignedOdd` in the payload is ignored rather than
|
|
432
|
+
// rendered, which is what keeps a stored association from reappearing on
|
|
433
|
+
// screen after the entity stopped owning it.
|
|
434
|
+
|
|
435
|
+
// Line counts for the change size (S16). `{ added, deleted }` is the fact's
|
|
436
|
+
// own shape; a caller that supplies only a file count can prove cleanliness
|
|
437
|
+
// (`files: 0` means no changed file could add or delete a line) but never a
|
|
438
|
+
// size, which stays unreadable rather than being faked or rendered as the
|
|
439
|
+
// retired file count.
|
|
440
|
+
function changeCounts(value) {
|
|
441
|
+
if (value && typeof value === 'object') {
|
|
442
|
+
const added = Number.isInteger(value.added) && value.added >= 0 ? value.added : null;
|
|
443
|
+
const deleted = Number.isInteger(value.deleted) && value.deleted >= 0 ? value.deleted : null;
|
|
444
|
+
if (added !== null && deleted !== null) return { added, deleted };
|
|
445
|
+
if (Number.isInteger(value.files) && value.files >= 0) {
|
|
446
|
+
return value.files === 0 ? { added: 0, deleted: 0 } : null;
|
|
447
|
+
}
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
if (Number.isInteger(value) && value >= 0) return value === 0 ? { added: 0, deleted: 0 } : null;
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// The rendered change size, or `null` when it is a measured zero and the
|
|
455
|
+
// field must not appear at all (S16). `unavailable` is NOT a zero and stays.
|
|
456
|
+
// A readable size is the bare `+A −D`: the sign already says what the numbers
|
|
457
|
+
// are, so the label in front of it was noise (feedback). The label survives
|
|
458
|
+
// ONLY where the size is unreadable, because a bare `unavailable` would not
|
|
459
|
+
// say which fact is missing.
|
|
460
|
+
function changesFact(value) {
|
|
461
|
+
if (missing(value)) return UNAVAILABLE;
|
|
462
|
+
const counts = changeCounts(value);
|
|
463
|
+
if (counts === null) return UNAVAILABLE;
|
|
464
|
+
if (counts.added === 0 && counts.deleted === 0) return null;
|
|
465
|
+
// A zero side is dropped rather than printed: `−0` and `+0` say nothing a
|
|
466
|
+
// reader needs, so only the nonzero half of the pair appears.
|
|
467
|
+
const parts = [];
|
|
468
|
+
if (counts.added !== 0) parts.push(`+${counts.added}`);
|
|
469
|
+
if (counts.deleted !== 0) parts.push(`−${counts.deleted}`);
|
|
470
|
+
return parts.join(' ');
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// One direction of ahead/behind (S16): a measured zero is omitted (`null`),
|
|
474
|
+
// while an unreadable value is not a zero and stays with its glyph.
|
|
475
|
+
function arrowFact(glyph, value) {
|
|
476
|
+
if (missing(value)) return `${glyph}${UNAVAILABLE}`;
|
|
477
|
+
const count = Number.isInteger(value) && value >= 0 ? value : null;
|
|
478
|
+
if (count === null) return `${glyph}${UNAVAILABLE}`;
|
|
479
|
+
if (count === 0) return null;
|
|
480
|
+
return `${glyph}${count}`;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Relative age in this package's style, the same one the issues view uses
|
|
484
|
+
// (including its `ago` suffix) so two surfaces never describe the same clock
|
|
485
|
+
// two different ways: just now, minutes, hours, days, months, years.
|
|
486
|
+
function relativeAge(ms, nowMs) {
|
|
487
|
+
const diff = nowMs - ms;
|
|
488
|
+
if (!Number.isFinite(diff) || diff < 0) return 'just now';
|
|
489
|
+
const minutes = Math.floor(diff / 60000);
|
|
490
|
+
if (minutes < 1) return 'just now';
|
|
491
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
492
|
+
const hours = Math.floor(minutes / 60);
|
|
493
|
+
if (hours < 24) return `${hours}h ago`;
|
|
494
|
+
const days = Math.floor(hours / 24);
|
|
495
|
+
if (days < 30) return `${days}d ago`;
|
|
496
|
+
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
|
|
497
|
+
return `${Math.floor(days / 365)}y ago`;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// The brief marker a compact facts row renders for an age it cannot read
|
|
501
|
+
// (S15). The caller's prose reason ("no activity since the checkout was
|
|
502
|
+
// created") stays in the row's data, never on screen: a preview line is a
|
|
503
|
+
// value, not an explanation.
|
|
504
|
+
const UNAVAILABLE_AGE = '—';
|
|
505
|
+
|
|
506
|
+
// One age fact: a timestamp in any accepted form, or the checkout-facts
|
|
507
|
+
// envelope. Unreadable renders the brief marker — never a fake date and never
|
|
508
|
+
// the caller's long reason.
|
|
509
|
+
function ageFact(raw, nowMs) {
|
|
510
|
+
let value = raw;
|
|
511
|
+
if (value && typeof value === 'object' && !(value instanceof Date)) value = value.value;
|
|
512
|
+
const at = toTimestampMs(value);
|
|
513
|
+
return at === null ? UNAVAILABLE_AGE : relativeAge(at, nowMs);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// A panel data line (S4a/S4d): every fact of one checkout, one line, each
|
|
517
|
+
// independently honest (S9). S16 renders the change size as line counts and
|
|
518
|
+
// DROPS a measured zero for the change size and for each of `↑`/`↓`, while
|
|
519
|
+
// `unavailable` always stays. S15 keeps the
|
|
520
|
+
// two ages brief: an unreadable one is `—`, and the diagnostic reason the
|
|
521
|
+
// caller supplied travels with the row's data instead of this line. Exported
|
|
522
|
+
// so the wiring unit and its tests format exactly what the panel renders.
|
|
523
|
+
// S1: no association fact is rendered (a legacy one in the payload is
|
|
524
|
+
// ignored). `opts.now` pins the clock both ages use.
|
|
525
|
+
function formatFactsLine(facts, opts) {
|
|
526
|
+
const value = facts && typeof facts === 'object' ? facts : {};
|
|
527
|
+
const settings = opts && typeof opts === 'object' ? opts : {};
|
|
528
|
+
const nowMs = Number.isFinite(settings.now) ? settings.now : Date.now();
|
|
529
|
+
const parts = [`branch ${textFact(value.branch)}`];
|
|
530
|
+
const changes = changesFact(value.changes);
|
|
531
|
+
// S16: a readable size stands alone; an unreadable one keeps its label so
|
|
532
|
+
// the line still names the fact that could not be read.
|
|
533
|
+
if (changes === UNAVAILABLE) parts.push(`changes ${UNAVAILABLE}`);
|
|
534
|
+
else if (changes !== null) parts.push(changes);
|
|
535
|
+
const arrows = [arrowFact('↑', value.ahead), arrowFact('↓', value.behind)]
|
|
536
|
+
.filter((part) => part !== null)
|
|
537
|
+
.join(' ');
|
|
538
|
+
if (arrows.length > 0) parts.push(arrows);
|
|
539
|
+
parts.push(
|
|
540
|
+
`created ${ageFact(value.createdAt, nowMs)}`,
|
|
541
|
+
`modified ${ageFact(value.lastActivity, nowMs)}`,
|
|
542
|
+
);
|
|
543
|
+
return parts.join(' · ');
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function filterCheckouts(checkouts, query) {
|
|
547
|
+
const needle = String(query ?? '').trim().toLowerCase();
|
|
548
|
+
if (needle === '') return checkouts.slice();
|
|
549
|
+
return checkouts.filter((entry) => [entry.name, entry.branch || '', entry.path]
|
|
550
|
+
.some((field) => String(field).toLowerCase().includes(needle)));
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function createWarpSelectorView(options = {}) {
|
|
554
|
+
const opts = options && typeof options === 'object' ? options : {};
|
|
555
|
+
let checkouts = normalizeCheckouts(opts.checkouts);
|
|
556
|
+
const current = normalizeCurrent(opts.current, checkouts);
|
|
557
|
+
const currentPath = current ? current.path : null;
|
|
558
|
+
const canonicalizer = typeof opts.canonicalPath === 'function' ? opts.canonicalPath : null;
|
|
559
|
+
// path -> checkout identity, filled when the list is NORMALIZED (mount,
|
|
560
|
+
// reload, enrichment) and read by the frames: render() performs no I/O.
|
|
561
|
+
const identities = new Map();
|
|
562
|
+
const currentId = identityOf(currentPath);
|
|
563
|
+
rememberIdentities();
|
|
564
|
+
const factsReader = typeof opts.facts === 'function' ? opts.facts : null;
|
|
565
|
+
const reloader = typeof opts.reload === 'function' ? opts.reload : null;
|
|
566
|
+
const toggler = typeof opts.onToggle === 'function' ? opts.onToggle : null;
|
|
567
|
+
const helpers = opts.helpers || null;
|
|
568
|
+
const theme = opts.theme || null;
|
|
569
|
+
const requestRender = typeof opts.requestRender === 'function' ? opts.requestRender : null;
|
|
570
|
+
const rawFinish = typeof opts.done === 'function' ? opts.done : () => {};
|
|
571
|
+
const nowOption = opts.now;
|
|
572
|
+
const nowFn = typeof nowOption === 'function'
|
|
573
|
+
? () => {
|
|
574
|
+
const value = nowOption();
|
|
575
|
+
return Number.isFinite(value) ? value : Date.now();
|
|
576
|
+
}
|
|
577
|
+
: (Number.isFinite(nowOption) ? () => nowOption : () => Date.now());
|
|
578
|
+
let settled = false;
|
|
579
|
+
|
|
580
|
+
// The state is exposed for the wiring unit and for its tests: `view` is
|
|
581
|
+
// 'popup' | 'list', `cursor` indexes the panel's selectable rows, and
|
|
582
|
+
// `listCursor` the filtered extended list; `filter`/`filterOpen` are the
|
|
583
|
+
// always-active filter, `page` the visible page, `help` the overlay, and
|
|
584
|
+
// `checkouts`/`facts` the last data the component was given.
|
|
585
|
+
const state = {
|
|
586
|
+
view: 'popup',
|
|
587
|
+
cursor: 0,
|
|
588
|
+
listCursor: 0,
|
|
589
|
+
filter: '',
|
|
590
|
+
filterOpen: false,
|
|
591
|
+
page: 0,
|
|
592
|
+
checkouts,
|
|
593
|
+
facts: { path: null, status: 'idle' },
|
|
594
|
+
};
|
|
595
|
+
// path -> { status: 'pending'|'ready'|'error', value }; S9 caches so a
|
|
596
|
+
// frame never re-reads and a move back is instant.
|
|
597
|
+
const factCache = new Map();
|
|
598
|
+
// S4a/S4d: the panel opens with two data lines — the current worktree's
|
|
599
|
+
// and the first focused row's — so both reads start here instead of in
|
|
600
|
+
// render() (S1: a frame performs no I/O).
|
|
601
|
+
startFacts(currentPath);
|
|
602
|
+
startFacts(focusedPopupPath());
|
|
603
|
+
|
|
604
|
+
function finish(value) {
|
|
605
|
+
if (settled) return;
|
|
606
|
+
settled = true;
|
|
607
|
+
rawFinish(value);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function requestPaint() {
|
|
611
|
+
if (!requestRender) return;
|
|
612
|
+
try {
|
|
613
|
+
requestRender();
|
|
614
|
+
} catch {
|
|
615
|
+
// A repaint failure never breaks navigation.
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function availableRows() {
|
|
620
|
+
let raw;
|
|
621
|
+
try {
|
|
622
|
+
raw = typeof opts.rowsAvailable === 'function' ? opts.rowsAvailable() : opts.rowsAvailable;
|
|
623
|
+
} catch {
|
|
624
|
+
return 0;
|
|
625
|
+
}
|
|
626
|
+
const rows = Math.floor(Number(raw));
|
|
627
|
+
return Number.isFinite(rows) && rows > 0 ? rows : 0;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function startFacts(path) {
|
|
631
|
+
if (typeof path !== 'string' || path.length === 0) return;
|
|
632
|
+
if (factCache.has(path)) return;
|
|
633
|
+
if (!factsReader) {
|
|
634
|
+
factCache.set(path, { status: 'error', value: null });
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
factCache.set(path, { status: 'pending', value: null });
|
|
638
|
+
state.facts = { path, status: 'pending' };
|
|
639
|
+
let answer;
|
|
640
|
+
try {
|
|
641
|
+
answer = factsReader(path);
|
|
642
|
+
} catch {
|
|
643
|
+
factCache.set(path, { status: 'error', value: null });
|
|
644
|
+
state.facts = { path, status: 'error' };
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
Promise.resolve(answer).then(
|
|
648
|
+
(value) => {
|
|
649
|
+
factCache.set(path, { status: 'ready', value });
|
|
650
|
+
if (state.facts.path === path) state.facts = { path, status: 'ready' };
|
|
651
|
+
if (state.view === 'popup' && (focusedPopupPath() === path || currentPath === path)) requestPaint();
|
|
652
|
+
},
|
|
653
|
+
() => {
|
|
654
|
+
factCache.set(path, { status: 'error', value: null });
|
|
655
|
+
if (state.facts.path === path) state.facts = { path, status: 'error' };
|
|
656
|
+
if (state.view === 'popup' && (focusedPopupPath() === path || currentPath === path)) requestPaint();
|
|
657
|
+
},
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// The destinations this session can move to: every checkout except the one
|
|
662
|
+
// it already stands in (S4b). Both views read this one list, so neither can
|
|
663
|
+
// offer a route the session is already on.
|
|
664
|
+
function others() {
|
|
665
|
+
return checkouts.filter((entry) => !isCurrent(entry));
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// The identity of one path: the canonical spelling when the caller supplied
|
|
669
|
+
// a reader, the path itself otherwise. A reader that throws or answers
|
|
670
|
+
// nothing leaves the spelling as the identity instead of breaking a frame.
|
|
671
|
+
function identityOf(value) {
|
|
672
|
+
if (typeof value !== 'string' || value.length === 0) return null;
|
|
673
|
+
if (!canonicalizer) return value;
|
|
674
|
+
try {
|
|
675
|
+
const resolved = canonicalizer(value);
|
|
676
|
+
if (typeof resolved === 'string' && resolved.length > 0) return resolved;
|
|
677
|
+
} catch {
|
|
678
|
+
// A hostile reader leaves the spelling as the identity.
|
|
679
|
+
}
|
|
680
|
+
return value;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Rebuilt from the list that was just normalized, so a checkout that left
|
|
684
|
+
// the listing leaves no identity behind and a moved one is re-read.
|
|
685
|
+
function rememberIdentities() {
|
|
686
|
+
identities.clear();
|
|
687
|
+
for (const entry of checkouts) identities.set(entry.path, identityOf(entry.path));
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// S4b: the one notion of "the checkout this session is in", shared by the
|
|
691
|
+
// panel and the extended list. Identity, never a string prefix: `feat-x` is
|
|
692
|
+
// not `feat-x2`, and a symlink spelling of the current checkout is still the
|
|
693
|
+
// current checkout. A session standing outside every listed checkout (or no
|
|
694
|
+
// `current` at all) excludes nothing, which is the honest answer.
|
|
695
|
+
function isCurrent(entry) {
|
|
696
|
+
if (currentId === null) return false;
|
|
697
|
+
const id = identities.has(entry.path) ? identities.get(entry.path) : entry.path;
|
|
698
|
+
return id === currentId;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// S5: a row is the name alone. `describe` appends only the non-branch role
|
|
702
|
+
// markers (principal); the current checkout is never a row to mark.
|
|
703
|
+
function describe(entry) {
|
|
704
|
+
return entry.isPrincipal ? 'use the local project' : null;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function rowText(entry, marker) {
|
|
708
|
+
const desc = describe(entry);
|
|
709
|
+
return `${marker}${entry.name}${desc ? ` · ${desc}` : ''}`;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// S4b/S4c: the list area holds at most five worktree rows and, only when
|
|
713
|
+
// at least one worktree is left out, one extra `view all worktrees` row.
|
|
714
|
+
// `maxRows` is the ceiling the host offers; `height` is what this plan
|
|
715
|
+
// actually renders, so the frame fills it exactly.
|
|
716
|
+
function popupPlan(maxRows) {
|
|
717
|
+
const list = others();
|
|
718
|
+
const area = Math.max(0, maxRows - POPUP_FIXED_ROWS);
|
|
719
|
+
if (list.length === 0) {
|
|
720
|
+
// One muted row explains the empty list inside the frame.
|
|
721
|
+
return { list, shown: [], viewAll: false, height: POPUP_FIXED_ROWS + 1 };
|
|
722
|
+
}
|
|
723
|
+
if (list.length <= Math.min(MAX_LIST_ROWS, area)) {
|
|
724
|
+
return { list, shown: list, viewAll: false, height: POPUP_FIXED_ROWS + list.length };
|
|
725
|
+
}
|
|
726
|
+
// Something is hidden, so the offer row costs one of the area's rows.
|
|
727
|
+
const shown = list.slice(0, Math.max(0, Math.min(MAX_LIST_ROWS, area - 1)));
|
|
728
|
+
const viewAll = area >= 1;
|
|
729
|
+
return { list, shown, viewAll, height: POPUP_FIXED_ROWS + shown.length + (viewAll ? 1 : 0) };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// S6: creation is a hotkey, never a list row, so the selectable rows are
|
|
733
|
+
// exactly the worktrees shown plus the offer row.
|
|
734
|
+
function popupItems(plan) {
|
|
735
|
+
const items = [];
|
|
736
|
+
for (const entry of plan.shown) items.push({ kind: 'checkout', checkout: entry });
|
|
737
|
+
if (plan.viewAll) items.push({ kind: 'viewall' });
|
|
738
|
+
return items;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function popupItemsNow() {
|
|
742
|
+
return popupItems(popupPlan(availableRows()));
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function focusedPopupItem() {
|
|
746
|
+
const items = popupItemsNow();
|
|
747
|
+
return items[Math.min(Math.max(0, state.cursor), items.length - 1)] || null;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function focusedPopupPath() {
|
|
751
|
+
const item = focusedPopupItem();
|
|
752
|
+
return item && item.kind === 'checkout' ? item.checkout.path : null;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// S4a/S4d: one formatter for every data line this panel draws. A cached
|
|
756
|
+
// read is rendered as it is, a pending one says so, and a path that was
|
|
757
|
+
// never read stays empty instead of claiming a fact.
|
|
758
|
+
function factsTextFor(path) {
|
|
759
|
+
if (typeof path !== 'string' || path.length === 0) return '';
|
|
760
|
+
const nowMs = nowFn();
|
|
761
|
+
const line = (value) => formatFactsLine(value, { now: nowMs });
|
|
762
|
+
const entry = factCache.get(path);
|
|
763
|
+
if (!entry) return '';
|
|
764
|
+
if (entry.status === 'ready') return line(entry.value);
|
|
765
|
+
if (entry.status === 'pending') return 'reading …';
|
|
766
|
+
return line(null);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function currentFactsText() {
|
|
770
|
+
return factsTextFor(currentPath);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// S4d: the row the cursor sits on, or an empty line when nothing is
|
|
774
|
+
// listed (the list area already says why).
|
|
775
|
+
function factsRowText() {
|
|
776
|
+
const item = focusedPopupItem();
|
|
777
|
+
if (!item) return '';
|
|
778
|
+
if (item.kind === 'viewall') return 'every worktree, searchable';
|
|
779
|
+
return factsTextFor(item.checkout.path);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// S4b: the extended list is the same set of destinations the panel offers —
|
|
783
|
+
// every checkout except the one this session already stands in — filtered
|
|
784
|
+
// by the always-active query and paged.
|
|
785
|
+
function listPlan(height) {
|
|
786
|
+
const rows = filterCheckouts(others(), state.filter);
|
|
787
|
+
const pageSize = Math.max(1, height - LIST_FIXED_ROWS);
|
|
788
|
+
const requested = rows.length === 0 ? 0 : Math.floor(Math.max(0, state.listCursor) / pageSize);
|
|
789
|
+
const page = paginate(rows, requested, pageSize);
|
|
790
|
+
const start = page.page * pageSize;
|
|
791
|
+
const end = Math.min(rows.length, start + page.items.length);
|
|
792
|
+
if (rows.length === 0) state.listCursor = 0;
|
|
793
|
+
else state.listCursor = Math.min(Math.max(start, state.listCursor), end - 1);
|
|
794
|
+
state.page = page.page;
|
|
795
|
+
return { rows, page: page.page, pageCount: page.pageCount, pageSize, items: page.items, cursor: state.listCursor };
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function focusedListCheckout() {
|
|
799
|
+
const height = availableRows();
|
|
800
|
+
if (!height) return null;
|
|
801
|
+
const plan = listPlan(height);
|
|
802
|
+
return plan.items[plan.cursor - plan.page * plan.pageSize] || null;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function movePopup(delta) {
|
|
806
|
+
const height = availableRows();
|
|
807
|
+
if (!height) return;
|
|
808
|
+
const items = popupItems(popupPlan(height));
|
|
809
|
+
if (items.length === 0) return;
|
|
810
|
+
state.cursor = Math.min(items.length - 1, Math.max(0, state.cursor + delta));
|
|
811
|
+
// S1: a move is what starts the focused checkout's read. render() itself
|
|
812
|
+
// never performs I/O.
|
|
813
|
+
if (state.view === 'popup') startFacts(focusedPopupPath());
|
|
814
|
+
requestPaint();
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function moveList(delta) {
|
|
818
|
+
const height = availableRows();
|
|
819
|
+
if (!height) return;
|
|
820
|
+
const plan = listPlan(height);
|
|
821
|
+
if (plan.rows.length === 0) return;
|
|
822
|
+
state.listCursor = Math.min(plan.rows.length - 1, Math.max(0, plan.cursor + delta));
|
|
823
|
+
requestPaint();
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function pageList(direction) {
|
|
827
|
+
const height = availableRows();
|
|
828
|
+
if (!height) return;
|
|
829
|
+
const plan = listPlan(height);
|
|
830
|
+
if (plan.rows.length === 0) return;
|
|
831
|
+
const next = direction > 0
|
|
832
|
+
? Math.min(plan.page + 1, plan.pageCount - 1)
|
|
833
|
+
: Math.max(0, plan.page - 1);
|
|
834
|
+
state.listCursor = Math.min(plan.rows.length - 1, next * plan.pageSize);
|
|
835
|
+
requestPaint();
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function openList() {
|
|
839
|
+
state.view = 'list';
|
|
840
|
+
state.listCursor = 0;
|
|
841
|
+
const focused = focusedPopupItem();
|
|
842
|
+
if (focused && focused.kind === 'checkout') {
|
|
843
|
+
const rows = filterCheckouts(others(), state.filter);
|
|
844
|
+
const at = rows.findIndex((entry) => entry.path === focused.checkout.path);
|
|
845
|
+
if (at >= 0) state.listCursor = at;
|
|
846
|
+
}
|
|
847
|
+
requestPaint();
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function openFilter(seed) {
|
|
851
|
+
state.filterOpen = true;
|
|
852
|
+
if (typeof seed === 'string' && seed.length > 0) state.filter += seed;
|
|
853
|
+
state.listCursor = 0;
|
|
854
|
+
requestPaint();
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function closeFilter() {
|
|
858
|
+
state.filterOpen = false;
|
|
859
|
+
requestPaint();
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function appendFilter(text) {
|
|
863
|
+
state.filter += text;
|
|
864
|
+
state.listCursor = 0;
|
|
865
|
+
requestPaint();
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function backspaceFilter() {
|
|
869
|
+
const chars = [...state.filter];
|
|
870
|
+
if (chars.length === 0) return;
|
|
871
|
+
chars.pop();
|
|
872
|
+
state.filter = chars.join('');
|
|
873
|
+
state.listCursor = 0;
|
|
874
|
+
requestPaint();
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// S12/S4: Delete empties the whole query at once while the search field is
|
|
878
|
+
// active. It is a text-editing key here, never the worktree deletion
|
|
879
|
+
// binding (`ctrl+d` owns that).
|
|
880
|
+
function clearFilter() {
|
|
881
|
+
if (state.filter === '') return;
|
|
882
|
+
state.filter = '';
|
|
883
|
+
state.listCursor = 0;
|
|
884
|
+
requestPaint();
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function reload() {
|
|
888
|
+
factCache.clear();
|
|
889
|
+
state.facts = { path: null, status: 'idle' };
|
|
890
|
+
// The current worktree's line is not part of the reloaded list, so its
|
|
891
|
+
// read restarts before the reloader's answer can start the focused one.
|
|
892
|
+
if (state.view === 'popup') startFacts(currentPath);
|
|
893
|
+
let next;
|
|
894
|
+
try {
|
|
895
|
+
next = reloader ? reloader() : null;
|
|
896
|
+
} catch {
|
|
897
|
+
next = null;
|
|
898
|
+
}
|
|
899
|
+
const settle = (value) => { if (Array.isArray(value)) apply(value); };
|
|
900
|
+
if (next && typeof next.then === 'function') Promise.resolve(next).then(settle, () => {});
|
|
901
|
+
else settle(next);
|
|
902
|
+
requestPaint();
|
|
903
|
+
if (state.view === 'popup') startFacts(focusedPopupPath());
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// S1: apply enrichment that arrived AFTER the mount (activity ages, newly
|
|
907
|
+
// readable facts). The rows re-sort, but the focused checkout keeps its
|
|
908
|
+
// identity across the reorder instead of the cursor silently landing on a
|
|
909
|
+
// different row. A settled component ignores late work (only still-alive
|
|
910
|
+
// components repaint).
|
|
911
|
+
function apply(list) {
|
|
912
|
+
if (settled || !Array.isArray(list)) return;
|
|
913
|
+
const focusPath = state.view === 'list'
|
|
914
|
+
? ((focusedListCheckout() || {}).path || null)
|
|
915
|
+
: focusedPopupPath();
|
|
916
|
+
checkouts = normalizeCheckouts(list);
|
|
917
|
+
state.checkouts = checkouts;
|
|
918
|
+
rememberIdentities();
|
|
919
|
+
refocus(focusPath);
|
|
920
|
+
requestPaint();
|
|
921
|
+
if (state.view === 'popup') startFacts(focusedPopupPath());
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function refocus(path) {
|
|
925
|
+
if (typeof path !== 'string' || path.length === 0) return;
|
|
926
|
+
if (state.view === 'list') {
|
|
927
|
+
const rows = filterCheckouts(others(), state.filter);
|
|
928
|
+
const at = rows.findIndex((entry) => entry.path === path);
|
|
929
|
+
if (at >= 0) state.listCursor = at;
|
|
930
|
+
else state.listCursor = Math.min(state.listCursor, Math.max(0, rows.length - 1));
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
const items = popupItemsNow();
|
|
934
|
+
const at = items.findIndex((item) => item.kind === 'checkout' && item.checkout.path === path);
|
|
935
|
+
if (at >= 0) state.cursor = at;
|
|
936
|
+
else state.cursor = Math.min(state.cursor, Math.max(0, items.length - 1));
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function choosePopup() {
|
|
940
|
+
const item = focusedPopupItem();
|
|
941
|
+
if (!item) return;
|
|
942
|
+
if (item.kind === 'viewall') {
|
|
943
|
+
openList();
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
finish({ warp: item.checkout.path });
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// S4: `ctrl+d` NAMES the checkout to delete and nothing else. The marker
|
|
950
|
+
// carries the focused path, so the extension re-resolves it against a fresh
|
|
951
|
+
// listing and asks its own question; nothing here reads a permission mode,
|
|
952
|
+
// inspects changes or removes anything. The offer row is not a checkout, so
|
|
953
|
+
// it resolves nothing.
|
|
954
|
+
function askDelete(item) {
|
|
955
|
+
if (!item || item.kind !== 'checkout') return;
|
|
956
|
+
finish({ delete: item.checkout.path });
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function askDeletePopup() {
|
|
960
|
+
askDelete(focusedPopupItem());
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function askDeleteList() {
|
|
964
|
+
const entry = focusedListCheckout();
|
|
965
|
+
if (!entry) return;
|
|
966
|
+
finish({ delete: entry.path });
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function chooseList() {
|
|
970
|
+
const entry = focusedListCheckout();
|
|
971
|
+
if (!entry) return;
|
|
972
|
+
finish({ warp: entry.path });
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function handlePopupInput(data) {
|
|
976
|
+
if (keyText(helpers, data, ['escape', 'ctrl+c'])) {
|
|
977
|
+
finish(null);
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
if (keyText(helpers, data, ['enter', 'return'])) {
|
|
981
|
+
choosePopup();
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
if (keyText(helpers, data, ['up', 'k'])) {
|
|
985
|
+
movePopup(-1);
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
if (keyText(helpers, data, ['down', 'j'])) {
|
|
989
|
+
movePopup(1);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
if (keyText(helpers, data, ['c'])) {
|
|
993
|
+
// S6: creation is a hotkey, never one of the list rows.
|
|
994
|
+
finish({ create: true });
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
if (keyText(helpers, data, ['ctrl+d'])) {
|
|
998
|
+
askDeletePopup();
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
if (keyText(helpers, data, ['v'])) {
|
|
1002
|
+
openList();
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
if (keyText(helpers, data, ['r'])) {
|
|
1006
|
+
reload();
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function handleListInput(data) {
|
|
1011
|
+
const open = state.filterOpen;
|
|
1012
|
+
// Navigation and confirmation stay bindings while the filter is open (S6).
|
|
1013
|
+
if (keyText(helpers, data, ['up'])) {
|
|
1014
|
+
moveList(-1);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
if (keyText(helpers, data, ['down'])) {
|
|
1018
|
+
moveList(1);
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
if (keyText(helpers, data, ['pageup'])) {
|
|
1022
|
+
pageList(-1);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
if (keyText(helpers, data, ['pagedown'])) {
|
|
1026
|
+
pageList(1);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
if (keyText(helpers, data, ['escape', 'ctrl+c'])) {
|
|
1030
|
+
if (open) closeFilter();
|
|
1031
|
+
else {
|
|
1032
|
+
state.view = 'popup';
|
|
1033
|
+
// S1: stepping back into the panel may focus a checkout that has not
|
|
1034
|
+
// been read yet; start its read now, never inside render().
|
|
1035
|
+
startFacts(focusedPopupPath());
|
|
1036
|
+
requestPaint();
|
|
1037
|
+
}
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
if (keyText(helpers, data, ['enter', 'return'])) {
|
|
1041
|
+
chooseList();
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
if (!open && keyText(helpers, data, ['k'])) {
|
|
1045
|
+
moveList(-1);
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
if (!open && keyText(helpers, data, ['j'])) {
|
|
1049
|
+
moveList(1);
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
if (!open && keyText(helpers, data, ['r'])) {
|
|
1053
|
+
reload();
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
// S4: the delete request is a browsing binding, so the search field owns
|
|
1057
|
+
// the keyboard instead (`delete` there clears the query).
|
|
1058
|
+
if (!open && keyText(helpers, data, ['ctrl+d'])) {
|
|
1059
|
+
askDeleteList();
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
if (isSpaceKey(helpers, data)) {
|
|
1063
|
+
if (open) {
|
|
1064
|
+
appendFilter(' ');
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
if (toggler) {
|
|
1068
|
+
const entry = focusedListCheckout();
|
|
1069
|
+
if (!entry) return;
|
|
1070
|
+
try {
|
|
1071
|
+
toggler(entry.path);
|
|
1072
|
+
} catch {
|
|
1073
|
+
// A hostile toggle never breaks navigation.
|
|
1074
|
+
}
|
|
1075
|
+
requestPaint();
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
openFilter(' ');
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
if (keyText(helpers, data, ['backspace'])) {
|
|
1082
|
+
if (open) backspaceFilter();
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
if (keyText(helpers, data, ['delete'])) {
|
|
1086
|
+
if (open) clearFilter();
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
const typed = printableChar(data);
|
|
1090
|
+
if (!open && (typed === 'T' || typed === '/')) {
|
|
1091
|
+
// The explicit openers start a NEW search, so a stale query never
|
|
1092
|
+
// silently hides the rows the user is about to browse.
|
|
1093
|
+
state.filter = '';
|
|
1094
|
+
openFilter('');
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
if (typed) {
|
|
1098
|
+
if (open) appendFilter(typed);
|
|
1099
|
+
else openFilter(typed);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// S11: every frame mark takes the palette's border role, so a theme that
|
|
1104
|
+
// names one decides the whole frame.
|
|
1105
|
+
function border(text) {
|
|
1106
|
+
return palette.paint(theme, 'border', text);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function frameTop(width, title) {
|
|
1110
|
+
const shownTitle = fit(helpers, title, Math.max(1, width - 11));
|
|
1111
|
+
const left = `${border('╭─')} ${palette.title(theme, shownTitle)} `;
|
|
1112
|
+
const right = ` ${palette.key(theme, 'esc')} ${border('─╮')}`;
|
|
1113
|
+
const fill = Math.max(0, width - measure(helpers, left) - measure(helpers, right));
|
|
1114
|
+
return `${left}${border('─'.repeat(fill))}${right}`;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function frameBottom(width) {
|
|
1118
|
+
return border(`╰${'─'.repeat(Math.max(0, width - 2))}╯`);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// S11: a row body is either the focused band (the monochrome `▸` still marks
|
|
1122
|
+
// it when the theme offers no background) or one palette KIND.
|
|
1123
|
+
function frameRow(width, text, kind, focused) {
|
|
1124
|
+
const inner = Math.max(1, width - 4);
|
|
1125
|
+
const padded = pad(helpers, text, inner);
|
|
1126
|
+
const body = focused ? palette.highlight(theme, padded, true) : paintKind(theme, kind, padded);
|
|
1127
|
+
return `${border('│')} ${body} ${border('│')}`;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// The panel legend's tokens: the offer row's key only when there is an
|
|
1131
|
+
// offer. S1/S4: no capability token is conditional any more — the delete
|
|
1132
|
+
// key and its predicate left with the worktree association.
|
|
1133
|
+
function popupLegend(plan) {
|
|
1134
|
+
return plan.viewAll ? POPUP_KEYS.slice() : POPUP_KEYS.filter((entry) => entry.text !== 'v all');
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// S2/S11: the footer legend renders through the frame row, so it is
|
|
1138
|
+
// compacted to the same inner width the row then pads to — one row, never
|
|
1139
|
+
// wrapped — and every key/description pair carries its own role.
|
|
1140
|
+
function legendRow(width, entries) {
|
|
1141
|
+
return frameRow(width, palette.legendRow(theme, entries, Math.max(1, width - 4), (text) => measure(helpers, text)), null);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// S3/S4: the panel renders exactly `plan.height` rows, so a host that
|
|
1145
|
+
// anchors it at the bottom of the terminal gets a frame with no loose
|
|
1146
|
+
// lines inside or below it.
|
|
1147
|
+
function renderPopup(width, maxRows) {
|
|
1148
|
+
const plan = popupPlan(maxRows);
|
|
1149
|
+
const items = popupItems(plan);
|
|
1150
|
+
if (state.cursor > items.length - 1) state.cursor = Math.max(0, items.length - 1);
|
|
1151
|
+
if (state.cursor < 0) state.cursor = 0;
|
|
1152
|
+
const focused = items[state.cursor];
|
|
1153
|
+
|
|
1154
|
+
const lines = [frameTop(width, 'Warp')];
|
|
1155
|
+
lines.push(frameRow(width, current ? current.name : '(unknown checkout)', 'value'));
|
|
1156
|
+
lines.push(frameRow(width, currentFactsText(), 'count'));
|
|
1157
|
+
lines.push(frameRow(width, ''));
|
|
1158
|
+
|
|
1159
|
+
if (plan.list.length === 0) {
|
|
1160
|
+
lines.push(frameRow(width, '(no other worktrees)', 'count'));
|
|
1161
|
+
} else {
|
|
1162
|
+
for (const entry of plan.shown) {
|
|
1163
|
+
const isFocused = !!(focused && focused.kind === 'checkout' && focused.checkout.path === entry.path);
|
|
1164
|
+
lines.push(frameRow(width, rowText(entry, isFocused ? '▸ ' : ' '), null, isFocused));
|
|
1165
|
+
}
|
|
1166
|
+
if (plan.viewAll) {
|
|
1167
|
+
lines.push(frameRow(
|
|
1168
|
+
width,
|
|
1169
|
+
`${focused && focused.kind === 'viewall' ? '▸ ' : ' '}view all worktrees`,
|
|
1170
|
+
'value',
|
|
1171
|
+
!!(focused && focused.kind === 'viewall'),
|
|
1172
|
+
));
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
lines.push(frameRow(width, factsRowText(), 'count'));
|
|
1176
|
+
// S4c: with nothing left out there is no offer row, so the legend does
|
|
1177
|
+
// not advertise its key either. `v` itself keeps working.
|
|
1178
|
+
lines.push(legendRow(width, popupLegend(plan)));
|
|
1179
|
+
lines.push(frameBottom(width));
|
|
1180
|
+
return lines;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// S11: the search row paints its static label dim and the live query as a
|
|
1184
|
+
// value; the focused band marks it while the filter holds the keyboard.
|
|
1185
|
+
function filterLine() {
|
|
1186
|
+
const prefix = palette.label(theme, 'filter: ');
|
|
1187
|
+
if (state.filterOpen) return `${prefix}${palette.value(theme, `${state.filter}▏`)}`;
|
|
1188
|
+
if (state.filter === '') return `${prefix}${palette.description(theme, 'type to search')}`;
|
|
1189
|
+
return `${prefix}${palette.value(theme, state.filter)}`;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function renderList(width, height) {
|
|
1193
|
+
const plan = listPlan(height);
|
|
1194
|
+
const rowTexts = [];
|
|
1195
|
+
if (plan.items.length === 0) {
|
|
1196
|
+
const needle = state.filter.trim();
|
|
1197
|
+
rowTexts.push({ text: needle === '' ? '(no other worktrees)' : `(no matches for "${needle}")`, kind: 'count' });
|
|
1198
|
+
} else {
|
|
1199
|
+
const pageStart = plan.page * plan.pageSize;
|
|
1200
|
+
plan.items.forEach((entry, offset) => {
|
|
1201
|
+
const isFocused = pageStart + offset === plan.cursor;
|
|
1202
|
+
rowTexts.push({
|
|
1203
|
+
text: rowText(entry, isFocused ? '▸ ' : ' '),
|
|
1204
|
+
focused: isFocused,
|
|
1205
|
+
});
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
const filterText = filterLine();
|
|
1209
|
+
const lines = [frameTop(width, 'Warp · all worktrees')];
|
|
1210
|
+
lines.push(frameRow(width, filterText, state.filterOpen ? null : 'count', state.filterOpen));
|
|
1211
|
+
for (let row = 0; row < plan.pageSize; row += 1) {
|
|
1212
|
+
const entry = rowTexts[row];
|
|
1213
|
+
lines.push(entry ? frameRow(width, entry.text, entry.kind, entry.focused) : frameRow(width, ''));
|
|
1214
|
+
}
|
|
1215
|
+
const total = plan.rows.length;
|
|
1216
|
+
// S11: a static label, a live value and a count — each with its own role.
|
|
1217
|
+
lines.push(frameRow(width,
|
|
1218
|
+
`${palette.label(theme, 'page ')}${palette.value(theme, `${plan.page + 1}/${plan.pageCount}`)}`
|
|
1219
|
+
+ `${palette.count(theme, ` · ${total} ${total === 1 ? 'worktree' : 'worktrees'}`)}`,
|
|
1220
|
+
null));
|
|
1221
|
+
// S12/S4: an active search names the Delete-clear key; the browsing list
|
|
1222
|
+
// keeps its own legend. Either way it is exactly one footer row.
|
|
1223
|
+
lines.push(legendRow(width, state.filterOpen ? LIST_FILTER_KEYS : LIST_KEYS));
|
|
1224
|
+
lines.push(frameBottom(width));
|
|
1225
|
+
return lines;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function render(width) {
|
|
1229
|
+
const w = Math.floor(Number(width));
|
|
1230
|
+
if (!Number.isFinite(w) || w <= 0) return [];
|
|
1231
|
+
const maxRows = availableRows();
|
|
1232
|
+
const minimum = state.view === 'list' ? LIST_MIN_HEIGHT : POPUP_MIN_HEIGHT;
|
|
1233
|
+
if (w < MIN_WIDTH || maxRows < minimum) {
|
|
1234
|
+
return [pad(helpers, fit(helpers, 'Warp — enlarge the terminal', w), w)];
|
|
1235
|
+
}
|
|
1236
|
+
// S3/S4: the panel takes its own height (never taller than the host
|
|
1237
|
+
// offers); the extended list still fills every row it is given.
|
|
1238
|
+
return state.view === 'list' ? renderList(w, maxRows) : renderPopup(w, maxRows);
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
function handleInput(data) {
|
|
1242
|
+
if (settled) return;
|
|
1243
|
+
if (isKeyRelease(helpers, data)) return;
|
|
1244
|
+
if (state.view === 'list') {
|
|
1245
|
+
handleListInput(data);
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
handlePopupInput(data);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function invalidate() {}
|
|
1252
|
+
|
|
1253
|
+
return { render, handleInput, invalidate, apply, state };
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
module.exports = {
|
|
1257
|
+
createWarpSelectorView,
|
|
1258
|
+
formatFactsLine,
|
|
1259
|
+
MIN_WIDTH,
|
|
1260
|
+
MAX_LIST_ROWS,
|
|
1261
|
+
POPUP_MIN_HEIGHT,
|
|
1262
|
+
LIST_MIN_HEIGHT,
|
|
1263
|
+
POPUP_FIXED_ROWS,
|
|
1264
|
+
LIST_FIXED_ROWS,
|
|
1265
|
+
};
|