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,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
|
+
};
|