pi-odd-worktree 0.0.0-stage → 0.1.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.
Files changed (60) hide show
  1. package/README.md +223 -2
  2. package/extensions/odd-worktree-tools.ts +7593 -0
  3. package/lib/checkout-facts.js +598 -0
  4. package/lib/clipboard.js +226 -0
  5. package/lib/gh-issues.js +1555 -0
  6. package/lib/issue-preview.js +273 -0
  7. package/lib/issues-view.js +2274 -0
  8. package/lib/odd-docs.js +505 -0
  9. package/lib/odd-markdown.js +413 -0
  10. package/lib/odd-view.js +990 -0
  11. package/lib/open-external.js +106 -0
  12. package/lib/preview-scroll-keys.js +192 -0
  13. package/lib/session-auto-name.js +292 -0
  14. package/lib/session-worktree-register.js +62 -0
  15. package/lib/tui-paint.js +273 -0
  16. package/lib/view-layout.js +368 -0
  17. package/lib/warp-create-panel.js +722 -0
  18. package/lib/warp-create-picker.js +906 -0
  19. package/lib/warp-delete-confirm.js +465 -0
  20. package/lib/warp-selector-view.js +1265 -0
  21. package/lib/worktree-card.js +1117 -0
  22. package/lib/worktree-changes.js +1395 -0
  23. package/lib/worktree-close-herdr.js +73 -0
  24. package/lib/worktree-git.js +1048 -0
  25. package/lib/worktree-hooks.js +738 -0
  26. package/lib/worktree-model-result.js +291 -0
  27. package/lib/worktree-registry.js +886 -0
  28. package/lib/worktree-session-guard.js +595 -0
  29. package/lib/worktree-symlinks.js +336 -0
  30. package/lib/worktree-warp-sessions.js +566 -0
  31. package/package.json +36 -4
  32. package/tools/commands.test.js +176 -0
  33. package/tools/fixtures/gh-api-user.json +1 -0
  34. package/tools/fixtures/gh-issue-list.json +1 -0
  35. package/tools/fixtures/gh-issue-view.json +1 -0
  36. package/tools/fixtures/gh-label-list.json +1 -0
  37. package/tools/fixtures/gh-repo-view.json +1 -0
  38. package/tools/fixtures/load-extension.js +66 -0
  39. package/tools/gh-issues-feedback.test.js +359 -0
  40. package/tools/gh-issues.test.js +1502 -0
  41. package/tools/issue-preview.test.js +216 -0
  42. package/tools/issues-command.test.js +1217 -0
  43. package/tools/issues-view.test.js +4401 -0
  44. package/tools/odd-docs.test.js +803 -0
  45. package/tools/odd-markdown.test.js +236 -0
  46. package/tools/odd-view.test.js +2328 -0
  47. package/tools/package-surface.test.js +138 -0
  48. package/tools/t5-session-identity.test.js +306 -0
  49. package/tools/warp-create-panel.test.js +185 -0
  50. package/tools/warp-create-picker.test.js +1357 -0
  51. package/tools/warp-delete-confirm.test.js +616 -0
  52. package/tools/warp-selector-view.test.js +1463 -0
  53. package/tools/worktree-changes.test.js +951 -0
  54. package/tools/worktree-close-herdr-async.test.js +204 -0
  55. package/tools/worktree-odd-migration.test.js +224 -0
  56. package/tools/worktree-registry-cache.test.js +163 -0
  57. package/tools/worktree-registry-odd.test.js +231 -0
  58. package/tools/worktree-registry.test.js +244 -0
  59. package/tools/worktree-session-guard.test.js +507 -0
  60. package/tools/worktree-tools.test.js +434 -0
