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.
- package/README.md +223 -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,1463 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// T2/T3 + T4 (warp S2,S3,S4,S5,S6,S9,S12,S16; worktree-ui-feedback S4,S12,S15)
|
|
3
|
+
// tests: lib/warp-selector-view.js — the warp surface as ONE component with
|
|
4
|
+
// two views: the S4 bottom panel
|
|
5
|
+
// (title, the current worktree's name, its data line, a separator, up to five
|
|
6
|
+
// worktree rows, an optional `view all worktrees` row, the focused worktree's
|
|
7
|
+
// data line, the one-row key legend) and the extended list (always-active
|
|
8
|
+
// filter row, pagination, `esc` back one level).
|
|
9
|
+
//
|
|
10
|
+
// Contract under test, as observable output only: every line is exactly the
|
|
11
|
+
// requested width; the panel is exactly as tall as its content and never
|
|
12
|
+
// taller than the rows the host offers (S3: a bottom panel that computes its
|
|
13
|
+
// own height, not a box with slack); the S4 order above is pinned; the current
|
|
14
|
+
// worktree owns the header and is never one of the five rows (S4b), and it is
|
|
15
|
+
// never a DESTINATION either: `view all worktrees` lists every other checkout
|
|
16
|
+
// and never the one the session already stands in; the
|
|
17
|
+
// principal leads when it is not current, then the rows follow the most
|
|
18
|
+
// recent activity; `view all worktrees` appears only when at least one
|
|
19
|
+
// worktree is left out (S4c, reconciled in L2) and never when everything
|
|
20
|
+
// fits; the legend is exactly ONE row (S2); S5 rows are the checkout NAMES
|
|
21
|
+
// alone (the branch lives in a data line, never repeated on the row); a data
|
|
22
|
+
// line carries branch, the change size as LINE COUNTS (S16: bare `+A −D`,
|
|
23
|
+
// because the sign already says what the number is — the label survives only
|
|
24
|
+
// where the counts are unreadable, as `changes unavailable`),
|
|
25
|
+
// `↑N ↓M` and both ages, omits a measured zero for the change size and for
|
|
26
|
+
// each of `↑`/`↓`, and says `unavailable` — never `0` — for a fact it cannot
|
|
27
|
+
// read, while an unreadable AGE is the brief `—` and never the caller's prose
|
|
28
|
+
// reason (S15: the reason stays in the data). S1/S4: a worktree carries no
|
|
29
|
+
// issue or ODD association any more and the panel keeps no edit view, so the
|
|
30
|
+
// line names neither association (a legacy registry value never
|
|
31
|
+
// reaches it) and `e` is NOT a binding; `ctrl+d` asks to delete the focused
|
|
32
|
+
// checkout, in both views, and both legends advertise it (the extension owns
|
|
33
|
+
// the confirmation and every refusal); the facts are read for the current and the
|
|
34
|
+
// focused checkout, at most once each, and render() performs no I/O; `c`
|
|
35
|
+
// opens the create flow instead of owning a list row (S6) while `v`,
|
|
36
|
+
// `enter`, `r` and `esc` keep their meanings; `esc` steps back one level from
|
|
37
|
+
// the list; while the search is active `delete` clears the whole query and
|
|
38
|
+
// `backspace` edits one character (S4/S12); no key outside the documented map
|
|
39
|
+
// changes state.
|
|
40
|
+
//
|
|
41
|
+
// The panel frame is pinned: 7 fixed rows (top rule, current name, current
|
|
42
|
+
// data, separator, focused data, legend, bottom rule) plus up to five
|
|
43
|
+
// worktree rows and the optional `view all worktrees` row. The list frame is
|
|
44
|
+
// pinned: 5 fixed rows (top rule, filter, page row, footer, bottom rule) plus
|
|
45
|
+
// a page.
|
|
46
|
+
|
|
47
|
+
const { test, describe } = require('node:test');
|
|
48
|
+
const assert = require('node:assert/strict');
|
|
49
|
+
const fs = require('node:fs');
|
|
50
|
+
const os = require('node:os');
|
|
51
|
+
const path = require('node:path');
|
|
52
|
+
|
|
53
|
+
const { createWarpSelectorView, formatFactsLine } = require('../lib/warp-selector-view.js');
|
|
54
|
+
|
|
55
|
+
function strip(value) {
|
|
56
|
+
return String(value).replace(/\x1b\[[0-9;]*m/g, '');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const helpers = {
|
|
60
|
+
visibleWidth: (text) => [...strip(text)].length,
|
|
61
|
+
truncateToWidth: (text, width) => {
|
|
62
|
+
const chars = [...String(text)];
|
|
63
|
+
return chars.length > width ? chars.slice(0, Math.max(0, width - 1)).join('') + '…' : String(text);
|
|
64
|
+
},
|
|
65
|
+
matchesKey: (key, name) => key === name,
|
|
66
|
+
isKeyRelease: (key) => key === 'release',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const plainTheme = { fg: (_role, text) => String(text), bg: (_role, text) => String(text) };
|
|
70
|
+
|
|
71
|
+
// S11: a theme that RECORDS which role painted each string and paints every
|
|
72
|
+
// role with its own SGR code, so a rendered line can be read for the ROLE
|
|
73
|
+
// behind a token instead of trusting an eyeballed colour. A role the map does
|
|
74
|
+
// not name still gets its own code, so an unexpected role shows up instead of
|
|
75
|
+
// passing silently.
|
|
76
|
+
const ROLE_SGR = { accent: 96, customMessageLabel: 33, muted: 90, dim: 2, border: 34, error: 91, text: 39, mdHeading: 35 };
|
|
77
|
+
|
|
78
|
+
function recordingTheme() {
|
|
79
|
+
const calls = [];
|
|
80
|
+
const code = (role) => ROLE_SGR[role] ?? 95;
|
|
81
|
+
return {
|
|
82
|
+
calls,
|
|
83
|
+
fg(role, text) {
|
|
84
|
+
const value = String(text);
|
|
85
|
+
calls.push({ role, text: value });
|
|
86
|
+
return `\x1b[${code(role)}m${value}\x1b[0m`;
|
|
87
|
+
},
|
|
88
|
+
bold(text) {
|
|
89
|
+
const value = String(text);
|
|
90
|
+
calls.push({ role: 'bold', text: value });
|
|
91
|
+
return `\x1b[1m${value}\x1b[22m`;
|
|
92
|
+
},
|
|
93
|
+
bg(role, text) {
|
|
94
|
+
calls.push({ role: `bg:${role}`, text: String(text) });
|
|
95
|
+
return String(text);
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const WIDTH = 80;
|
|
101
|
+
const WIDE = 200;
|
|
102
|
+
const HEIGHT = 20;
|
|
103
|
+
// Pinned clock: the ages a data line renders are asserted against this
|
|
104
|
+
// instant instead of the host's real time.
|
|
105
|
+
const NOW = Date.parse('2026-03-01T00:00:00.000Z');
|
|
106
|
+
const ago = (ms) => new Date(NOW - ms).toISOString();
|
|
107
|
+
|
|
108
|
+
const CURRENT = { name: 'feat-odd-viewer', path: '/w/feat-odd-viewer', branch: 'feat/odd-viewer' };
|
|
109
|
+
const CHECKOUTS = [
|
|
110
|
+
CURRENT,
|
|
111
|
+
{ name: 'main', path: '/w/herdr', branch: 'main', isPrincipal: true },
|
|
112
|
+
{ name: 'feat-alpha', path: '/w/feat-alpha', branch: 'feat/alpha' },
|
|
113
|
+
{ name: 'fix-beta', path: '/w/fix-beta', branch: 'fix/beta' },
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
const DEFAULT_FACTS = { branch: 'feat/alpha', changes: { added: 2, deleted: 1 }, ahead: 1, behind: 0, createdAt: '2026-02-01' };
|
|
117
|
+
// S1: a LEGACY registry association still travels in the facts payload (the
|
|
118
|
+
// stored data is preserved), and the line must never print it.
|
|
119
|
+
const LEGACY_FACTS = { ...DEFAULT_FACTS, issues: [12], assignedOdd: 'odd/tasks/w1-a.md', odd: ['w1-a', 'w2-b'] };
|
|
120
|
+
// The same facts rendered, so an assertion names the behaviour, not the
|
|
121
|
+
// formatter's punctuation.
|
|
122
|
+
const DEFAULT_LINE = 'branch feat/alpha · +2 −1 · ↑1 · created 28d ago · modified —';
|
|
123
|
+
// S15: the two ages are the facts that carry a prose reason when they are
|
|
124
|
+
// unreadable, so a compact preview line names them with a dash instead of
|
|
125
|
+
// repeating that explanation. The reason itself stays in the data.
|
|
126
|
+
const NO_AGES = 'created — · modified —';
|
|
127
|
+
|
|
128
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
129
|
+
|
|
130
|
+
function makeView(options = {}) {
|
|
131
|
+
const paints = [];
|
|
132
|
+
const results = [];
|
|
133
|
+
const factCalls = [];
|
|
134
|
+
const reloadCalls = [];
|
|
135
|
+
const toggles = [];
|
|
136
|
+
const factsReader = options.facts || (() => DEFAULT_FACTS);
|
|
137
|
+
const view = createWarpSelectorView({
|
|
138
|
+
checkouts: options.checkouts || CHECKOUTS,
|
|
139
|
+
current: options.current === undefined ? { name: CURRENT.name, path: CURRENT.path } : options.current,
|
|
140
|
+
facts: (path) => {
|
|
141
|
+
factCalls.push(path);
|
|
142
|
+
return factsReader(path);
|
|
143
|
+
},
|
|
144
|
+
reload: options.reload || (() => {
|
|
145
|
+
reloadCalls.push(true);
|
|
146
|
+
return options.reloadTo || options.checkouts || CHECKOUTS;
|
|
147
|
+
}),
|
|
148
|
+
onToggle: options.onToggle ? (path) => {
|
|
149
|
+
toggles.push(path);
|
|
150
|
+
return options.onToggle(path);
|
|
151
|
+
} : undefined,
|
|
152
|
+
canonicalPath: typeof options.canonicalPath === 'function' ? options.canonicalPath : undefined,
|
|
153
|
+
helpers,
|
|
154
|
+
theme: options.theme === undefined ? plainTheme : options.theme,
|
|
155
|
+
now: options.now === undefined ? NOW : options.now,
|
|
156
|
+
rowsAvailable: () => (options.rows === undefined ? HEIGHT : options.rows),
|
|
157
|
+
requestRender: () => { paints.push(true); },
|
|
158
|
+
done: (value) => { results.push(value); },
|
|
159
|
+
});
|
|
160
|
+
return { view, paints, results, factCalls, reloadCalls, toggles };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A view mounted the way the host does it: the mount's own reads have
|
|
164
|
+
// settled before the first frame is inspected.
|
|
165
|
+
async function opened(options = {}) {
|
|
166
|
+
const made = makeView(options);
|
|
167
|
+
await tick();
|
|
168
|
+
return made;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Strip the frame and return the exact inner text of every content row
|
|
172
|
+
// (the frame is `│ <inner> │`, so inner is width-4 wide).
|
|
173
|
+
function screen(view, width = WIDTH) {
|
|
174
|
+
const lines = view.render(width).map(strip);
|
|
175
|
+
const rows = lines.map((line) => (line.startsWith('│') ? line.slice(2, -2).replace(/\s+$/, '') : line));
|
|
176
|
+
return { lines, rows };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// S4: the panel is pinned, so every region is reachable by position — a
|
|
180
|
+
// layout regression then fails ONE named assertion instead of thirty magic
|
|
181
|
+
// indices. The list area is whatever sits between the separator and the
|
|
182
|
+
// three trailing rows (focused data, legend, bottom rule).
|
|
183
|
+
function panel(view, width = WIDTH) {
|
|
184
|
+
const { lines, rows } = screen(view, width);
|
|
185
|
+
const list = rows.slice(4, rows.length - 3);
|
|
186
|
+
return {
|
|
187
|
+
lines,
|
|
188
|
+
rows,
|
|
189
|
+
height: lines.length,
|
|
190
|
+
title: lines[0],
|
|
191
|
+
currentName: rows[1],
|
|
192
|
+
currentData: rows[2],
|
|
193
|
+
separator: rows[3],
|
|
194
|
+
list,
|
|
195
|
+
worktrees: list.filter((text) => !/view all worktrees/.test(text)),
|
|
196
|
+
viewAll: list.find((text) => /view all worktrees/.test(text)) || null,
|
|
197
|
+
focusData: rows[rows.length - 3],
|
|
198
|
+
legend: rows[rows.length - 2],
|
|
199
|
+
bottom: lines[lines.length - 1],
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function assertWidth(lines, width = WIDTH) {
|
|
204
|
+
for (const line of lines) {
|
|
205
|
+
assert.equal(helpers.visibleWidth(line), width, `not ${width} wide: "${line}"`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
describe('bottom panel layout (S3, S4)', () => {
|
|
210
|
+
test('renders the S4 order from top to bottom at a content-fitting height', async () => {
|
|
211
|
+
const { view } = await opened();
|
|
212
|
+
const view80 = panel(view);
|
|
213
|
+
assert.equal(view80.height, 10, 'top + name + data + separator + 3 rows + focused data + legend + bottom');
|
|
214
|
+
|
|
215
|
+
const { lines, rows } = screen(view, WIDE);
|
|
216
|
+
assertWidth(lines, WIDE);
|
|
217
|
+
assert.match(lines[0], /^╭─ Warp ─+ esc ─╮$/, 'line 1 is the title line');
|
|
218
|
+
assert.equal(rows[1], 'feat-odd-viewer', 'line 2 is the current session worktree');
|
|
219
|
+
assert.equal(rows[2], DEFAULT_LINE, 'line 3 is that worktree\'s data');
|
|
220
|
+
assert.equal(rows[3], '', 'a separator row follows the header');
|
|
221
|
+
assert.equal(rows[4], '▸ main · use the local project', 'the list starts on line 5, focused first');
|
|
222
|
+
assert.equal(rows[5], ' feat-alpha');
|
|
223
|
+
assert.equal(rows[6], ' fix-beta');
|
|
224
|
+
assert.equal(rows[7], DEFAULT_LINE, 'the focused worktree\'s data line follows the list');
|
|
225
|
+
assert.equal(rows[8], '↑↓ move · enter choose · c create · ctrl+d delete · r reload · esc close', 'one legend row, with no `v all` while nothing is hidden, the delete key, and no retired edit key');
|
|
226
|
+
assert.match(lines[9], /^╰─+╯$/, 'the frame is closed');
|
|
227
|
+
assert.equal(lines.length, 10, 'the frame fills its computed height exactly');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('the frame is exactly as tall as its content, never taller than the rows offered', async () => {
|
|
231
|
+
for (const rows of [8, 9, 10, 12, 20, 40]) {
|
|
232
|
+
for (const width of [40, WIDTH, 120]) {
|
|
233
|
+
const { view } = await opened({ rows });
|
|
234
|
+
const frame = panel(view, width);
|
|
235
|
+
const expected = Math.min(7 + 3, rows);
|
|
236
|
+
assert.equal(frame.height, expected, `rows ${rows}/${width}: the frame fits its content`);
|
|
237
|
+
assertWidth(frame.lines, width);
|
|
238
|
+
assert.match(frame.title, /^╭─ Warp /, `rows ${rows}/${width}: the title line survives`);
|
|
239
|
+
assert.match(frame.bottom, /^╰─+╯$/, `rows ${rows}/${width}: the frame stays closed`);
|
|
240
|
+
assert.equal(frame.separator, '', `rows ${rows}/${width}: the separator never moves`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('shows at most five worktree rows, with the offer row only when one is left out', async () => {
|
|
246
|
+
const many = [
|
|
247
|
+
{ name: 'cur', path: '/w/cur', branch: 'feat/cur' },
|
|
248
|
+
...Array.from({ length: 8 }, (_, index) => ({ name: `wt-${index + 1}`, path: `/w/wt-${index + 1}`, branch: `feat/wt-${index + 1}` })),
|
|
249
|
+
];
|
|
250
|
+
const { view } = await opened({ checkouts: many, current: { name: 'cur', path: '/w/cur' }, rows: HEIGHT });
|
|
251
|
+
const frame = panel(view);
|
|
252
|
+
assert.equal(frame.worktrees.length, 5, 'S4b: at most five worktree rows');
|
|
253
|
+
assert.equal(frame.viewAll, ' view all worktrees', 'S4c: the offer row closes the list');
|
|
254
|
+
assert.equal(frame.height, 7 + 5 + 1, 'the offer row is part of the computed height');
|
|
255
|
+
assert.equal(frame.height, frame.lines.length, 'the frame fills that height exactly');
|
|
256
|
+
const body = frame.rows.join('\n');
|
|
257
|
+
assert.ok(!body.includes('wt-6'), 'the sixth worktree is beyond the bound');
|
|
258
|
+
assert.ok(!body.includes('wt-8'));
|
|
259
|
+
assert.ok(frame.focusData.length > 0, 'the focused row keeps its data line');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('the offer row is absent when every worktree fits, and at its five-row boundary', async () => {
|
|
263
|
+
const five = [
|
|
264
|
+
{ name: 'cur', path: '/w/cur', branch: 'feat/cur' },
|
|
265
|
+
...Array.from({ length: 5 }, (_, index) => ({ name: `wt-${index + 1}`, path: `/w/wt-${index + 1}`, branch: `feat/wt-${index + 1}` })),
|
|
266
|
+
];
|
|
267
|
+
const fits = panel((await opened({ checkouts: five, current: { name: 'cur', path: '/w/cur' } })).view);
|
|
268
|
+
assert.equal(fits.worktrees.length, 5);
|
|
269
|
+
assert.equal(fits.viewAll, null, 'S4c: everything fits, so neither the row nor its key appears');
|
|
270
|
+
assert.ok(!fits.rows.join('\n').includes('view all'), 'the offer is nowhere on screen');
|
|
271
|
+
assert.equal(fits.height, 7 + 5);
|
|
272
|
+
|
|
273
|
+
const six = [...five, { name: 'wt-6', path: '/w/wt-6', branch: 'feat/wt-6' }];
|
|
274
|
+
const hidden = panel((await opened({ checkouts: six, current: { name: 'cur', path: '/w/cur' } })).view);
|
|
275
|
+
assert.equal(hidden.worktrees.length, 5, 'the sixth row is what makes the offer appear');
|
|
276
|
+
assert.equal(hidden.viewAll, ' view all worktrees');
|
|
277
|
+
assert.equal(hidden.height, 7 + 5 + 1);
|
|
278
|
+
|
|
279
|
+
const few = panel((await opened({ checkouts: CHECKOUTS, current: { name: CURRENT.name, path: CURRENT.path } })).view);
|
|
280
|
+
assert.equal(few.viewAll, null, 'three other worktrees need no offer row');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('the current worktree owns the header and is never one of the rows', async () => {
|
|
284
|
+
const { view } = await opened();
|
|
285
|
+
const frame = panel(view, WIDE);
|
|
286
|
+
assert.equal(frame.currentName, 'feat-odd-viewer');
|
|
287
|
+
assert.ok(!frame.worktrees.some((row) => row.includes('feat-odd-viewer')), 'S4b: the current is not listed again');
|
|
288
|
+
assert.equal(frame.lines.filter((line) => line.includes('feat-odd-viewer')).length, 1, 'it appears exactly once');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('an empty list names its state and keeps creation on the hotkey', async () => {
|
|
292
|
+
const { view, results } = await opened({ checkouts: [CURRENT], current: { name: CURRENT.name, path: CURRENT.path } });
|
|
293
|
+
const frame = panel(view);
|
|
294
|
+
assert.deepEqual(frame.list, ['(no other worktrees)'], 'the list area explains itself');
|
|
295
|
+
assert.equal(frame.focusData, '', 'there is no focused worktree to describe');
|
|
296
|
+
assert.equal(frame.height, 8, 'the panel shrinks to its content');
|
|
297
|
+
view.handleInput('enter');
|
|
298
|
+
view.handleInput('e');
|
|
299
|
+
assert.deepEqual(results, [], 'enter and e are inert with nothing to focus');
|
|
300
|
+
view.handleInput('c');
|
|
301
|
+
assert.deepEqual(results, [{ create: true }], 'S6: c opens the create flow');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('a terminal too small for the panel says so in one line', async () => {
|
|
305
|
+
const { view } = await opened({ rows: 4 });
|
|
306
|
+
const lines = view.render(WIDTH).map(strip);
|
|
307
|
+
assert.equal(lines.length, 1);
|
|
308
|
+
assert.match(lines[0], /Warp .* enlarge the terminal/);
|
|
309
|
+
assertWidth(lines);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe('current worktree data line (S4a)', () => {
|
|
314
|
+
const CURRENT_FACTS = {
|
|
315
|
+
branch: 'feat/odd-viewer', changes: { added: 0, deleted: 0 }, ahead: 0, behind: 2, createdAt: ago(2 * 86400_000), lastActivity: ago(3600_000),
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
test('the line under the current name reports that worktree\'s own facts', async () => {
|
|
319
|
+
const { view } = await opened({
|
|
320
|
+
facts: (path) => (path === CURRENT.path ? CURRENT_FACTS : { ...CURRENT_FACTS, branch: 'main' }),
|
|
321
|
+
});
|
|
322
|
+
const frame = panel(view, WIDE);
|
|
323
|
+
assert.equal(frame.currentData, 'branch feat/odd-viewer · ↓2 · created 2d ago · modified 1h ago');
|
|
324
|
+
assert.ok(frame.focusData.startsWith('branch main'), 'the focused line keeps its own worktree');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test('an unreadable current worktree says unavailable instead of a zero', async () => {
|
|
328
|
+
const { view } = await opened({ facts: (path) => (path === CURRENT.path ? null : CURRENT_FACTS) });
|
|
329
|
+
const frame = panel(view, WIDE);
|
|
330
|
+
assert.equal(
|
|
331
|
+
frame.currentData,
|
|
332
|
+
`branch unavailable · changes unavailable · ↑unavailable ↓unavailable · ${NO_AGES}`,
|
|
333
|
+
);
|
|
334
|
+
assert.ok(!frame.currentData.includes(' 0'), 'a failed read never claims a number');
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe('focused checkout facts (S4d, S9)', () => {
|
|
339
|
+
const BASE = { branch: 'main', changes: { added: 12, deleted: 3 }, ahead: 1, behind: 2, createdAt: '2026-01-05T00:00:00.000Z', lastActivity: '2026-02-28T00:00:00.000Z' };
|
|
340
|
+
const FULL = 'branch main · +12 −3 · ↑1 ↓2 · created 1mo ago · modified 1d ago';
|
|
341
|
+
|
|
342
|
+
test('the focused checkout reports branch, the change-size line counts, ahead/behind and created-at', async () => {
|
|
343
|
+
const { view } = await opened({ facts: () => BASE });
|
|
344
|
+
const frame = panel(view, WIDE);
|
|
345
|
+
assert.equal(frame.worktrees[0], '▸ main · use the local project', 'the principal is focused first');
|
|
346
|
+
assert.equal(frame.focusData, FULL, 'the focused data line carries every fact');
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test('every fact that cannot be read renders unavailable, never zero', async () => {
|
|
350
|
+
const { view } = await opened({ facts: () => null });
|
|
351
|
+
const frame = panel(view, WIDE);
|
|
352
|
+
assert.equal(frame.focusData, `branch unavailable · changes unavailable · ↑unavailable ↓unavailable · ${NO_AGES}`);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test('S1: a legacy association in the facts is never rendered, not even as a count', async () => {
|
|
356
|
+
const { view } = await opened({ facts: () => ({ ...BASE, issues: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], assignedOdd: 'odd/tasks/w9-x.md', odd: ['w9-x', 'w9-y'] }) });
|
|
357
|
+
const frame = panel(view, WIDE);
|
|
358
|
+
assert.equal(frame.focusData, FULL, `the line carries no association: ${frame.focusData}`);
|
|
359
|
+
for (const word of ['issues', 'w9-x', 'w9-y', '#1', '#10']) {
|
|
360
|
+
assert.ok(!frame.lines.join('\n').includes(word), `no association reaches the frame (${word})`);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('each single unavailable fact is reported on its own while the others survive', async () => {
|
|
365
|
+
const cases = [
|
|
366
|
+
['branch', 'branch unavailable'],
|
|
367
|
+
['changes', 'changes unavailable'],
|
|
368
|
+
['ahead', '↑unavailable'],
|
|
369
|
+
['behind', '↓unavailable'],
|
|
370
|
+
['createdAt', 'created —'],
|
|
371
|
+
['lastActivity', 'modified —'],
|
|
372
|
+
];
|
|
373
|
+
for (const [field, expected] of cases) {
|
|
374
|
+
const facts = { ...BASE, [field]: field === 'createdAt' ? 'unavailable' : null };
|
|
375
|
+
const { view } = await opened({ facts: () => facts });
|
|
376
|
+
const line = panel(view, WIDE).focusData;
|
|
377
|
+
assert.ok(line.includes(expected), `${field} renders "${expected}": ${line}`);
|
|
378
|
+
assert.ok(!/↑0|↓0|\+0 −0/.test(line), `a missing number is never 0: ${line}`);
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test('a measured zero is omitted for the change size and both arrows', async () => {
|
|
383
|
+
const { view } = await opened({ facts: () => ({ ...BASE, changes: { added: 0, deleted: 0 }, ahead: 0, behind: 0, issues: [], odd: [] }) });
|
|
384
|
+
assert.equal(panel(view, WIDE).focusData, 'branch main · created 1mo ago · modified 1d ago');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test('a one-sided ahead shows only ↑N, and a one-sided behind only ↓M', async () => {
|
|
388
|
+
const aheadOnly = await opened({ facts: () => ({ ...BASE, behind: 0 }) });
|
|
389
|
+
assert.equal(panel(aheadOnly.view, WIDE).focusData, 'branch main · +12 −3 · ↑1 · created 1mo ago · modified 1d ago');
|
|
390
|
+
const behindOnly = await opened({ facts: () => ({ ...BASE, ahead: 0 }) });
|
|
391
|
+
assert.equal(panel(behindOnly.view, WIDE).focusData, 'branch main · +12 −3 · ↓2 · created 1mo ago · modified 1d ago');
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('the change size is the tracked and untracked LINE counts, never a file count', async () => {
|
|
395
|
+
const { view } = await opened({ facts: () => ({ ...BASE, changes: { added: 4, deleted: 1 } }) });
|
|
396
|
+
const line = panel(view, WIDE).focusData;
|
|
397
|
+
assert.ok(line.includes('+4 −1'), `the line counts render: ${line}`);
|
|
398
|
+
assert.ok(!line.includes('changes +'), `the sign already says what the number is: ${line}`);
|
|
399
|
+
assert.ok(!/files|uncommitted/.test(line), `no file count survives: ${line}`);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test('the change size drops its label, and keeps it only where the counts cannot be read', async () => {
|
|
403
|
+
// S16 (feedback): `+A −D` already says what the number is, so the word
|
|
404
|
+
// before it was noise. An UNREADABLE size still needs the label, or
|
|
405
|
+
// `unavailable` would not say which fact is missing.
|
|
406
|
+
const known = await opened({ facts: () => ({ ...BASE, changes: { added: 7, deleted: 2 } }) });
|
|
407
|
+
const knownLine = panel(known.view, WIDE).focusData;
|
|
408
|
+
assert.ok(knownLine.includes('· +7 −2 ·'), `the bare counts render: ${knownLine}`);
|
|
409
|
+
assert.ok(!/changes \+/.test(knownLine), `no label before readable counts: ${knownLine}`);
|
|
410
|
+
|
|
411
|
+
const unknown = await opened({ facts: () => ({ ...BASE, changes: null }) });
|
|
412
|
+
assert.ok(panel(unknown.view, WIDE).focusData.includes('· changes unavailable ·'), 'unknown keeps an intelligible label');
|
|
413
|
+
|
|
414
|
+
const fileCountOnly = await opened({ facts: () => ({ ...BASE, changes: { files: 3 } }) });
|
|
415
|
+
const fileCountLine = panel(fileCountOnly.view, WIDE).focusData;
|
|
416
|
+
assert.ok(fileCountLine.includes('changes unavailable'), `a bare file count is not a size: ${fileCountLine}`);
|
|
417
|
+
assert.ok(!/\+\d/.test(fileCountLine), 'and it never becomes a made-up count');
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test('an unavailable value still renders where its zero would have been omitted', async () => {
|
|
421
|
+
const { view } = await opened({ facts: () => ({ ...BASE, changes: null, ahead: null, behind: null }) });
|
|
422
|
+
const line = panel(view, WIDE).focusData;
|
|
423
|
+
assert.ok(line.includes('changes unavailable'), `the change size stays visible: ${line}`);
|
|
424
|
+
assert.ok(line.includes('↑unavailable ↓unavailable'), `both arrows stay visible: ${line}`);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test('moving the selection refreshes the data line and reads each checkout only once', async () => {
|
|
428
|
+
const facts = (path) => ({ ...BASE, branch: path === '/w/feat-alpha' ? 'feat/alpha' : 'main' });
|
|
429
|
+
const { view, factCalls, paints } = await opened({ facts });
|
|
430
|
+
assert.deepEqual(factCalls, ['/w/feat-odd-viewer', '/w/herdr'], 'the current and the focused checkout are read');
|
|
431
|
+
assert.ok(paints.length > 0, 'the component repaints after a move');
|
|
432
|
+
view.handleInput('down');
|
|
433
|
+
screen(view, WIDE);
|
|
434
|
+
await tick();
|
|
435
|
+
const moved = panel(view, WIDE);
|
|
436
|
+
assert.deepEqual(factCalls, ['/w/feat-odd-viewer', '/w/herdr', '/w/feat-alpha'], 'the new focus is read on demand');
|
|
437
|
+
assert.ok(moved.focusData.startsWith('branch feat/alpha'), `the data follows the focus: ${moved.focusData}`);
|
|
438
|
+
view.handleInput('up');
|
|
439
|
+
screen(view, WIDE);
|
|
440
|
+
view.handleInput('down');
|
|
441
|
+
screen(view, WIDE);
|
|
442
|
+
assert.equal(factCalls.length, 3, 'cached facts are never re-read');
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test('a slow facts read never blocks a frame and cannot be shown as zero', async () => {
|
|
446
|
+
const { view } = makeView({ facts: () => new Promise(() => {}) });
|
|
447
|
+
const frame = panel(view, WIDE);
|
|
448
|
+
assert.equal(frame.height, 10, 'the frame renders while both reads are in flight');
|
|
449
|
+
assertWidth(frame.lines, WIDE);
|
|
450
|
+
assert.match(frame.focusData, /reading/, 'the pending read says so');
|
|
451
|
+
assert.match(frame.currentData, /reading/, 'the current worktree says so too');
|
|
452
|
+
assert.ok(!/↑0|↓0/.test(frame.focusData));
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test('a failing facts read degrades to unavailable instead of throwing', async () => {
|
|
456
|
+
const { view } = await opened({ facts: () => Promise.reject(new Error('git failed')) });
|
|
457
|
+
const frame = panel(view, WIDE);
|
|
458
|
+
assert.ok(frame.focusData.includes('branch unavailable'), frame.focusData);
|
|
459
|
+
assert.ok(!frame.focusData.includes('0'), 'a failed read never claims a number');
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
describe('ordering and both ages (S12)', () => {
|
|
464
|
+
const ORDERED = [
|
|
465
|
+
{ name: 'cur', path: '/w/cur', branch: 'feat/cur' },
|
|
466
|
+
{ name: 'stale', path: '/w/stale', branch: 'feat/stale', lastActivity: ago(10 * 86400_000) },
|
|
467
|
+
{ name: 'open', path: '/w/open', branch: 'feat/open', lastActivity: null, lastActivityReason: 'not a git repository' },
|
|
468
|
+
{ name: 'commit-fresh', path: '/w/commit-fresh', branch: 'feat/cf', lastActivity: ago(60_000) },
|
|
469
|
+
{ name: 'dirty-fresh', path: '/w/dirty-fresh', branch: 'feat/df', lastActivity: ago(3600_000) },
|
|
470
|
+
];
|
|
471
|
+
|
|
472
|
+
test('orders the rows by last activity descending, keeps the unreadable one last and never prints its reason', async () => {
|
|
473
|
+
const { view } = await opened({ checkouts: ORDERED, current: { name: 'cur', path: '/w/cur' }, facts: () => null });
|
|
474
|
+
const rows = panel(view, WIDE).worktrees;
|
|
475
|
+
assert.match(rows[0], /^▸ commit-fresh/, 'the most recent commit leads and takes the focus');
|
|
476
|
+
assert.match(rows[1], /^ {2}dirty-fresh/, 'a recent dirty file follows the commit');
|
|
477
|
+
assert.match(rows[2], /^ {2}stale/);
|
|
478
|
+
assert.match(rows[3], /^ {2}open/, 'the unreadable row sorts last');
|
|
479
|
+
for (let i = 0; i < 4; i += 1) view.handleInput('down');
|
|
480
|
+
screen(view, WIDE);
|
|
481
|
+
await tick();
|
|
482
|
+
const focused = panel(view, WIDE);
|
|
483
|
+
assert.match(focused.worktrees[3], /^▸ open/);
|
|
484
|
+
assert.ok(focused.focusData.endsWith('modified —'), `a brief value replaces a fake date: ${focused.focusData}`);
|
|
485
|
+
assert.ok(!focused.focusData.includes('not a git repository'), `the reason stays out of the row: ${focused.focusData}`);
|
|
486
|
+
assert.ok(!/modified 20\d\d/.test(focused.focusData));
|
|
487
|
+
const stored = view.state.checkouts.find((entry) => entry.path === '/w/open');
|
|
488
|
+
assert.equal(stored.lastActivityReason, 'not a git repository', 'the provenance is kept in data');
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test('the extended list keeps the same order', async () => {
|
|
492
|
+
const { view } = await opened({ checkouts: ORDERED, current: { name: 'cur', path: '/w/cur' }, facts: () => null });
|
|
493
|
+
view.handleInput('v');
|
|
494
|
+
const { rows } = screen(view, WIDE);
|
|
495
|
+
assert.match(rows[2], /commit-fresh/);
|
|
496
|
+
assert.match(rows[3], /dirty-fresh/);
|
|
497
|
+
assert.match(rows[4], /stale/);
|
|
498
|
+
assert.match(rows[5], /open/);
|
|
499
|
+
assert.equal(rows[6], '', 'the current checkout is not a row, so `open` is the last one');
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test('renders both ages in the package style, from just now to years', () => {
|
|
503
|
+
const line = (facts) => formatFactsLine(facts, { now: NOW });
|
|
504
|
+
// The suffix matches lib/issues-view.js, so one clock never reads two ways
|
|
505
|
+
// across the package's surfaces.
|
|
506
|
+
assert.ok(line({ createdAt: ago(30_000), lastActivity: ago(30_000) }).endsWith('created just now · modified just now'));
|
|
507
|
+
assert.ok(line({ createdAt: ago(60_000), lastActivity: ago(2 * 60_000) }).endsWith('created 1m ago · modified 2m ago'));
|
|
508
|
+
assert.ok(line({ createdAt: ago(3600_000), lastActivity: ago(2 * 3600_000) }).endsWith('created 1h ago · modified 2h ago'));
|
|
509
|
+
assert.ok(line({ createdAt: ago(86400_000), lastActivity: ago(2 * 86400_000) }).endsWith('created 1d ago · modified 2d ago'));
|
|
510
|
+
assert.ok(line({ createdAt: ago(30 * 86400_000), lastActivity: ago(60 * 86400_000) }).endsWith('created 1mo ago · modified 2mo ago'));
|
|
511
|
+
assert.ok(line({ createdAt: ago(365 * 86400_000), lastActivity: ago(2 * 365 * 86400_000) }).endsWith('created 1y ago · modified 2y ago'));
|
|
512
|
+
assert.ok(line({ createdAt: null, lastActivity: null }).endsWith(NO_AGES));
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// S15: "las preview son breves, no puedes poner ese texto entenso para ese
|
|
517
|
+
// caso de modified". The screenshot showed `modified no activity since the
|
|
518
|
+
// checkout was created`; a compact row now carries the brief value only, and
|
|
519
|
+
// the provenance the caller supplied stays available in the component's data.
|
|
520
|
+
describe('S15: compact facts rows never print the raw reason', () => {
|
|
521
|
+
const LONG = 'no activity since the checkout was created';
|
|
522
|
+
const WITH_REASON = { ...DEFAULT_FACTS, lastActivity: null, lastActivityReason: LONG };
|
|
523
|
+
|
|
524
|
+
test('an unreadable age renders a dash instead of the long explanation', async () => {
|
|
525
|
+
const { view } = await opened({ facts: () => WITH_REASON });
|
|
526
|
+
const frame = panel(view, WIDE);
|
|
527
|
+
assert.ok(frame.focusData.endsWith('created 28d ago · modified —'), `the readable age survives and the other stays brief: ${frame.focusData}`);
|
|
528
|
+
assert.ok(!frame.focusData.includes(LONG), `no prose reason in the row: ${frame.focusData}`);
|
|
529
|
+
assert.ok(!frame.currentData.includes(LONG), `nor under the current worktree: ${frame.currentData}`);
|
|
530
|
+
assert.ok(!frame.lines.join('\n').includes('activity since'), 'the reason appears nowhere on screen');
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test('a row-level reason is kept as data and never rendered', async () => {
|
|
534
|
+
const checkouts = [
|
|
535
|
+
{ name: 'cur', path: '/w/cur', branch: 'feat/cur' },
|
|
536
|
+
{ name: 'stale', path: '/w/stale', branch: 'feat/stale', lastActivity: null, lastActivityReason: LONG },
|
|
537
|
+
];
|
|
538
|
+
const { view } = await opened({ checkouts, current: { name: 'cur', path: '/w/cur' }, facts: () => null });
|
|
539
|
+
const frame = panel(view, WIDE);
|
|
540
|
+
assert.match(frame.worktrees[0], /^▸ stale/, 'the unreadable row still sorts last and takes the focus');
|
|
541
|
+
assert.ok(frame.focusData.endsWith('modified —'), `the row's own reason is not printed: ${frame.focusData}`);
|
|
542
|
+
const stored = view.state.checkouts.find((entry) => entry.path === '/w/stale');
|
|
543
|
+
assert.equal(stored.lastActivityReason, LONG, 'the provenance survives in the component data');
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test('the created age uses the same brief form, and a real timestamp is never replaced', async () => {
|
|
547
|
+
const line = (facts) => formatFactsLine(facts, { now: NOW });
|
|
548
|
+
assert.ok(line({ createdAt: { available: false, reason: 'birthtime unavailable on this filesystem' }, lastActivity: { available: false, reason: LONG } }).endsWith(NO_AGES));
|
|
549
|
+
assert.ok(line({ createdAt: '2026-02-01', lastActivity: ago(2 * 86400_000) }).endsWith('created 28d ago · modified 2d ago'), 'readable values never lose their age');
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
describe('the create key and the retired edit key (S1, S4, S6)', () => {
|
|
554
|
+
test('c opens the create flow and no list row offers creation', async () => {
|
|
555
|
+
const { view, results } = await opened();
|
|
556
|
+
const frame = panel(view, WIDE);
|
|
557
|
+
assert.ok(!frame.rows.join('\n').includes('create a worktree'), 'S6: creation is not a row any more');
|
|
558
|
+
assert.match(frame.legend, /c create/, 'the legend names the hotkey');
|
|
559
|
+
view.handleInput('c');
|
|
560
|
+
assert.deepEqual(results, [{ create: true }]);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test('c stays ordinary text in the extended list, where the filter owns printables', async () => {
|
|
564
|
+
const { view, results } = await opened();
|
|
565
|
+
view.handleInput('v');
|
|
566
|
+
view.handleInput('c');
|
|
567
|
+
assert.equal(view.state.filter, 'c', 'the filter took the character');
|
|
568
|
+
assert.deepEqual(results, []);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test('S4: `e` is not a binding any more — no edit request, no legend token, nothing edited', async () => {
|
|
572
|
+
const { view, results } = await opened();
|
|
573
|
+
const frame = panel(view, WIDE);
|
|
574
|
+
assert.ok(!frame.legend.includes('e edit'), `the legend names no edit key: ${frame.legend}`);
|
|
575
|
+
for (const width of [40, 80, 120]) {
|
|
576
|
+
assert.ok(!panel(view, width).legend.includes('e edit'), `width ${width}: no edit token at any width`);
|
|
577
|
+
}
|
|
578
|
+
view.handleInput('e');
|
|
579
|
+
assert.deepEqual(results, [], 'e resolves nothing on a focused checkout row');
|
|
580
|
+
|
|
581
|
+
const empty = await opened({ checkouts: [CURRENT], current: { name: CURRENT.name, path: CURRENT.path } });
|
|
582
|
+
empty.view.handleInput('e');
|
|
583
|
+
assert.deepEqual(empty.results, [], 'e on an empty list is inert');
|
|
584
|
+
|
|
585
|
+
const many = [
|
|
586
|
+
{ name: 'cur', path: '/w/cur', branch: 'feat/cur' },
|
|
587
|
+
...Array.from({ length: 8 }, (_, index) => ({ name: `wt-${index + 1}`, path: `/w/wt-${index + 1}`, branch: `feat/wt-${index + 1}` })),
|
|
588
|
+
];
|
|
589
|
+
const offered = await opened({ checkouts: many, current: { name: 'cur', path: '/w/cur' } });
|
|
590
|
+
for (let i = 0; i < 5; i += 1) offered.view.handleInput('down');
|
|
591
|
+
assert.match(panel(offered.view).viewAll || '', /^▸ view all worktrees$/, 'the offer row is focused');
|
|
592
|
+
offered.view.handleInput('e');
|
|
593
|
+
assert.deepEqual(offered.results, [], 'e on the offer row is inert too');
|
|
594
|
+
|
|
595
|
+
const listed = await opened();
|
|
596
|
+
listed.view.handleInput('v');
|
|
597
|
+
listed.view.handleInput('T');
|
|
598
|
+
listed.view.handleInput('e');
|
|
599
|
+
assert.equal(listed.view.state.filter, 'e', 'e is typed text while the filter is open');
|
|
600
|
+
assert.deepEqual(listed.results, []);
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
describe('extended list (S1, S6)', () => {
|
|
605
|
+
test('v opens a paginated list of every other checkout with an always-active filter row', async () => {
|
|
606
|
+
const { view } = await opened();
|
|
607
|
+
view.handleInput('v');
|
|
608
|
+
assert.equal(view.state.view, 'list');
|
|
609
|
+
const { lines, rows } = screen(view);
|
|
610
|
+
assert.equal(lines.length, HEIGHT);
|
|
611
|
+
assertWidth(lines);
|
|
612
|
+
assert.match(lines[0], /^╭─ Warp · all worktrees ─+ esc ─╮$/);
|
|
613
|
+
assert.equal(rows[1], 'filter: type to search', 'the filter row is always present and never selectable');
|
|
614
|
+
assert.equal(rows[2], '▸ main · use the local project', 'S6: the principal leads the list');
|
|
615
|
+
assert.equal(rows[3], ' feat-alpha');
|
|
616
|
+
assert.equal(rows[4], ' fix-beta');
|
|
617
|
+
assert.equal(rows[5], '', 'the checkout the session stands in is not a destination');
|
|
618
|
+
assert.equal(rows[17], 'page 1/1 · 3 worktrees');
|
|
619
|
+
assert.match(rows[18], /enter warp/);
|
|
620
|
+
assert.match(rows[18], /esc back/);
|
|
621
|
+
assert.ok(!rows.slice(2, 17).join('\n').includes('current: '), 'the filter row is not a data row');
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test('typing filters the list live and the filter row is not a selectable item', async () => {
|
|
625
|
+
const { view } = await opened();
|
|
626
|
+
view.handleInput('v');
|
|
627
|
+
view.handleInput('a');
|
|
628
|
+
assert.equal(view.state.filterOpen, true);
|
|
629
|
+
assert.equal(view.state.filter, 'a');
|
|
630
|
+
let { rows } = screen(view);
|
|
631
|
+
assert.equal(rows[1], 'filter: a▏', 'the typed text lands in the filter row');
|
|
632
|
+
for (const ch of 'lpha') view.handleInput(ch);
|
|
633
|
+
({ rows } = screen(view));
|
|
634
|
+
assert.equal(rows[1], 'filter: alpha▏');
|
|
635
|
+
assert.equal(rows[2], '▸ feat-alpha');
|
|
636
|
+
assert.equal(rows[3], '');
|
|
637
|
+
const body = rows.join('\n');
|
|
638
|
+
assert.ok(!body.includes('fix-beta'), 'non-matching rows are filtered out');
|
|
639
|
+
assert.equal(rows[17], 'page 1/1 · 1 worktree');
|
|
640
|
+
assert.equal(view.state.listCursor, 0, 'the cursor tracks the filtered rows');
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test('the filter excludes rows that match nothing and enter cannot warp from it', async () => {
|
|
644
|
+
const { view, results } = await opened();
|
|
645
|
+
view.handleInput('v');
|
|
646
|
+
for (const ch of 'zzz') view.handleInput(ch);
|
|
647
|
+
const { lines, rows } = screen(view);
|
|
648
|
+
assertWidth(lines);
|
|
649
|
+
assert.equal(rows[1], 'filter: zzz▏');
|
|
650
|
+
assert.equal(rows[2], '(no matches for "zzz")');
|
|
651
|
+
assert.equal(rows[17], 'page 1/1 · 0 worktrees');
|
|
652
|
+
view.handleInput('enter');
|
|
653
|
+
assert.deepEqual(results, [], 'enter on an empty result warps nowhere');
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test('pgUp/pgDn page through every other checkout', async () => {
|
|
657
|
+
const many = Array.from({ length: 12 }, (_, index) => ({ name: `wt-${index + 1}`, path: `/w/wt-${index + 1}`, branch: `feat/wt-${index + 1}` }));
|
|
658
|
+
const { view } = await opened({ checkouts: many, current: { name: 'wt-1', path: '/w/wt-1' }, rows: 11 });
|
|
659
|
+
view.handleInput('v');
|
|
660
|
+
let { rows } = screen(view);
|
|
661
|
+
// 11 rows - 5 fixed rows = a 6-row page, and wt-1 (the session's own
|
|
662
|
+
// checkout) is not one of the eleven destinations.
|
|
663
|
+
assert.equal(rows[2], '▸ wt-2');
|
|
664
|
+
assert.equal(rows[7], ' wt-7');
|
|
665
|
+
assert.equal(rows[8], 'page 1/2 · 11 worktrees', 'the page row sits right below the six rows');
|
|
666
|
+
view.handleInput('pagedown');
|
|
667
|
+
({ rows } = screen(view));
|
|
668
|
+
assert.equal(view.state.page, 1);
|
|
669
|
+
assert.equal(rows[2], '▸ wt-8');
|
|
670
|
+
assert.equal(rows[6], ' wt-12');
|
|
671
|
+
assert.equal(rows[7], '', 'the second page holds the remaining five rows');
|
|
672
|
+
assert.equal(rows[8], 'page 2/2 · 11 worktrees');
|
|
673
|
+
view.handleInput('pagedown');
|
|
674
|
+
assert.equal(view.state.page, 1, 'the last page is a wall');
|
|
675
|
+
view.handleInput('pageup');
|
|
676
|
+
({ rows } = screen(view));
|
|
677
|
+
assert.equal(view.state.page, 0);
|
|
678
|
+
assert.equal(rows[2], '▸ wt-2');
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
test('esc closes the filter first and then steps back to the panel', async () => {
|
|
682
|
+
const { view, results } = await opened();
|
|
683
|
+
view.handleInput('v');
|
|
684
|
+
view.handleInput('a');
|
|
685
|
+
assert.equal(view.state.filterOpen, true);
|
|
686
|
+
view.handleInput('escape');
|
|
687
|
+
assert.equal(view.state.filterOpen, false, 'esc closes the filter, keeping the query');
|
|
688
|
+
assert.equal(view.state.view, 'list', 'and stays in the list');
|
|
689
|
+
assert.equal(view.state.filter, 'a');
|
|
690
|
+
view.handleInput('escape');
|
|
691
|
+
assert.equal(view.state.view, 'popup', 'esc steps back exactly one level');
|
|
692
|
+
assert.deepEqual(results, [], 'stepping back never resolves the component');
|
|
693
|
+
view.handleInput('escape');
|
|
694
|
+
assert.deepEqual(results, [null], 'esc from the panel closes the component');
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test('ctrl+c steps back from the list too', async () => {
|
|
698
|
+
const { view, results } = await opened();
|
|
699
|
+
view.handleInput('v');
|
|
700
|
+
view.handleInput('ctrl+c');
|
|
701
|
+
assert.equal(view.state.view, 'popup');
|
|
702
|
+
assert.deepEqual(results, []);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
test('enter on a checkout warps to its path', async () => {
|
|
706
|
+
const { view, results } = await opened();
|
|
707
|
+
view.handleInput('v');
|
|
708
|
+
view.handleInput('down');
|
|
709
|
+
view.handleInput('enter');
|
|
710
|
+
assert.deepEqual(results, [{ warp: '/w/feat-alpha' }], 'the second row is feat-alpha: the current checkout is not listed');
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
describe('the S6 space rule', () => {
|
|
715
|
+
test('space toggles while the filter is closed, types once it is open, and toggles again after it closes', async () => {
|
|
716
|
+
const { view, toggles } = await opened({ onToggle: () => {} });
|
|
717
|
+
view.handleInput('v');
|
|
718
|
+
view.handleInput('down');
|
|
719
|
+
assert.equal(view.state.listCursor, 1);
|
|
720
|
+
|
|
721
|
+
view.handleInput(' ');
|
|
722
|
+
assert.deepEqual(toggles, ['/w/feat-alpha'], 'space toggles the focused row while the filter is closed');
|
|
723
|
+
assert.equal(view.state.filterOpen, false, 'and never opens the filter');
|
|
724
|
+
assert.equal(view.state.filter, '');
|
|
725
|
+
|
|
726
|
+
view.handleInput('z');
|
|
727
|
+
assert.equal(view.state.filterOpen, true, 'a printable that is not a binding opens the filter');
|
|
728
|
+
assert.equal(view.state.filter, 'z');
|
|
729
|
+
view.handleInput(' ');
|
|
730
|
+
assert.equal(view.state.filter, 'z ', 'once open, space goes to the text');
|
|
731
|
+
assert.deepEqual(toggles, ['/w/feat-alpha'], 'and no longer toggles');
|
|
732
|
+
|
|
733
|
+
view.handleInput('escape');
|
|
734
|
+
assert.equal(view.state.filterOpen, false);
|
|
735
|
+
assert.equal(view.state.view, 'list');
|
|
736
|
+
view.handleInput('T');
|
|
737
|
+
assert.equal(view.state.filterOpen, true, 'T opens the filter explicitly');
|
|
738
|
+
assert.equal(view.state.filter, '', 'and starts an empty query');
|
|
739
|
+
view.handleInput('escape');
|
|
740
|
+
view.handleInput('down');
|
|
741
|
+
view.handleInput(' ');
|
|
742
|
+
assert.deepEqual(toggles, ['/w/feat-alpha', '/w/feat-alpha'], 'closing an empty filter restores the toggle');
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
test('without a toggle, space is a printable and opens the filter', async () => {
|
|
746
|
+
const { view } = await opened();
|
|
747
|
+
view.handleInput('v');
|
|
748
|
+
view.handleInput(' ');
|
|
749
|
+
assert.equal(view.state.filterOpen, true);
|
|
750
|
+
assert.equal(view.state.filter, ' ');
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
describe('S9: no redundant help overlay', () => {
|
|
755
|
+
test('? is unbound in both views and never replaces the rows', async () => {
|
|
756
|
+
const { view } = await opened();
|
|
757
|
+
const before = JSON.parse(JSON.stringify(view.state));
|
|
758
|
+
view.handleInput('?');
|
|
759
|
+
assert.ok(panel(view).worktrees.some((text) => /feat-alpha/.test(text)), 'the panel rows stay');
|
|
760
|
+
assert.deepEqual(view.state, before, '? changes no state in the panel');
|
|
761
|
+
|
|
762
|
+
view.handleInput('v');
|
|
763
|
+
view.handleInput('?');
|
|
764
|
+
const { rows } = screen(view);
|
|
765
|
+
assert.equal(view.state.filter, '?', '? is ordinary printable text in the list, never a help toggle');
|
|
766
|
+
assert.match(rows[1], /filter: \?/);
|
|
767
|
+
assert.match(rows[2], /no matches for/);
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
test('the legends name every shortcut and never advertise ? help', async () => {
|
|
771
|
+
const { view } = await opened();
|
|
772
|
+
assert.equal(panel(view, WIDE).legend, '↑↓ move · enter choose · c create · ctrl+d delete · r reload · esc close');
|
|
773
|
+
assert.ok(!panel(view, WIDE).legend.includes('?'), 'no generic help hint in the panel legend');
|
|
774
|
+
|
|
775
|
+
// S4c: the offer row and its key appear together, only when a worktree
|
|
776
|
+
// is left out.
|
|
777
|
+
const many = [
|
|
778
|
+
{ name: 'cur', path: '/w/cur', branch: 'feat/cur' },
|
|
779
|
+
...Array.from({ length: 8 }, (_, index) => ({ name: `wt-${index + 1}`, path: `/w/wt-${index + 1}`, branch: `feat/wt-${index + 1}` })),
|
|
780
|
+
];
|
|
781
|
+
const offered = await opened({ checkouts: many, current: { name: 'cur', path: '/w/cur' } });
|
|
782
|
+
assert.match(panel(offered.view, WIDE).legend, /v all/, 'the offer key rides with the offer row');
|
|
783
|
+
|
|
784
|
+
view.handleInput('v');
|
|
785
|
+
const { rows } = screen(view);
|
|
786
|
+
assert.equal(rows[18], '↑↓ move · pgUp/pgDn page · enter warp · ctrl+d delete · r reload · esc back');
|
|
787
|
+
assert.ok(!rows[18].includes('?'), 'no generic help hint in the list legend');
|
|
788
|
+
});
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
describe('S6: the principal leads when it is not the current checkout', () => {
|
|
792
|
+
test('a principal with the oldest activity still renders before newer checkouts', async () => {
|
|
793
|
+
const checkouts = [
|
|
794
|
+
{ name: 'cur', path: '/w/cur', branch: 'feat/cur' },
|
|
795
|
+
{ name: 'recent', path: '/w/recent', branch: 'feat/recent', lastActivity: ago(60_000) },
|
|
796
|
+
{ name: 'main', path: '/w/herdr', branch: 'main', isPrincipal: true, lastActivity: ago(90 * 86400_000) },
|
|
797
|
+
{ name: 'older', path: '/w/older', branch: 'feat/older', lastActivity: ago(120 * 86400_000) },
|
|
798
|
+
];
|
|
799
|
+
const { view } = await opened({ checkouts, current: { name: 'cur', path: '/w/cur' }, facts: () => null });
|
|
800
|
+
const rows = panel(view, WIDE).worktrees;
|
|
801
|
+
assert.equal(rows[0], '▸ main · use the local project', 'the principal is first despite the oldest activity');
|
|
802
|
+
assert.equal(rows[1], ' recent');
|
|
803
|
+
assert.equal(rows[2], ' older');
|
|
804
|
+
});
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
describe('S1: render performs no I/O and enrichment keeps the selection', () => {
|
|
808
|
+
test('the mount reads the current and the focused checkout; render() adds no read', async () => {
|
|
809
|
+
let calls = 0;
|
|
810
|
+
const { view } = makeView({ facts: () => { calls += 1; return DEFAULT_FACTS; } });
|
|
811
|
+
assert.equal(calls, 2, 'the mount reads the current worktree and the first focused row');
|
|
812
|
+
screen(view);
|
|
813
|
+
screen(view, 120);
|
|
814
|
+
assert.equal(calls, 2, 'render() reads nothing');
|
|
815
|
+
view.handleInput('down');
|
|
816
|
+
assert.equal(calls, 3, 'moving the focus starts the one read');
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
test('apply() re-sorts by the newly arrived activity and keeps the focused checkout', async () => {
|
|
820
|
+
const initial = [
|
|
821
|
+
{ name: 'cur', path: '/w/cur', branch: 'feat/cur' },
|
|
822
|
+
{ name: 'alpha', path: '/w/alpha', branch: 'feat/alpha' },
|
|
823
|
+
{ name: 'beta', path: '/w/beta', branch: 'feat/beta' },
|
|
824
|
+
];
|
|
825
|
+
const { view } = await opened({ checkouts: initial, current: { name: 'cur', path: '/w/cur' }, facts: () => null });
|
|
826
|
+
view.handleInput('down');
|
|
827
|
+
view.handleInput('down'); // focus beta (the second list row, clamped)
|
|
828
|
+
assert.equal(view.state.cursor, 1);
|
|
829
|
+
view.apply([
|
|
830
|
+
{ name: 'cur', path: '/w/cur', branch: 'feat/cur' },
|
|
831
|
+
{ name: 'alpha', path: '/w/alpha', branch: 'feat/alpha', lastActivity: '2026-02-01T00:00:00.000Z' },
|
|
832
|
+
{ name: 'beta', path: '/w/beta', branch: 'feat/beta', lastActivity: '2026-02-02T00:00:00.000Z' },
|
|
833
|
+
]);
|
|
834
|
+
const rows = panel(view, WIDE).worktrees;
|
|
835
|
+
assert.equal(rows[0], '▸ beta', 'beta now leads by activity and holds the focus');
|
|
836
|
+
assert.equal(rows[1], ' alpha');
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
test('apply() after escape is ignored while a delayed facts answer settles, and never throws', async () => {
|
|
840
|
+
let releaseFacts;
|
|
841
|
+
const delayed = new Promise((resolve) => { releaseFacts = resolve; });
|
|
842
|
+
const { view, results } = makeView({ facts: () => delayed });
|
|
843
|
+
await tick();
|
|
844
|
+
view.handleInput('down'); // start the focused checkout's pending read
|
|
845
|
+
view.handleInput('escape'); // the panel resolves: the component is settled
|
|
846
|
+
assert.deepEqual(results, [null]);
|
|
847
|
+
const before = JSON.parse(JSON.stringify(view.state.checkouts));
|
|
848
|
+
|
|
849
|
+
const replacement = [
|
|
850
|
+
{ name: 'cur', path: '/w/cur', branch: 'feat/cur' },
|
|
851
|
+
{ name: 'late', path: '/w/late', branch: 'feat/late', lastActivity: '2026-02-02T00:00:00.000Z' },
|
|
852
|
+
];
|
|
853
|
+
assert.doesNotThrow(() => view.apply(replacement));
|
|
854
|
+
releaseFacts(DEFAULT_FACTS);
|
|
855
|
+
await tick();
|
|
856
|
+
await tick();
|
|
857
|
+
assert.deepEqual(view.state.checkouts, before, 'a settled view ignores late enrichment');
|
|
858
|
+
});
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
// S12/S4: Delete clears the WHOLE query only while the search field is
|
|
862
|
+
// active, Backspace still removes one character, and neither of them ever
|
|
863
|
+
// signals anything else — S4 removed the worktree deletion binding entirely.
|
|
864
|
+
describe('S12: Delete clears the search, Backspace edits it', () => {
|
|
865
|
+
const filterRow = (view, width = WIDTH) => screen(view, width).rows[1];
|
|
866
|
+
|
|
867
|
+
test('Delete empties the whole query while the search is active', async () => {
|
|
868
|
+
const { view, results } = await opened();
|
|
869
|
+
view.handleInput('v');
|
|
870
|
+
for (const char of 'alpha') view.handleInput(char);
|
|
871
|
+
assert.equal(view.state.filter, 'alpha');
|
|
872
|
+
view.handleInput('\x1b[3~'); // the raw Delete sequence
|
|
873
|
+
assert.equal(view.state.filter, '', 'one Delete clears everything typed');
|
|
874
|
+
assert.equal(view.state.filterOpen, true, 'the search stays active');
|
|
875
|
+
assert.equal(view.state.listCursor, 0, 'the cursor starts over on the full list');
|
|
876
|
+
assert.equal(filterRow(view), 'filter: ▏', 'the empty query is back in the field');
|
|
877
|
+
assert.ok(!screen(view).rows.slice(2, 17).join('\n').includes('no matches'), 'every row is visible again');
|
|
878
|
+
assert.deepEqual(results, [], 'clearing a search never resolves a deletion');
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
test('the installed matcher spelling of Delete clears it too', async () => {
|
|
882
|
+
const { view } = await opened();
|
|
883
|
+
view.handleInput('v');
|
|
884
|
+
for (const char of 'beta') view.handleInput(char);
|
|
885
|
+
view.handleInput('delete');
|
|
886
|
+
assert.equal(view.state.filter, '');
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
test('Backspace still deletes exactly one character', async () => {
|
|
890
|
+
const { view } = await opened();
|
|
891
|
+
view.handleInput('v');
|
|
892
|
+
for (const char of 'alpha') view.handleInput(char);
|
|
893
|
+
view.handleInput('backspace');
|
|
894
|
+
assert.equal(view.state.filter, 'alph');
|
|
895
|
+
view.handleInput('\x7f');
|
|
896
|
+
assert.equal(view.state.filter, 'alp');
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
test('Delete with the search closed changes nothing', async () => {
|
|
900
|
+
const { view, results } = await opened();
|
|
901
|
+
view.handleInput('down');
|
|
902
|
+
assert.match(panel(view).worktrees[1], /^▸ feat-alpha$/, 'a row is focused');
|
|
903
|
+
const before = JSON.parse(JSON.stringify(view.state));
|
|
904
|
+
view.handleInput('\x1b[3~');
|
|
905
|
+
assert.deepEqual(results, [], 'Delete is not a binding');
|
|
906
|
+
assert.deepEqual(view.state, before, 'and it changes no state');
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
test('the footer advertises Delete clear on one row only while the search is active', async () => {
|
|
910
|
+
for (const width of [40, 80, 120]) {
|
|
911
|
+
const { view } = await opened();
|
|
912
|
+
view.handleInput('v');
|
|
913
|
+
view.handleInput('T');
|
|
914
|
+
const open = view.render(width).map(strip);
|
|
915
|
+
const openLegend = open[open.length - 2];
|
|
916
|
+
assert.match(openLegend, /clear/, `width ${width}: the active search names the clear key: ${openLegend}`);
|
|
917
|
+
assert.equal(open.filter((line) => /clear/.test(line)).length, 1, `width ${width}: hints stay on the one footer row`);
|
|
918
|
+
assert.ok(!openLegend.includes('…'), `width ${width}: compacted, never ellipsis-clipped: ${openLegend}`);
|
|
919
|
+
view.handleInput('\x1b'); // close the search, keeping the query
|
|
920
|
+
const closed = view.render(width).map(strip);
|
|
921
|
+
assert.ok(!/clear/.test(closed[closed.length - 2]), `width ${width}: no clear hint when no search is active`);
|
|
922
|
+
assert.equal(closed.length, HEIGHT, `width ${width}: the legend takes no extra row`);
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
describe('keys that are not in the map', () => {
|
|
928
|
+
test('no key outside the documented map changes the panel state', async () => {
|
|
929
|
+
const { view, results } = await opened();
|
|
930
|
+
screen(view, WIDE);
|
|
931
|
+
const before = JSON.parse(JSON.stringify(view.state));
|
|
932
|
+
for (const key of ['x', 'q', 'tab', 'home', 'end', 'left', 'right', 'ctrl+s', 'backspace']) {
|
|
933
|
+
view.handleInput(key);
|
|
934
|
+
}
|
|
935
|
+
assert.deepEqual(view.state, before);
|
|
936
|
+
assert.deepEqual(results, [], 'and none of them resolves the component');
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
test('no key outside the documented map changes the list state', async () => {
|
|
940
|
+
const { view, results } = await opened();
|
|
941
|
+
view.handleInput('v');
|
|
942
|
+
screen(view);
|
|
943
|
+
const before = { listCursor: view.state.listCursor, filter: view.state.filter, filterOpen: view.state.filterOpen, view: view.state.view, help: view.state.help };
|
|
944
|
+
for (const key of ['tab', 'home', 'end', 'left', 'right', 'ctrl+s']) view.handleInput(key);
|
|
945
|
+
assert.deepEqual({ listCursor: view.state.listCursor, filter: view.state.filter, filterOpen: view.state.filterOpen, view: view.state.view, help: view.state.help }, before);
|
|
946
|
+
assert.deepEqual(results, []);
|
|
947
|
+
});
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
describe('selection and reload', () => {
|
|
951
|
+
test('j/k move the selection and clamp at both ends', async () => {
|
|
952
|
+
const { view } = await opened();
|
|
953
|
+
view.handleInput('j');
|
|
954
|
+
assert.equal(view.state.cursor, 1);
|
|
955
|
+
view.handleInput('k');
|
|
956
|
+
assert.equal(view.state.cursor, 0);
|
|
957
|
+
view.handleInput('k');
|
|
958
|
+
assert.equal(view.state.cursor, 0, 'the first row is a clamp');
|
|
959
|
+
for (let i = 0; i < 10; i += 1) view.handleInput('down');
|
|
960
|
+
assert.equal(view.state.cursor, 2, 'clamped on the third checkout row');
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
test('c resolves create and esc resolves null exactly once', async () => {
|
|
964
|
+
const create = await opened();
|
|
965
|
+
create.view.handleInput('c');
|
|
966
|
+
assert.deepEqual(create.results, [{ create: true }]);
|
|
967
|
+
|
|
968
|
+
const closed = await opened();
|
|
969
|
+
closed.view.handleInput('escape');
|
|
970
|
+
closed.view.handleInput('escape');
|
|
971
|
+
assert.deepEqual(closed.results, [null], 'the component resolves once');
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
test('enter on the offer row opens the extended list instead of resolving', async () => {
|
|
975
|
+
const many = [
|
|
976
|
+
{ name: 'cur', path: '/w/cur', branch: 'feat/cur' },
|
|
977
|
+
...Array.from({ length: 8 }, (_, index) => ({ name: `wt-${index + 1}`, path: `/w/wt-${index + 1}`, branch: `feat/wt-${index + 1}` })),
|
|
978
|
+
];
|
|
979
|
+
const { view, results } = await opened({ checkouts: many, current: { name: 'cur', path: '/w/cur' }, rows: 14 });
|
|
980
|
+
for (let i = 0; i < 5; i += 1) view.handleInput('down');
|
|
981
|
+
assert.match(panel(view).viewAll || '', /^▸ view all worktrees$/, 'the offer row is focused');
|
|
982
|
+
view.handleInput('enter');
|
|
983
|
+
assert.equal(view.state.view, 'list');
|
|
984
|
+
assert.deepEqual(results, []);
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
test('r reloads through the injected callback and re-reads both data lines', async () => {
|
|
988
|
+
const reloaded = [
|
|
989
|
+
{ name: 'cur', path: '/w/cur', branch: 'feat/cur' },
|
|
990
|
+
{ name: 'new-shape', path: '/w/new-shape', branch: 'feat/new-shape' },
|
|
991
|
+
];
|
|
992
|
+
const { view, reloadCalls, factCalls } = await opened({
|
|
993
|
+
checkouts: [
|
|
994
|
+
{ name: 'cur', path: '/w/cur', branch: 'feat/cur' },
|
|
995
|
+
{ name: 'old-shape', path: '/w/old-shape', branch: 'feat/old-shape' },
|
|
996
|
+
],
|
|
997
|
+
current: { name: 'cur', path: '/w/cur' },
|
|
998
|
+
reloadTo: reloaded,
|
|
999
|
+
});
|
|
1000
|
+
assert.deepEqual(factCalls, ['/w/cur', '/w/old-shape'], 'the mount reads the current and the focused row once');
|
|
1001
|
+
view.handleInput('r');
|
|
1002
|
+
assert.equal(reloadCalls.length, 1, 'the reload reader is the only source');
|
|
1003
|
+
await tick();
|
|
1004
|
+
const rows = panel(view).worktrees;
|
|
1005
|
+
assert.match(rows[0], /new-shape/);
|
|
1006
|
+
assert.deepEqual(
|
|
1007
|
+
factCalls,
|
|
1008
|
+
['/w/cur', '/w/old-shape', '/w/cur', '/w/new-shape'],
|
|
1009
|
+
'the cached facts are dropped on reload and both data lines are re-read',
|
|
1010
|
+
);
|
|
1011
|
+
});
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
// S2: a key legend is ONE terminal row. A legend that does not fit the frame
|
|
1015
|
+
// is compacted (short tokens, then the lowest-priority keys dropped), so it
|
|
1016
|
+
// is never ellipsis-clipped and never spills onto a second row.
|
|
1017
|
+
describe('S2: one-row key legends', () => {
|
|
1018
|
+
const WIDTHS = [40, 60, 80, 120];
|
|
1019
|
+
|
|
1020
|
+
function legendRows(lines, marker) {
|
|
1021
|
+
return lines.map(strip).filter((line) => marker.test(line));
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
test('the panel legend owns exactly one row at every width', async () => {
|
|
1025
|
+
for (const width of WIDTHS) {
|
|
1026
|
+
const { view } = await opened();
|
|
1027
|
+
const frame = panel(view, width);
|
|
1028
|
+
assert.equal(frame.height, 10, `width ${width}: the content height is unchanged`);
|
|
1029
|
+
const legend = legendRows(frame.lines, /esc close|↑↓/);
|
|
1030
|
+
assert.equal(legend.length, 1, `width ${width}: exactly one legend row, got ${legend.length}`);
|
|
1031
|
+
assert.ok(!legend[0].includes('…'), `width ${width}: compacted, never ellipsis-clipped: ${legend[0]}`);
|
|
1032
|
+
assert.match(legend[0], /↑↓/, `width ${width}: the movement keys survive`);
|
|
1033
|
+
assert.match(legend[0], /esc close/, `width ${width}: the close key survives`);
|
|
1034
|
+
assert.equal(helpers.visibleWidth(frame.lines[frame.lines.length - 2]), width, `width ${width}: the legend row is cell-exact`);
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
test('the extended-list legend owns exactly one row at every width', async () => {
|
|
1039
|
+
for (const width of WIDTHS) {
|
|
1040
|
+
const { view } = await opened();
|
|
1041
|
+
view.handleInput('v');
|
|
1042
|
+
const { lines } = screen(view, width);
|
|
1043
|
+
assert.equal(lines.length, HEIGHT, `width ${width}: the list still fills its rows`);
|
|
1044
|
+
const legend = legendRows(lines, /esc back|↑↓/);
|
|
1045
|
+
assert.equal(legend.length, 1, `width ${width}: exactly one legend row, got ${legend.length}`);
|
|
1046
|
+
assert.ok(!legend[0].includes('…'), `width ${width}: compacted, never ellipsis-clipped: ${legend[0]}`);
|
|
1047
|
+
assert.match(legend[0], /↑↓/, `width ${width}: the movement keys survive`);
|
|
1048
|
+
assert.match(legend[0], /esc back/, `width ${width}: the back key survives`);
|
|
1049
|
+
assert.equal(helpers.visibleWidth(lines[18]), width, `width ${width}: the legend row is cell-exact`);
|
|
1050
|
+
}
|
|
1051
|
+
});
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
// S4: the panel and the extended list both ask to DELETE the focused
|
|
1055
|
+
// checkout with `ctrl+d`. The component only names the request — the
|
|
1056
|
+
// extension owns the confirmation, the evidence and every refusal — so what
|
|
1057
|
+
// is pinned here is that the exact focused path travels in the marker, that
|
|
1058
|
+
// nothing else resolves (the offer row, an empty list), and that both
|
|
1059
|
+
// legends advertise the key while the search footer keeps its own meaning
|
|
1060
|
+
// for `delete`.
|
|
1061
|
+
describe('S4: ctrl+d asks to delete the focused checkout', () => {
|
|
1062
|
+
test('ctrl+d on a checkout row resolves the delete request for that exact path', async () => {
|
|
1063
|
+
for (const spelling of ['\x04', 'ctrl+d']) {
|
|
1064
|
+
const { view, results } = await opened();
|
|
1065
|
+
view.handleInput('down');
|
|
1066
|
+
assert.match(panel(view).worktrees[1], /^▸ feat-alpha$/, 'a checkout row is focused');
|
|
1067
|
+
view.handleInput(spelling);
|
|
1068
|
+
assert.deepEqual(results, [{ delete: '/w/feat-alpha' }], `${JSON.stringify(spelling)} names the focused path`);
|
|
1069
|
+
}
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
test('ctrl+d on the offer row and on an empty panel resolves nothing', async () => {
|
|
1073
|
+
const many = [
|
|
1074
|
+
{ name: 'cur', path: '/w/cur', branch: 'feat/cur' },
|
|
1075
|
+
...Array.from({ length: 8 }, (_, index) => ({ name: `wt-${index + 1}`, path: `/w/wt-${index + 1}`, branch: `feat/wt-${index + 1}` })),
|
|
1076
|
+
];
|
|
1077
|
+
const offered = await opened({ checkouts: many, current: { name: 'cur', path: '/w/cur' } });
|
|
1078
|
+
for (let i = 0; i < 5; i += 1) offered.view.handleInput('down');
|
|
1079
|
+
assert.match(panel(offered.view).viewAll || '', /^▸ view all worktrees$/, 'the offer row is focused');
|
|
1080
|
+
offered.view.handleInput('ctrl+d');
|
|
1081
|
+
assert.deepEqual(offered.results, [], 'the offer row is not a checkout to delete');
|
|
1082
|
+
|
|
1083
|
+
const empty = await opened({ checkouts: [CURRENT], current: { name: CURRENT.name, path: CURRENT.path } });
|
|
1084
|
+
empty.view.handleInput('ctrl+d');
|
|
1085
|
+
assert.deepEqual(empty.results, [], 'an empty panel has nothing to delete');
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
test('ctrl+d in the extended list names the focused row and stays inert while the filter is open', async () => {
|
|
1089
|
+
const { view, results } = await opened();
|
|
1090
|
+
view.handleInput('v');
|
|
1091
|
+
// The list is every OTHER checkout, principal first: main, then feat-alpha
|
|
1092
|
+
// — so the focused row after one move is feat-alpha.
|
|
1093
|
+
view.handleInput('down');
|
|
1094
|
+
view.handleInput('\x04');
|
|
1095
|
+
assert.deepEqual(results, [{ delete: '/w/feat-alpha' }], 'the list speaks the same marker');
|
|
1096
|
+
|
|
1097
|
+
const searching = await opened();
|
|
1098
|
+
searching.view.handleInput('v');
|
|
1099
|
+
searching.view.handleInput('T');
|
|
1100
|
+
const before = { listCursor: searching.view.state.listCursor, filter: searching.view.state.filter, view: searching.view.state.view };
|
|
1101
|
+
searching.view.handleInput('ctrl+d');
|
|
1102
|
+
assert.deepEqual(searching.results, [], 'while typing, ctrl+d deletes nothing');
|
|
1103
|
+
assert.deepEqual({ listCursor: searching.view.state.listCursor, filter: searching.view.state.filter, view: searching.view.state.view }, before);
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
test('both legends advertise the delete key at every width it fits', async () => {
|
|
1107
|
+
for (const width of [80, 120]) {
|
|
1108
|
+
const { view } = await opened();
|
|
1109
|
+
assert.match(panel(view, width).legend, /ctrl\+d delete/, `width ${width}: the panel names it`);
|
|
1110
|
+
view.handleInput('v');
|
|
1111
|
+
const { lines } = screen(view, width);
|
|
1112
|
+
assert.match(lines[lines.length - 2], /ctrl\+d delete/, `width ${width}: and so does the extended list`);
|
|
1113
|
+
}
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
test('the panel legend is the same at every width and every focus', async () => {
|
|
1117
|
+
for (const width of [40, 60, 80, 120]) {
|
|
1118
|
+
const { view } = await opened();
|
|
1119
|
+
const first = panel(view, width).legend;
|
|
1120
|
+
view.handleInput('down');
|
|
1121
|
+
assert.equal(panel(view, width).legend, first, `width ${width}: moving the focus adds no capability token`);
|
|
1122
|
+
assert.equal(helpers.visibleWidth(panel(view, width).lines[8]), width, `width ${width}: the legend row is cell-exact`);
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
// ---------------------------------------------------------------------------
|
|
1128
|
+
// S1: worktrees no longer receive issues or ODD. The stored registry data is
|
|
1129
|
+
// preserved (the facts payload still carries it), and no association — id,
|
|
1130
|
+
// name, path or count — may reach the panel's line.
|
|
1131
|
+
// ---------------------------------------------------------------------------
|
|
1132
|
+
describe('S1: no issue or ODD association is rendered', () => {
|
|
1133
|
+
const line = (facts) => formatFactsLine(facts, { now: NOW });
|
|
1134
|
+
const BASE = { branch: 'feat/x', changes: { added: 1, deleted: 0 }, ahead: 1, behind: 0, createdAt: '2026-02-01' };
|
|
1135
|
+
|
|
1136
|
+
test('the rendered line carries no association, even when the facts do', () => {
|
|
1137
|
+
const facts = line({ ...BASE, issues: [76, 77], assignedOdd: 'odd/tasks/alpha.md', odd: ['alpha', 'beta'] });
|
|
1138
|
+
assert.equal(facts, 'branch feat/x · +1 · ↑1 · created 28d ago · modified —');
|
|
1139
|
+
for (const word of ['issues', 'odd', 'alpha', 'beta', '#76']) {
|
|
1140
|
+
assert.ok(!facts.includes(word), `no association in the line (${word}): ${facts}`);
|
|
1141
|
+
}
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
test('an empty or unreadable association is simply absent, never `-` or `unavailable`', () => {
|
|
1145
|
+
const empty = line({ ...BASE, issues: [], odd: [] });
|
|
1146
|
+
const unreadable = line({ ...BASE, issues: null, odd: 'unavailable' });
|
|
1147
|
+
assert.equal(empty, unreadable, 'both read as "no association" because the line has no such fact');
|
|
1148
|
+
assert.ok(!empty.includes('-') || empty.includes('modified —'), `no association placeholder: ${empty}`);
|
|
1149
|
+
assert.ok(!/issues: unavailable|odd: unavailable/.test(unreadable), unreadable);
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
test('the panel and the extended list never print an association', async () => {
|
|
1153
|
+
const { view } = await opened({ facts: () => LEGACY_FACTS });
|
|
1154
|
+
const frame = panel(view, WIDE);
|
|
1155
|
+
assert.equal(frame.focusData, DEFAULT_LINE, 'the focused line is association-free');
|
|
1156
|
+
assert.equal(frame.currentData, DEFAULT_LINE, 'so is the current worktree line');
|
|
1157
|
+
view.handleInput('v');
|
|
1158
|
+
const listed = screen(view, WIDE).lines.join('\n');
|
|
1159
|
+
assert.ok(!listed.includes('issues'), 'the extended list names no issue association');
|
|
1160
|
+
assert.ok(!/w1-a|w2-b|#12/.test(listed), 'and no ODD document or issue id either');
|
|
1161
|
+
});
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
// ---------------------------------------------------------------------------
|
|
1165
|
+
// S11/T9: every footer and header token paints through the ONE shared palette
|
|
1166
|
+
// (lib/tui-paint.js) with the injected theme — a shortcut KEY (a button) is
|
|
1167
|
+
// accent+bold, its description muted, a TITLE takes the reference UI's `dim`
|
|
1168
|
+
// title role (its Agents title: ~/.config/herdr-app/mods/agents/main.js:413),
|
|
1169
|
+
// ordinary values are the text role and counts/labels are muted
|
|
1170
|
+
// or dim — while the focus cue stays monochrome (`▸`), so a theme that answers
|
|
1171
|
+
// nothing still reads as a frame.
|
|
1172
|
+
describe('S11: the shared palette', () => {
|
|
1173
|
+
test('the panel legend paints every key accent+bold and its description muted', async () => {
|
|
1174
|
+
const theme = recordingTheme();
|
|
1175
|
+
const { view } = await opened({ theme });
|
|
1176
|
+
const legend = view.render(WIDE).at(-2);
|
|
1177
|
+
assert.match(legend, /\x1b\[1m\x1b\[96m↑↓\x1b\[0m\x1b\[22m\x1b\[90m move/, 'the movement key is accent+bold, its description muted');
|
|
1178
|
+
assert.match(legend, /\x1b\[1m\x1b\[96menter\x1b\[0m\x1b\[22m\x1b\[90m choose/, 'the confirm key keeps its own role');
|
|
1179
|
+
assert.match(legend, /\x1b\[1m\x1b\[96mesc\x1b\[0m\x1b\[22m\x1b\[90m close/, 'so does esc, with its words muted');
|
|
1180
|
+
assert.match(legend, /\x1b\[34m · \x1b\[0m/, 'the entries are joined by the border role');
|
|
1181
|
+
assert.ok(theme.calls.some((call) => call.role === 'accent' && call.text === '↑↓'),
|
|
1182
|
+
'the accent role is requested from the injected theme');
|
|
1183
|
+
assert.ok(theme.calls.some((call) => call.role === 'bold' && call.text.includes('↑↓')),
|
|
1184
|
+
'the key is bolded through the theme, never by a literal escape');
|
|
1185
|
+
assert.ok(theme.calls.some((call) => call.role === 'muted' && call.text === ' move'),
|
|
1186
|
+
'the description asks the injected theme for the muted role');
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
test('the extended list and the active-search legend use the same key/description palette', async () => {
|
|
1190
|
+
const theme = recordingTheme();
|
|
1191
|
+
const { view } = await opened({ theme });
|
|
1192
|
+
view.handleInput('v');
|
|
1193
|
+
const browse = view.render(WIDE).at(-2);
|
|
1194
|
+
assert.match(browse, /\x1b\[1m\x1b\[96mpgUp\/pgDn\x1b\[0m\x1b\[22m\x1b\[90m page/, 'a compound key is ONE key run');
|
|
1195
|
+
assert.match(browse, /\x1b\[1m\x1b\[96menter\x1b\[0m\x1b\[22m\x1b\[90m warp/, 'and its description is muted');
|
|
1196
|
+
assert.match(browse, /\x1b\[1m\x1b\[96mesc\x1b\[0m\x1b\[22m\x1b\[90m back/, 'the back key is a key');
|
|
1197
|
+
view.handleInput('T');
|
|
1198
|
+
const search = view.render(WIDE).at(-2);
|
|
1199
|
+
assert.match(search, /\x1b\[1m\x1b\[96mbackspace\x1b\[0m\x1b\[22m\x1b\[90m del char/, 'the search legend paints its keys too');
|
|
1200
|
+
assert.match(search, /\x1b\[1m\x1b\[96mdelete\x1b\[0m\x1b\[22m\x1b\[90m clear/, 'delete keeps the key role');
|
|
1201
|
+
assert.match(search, /\x1b\[1m\x1b\[96mesc\x1b\[0m\x1b\[22m\x1b\[90m keep filter/, 'so does esc keep filter');
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
test('a compacted legend keeps the key roles instead of falling back to plain descriptions', async () => {
|
|
1205
|
+
const theme = recordingTheme();
|
|
1206
|
+
const { view } = await opened({ theme });
|
|
1207
|
+
const legend = view.render(40).at(-2);
|
|
1208
|
+
assert.match(legend, /\x1b\[1m\x1b\[96m↑↓\x1b\[0m\x1b\[22m/, 'the compact form still paints the movement key');
|
|
1209
|
+
assert.match(legend, /\x1b\[1m\x1b\[96mesc\x1b\[0m\x1b\[22m\x1b\[90m close/, 'esc keeps its key and its description');
|
|
1210
|
+
assert.equal(helpers.visibleWidth(legend), 40, 'the compacted row is still cell-exact');
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
test('the header palette: secondary title+bold, values ordinary text, facts muted and the close key a button', async () => {
|
|
1214
|
+
const theme = recordingTheme();
|
|
1215
|
+
const { view } = await opened({ theme });
|
|
1216
|
+
const lines = view.render(WIDE);
|
|
1217
|
+
assert.match(lines[0], /\x1b\[1m\x1b\[33mWarp\x1b\[0m\x1b\[22m/,
|
|
1218
|
+
'the frame title is the Agents view title role, bold');
|
|
1219
|
+
assert.ok(!theme.calls.some((call) => call.role === 'accent' && call.text === 'Warp'),
|
|
1220
|
+
'the title never takes the button accent');
|
|
1221
|
+
assert.match(lines[0], /\x1b\[1m\x1b\[96mesc\x1b\[0m\x1b\[22m/, 'the close hint in the header is painted as a key');
|
|
1222
|
+
assert.match(lines[0], /\x1b\[34m╭─\x1b\[0m/, 'the frame rule keeps the border role');
|
|
1223
|
+
assert.match(lines[1], /\x1b\[39mfeat-odd-viewer/, 'the current worktree name is ordinary text');
|
|
1224
|
+
assert.ok(!/\x1b\[1m\x1b\[96mfeat-odd-viewer/.test(lines[1]), 'a value is never emphasized like a key');
|
|
1225
|
+
assert.ok(!/\x1b\[96mfeat-odd-viewer/.test(lines[1]), 'a value is never the button accent');
|
|
1226
|
+
assert.match(lines[2], /\x1b\[90mbranch feat\/alpha/, 'the facts line is muted');
|
|
1227
|
+
assert.match(lines.at(-3), /\x1b\[90mbranch feat\/alpha/, 'so is the focused worktree line');
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
test('the list page row paints its label dim, the page value as ordinary text and the total muted', async () => {
|
|
1231
|
+
const theme = recordingTheme();
|
|
1232
|
+
const { view } = await opened({ theme, rows: 20 });
|
|
1233
|
+
view.handleInput('v');
|
|
1234
|
+
const pageRow = view.render(WIDE).at(-3);
|
|
1235
|
+
assert.match(pageRow, /\x1b\[2mpage \x1b\[0m/, 'the `page` label is dim');
|
|
1236
|
+
assert.match(pageRow, /\x1b\[39m1\/1\x1b\[0m/, 'the page number is ordinary text');
|
|
1237
|
+
assert.match(pageRow, /\x1b\[90m · 3 worktrees\x1b\[0m/, 'the worktree count is muted');
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
test('the search row paints its static label dim and the live query as ordinary text', async () => {
|
|
1241
|
+
const theme = recordingTheme();
|
|
1242
|
+
const { view } = await opened({ theme });
|
|
1243
|
+
view.handleInput('v');
|
|
1244
|
+
const idle = view.render(WIDE)[1];
|
|
1245
|
+
assert.match(idle, /\x1b\[2mfilter: \x1b\[0m\x1b\[90mtype to search/, 'the idle hint is dim/muted, never accent');
|
|
1246
|
+
view.handleInput('a');
|
|
1247
|
+
view.handleInput('l');
|
|
1248
|
+
const live = view.render(WIDE)[1];
|
|
1249
|
+
assert.match(live, /\x1b\[2mfilter: \x1b\[0m/, 'the label stays dim while typing');
|
|
1250
|
+
assert.match(live, /\x1b\[39mal/, 'the typed query is ordinary text');
|
|
1251
|
+
assert.ok(!/\x1b\[96mal/.test(live), 'the typed query is never the button accent');
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
test('a hostile theme never breaks the frame and keeps the keys and the focus mark readable', async () => {
|
|
1255
|
+
const hostile = {
|
|
1256
|
+
fg() { throw new Error('no colour support'); },
|
|
1257
|
+
bold() { throw new Error('no bold support'); },
|
|
1258
|
+
bg() { throw new Error('no background support'); },
|
|
1259
|
+
};
|
|
1260
|
+
const { view } = await opened({ theme: hostile });
|
|
1261
|
+
const { lines, rows } = screen(view, WIDE);
|
|
1262
|
+
assert.match(lines[0], /^╭─ Warp ─+ esc ─╮$/, 'the title line still renders without colour');
|
|
1263
|
+
assert.match(rows[rows.length - 2], /↑↓ move · enter choose · c create · ctrl\+d delete · r reload · esc close/,
|
|
1264
|
+
'the legend still spells every key and its words');
|
|
1265
|
+
assert.equal(rows[4], '▸ main · use the local project', 'the focus marker survives monochrome');
|
|
1266
|
+
view.handleInput('v');
|
|
1267
|
+
assert.match(screen(view, WIDE).rows.at(-2), /pgUp\/pgDn page/, 'the list legend survives too');
|
|
1268
|
+
});
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
// ---------------------------------------------------------------------------
|
|
1272
|
+
// Feedback: warping to the checkout the session already stands in is a move to
|
|
1273
|
+
// the same route, so `view all worktrees` never offers it. The exclusion is by
|
|
1274
|
+
// CHECKOUT IDENTITY, never by spelling or by string prefix: the fixtures below
|
|
1275
|
+
// are real directories with a real symlink alias, and the canonical reader the
|
|
1276
|
+
// wiring hands the view (fs.realpathSync) is what makes two spellings one
|
|
1277
|
+
// checkout, while `feat-x2` stays its own checkout even though it starts with
|
|
1278
|
+
// `feat-x`.
|
|
1279
|
+
// ---------------------------------------------------------------------------
|
|
1280
|
+
describe('the current checkout is never a destination', () => {
|
|
1281
|
+
const realpath = (value) => {
|
|
1282
|
+
try { return fs.realpathSync(value); } catch { return value; }
|
|
1283
|
+
};
|
|
1284
|
+
|
|
1285
|
+
// A real temporary workspace: real checkout directories, real symlinks, and
|
|
1286
|
+
// every scratch path removed when the test ends.
|
|
1287
|
+
function workspace(t) {
|
|
1288
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'warp-view-'));
|
|
1289
|
+
t.after(() => {
|
|
1290
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* already gone */ }
|
|
1291
|
+
});
|
|
1292
|
+
return {
|
|
1293
|
+
dir,
|
|
1294
|
+
checkout(name) {
|
|
1295
|
+
const full = path.join(dir, name);
|
|
1296
|
+
fs.mkdirSync(full);
|
|
1297
|
+
return full;
|
|
1298
|
+
},
|
|
1299
|
+
alias(name, target) {
|
|
1300
|
+
const full = path.join(dir, name);
|
|
1301
|
+
fs.symlinkSync(target, full);
|
|
1302
|
+
return full;
|
|
1303
|
+
},
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
test('the extended list drops the checkout the session is in and keeps the others', async (t) => {
|
|
1308
|
+
const ws = workspace(t);
|
|
1309
|
+
const principal = ws.checkout('herdr');
|
|
1310
|
+
const feat = ws.checkout('feat-alpha');
|
|
1311
|
+
const fix = ws.checkout('fix-beta');
|
|
1312
|
+
const { view } = await opened({
|
|
1313
|
+
checkouts: [
|
|
1314
|
+
{ name: 'feat-alpha', path: feat, branch: 'feat/alpha' },
|
|
1315
|
+
{ name: 'principal', path: principal, branch: 'main', isPrincipal: true },
|
|
1316
|
+
{ name: 'fix-beta', path: fix, branch: 'fix/beta' },
|
|
1317
|
+
],
|
|
1318
|
+
current: { name: 'feat-alpha', path: feat },
|
|
1319
|
+
canonicalPath: realpath,
|
|
1320
|
+
});
|
|
1321
|
+
view.handleInput('v');
|
|
1322
|
+
const { lines, rows } = screen(view);
|
|
1323
|
+
assertWidth(lines);
|
|
1324
|
+
assert.equal(rows[2], '▸ principal · use the local project', 'the principal still leads the destinations');
|
|
1325
|
+
assert.equal(rows[3], ' fix-beta');
|
|
1326
|
+
assert.equal(rows[4], '', 'and nothing else follows');
|
|
1327
|
+
assert.ok(!rows.join('\n').includes('feat-alpha'), 'the checkout the session is in is not a destination');
|
|
1328
|
+
assert.equal(rows[17], 'page 1/1 · 2 worktrees');
|
|
1329
|
+
});
|
|
1330
|
+
|
|
1331
|
+
test('a symlink spelling of the current checkout is the same checkout, in both directions', async (t) => {
|
|
1332
|
+
const ws = workspace(t);
|
|
1333
|
+
const feat = ws.checkout('feat-alpha');
|
|
1334
|
+
const alias = ws.alias('alias-alpha', feat);
|
|
1335
|
+
const principal = ws.checkout('herdr');
|
|
1336
|
+
|
|
1337
|
+
const listedByAlias = await opened({
|
|
1338
|
+
checkouts: [
|
|
1339
|
+
{ name: 'principal', path: principal, branch: 'main', isPrincipal: true },
|
|
1340
|
+
{ name: 'alias-alpha', path: alias, branch: 'feat/alpha' },
|
|
1341
|
+
],
|
|
1342
|
+
current: { name: 'feat-alpha', path: feat },
|
|
1343
|
+
canonicalPath: realpath,
|
|
1344
|
+
});
|
|
1345
|
+
listedByAlias.view.handleInput('v');
|
|
1346
|
+
assert.ok(!screen(listedByAlias.view).rows.join('\n').includes('alias-alpha'),
|
|
1347
|
+
'the alias of the current checkout is not a destination');
|
|
1348
|
+
assert.equal(screen(listedByAlias.view).rows[17], 'page 1/1 · 1 worktree');
|
|
1349
|
+
|
|
1350
|
+
const listedByReal = await opened({
|
|
1351
|
+
checkouts: [
|
|
1352
|
+
{ name: 'principal', path: principal, branch: 'main', isPrincipal: true },
|
|
1353
|
+
{ name: 'feat-alpha', path: feat, branch: 'feat/alpha' },
|
|
1354
|
+
],
|
|
1355
|
+
current: { name: 'alias-alpha', path: alias },
|
|
1356
|
+
canonicalPath: realpath,
|
|
1357
|
+
});
|
|
1358
|
+
listedByReal.view.handleInput('v');
|
|
1359
|
+
assert.ok(!screen(listedByReal.view).rows.join('\n').includes('feat-alpha'),
|
|
1360
|
+
'the real path is the same checkout as the alias the session stands on');
|
|
1361
|
+
assert.equal(screen(listedByReal.view).rows[17], 'page 1/1 · 1 worktree');
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
test('a checkout whose name merely starts with the current path is another checkout', async (t) => {
|
|
1365
|
+
const ws = workspace(t);
|
|
1366
|
+
const feat = ws.checkout('feat-x');
|
|
1367
|
+
const featTwo = ws.checkout('feat-x2');
|
|
1368
|
+
const { view } = await opened({
|
|
1369
|
+
checkouts: [
|
|
1370
|
+
{ name: 'feat-x', path: feat, branch: 'feat/x' },
|
|
1371
|
+
{ name: 'feat-x2', path: featTwo, branch: 'feat/x2' },
|
|
1372
|
+
],
|
|
1373
|
+
current: { name: 'feat-x', path: feat },
|
|
1374
|
+
canonicalPath: realpath,
|
|
1375
|
+
});
|
|
1376
|
+
view.handleInput('v');
|
|
1377
|
+
const { rows } = screen(view);
|
|
1378
|
+
assert.equal(rows[2], '▸ feat-x2', 'a shared name prefix is not the same checkout');
|
|
1379
|
+
assert.equal(rows[3], '', 'and the current checkout itself is still absent');
|
|
1380
|
+
assert.equal(rows[17], 'page 1/1 · 1 worktree');
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
test('the panel and the list share one exclusion, so neither lists the current checkout', async (t) => {
|
|
1384
|
+
const ws = workspace(t);
|
|
1385
|
+
const feat = ws.checkout('feat-alpha');
|
|
1386
|
+
const alias = ws.alias('alias-alpha', feat);
|
|
1387
|
+
const principal = ws.checkout('herdr');
|
|
1388
|
+
const { view } = await opened({
|
|
1389
|
+
checkouts: [
|
|
1390
|
+
{ name: 'principal', path: principal, branch: 'main', isPrincipal: true },
|
|
1391
|
+
{ name: 'alias-alpha', path: alias, branch: 'feat/alpha' },
|
|
1392
|
+
],
|
|
1393
|
+
current: { name: 'feat-alpha', path: feat },
|
|
1394
|
+
canonicalPath: realpath,
|
|
1395
|
+
});
|
|
1396
|
+
const frame = panel(view, WIDE);
|
|
1397
|
+
assert.deepEqual(frame.worktrees, ['▸ principal · use the local project'], 'the panel lists only the other checkout');
|
|
1398
|
+
assert.equal(frame.viewAll, null, 'nothing is left out, so there is no offer row');
|
|
1399
|
+
assert.equal(frame.height, 8, 'the panel shrinks to its content');
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
test('with nothing else to list, the extended list says so and resolves nothing', async (t) => {
|
|
1403
|
+
const ws = workspace(t);
|
|
1404
|
+
const feat = ws.checkout('feat-alpha');
|
|
1405
|
+
const { view, results } = await opened({
|
|
1406
|
+
checkouts: [{ name: 'feat-alpha', path: feat, branch: 'feat/alpha' }],
|
|
1407
|
+
current: { name: 'feat-alpha', path: feat },
|
|
1408
|
+
canonicalPath: realpath,
|
|
1409
|
+
});
|
|
1410
|
+
view.handleInput('v');
|
|
1411
|
+
const { lines, rows } = screen(view);
|
|
1412
|
+
assertWidth(lines);
|
|
1413
|
+
assert.equal(rows[2], '(no other worktrees)', 'the list explains itself instead of naming the current checkout');
|
|
1414
|
+
assert.equal(rows[17], 'page 1/1 · 0 worktrees');
|
|
1415
|
+
view.handleInput('enter');
|
|
1416
|
+
view.handleInput('ctrl+d');
|
|
1417
|
+
assert.deepEqual(results, [], 'an empty destination list warps nowhere and deletes nothing');
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
test('a session standing outside every listed checkout keeps every row', async (t) => {
|
|
1421
|
+
const ws = workspace(t);
|
|
1422
|
+
const principal = ws.checkout('herdr');
|
|
1423
|
+
const feat = ws.checkout('feat-alpha');
|
|
1424
|
+
const { view } = await opened({
|
|
1425
|
+
checkouts: [
|
|
1426
|
+
{ name: 'principal', path: principal, branch: 'main', isPrincipal: true },
|
|
1427
|
+
{ name: 'feat-alpha', path: feat, branch: 'feat/alpha' },
|
|
1428
|
+
],
|
|
1429
|
+
current: { name: 'unknown', path: path.join(ws.dir, 'nowhere') },
|
|
1430
|
+
canonicalPath: realpath,
|
|
1431
|
+
});
|
|
1432
|
+
view.handleInput('v');
|
|
1433
|
+
const { rows } = screen(view);
|
|
1434
|
+
assert.equal(rows[2], '▸ principal · use the local project');
|
|
1435
|
+
assert.equal(rows[3], ' feat-alpha');
|
|
1436
|
+
assert.equal(rows[17], 'page 1/1 · 2 worktrees', 'a current checkout outside the list excludes nothing');
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
test('a reload that re-lists the current checkout under another spelling still excludes it', async (t) => {
|
|
1440
|
+
const ws = workspace(t);
|
|
1441
|
+
const feat = ws.checkout('feat-alpha');
|
|
1442
|
+
const alias = ws.alias('alias-alpha', feat);
|
|
1443
|
+
const principal = ws.checkout('herdr');
|
|
1444
|
+
const { view } = await opened({
|
|
1445
|
+
checkouts: [
|
|
1446
|
+
{ name: 'principal', path: principal, branch: 'main', isPrincipal: true },
|
|
1447
|
+
{ name: 'feat-alpha', path: feat, branch: 'feat/alpha' },
|
|
1448
|
+
],
|
|
1449
|
+
current: { name: 'feat-alpha', path: feat },
|
|
1450
|
+
canonicalPath: realpath,
|
|
1451
|
+
reloadTo: [
|
|
1452
|
+
{ name: 'principal', path: principal, branch: 'main', isPrincipal: true },
|
|
1453
|
+
{ name: 'alias-alpha', path: alias, branch: 'feat/alpha' },
|
|
1454
|
+
],
|
|
1455
|
+
});
|
|
1456
|
+
view.handleInput('v');
|
|
1457
|
+
assert.ok(!screen(view).rows.join('\n').includes('alias-alpha'), 'the mount already excludes it');
|
|
1458
|
+
view.handleInput('r');
|
|
1459
|
+
await tick();
|
|
1460
|
+
assert.ok(!screen(view).rows.join('\n').includes('alias-alpha'), 'the reloaded spelling is the same checkout');
|
|
1461
|
+
assert.equal(screen(view).rows[17], 'page 1/1 · 1 worktree');
|
|
1462
|
+
});
|
|
1463
|
+
});
|