@@ -0,0 +1,722 @@
1
+ 'use strict';
2
+ // The worktree CREATION panel (worktree-ui-feedback S4,S12,S15; simplify
3
+ // S1/S6).
4
+ //
5
+ // It is the TABBED bottom flow a human creates with (S7): a live branch/path
6
+ // preview ABOVE, then the change type and the change name as the two TABS of
7
+ // one strip (`Type · Name`, with only the ACTIVE field's content under it).
8
+ // The Type tab holds the change type as an inline LIST (there is no native
9
+ // dropdown in this host, and no second pop-up surface); the Name tab holds
10
+ // the text field. The preview shows the selected type's own prefix as soon as
11
+ // the form opens (`feat/` and the checkout directory `…-worktrees/feat-`) and
12
+ // completes as the name is typed: an incomplete form is not an error, so
13
+ // nothing in the preview is painted red or names a failure, while a refused
14
+ // submit still says exactly why. `tab`, `shift+tab`, `←` and `→` move between
15
+ // the two tabs; `↑`/`↓` pick the type; `enter` on the name creates (S12 keeps
16
+ // ONE create key — the duplicate `ctrl+s` was dropped and is not a binding).
17
+ //
18
+ // S1: worktrees receive no issue or ODD association, so this panel asks for a
19
+ // type and a name and NOTHING else. The former EDIT mode — the association
20
+ // editor with its Issues/Odd panes, selectors, previews and save — is gone
21
+ // with the association itself: there is no `mode`, no `branch`/`path` seam,
22
+ // no `initialIssues`/`initialOdd`, no association source, and the panel reads
23
+ // no dataset of any kind. A worktree is never edited from here.
24
+ //
25
+ // Pure Node, no package imports. The repository's own helpers do the work
26
+ // instead of a second implementation of the same convention: lib/worktree-git.js
27
+ // `slugifyBranch` / `canonicalWorktreePathOf` (or `canonicalWorktreePath` /
28
+ // `worktreePath`) over its memoized `repoIdentity` derive the branch and the
29
+ // target path, exactly as the create path does. The git seam is injectable
30
+ // (`git`), so tests never touch a real checkout.
31
+ //
32
+ // S1 (this round): the panel is a FORM and spawns no `git check-ref-format`.
33
+ // Its preview derives from the PURE halves of those helpers, so a typed
34
+ // character costs no child at all — the blocking child that ran here from
35
+ // `render()` froze Pi for its whole lifetime per keystroke, which is the
36
+ // reported "creating a worktree left Pi stuck". The one authoritative branch
37
+ // check lives where it can be awaited: the create path that receives this
38
+ // panel's payload (`worktree_create` → `validateBranchAsync`), which refuses an
39
+ // invalid branch with its own message and nothing created.
40
+ //
41
+ // Host component contract (the same seam the customize panel and the pickers
42
+ // use): `createWarpCreatePanel(options)` returns
43
+ // `{ render(width), handleInput(data), invalidate(), state }`.
44
+ //
45
+ // Completion (documented exactly, because it is the module's only output):
46
+ // done({ ok: true, type, name })
47
+ // on `enter` in the LAST step (the name), and
48
+ // `done(null)` on `esc` (cancel). `type` is the conventional change-type id
49
+ // (`feat`, `fix`, …) and `name` is the trimmed change name as typed. A
50
+ // completion NEVER carries `issues` or `odd` (S1/S6): a new row is born
51
+ // without an association, and there is no other completion shape. A
52
+ // completion fires EXACTLY once. An invalid branch/path refuses creation
53
+ // (nothing is emitted) and the reason stays on screen.
54
+ //
55
+ // Options (every one optional; the seams exist so tests own the world):
56
+ // cwd checkout the panel derives the path from
57
+ // (default: process.cwd())
58
+ // changeTypes `[{id,label}]`; default exports CHANGE_TYPES, the
59
+ // repository's branch-pr convention
60
+ // namePlaceholder ghost name hint (default NAME_PLACEHOLDER)
61
+ // initialName pre-filled change name
62
+ // initialType pre-selected change-type id
63
+ // rowsAvailable the row budget the host offers: the surface renders
64
+ // exactly its own content height (clamped by this)
65
+ // principalLayout derive the target with `canonicalWorktreePath`
66
+ // (default true) instead of `worktreePath`
67
+ // git lib/worktree-git.js seam
68
+ // helpers/hostTui/theme the host seams the other panels use
69
+ // done (value) => void, see above
70
+ //
71
+ // Keys: the preview ABOVE (branch, then path), then the `Type` and `Name`
72
+ // tabs of one field strip (S7). `tab`/`shift+tab`/`←`/`→` move between Type
73
+ // and Name (a horizontal key at either end wraps to the other tab), `↑`/`↓`
74
+ // pick the type while Type is active, a printable character there starts the
75
+ // name (so nobody types into a list), `enter` on Type reaches Name, `enter` on
76
+ // Name creates (the ONE create key), `esc` cancels. There is no
77
+ // `?` key list: the footer names every shortcut on ONE row.
78
+
79
+ const gitLib = require('./worktree-git.js');
80
+ const path = require('node:path');
81
+ const picker = require('./warp-create-picker.js');
82
+ // S11: the ONE palette the readers share decides which theme role paints each
83
+ // kind of token (key, title, description, count, label, value, border, error),
84
+ // so a footer KEY is always distinguishable from the words beside it and this
85
+ // panel keeps no second copy of the hostile-theme guards.
86
+ const palette = require('./tui-paint.js');
87
+
88
+ // The Conventional Commit change types of the repository's branch-pr
89
+ // convention (`<type>/<slug>`), with its human labels and in its order —
90
+ // the SAME set the wizard's type step publishes. `CHANGE_TYPES` is the
91
+ // panel's default; tests pin it against the extension's own list so a
92
+ // drift can never leave two conventions in the repository.
93
+ const CHANGE_TYPES = Object.freeze([
94
+ { id: 'feat', label: 'Feature' },
95
+ { id: 'fix', label: 'Bug fix' },
96
+ { id: 'chore', label: 'Chore' },
97
+ { id: 'docs', label: 'Docs' },
98
+ { id: 'style', label: 'Style' },
99
+ { id: 'refactor', label: 'Refactor' },
100
+ { id: 'perf', label: 'Performance' },
101
+ { id: 'test', label: 'Test' },
102
+ { id: 'build', label: 'Build' },
103
+ { id: 'ci', label: 'CI' },
104
+ { id: 'revert', label: 'Revert' },
105
+ ].map((type) => Object.freeze(type)));
106
+
107
+ // The name placeholder the wizard shows, rendered as GHOST text (dim,
108
+ // never a value: creating from a placeholder is refused).
109
+ const NAME_PLACEHOLDER = 'name-change';
110
+
111
+ const PANEL_HEIGHT_MAX = 40;
112
+
113
+ // S12: the create surface's own fixed rows — top rule, title, branch preview,
114
+ // path preview, the `Type · Name` tab strip, the active field's own row, the
115
+ // one-row legend, the bottom rule. The inline type list (which belongs to the
116
+ // Type tab alone) takes whatever the host budget leaves.
117
+ const CREATE_FIXED_ROWS = 8;
118
+
119
+ // S7: the placeholder an underivable preview shows. `-` is the same
120
+ // missing-value mark the viewers use, so a preview with nothing derivable yet
121
+ // (a name holding no letter or digit) reads as empty instead of broken — the
122
+ // reason for a failure belongs to the refused submit, never to the live
123
+ // preview.
124
+ const PREVIEW_EMPTY = '-';
125
+
126
+ // Raw terminal bytes per key id, mirroring pi-tui's legacy sequences so
127
+ // the panel stays drivable when the injected matcher is absent (a panel
128
+ // that ignores Esc/Enter reads as frozen).
129
+ const RAW_KEY_BYTES = {
130
+ escape: ['\x1b'],
131
+ 'ctrl+c': ['\x03'],
132
+ 'ctrl+s': ['\x13'],
133
+ enter: ['\r', '\n'],
134
+ return: ['\r', '\n'],
135
+ tab: ['\t'],
136
+ 'shift+tab': ['\x1b[Z'],
137
+ space: [' '],
138
+ backspace: ['\x7f', '\b'],
139
+ delete: ['\x1b[3~', '\x7f'],
140
+ '\x7f': ['\x7f'],
141
+ up: ['\x1b[A'],
142
+ down: ['\x1b[B'],
143
+ right: ['\x1b[C'],
144
+ left: ['\x1b[D'],
145
+ pageup: ['\x1b[5~'],
146
+ pagedown: ['\x1b[6~'],
147
+ };
148
+
149
+ function keyText(helpers, data, names) {
150
+ for (const name of names) {
151
+ try {
152
+ if (helpers && typeof helpers.matchesKey === 'function' && helpers.matchesKey(data, name)) return true;
153
+ } catch {
154
+ // A throwing matcher never matches; the fallbacks below decide.
155
+ }
156
+ }
157
+ if (typeof data === 'string' && data.length > 0) {
158
+ for (const name of names) {
159
+ const raw = RAW_KEY_BYTES[String(name).toLowerCase()];
160
+ if (raw && raw.includes(data)) return true;
161
+ }
162
+ }
163
+ const text = typeof data === 'string' ? data : (data && (data.name || data.key)) || '';
164
+ const lower = String(text).toLowerCase();
165
+ return names.some((name) => lower === String(name).toLowerCase());
166
+ }
167
+
168
+ function isKeyRelease(helpers, data) {
169
+ try {
170
+ if (helpers && typeof helpers.isKeyRelease === 'function') return helpers.isKeyRelease(data) === true;
171
+ } catch {
172
+ // A throwing classifier never releases; fall through.
173
+ }
174
+ return false;
175
+ }
176
+
177
+ // One printable character, or '' for a control byte, an escape sequence
178
+ // or a key object. The filter rule (S6) needs exactly this distinction.
179
+ function printableChar(data) {
180
+ const raw = typeof data === 'string' ? data : (data && (data.name || data.key)) || '';
181
+ if (typeof raw !== 'string') return '';
182
+ const chars = [...raw];
183
+ if (chars.length !== 1) return '';
184
+ const char = chars[0];
185
+ if (char < ' ' || char === '\x7f') return '';
186
+ return char;
187
+ }
188
+
189
+ function stripAnsi(text) {
190
+ return String(text)
191
+ .replace(/\x1b\[[0-9;]*m/g, '')
192
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
193
+ .replace(/\x1b_[^\x07\x1b]*\x07/g, '');
194
+ }
195
+
196
+ // Word wrap for the pane state messages: a narrow list column must still
197
+ // say WHY it is empty instead of clipping the explanation away.
198
+ function wrapText(text, width) {
199
+ const limit = Math.max(1, width);
200
+ const words = String(text).split(/\s+/).filter((word) => word !== '');
201
+ const lines = [];
202
+ let line = '';
203
+ for (const word of words) {
204
+ if (line === '') line = word;
205
+ else if (line.length + 1 + word.length <= limit) line += ` ${word}`;
206
+ else {
207
+ lines.push(line);
208
+ line = word;
209
+ }
210
+ }
211
+ if (line !== '') lines.push(line);
212
+ return lines.length > 0 ? lines : [''];
213
+ }
214
+
215
+ function measure(helpers, text) {
216
+ try {
217
+ if (helpers && typeof helpers.visibleWidth === 'function') {
218
+ const size = Number(helpers.visibleWidth(text));
219
+ if (Number.isFinite(size)) return size;
220
+ }
221
+ } catch {
222
+ // Fall through to the ANSI-stripped codepoint count.
223
+ }
224
+ return [...stripAnsi(text)].length;
225
+ }
226
+
227
+ function fit(helpers, text, width) {
228
+ const clean = String(text).split('\n')[0] ?? '';
229
+ if (measure(helpers, clean) <= width) return clean;
230
+ try {
231
+ if (helpers && typeof helpers.truncateToWidth === 'function') {
232
+ const cut = helpers.truncateToWidth(clean, width, '…');
233
+ if (typeof cut === 'string') return cut;
234
+ }
235
+ } catch {
236
+ // Fall through to the plain slice.
237
+ }
238
+ const chars = [...stripAnsi(clean)];
239
+ return chars.length > width ? chars.slice(0, Math.max(0, width - 1)).join('') + '…' : clean;
240
+ }
241
+
242
+ function pad(helpers, text, width) {
243
+ const clipped = fit(helpers, text, width);
244
+ return clipped + ' '.repeat(Math.max(0, width - measure(helpers, clipped)));
245
+ }
246
+
247
+ // S2: a key legend stays on ONE row, and S11 decides its colours: `key` is the
248
+ // exact key spelling the token starts with, so lib/tui-paint.js paints that
249
+ // prefix with the key role (accent+bold) and the rest as its description. The
250
+ // compact spelling keeps the same prefix wherever it can, so a key never loses
251
+ // its role when the row compacts. `text` is the full token, `short` the compact
252
+ // one used when the full form does not fit, and `priority` decides what a
253
+ // still-too-wide legend drops first (the lowest goes first, so the cancel key
254
+ // outlives the rest).
255
+ // S12: creating is TWO fields, so the legend names the field keys (tab/←/→),
256
+ // the type-list keys and the ONE confirm key — never a pane or association key
257
+ // and never a second key that does the same thing (`ctrl+s` created too, which
258
+ // the user reported as a duplicate of `enter`).
259
+ const CREATE_KEYS = [
260
+ { key: 'tab/←/→', text: 'tab/←/→ fields', short: 'tab/←/→', priority: 70 },
261
+ { key: '↑/↓', text: '↑/↓ type', short: '↑/↓', priority: 60 },
262
+ { key: 'Enter', text: 'Enter confirm', short: 'Enter confirm', priority: 90 },
263
+ { key: 'Esc', text: 'Esc cancel', short: 'Esc cancel', priority: 100 },
264
+ ];
265
+ function shortError(err) {
266
+ if (err === null || err === undefined) return 'unknown error';
267
+ if (typeof err === 'string') return err.split('\n')[0];
268
+ if (err.code === 'ENOENT' || /ENOENT/i.test(String((err && err.message) || err))) return 'not available';
269
+ return String((err && err.message) || err).split('\n')[0];
270
+ }
271
+
272
+ function escapeRe(text) {
273
+ return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
274
+ }
275
+
276
+ function createWarpCreatePanel(options = {}) {
277
+ const opts = options && typeof options === 'object' ? options : {};
278
+ const cwd = typeof opts.cwd === 'string' && opts.cwd.length > 0 ? opts.cwd : process.cwd();
279
+ const helpers = opts.helpers || null;
280
+ const hostTui = opts.hostTui || null;
281
+ const theme = opts.theme || null;
282
+ const git = opts.git && typeof opts.git === 'object' ? opts.git : gitLib;
283
+ const principalLayout = opts.principalLayout !== false;
284
+ const changeTypes = Array.isArray(opts.changeTypes) && opts.changeTypes.length > 0
285
+ ? opts.changeTypes.filter((type) => type && typeof type.id === 'string' && type.id !== '')
286
+ : CHANGE_TYPES.slice();
287
+ const namePlaceholder = typeof opts.namePlaceholder === 'string' && opts.namePlaceholder !== ''
288
+ ? opts.namePlaceholder
289
+ : NAME_PLACEHOLDER;
290
+ const rawFinish = typeof opts.done === 'function' ? opts.done : () => {};
291
+
292
+ // The branch format is the repository's own: `<type-of-the-list>/<slug>`,
293
+ // derived from the SAME type list the field offers (never a second list).
294
+ const branchFormat = new RegExp(`^(${changeTypes.map((type) => escapeRe(type.id)).join('|')})/[a-z0-9._-]+$`);
295
+
296
+ let settled = false;
297
+ const finish = (value) => {
298
+ if (settled) return;
299
+ settled = true;
300
+ rawFinish(value);
301
+ };
302
+
303
+ const state = {
304
+ // S12: the type is picked from the inline list, so `typeIndex` IS the
305
+ // selection (there is no second cursor to keep in sync).
306
+ typeIndex: 0,
307
+ name: typeof opts.initialName === 'string' ? opts.initialName : '',
308
+ focus: 'type',
309
+ message: '',
310
+ };
311
+ if (typeof opts.initialType === 'string') {
312
+ const at = changeTypes.findIndex((type) => type.id === opts.initialType);
313
+ if (at >= 0) state.typeIndex = at;
314
+ }
315
+
316
+ // One derivation per distinct (type, name), memoized, so a repaint of an
317
+ // unchanged form derives nothing.
318
+ let deriveCache = { key: null, value: null };
319
+
320
+ // The repository identity behind the canonical layout costs two
321
+ // `git rev-parse` probes and cannot change while this panel is open, so it is
322
+ // read at most once instead of once per keystroke. `undefined` means NOT READ
323
+ // YET; `null` means unreadable, and the layout helper then reads it itself
324
+ // (the same fallback as a seam that has no `repoIdentity`).
325
+ let identityCache;
326
+
327
+ let cursorMarker = '';
328
+ try {
329
+ if (helpers && typeof helpers.CURSOR_MARKER === 'string') cursorMarker = helpers.CURSOR_MARKER;
330
+ } catch {
331
+ cursorMarker = '';
332
+ }
333
+ if (cursorMarker === '') {
334
+ try {
335
+ const tui = require('@earendil-works/pi-tui');
336
+ if (tui && typeof tui.CURSOR_MARKER === 'string') cursorMarker = tui.CURSOR_MARKER;
337
+ } catch {
338
+ cursorMarker = '';
339
+ }
340
+ }
341
+
342
+ function availableRows() {
343
+ try {
344
+ if (typeof opts.rowsAvailable === 'function') {
345
+ const size = Math.floor(opts.rowsAvailable());
346
+ if (Number.isFinite(size) && size > 0) return Math.min(PANEL_HEIGHT_MAX, size);
347
+ }
348
+ } catch {
349
+ // A hostile rows source never breaks the panel.
350
+ }
351
+ return 24;
352
+ }
353
+
354
+ function requestPaint() {
355
+ try {
356
+ if (hostTui && typeof hostTui.requestRender === 'function') hostTui.requestRender();
357
+ } catch {
358
+ // A repaint failure never breaks navigation.
359
+ }
360
+ }
361
+
362
+ // S11: one role decision for the whole surface. The role table lives in
363
+ // lib/tui-paint.js, so this panel names KINDS (title, key, label, value,
364
+ // count, description, border, error) instead of colours, and a missing,
365
+ // partial or throwing theme degrades to plain text inside the palette. A
366
+ // `null` kind is plain text.
367
+ function kindText(kind, text) {
368
+ const painter = palette[kind];
369
+ if (typeof painter === 'function') return painter(theme, text);
370
+ const role = palette.ROLES[kind];
371
+ return typeof role === 'string' ? palette.paint(theme, role, text) : String(text);
372
+ }
373
+
374
+ // A composed line of kind-tagged segments, painted only AFTER the plain text
375
+ // has been fitted to the width: painting first would let the fallback
376
+ // truncation cut through an escape sequence. Overlong content therefore falls
377
+ // back to an uncoloured, honestly truncated line instead of a broken one.
378
+ function composed(segments, width) {
379
+ const plain = segments.map((segment) => segment.text).join('');
380
+ if (measure(helpers, plain) > width) return fit(helpers, plain, width);
381
+ return segments.map((segment) => kindText(segment.kind, segment.text)).join('');
382
+ }
383
+
384
+ function currentType() {
385
+ return changeTypes[state.typeIndex] || changeTypes[0];
386
+ }
387
+
388
+ function repoIdentity() {
389
+ if (identityCache !== undefined) return identityCache;
390
+ identityCache = null;
391
+ try {
392
+ if (typeof git.repoIdentity === 'function') identityCache = git.repoIdentity(cwd) || null;
393
+ } catch {
394
+ identityCache = null;
395
+ }
396
+ return identityCache;
397
+ }
398
+
399
+ // The target path for a branch, through the pure layout helpers over the one
400
+ // memoized identity: `<principal>-worktrees/<slug>` beside the PRINCIPAL
401
+ // checkout under `principalLayout` (the default), the calling checkout's own
402
+ // layout otherwise — exactly what the create path resolves.
403
+ function layoutFor(branch) {
404
+ const identity = repoIdentity();
405
+ if (principalLayout) {
406
+ if (identity && typeof git.canonicalWorktreePathOf === 'function') return git.canonicalWorktreePathOf(identity, branch);
407
+ if (typeof git.canonicalWorktreePath === 'function') return git.canonicalWorktreePath(cwd, branch);
408
+ } else if (identity && typeof git.worktreePathOf === 'function') {
409
+ return git.worktreePathOf(identity, branch);
410
+ }
411
+ return git.worktreePath(cwd, branch);
412
+ }
413
+
414
+ // The type is known the moment the form opens, so an empty name still has a
415
+ // real answer to preview: the branch prefix `<type>/` and the checkout
416
+ // directory it will occupy, `<root>/<type>-`. This is DISPLAY ONLY — `ok`
417
+ // stays false, so an empty form still refuses to create — and the root is
418
+ // read from the same layout helper a completed branch uses, so the prefix
419
+ // never drifts from the directory the name will complete into.
420
+ function prefixPreview(type) {
421
+ const branch = `${type.id}/`;
422
+ let target = null;
423
+ let failure = null;
424
+ try {
425
+ target = layoutFor(`${branch}prefix`);
426
+ } catch (err) {
427
+ failure = shortError(err);
428
+ }
429
+ // `layoutFor(<type>/<slug>)` is `<root>/<type>-<slug>`: its directory IS
430
+ // the root the completed checkout lands in.
431
+ const resolved = typeof target === 'string' && target !== '' && !failure;
432
+ return {
433
+ ok: false,
434
+ branch,
435
+ branchOk: true,
436
+ path: resolved ? path.join(path.dirname(target), `${type.id}-`) : null,
437
+ pathOk: resolved,
438
+ reason: resolved ? 'name is empty' : `cannot resolve the worktree path: ${failure || 'unknown error'}`,
439
+ };
440
+ }
441
+
442
+ // The live derivation, memoized per distinct (type, name) and FREE of
443
+ // subprocesses: the slug convention, the branch format the repository
444
+ // publishes and the layout above are all pure, so painting a keystroke asks
445
+ // git for nothing. Only the plain rejections run here; git's own verdict
446
+ // belongs to the create path, which can await it.
447
+ function derivePreview() {
448
+ const type = currentType();
449
+ const name = state.name.trim();
450
+ const key = `${type.id}\u0000${name}`;
451
+ if (deriveCache.key === key) return deriveCache.value;
452
+ let value;
453
+ if (name === '') {
454
+ value = prefixPreview(type);
455
+ } else if (!/[a-z0-9]/i.test(name)) {
456
+ value = { ok: false, branch: null, branchOk: false, path: null, pathOk: false, reason: 'name has no letters or digits' };
457
+ } else {
458
+ let slug = '';
459
+ try {
460
+ slug = String(typeof git.slugifyBranch === 'function' ? git.slugifyBranch(name) : '');
461
+ } catch {
462
+ slug = '';
463
+ }
464
+ const branch = slug === '' ? '' : `${type.id}/${slug}`;
465
+ if (branch === '' || !branchFormat.test(branch)) {
466
+ value = {
467
+ ok: false,
468
+ branch: branch === '' ? null : branch,
469
+ branchOk: false,
470
+ path: null,
471
+ pathOk: false,
472
+ reason: `invalid branch name: ${branch === '' ? name : branch}`,
473
+ };
474
+ } else {
475
+ let target = null;
476
+ let failure = null;
477
+ try {
478
+ target = layoutFor(branch);
479
+ } catch (err) {
480
+ failure = shortError(err);
481
+ }
482
+ value = typeof target === 'string' && target !== '' && !failure
483
+ ? { ok: true, branch, branchOk: true, path: target, pathOk: true, reason: null }
484
+ : {
485
+ ok: false,
486
+ branch,
487
+ branchOk: true,
488
+ path: null,
489
+ pathOk: false,
490
+ reason: `cannot resolve the worktree path: ${failure || 'unknown error'}`,
491
+ };
492
+ }
493
+ }
494
+ deriveCache = { key, value };
495
+ return value;
496
+ }
497
+
498
+ // S12: the create surface's single name line. The placeholder is ghost
499
+ // text, never a value. S7: this row renders only under the FOCUSED Name tab,
500
+ // so the caret is the typing position exactly when it is on screen. S11: the
501
+ // static label is dim and the typed name is a value.
502
+ function nameLine(width) {
503
+ const caret = { kind: null, text: `${cursorMarker}▊` };
504
+ const segments = [{ kind: 'label', text: 'Name: ' }];
505
+ if (state.name === '') segments.push(caret, { kind: 'label', text: namePlaceholder });
506
+ else segments.push({ kind: 'value', text: state.name }, caret);
507
+ return composed(segments, width);
508
+ }
509
+
510
+ // S7: the two fields are TABS, not two stacked rows — one strip with the
511
+ // active label accented (and bold) and the other dimmed, so "where am I"
512
+ // reads the same everywhere.
513
+ function tabSegments() {
514
+ const tab = (field, text) => ({ kind: state.focus === field ? 'title' : 'label', text });
515
+ return [tab('type', 'Type'), { kind: null, text: ' · ' }, tab('name', 'Name')];
516
+ }
517
+
518
+ function rule(width) {
519
+ return kindText('border', '─'.repeat(Math.max(0, width)));
520
+ }
521
+
522
+ // S12: the inline change-type list. The SELECTED type (and therefore the
523
+ // preview) follows the cursor, and the list is windowed around it when the
524
+ // host offers fewer rows than the convention has types. The `❯` marker is
525
+ // the same one the fields use, so "where am I" reads the same everywhere.
526
+ function typeListWindow(room) {
527
+ const count = changeTypes.length;
528
+ const size = Math.min(count, Math.max(0, room));
529
+ if (size === 0) return { start: 0, size: 0 };
530
+ const centred = state.typeIndex - Math.floor(size / 2);
531
+ return { start: Math.min(Math.max(0, centred), count - size), size };
532
+ }
533
+
534
+ function typeListLines(width, room) {
535
+ const { start, size } = typeListWindow(room);
536
+ const out = [];
537
+ for (let offset = 0; offset < size; offset += 1) {
538
+ const at = start + offset;
539
+ const type = changeTypes[at];
540
+ const text = `${at === state.typeIndex ? '❯ ' : ' '}${type.label} (${type.id})`;
541
+ const fitted = fit(helpers, text, width);
542
+ out.push(at === state.typeIndex ? palette.highlight(theme, fitted, true) : fitted);
543
+ }
544
+ return out;
545
+ }
546
+
547
+ // S12: the create surface is COMPACT — it renders exactly its own content
548
+ // and never pads out to the host's row budget, so the host can mount it at
549
+ // the bottom of the terminal without slack rows above or below it. S7: the
550
+ // preview sits ABOVE the tab strip, only the ACTIVE tab's field renders
551
+ // under it (the type list belongs to the Type tab), and an underivable
552
+ // preview stays blank instead of red.
553
+ function renderCreate(w, maxRows) {
554
+ const messageRows = state.message ? 1 : 0;
555
+ if (maxRows < CREATE_FIXED_ROWS + messageRows) {
556
+ return [pad(helpers, kindText('label', 'New worktree — enlarge the terminal'), w)];
557
+ }
558
+ const derived = derivePreview();
559
+ const room = Math.max(0, maxRows - CREATE_FIXED_ROWS - messageRows);
560
+ const lines = [
561
+ rule(w),
562
+ composed([{ kind: 'title', text: 'New worktree' }], w),
563
+ composed([
564
+ { kind: 'label', text: 'preview branch: ' },
565
+ derived.branchOk ? { kind: 'value', text: derived.branch } : { kind: 'label', text: PREVIEW_EMPTY },
566
+ ], w),
567
+ composed([
568
+ { kind: 'label', text: 'preview path: ' },
569
+ derived.pathOk ? { kind: 'value', text: derived.path } : { kind: 'label', text: PREVIEW_EMPTY },
570
+ ], w),
571
+ composed(tabSegments(), w),
572
+ ];
573
+ if (state.focus === 'type') {
574
+ const type = currentType();
575
+ lines.push(composed([
576
+ { kind: 'label', text: 'Type: ' },
577
+ { kind: 'value', text: `${type.label} (${type.id})` },
578
+ ], w));
579
+ lines.push(...typeListLines(w, room));
580
+ } else {
581
+ lines.push(nameLine(w));
582
+ }
583
+ if (state.message) lines.push(composed([{ kind: 'error', text: `cannot create: ${state.message}` }], w));
584
+ lines.push(palette.legendRow(theme, CREATE_KEYS, w, (text) => measure(helpers, text)));
585
+ lines.push(rule(w));
586
+ return lines.map((line) => pad(helpers, line, w));
587
+ }
588
+
589
+ function render(width) {
590
+ const w = Number.isFinite(width) ? Math.floor(width) : 0;
591
+ if (w <= 0) return [];
592
+ const height = availableRows();
593
+ if (!height) return [];
594
+ return renderCreate(w, height);
595
+ }
596
+
597
+ // S1: the submit key does not validate either — a `check-ref-format` child
598
+ // here would freeze the host on the confirmation keystroke. The branch check
599
+ // is the create path's (`validateBranchAsync`, awaited on the async runner),
600
+ // which refuses with its own message and creates nothing.
601
+ function create() {
602
+ const derived = derivePreview();
603
+ if (!derived.ok) {
604
+ state.message = derived.reason;
605
+ requestPaint();
606
+ return;
607
+ }
608
+ // S6: a creation carries the two things it asked for and nothing else.
609
+ // No `issues`, no `odd`: a brand-new row is born without an association.
610
+ finish({
611
+ ok: true,
612
+ type: currentType().id,
613
+ name: state.name.trim(),
614
+ });
615
+ }
616
+
617
+ function handleCreateTypeField(data) {
618
+ if (keyText(helpers, data, ['up'])) {
619
+ moveType(-1);
620
+ return;
621
+ }
622
+ if (keyText(helpers, data, ['down'])) {
623
+ moveType(1);
624
+ return;
625
+ }
626
+ if (keyText(helpers, data, ['enter', 'return'])) {
627
+ state.focus = 'name';
628
+ requestPaint();
629
+ return;
630
+ }
631
+ const char = printableChar(data);
632
+ if (char !== '') {
633
+ state.focus = 'name';
634
+ state.name += char;
635
+ state.message = '';
636
+ requestPaint();
637
+ }
638
+ }
639
+
640
+ function moveType(delta) {
641
+ const last = Math.max(0, changeTypes.length - 1);
642
+ state.typeIndex = Math.min(last, Math.max(0, state.typeIndex + delta));
643
+ state.message = '';
644
+ requestPaint();
645
+ }
646
+
647
+ // S12: only two fields exist while creating, so `tab`, `shift+tab` and both
648
+ // horizontal arrows move between their tabs; a horizontal key at either end
649
+ // wraps to the other tab instead of dying.
650
+ function toggleCreateField() {
651
+ state.focus = state.focus === 'type' ? 'name' : 'type';
652
+ requestPaint();
653
+ }
654
+
655
+ function handleNameField(data) {
656
+ if (keyText(helpers, data, ['enter', 'return'])) {
657
+ // S12: the name is the SECOND and LAST field of a creation, so `enter`
658
+ // there creates.
659
+ create();
660
+ return;
661
+ }
662
+ if (keyText(helpers, data, ['down'])) {
663
+ // Nothing sits below the name — the surface is two fields — so `↓`
664
+ // stays put.
665
+ return;
666
+ }
667
+ if (keyText(helpers, data, ['up'])) {
668
+ state.focus = 'type';
669
+ requestPaint();
670
+ return;
671
+ }
672
+ if (keyText(helpers, data, ['backspace', 'delete', '\x7f'])) {
673
+ if (state.name !== '') {
674
+ state.name = [...state.name].slice(0, -1).join('');
675
+ state.message = '';
676
+ requestPaint();
677
+ }
678
+ return;
679
+ }
680
+ if (keyText(helpers, data, ['pageup', 'pagedown'])) return;
681
+ const char = printableChar(data);
682
+ if (char !== '') {
683
+ state.name += char;
684
+ state.message = '';
685
+ requestPaint();
686
+ }
687
+ }
688
+
689
+ function handleInput(data) {
690
+ if (settled) return;
691
+ if (isKeyRelease(helpers, data)) return;
692
+ // Esc cancels the interaction (the surface has no inner layer any more).
693
+ if (keyText(helpers, data, ['escape', 'ctrl+c'])) {
694
+ finish(null);
695
+ return;
696
+ }
697
+ // `tab`, `shift+tab` and the horizontal arrows move between the two
698
+ // fields, which is what the reported flow asked for (S12).
699
+ if (keyText(helpers, data, ['tab', 'shift+tab', 'left', 'right'])) {
700
+ toggleCreateField();
701
+ return;
702
+ }
703
+ if (state.focus === 'name') {
704
+ handleNameField(data);
705
+ return;
706
+ }
707
+ handleCreateTypeField(data);
708
+ }
709
+
710
+ // The panel caches nothing between frames (every line is derived from the
711
+ // two fields), so a repaint request has nothing to drop; the host seam still
712
+ // exists because the component contract asks for it.
713
+ function invalidate() {}
714
+
715
+ return { render, handleInput, invalidate, state };
716
+ }
717
+
718
+ module.exports = {
719
+ CHANGE_TYPES,
720
+ NAME_PLACEHOLDER,
721
+ createWarpCreatePanel,
722
+ };