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
package/lib/odd-view.js
ADDED
|
@@ -0,0 +1,990 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Odd viewer component (odd-viewer S3,S4,S6).
|
|
3
|
+
//
|
|
4
|
+
// S3: the left pane takes the smaller 30% list share and its documents are ONE
|
|
5
|
+
// continuously scrolling window — `←`/`→` move it a screenful and the focused
|
|
6
|
+
// document always stays inside it; there is no pager and no page marker.
|
|
7
|
+
//
|
|
8
|
+
// S12: the list opens in the loader's own sequence — most recently modified
|
|
9
|
+
// document first — and ONE shortcut (`s`) alternates it with a case-insensitive
|
|
10
|
+
// alphabetical order. The focused document survives the toggle, the visible
|
|
11
|
+
// window re-anchors on its new position, and the footer names both the key and
|
|
12
|
+
// the ACTIVE order.
|
|
13
|
+
//
|
|
14
|
+
// The host component contract Pi asks for (the same shape the customize
|
|
15
|
+
// panel uses in extensions/gentle-worktree-tools.ts): the factory returns
|
|
16
|
+
// { render(width), handleInput(data), invalidate(), handleMouse(event) }.
|
|
17
|
+
// Selection and scroll are LOCAL state: no key writes, notifies or reloads,
|
|
18
|
+
// and `esc` finishes through done(null). Nothing here reads or writes
|
|
19
|
+
// during render: the document is read once per view and scrolling only
|
|
20
|
+
// moves the window.
|
|
21
|
+
//
|
|
22
|
+
// S10b: the view is read-only with respect to the checkout↔document
|
|
23
|
+
// association. The `assigned`, `saveAssignment` and `assignmentEnabled`
|
|
24
|
+
// options the existing callers still pass are accepted and ignored — no
|
|
25
|
+
// mark, no toggle, no save — and the association lives in the worktree
|
|
26
|
+
// detail instead.
|
|
27
|
+
//
|
|
28
|
+
// The TUI helpers arrive injected (visibleWidth/truncateToWidth/matchesKey/
|
|
29
|
+
// isKeyRelease) because @earendil-works/pi-tui is a lazily imported peer of
|
|
30
|
+
// this package: the command loads it and passes it down, and the raw-byte
|
|
31
|
+
// fallbacks below keep the keys working when a host provides none. Layout
|
|
32
|
+
// follows the reference Changes view: a framed two-pane card, list on the
|
|
33
|
+
// left, document on the right, the keys on the ONE row above the bottom rule.
|
|
34
|
+
|
|
35
|
+
const { renderOddMarkdown, stripAnsi } = require('./odd-markdown.js');
|
|
36
|
+
const layout = require('./view-layout.js');
|
|
37
|
+
const scrollKeys = require('./preview-scroll-keys.js');
|
|
38
|
+
// S11: the ONE palette both readers share. Every footer key, footer
|
|
39
|
+
// description, frame title, count, label and live search value paints through
|
|
40
|
+
// it, so a shortcut key is always distinguishable from the words beside it and
|
|
41
|
+
// this view keeps no second copy of the hostile-theme guards.
|
|
42
|
+
const palette = require('./tui-paint.js');
|
|
43
|
+
const paint = palette.paint;
|
|
44
|
+
const emphasize = palette.emphasize;
|
|
45
|
+
const highlight = palette.highlight;
|
|
46
|
+
|
|
47
|
+
const MIN_WIDTH = 24;
|
|
48
|
+
// The initial list width, until the host paints the first frame and the shared
|
|
49
|
+
// 30/70 reader rule reports the real one: a conventional 80-column terminal.
|
|
50
|
+
const DEFAULT_LIST_WIDTH = layout.paneWidths(layout.innerWidth(80), { listShare: layout.READER_LIST_SHARE }).left;
|
|
51
|
+
const EMPTY_LIST = 'no odd documents';
|
|
52
|
+
const EMPTY_HINT = 'create one under odd/tasks/';
|
|
53
|
+
// S2: ONE legend, ONE terminal row — every key rides the same row instead of
|
|
54
|
+
// a second footer row. `text` is the full token, `short` the compact form used
|
|
55
|
+
// when the full one does not fit, and `priority` decides what a
|
|
56
|
+
// still-too-wide legend drops first (lowest first), so `esc close` outlives
|
|
57
|
+
// every other hint.
|
|
58
|
+
//
|
|
59
|
+
// S4 supersedes the earlier split: ALL hints belong to the footer, including
|
|
60
|
+
// the search key, so the search row above carries the query alone.
|
|
61
|
+
//
|
|
62
|
+
// S1: the preview scroll hints are NOT listed here. They come from the shared
|
|
63
|
+
// binding helper and are spliced after the selection hint in `contentKeys()`,
|
|
64
|
+
// so this legend and that handler can never disagree about the pair.
|
|
65
|
+
//
|
|
66
|
+
// S14: every scroll hint names the pane it moves, so the list and the document
|
|
67
|
+
// can never be read as the same binding («alguna key generidad como el page o
|
|
68
|
+
// half estan demas»).
|
|
69
|
+
//
|
|
70
|
+
// S8 («salen flecha izq/der ... esta bien el atajo pero demas marcarlo»): the
|
|
71
|
+
// list scroll pair keeps its BINDING and leaves this legend. `←`/`→` already
|
|
72
|
+
// read as movement, the document token beside them names its own target, and
|
|
73
|
+
// `[`/`]` were always undocumented alternates — so the extra `←/→ list` token
|
|
74
|
+
// only spent cells on a key the reader could simply try. Only the HINT is gone:
|
|
75
|
+
// the handler below still resolves `←`/`→` and `[`/`]`.
|
|
76
|
+
const CONTENT_KEYS = [
|
|
77
|
+
{ key: '↑↓', text: '↑↓ select', short: '↑↓', priority: 60 },
|
|
78
|
+
// The legend spells the lowercase search key; `T` keeps opening the box.
|
|
79
|
+
{ key: 't', text: 't search', short: 't', priority: 45 },
|
|
80
|
+
{ key: 'o', text: 'o open', short: 'o open', priority: 40 },
|
|
81
|
+
{ key: 'esc', text: 'esc close', short: 'esc close', priority: 70 },
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
// S12: the two list orders and the ONE key that alternates them. `recent` is
|
|
85
|
+
// the loader's own sequence (lib/odd-docs.js already sorts by modification
|
|
86
|
+
// time, newest first), so this view never re-sorts it; `name` is a sorted COPY
|
|
87
|
+
// of the same documents. `s` is free — no other binding in this view uses it —
|
|
88
|
+
// and a single token keeps the one-row legend cheap.
|
|
89
|
+
const ORDER = Object.freeze({ RECENT: 'recent', NAME: 'name' });
|
|
90
|
+
const ORDER_KEY = 's';
|
|
91
|
+
// S9: the reload key, the outcome state its footer token reports ('idle' keeps
|
|
92
|
+
// the plain `r reload` hint) and where that token sits when the compact legend
|
|
93
|
+
// has to give a hint up.
|
|
94
|
+
const RELOAD_KEY = 'r';
|
|
95
|
+
// S9: the outcome needs a few cells more than the idle `r`, and the reported
|
|
96
|
+
// terminal is one where the idle token fits but the row is otherwise full. Placing
|
|
97
|
+
// the reload token just above the external-open hint (40) makes the legend give
|
|
98
|
+
// up `o open` for the answer instead of pruning the answer itself; it stays below
|
|
99
|
+
// `t search` (45) and `s sort` (50), so a live action hint is still preferred over
|
|
100
|
+
// a spent reload message on a still-narrower row.
|
|
101
|
+
const RELOAD_PRIORITY = 41;
|
|
102
|
+
|
|
103
|
+
// While the filter box holds the keyboard the footer names THAT interaction
|
|
104
|
+
// once — enter and esc each keep the filter, Delete clears the whole query and
|
|
105
|
+
// Backspace edits one character (S4). Each is a single token so the extra keys
|
|
106
|
+
// never inflate the ONE legend row (S2).
|
|
107
|
+
const SEARCH_KEYS = [
|
|
108
|
+
{ key: 'type', text: 'type to filter', short: 'type', priority: 40 },
|
|
109
|
+
{ key: 'del', text: 'del clear', short: 'del', priority: 50 },
|
|
110
|
+
{ key: 'bksp', text: 'bksp edit', short: 'bksp', priority: 20 },
|
|
111
|
+
{ key: 'enter/esc', text: 'enter/esc keep', short: 'enter/esc', priority: 60 },
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
// S1: `J`/`K` are the preview paging keys now (the shared binding helper
|
|
115
|
+
// resolves them before any list key), so a loose case fallback must never let
|
|
116
|
+
// them answer for the `j`/`k` selection bindings — and never the reverse.
|
|
117
|
+
const SHIFTED_TWINS = new Set(['j', 'k']);
|
|
118
|
+
|
|
119
|
+
// Raw terminal bytes per key id, mirroring the installed pi-tui keys.js
|
|
120
|
+
// legacy sequences — the same convention lib/warp-create-picker.js uses.
|
|
121
|
+
// Bounded to the keys this view binds, so the preview stays drivable when
|
|
122
|
+
// the host helper module is absent (a scroll key that reads as frozen is
|
|
123
|
+
// worse than a redundant fallback). The preview scroll keys are not here:
|
|
124
|
+
// lib/preview-scroll-keys.js owns them, so the handler and the legend can
|
|
125
|
+
// never disagree about which pair is live.
|
|
126
|
+
const RAW_KEY_BYTES = {
|
|
127
|
+
escape: ['\x1b'],
|
|
128
|
+
'ctrl+c': ['\x03'],
|
|
129
|
+
backspace: ['\x7f', '\b'],
|
|
130
|
+
up: ['\x1b[A'],
|
|
131
|
+
down: ['\x1b[B'],
|
|
132
|
+
left: ['\x1b[D', '\x1bOD'],
|
|
133
|
+
right: ['\x1b[C', '\x1bOC'],
|
|
134
|
+
delete: ['\x1b[3~', '\x1b[57427u'],
|
|
135
|
+
home: ['\x1b[H', '\x1bOH', '\x1b[1~', '\x1b[57423u'],
|
|
136
|
+
end: ['\x1b[F', '\x1bOF', '\x1b[4~', '\x1b[57424u'],
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Mouse wheel as SGR / legacy X10 bytes: some hosts deliver the wheel through
|
|
140
|
+
// handleMouse, older ones through the input stream. Both are parsed here so a
|
|
141
|
+
// wheel notch scrolls the preview either way. Button bit 64 flags the wheel;
|
|
142
|
+
// bits 0-1 are the direction (0 = up, 1 = down).
|
|
143
|
+
const WHEEL_SGR = /^\x1b\[<(\d+);\d+;\d+[Mm]$/;
|
|
144
|
+
const WHEEL_LEGACY = /^\x1b\[M([\s\S]{3})$/;
|
|
145
|
+
|
|
146
|
+
function wheelDeltaFromInput(data) {
|
|
147
|
+
if (typeof data !== 'string' || data.length < 6 || data[0] !== '\x1b') return undefined;
|
|
148
|
+
let button;
|
|
149
|
+
const sgr = WHEEL_SGR.exec(data);
|
|
150
|
+
if (sgr) {
|
|
151
|
+
button = Number.parseInt(sgr[1], 10);
|
|
152
|
+
} else {
|
|
153
|
+
const legacy = WHEEL_LEGACY.exec(data);
|
|
154
|
+
if (!legacy) return undefined;
|
|
155
|
+
button = legacy[1].charCodeAt(0) - 32;
|
|
156
|
+
}
|
|
157
|
+
if (!Number.isFinite(button) || (button & 64) === 0) return undefined;
|
|
158
|
+
const direction = button & 3;
|
|
159
|
+
if (direction !== 0 && direction !== 1) return undefined;
|
|
160
|
+
return direction === 0 ? -1 : 1;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function measure(helpers, text) {
|
|
164
|
+
try {
|
|
165
|
+
if (helpers && typeof helpers.visibleWidth === 'function') {
|
|
166
|
+
const size = Number(helpers.visibleWidth(text));
|
|
167
|
+
if (Number.isFinite(size)) return size;
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// Fall through to the ANSI-stripped codepoint count.
|
|
171
|
+
}
|
|
172
|
+
return [...stripAnsi(text)].length;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// S23: every paint re-wraps every listed document name — once to measure the
|
|
176
|
+
// rows it costs for paging, once to paint the rows actually shown — so a long
|
|
177
|
+
// list paid its whole wrap per keystroke. This memo is owned by ONE view
|
|
178
|
+
// instance and keyed by the EXACT name text, the list width and both row
|
|
179
|
+
// prefixes (the focus marker changes the rows), so only an unchanged name can
|
|
180
|
+
// hit it: a renamed or reloaded list, a resized pane or a moved cursor always
|
|
181
|
+
// wraps again and a stale row is unpaintable.
|
|
182
|
+
const LABEL_MEMO_ENTRIES = 2000;
|
|
183
|
+
|
|
184
|
+
function createLabelRows(helpers, limit = LABEL_MEMO_ENTRIES) {
|
|
185
|
+
const max = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : LABEL_MEMO_ENTRIES;
|
|
186
|
+
const entries = new Map();
|
|
187
|
+
// The returned rows are read-only for the callers (cost = length, paint =
|
|
188
|
+
// copy each row), so a hit may hand back the same array.
|
|
189
|
+
return function labelRows(text, width, first, rest) {
|
|
190
|
+
const key = `${width}\u0000${first}\u0000${rest}\u0000${text}`;
|
|
191
|
+
const cached = entries.get(key);
|
|
192
|
+
if (cached) {
|
|
193
|
+
// Re-insert, so the Map's own order is the eviction order.
|
|
194
|
+
entries.delete(key);
|
|
195
|
+
entries.set(key, cached);
|
|
196
|
+
return cached;
|
|
197
|
+
}
|
|
198
|
+
const rows = layout.wrapLabel(helpers, text, width, { first, rest });
|
|
199
|
+
entries.set(key, rows);
|
|
200
|
+
while (entries.size > max) {
|
|
201
|
+
const oldest = entries.keys().next();
|
|
202
|
+
if (oldest.done) break;
|
|
203
|
+
entries.delete(oldest.value);
|
|
204
|
+
}
|
|
205
|
+
return rows;
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function fit(helpers, text, width) {
|
|
210
|
+
const clean = String(text).split('\n')[0] ?? '';
|
|
211
|
+
if (measure(helpers, clean) <= width) return clean;
|
|
212
|
+
try {
|
|
213
|
+
if (helpers && typeof helpers.truncateToWidth === 'function') {
|
|
214
|
+
const cut = helpers.truncateToWidth(clean, width, '…');
|
|
215
|
+
if (typeof cut === 'string') return cut;
|
|
216
|
+
}
|
|
217
|
+
} catch {
|
|
218
|
+
// Fall through to the plain slice.
|
|
219
|
+
}
|
|
220
|
+
const chars = [...clean];
|
|
221
|
+
return chars.length > width ? chars.slice(0, Math.max(0, width - 1)).join('') + '…' : clean;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function pad(helpers, text, width) {
|
|
225
|
+
const clipped = fit(helpers, text, width);
|
|
226
|
+
return clipped + ' '.repeat(Math.max(0, width - measure(helpers, clipped)));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// A row that carries its main text on the left and a value on the right edge
|
|
230
|
+
// (S13: the scroll position shares the search row). The value is dropped
|
|
231
|
+
// rather than allowed to crowd the left text out when the row is too narrow —
|
|
232
|
+
// S14 outranks it, so the Search widget and its page marker always survive.
|
|
233
|
+
function withRight(helpers, left, right, width) {
|
|
234
|
+
if (right === '') return pad(helpers, left, width);
|
|
235
|
+
const room = width - measure(helpers, right) - 2;
|
|
236
|
+
if (room < 8) return pad(helpers, left, width);
|
|
237
|
+
if (measure(helpers, left) > room) return pad(helpers, left, width);
|
|
238
|
+
const head = fit(helpers, left, room);
|
|
239
|
+
const gap = Math.max(1, width - measure(helpers, head) - measure(helpers, right));
|
|
240
|
+
return `${head}${' '.repeat(gap)}${right}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// S2: a key legend stays on ONE row. The full text is used while it fits;
|
|
244
|
+
// otherwise the legend compacts to its short tokens and then drops its
|
|
245
|
+
// lowest-priority keys (display order never changes, and no token carries a
|
|
246
|
+
// newline), so it never wraps onto a second row. The caller still pads, which
|
|
247
|
+
// keeps the row cell-exact if a single token is wider than the pane. The
|
|
248
|
+
// compaction and the role painting live in lib/tui-paint.js (S11), so the
|
|
249
|
+
// painted row and the measured one can never disagree.
|
|
250
|
+
|
|
251
|
+
function keyMatches(helpers, data, names) {
|
|
252
|
+
for (const name of names) {
|
|
253
|
+
try {
|
|
254
|
+
if (helpers && typeof helpers.matchesKey === 'function' && helpers.matchesKey(data, name)) return true;
|
|
255
|
+
} catch {
|
|
256
|
+
// A throwing matcher never matches; the raw fallback below decides.
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (typeof data === 'string' && data.length > 0) {
|
|
260
|
+
for (const name of names) {
|
|
261
|
+
const raw = RAW_KEY_BYTES[String(name).toLowerCase()];
|
|
262
|
+
if (raw && raw.includes(data)) return true;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// A host that classifies the raw bytes can still resolve the same name
|
|
266
|
+
// through its public parser, which is why the parser is consulted before the
|
|
267
|
+
// plain string comparison: a host may name one byte `pageDown` while another
|
|
268
|
+
// spells it `pagedown`.
|
|
269
|
+
let parsed = null;
|
|
270
|
+
try {
|
|
271
|
+
if (helpers && typeof helpers.parseKey === 'function') parsed = helpers.parseKey(data);
|
|
272
|
+
} catch {
|
|
273
|
+
// A throwing parser falls through to the plain comparison.
|
|
274
|
+
}
|
|
275
|
+
if (typeof parsed === 'string' && parsed.length > 0
|
|
276
|
+
&& names.some((name) => sameKeyName(parsed, name))) return true;
|
|
277
|
+
const text = typeof data === 'string' ? data : (data && (data.name || data.key)) || '';
|
|
278
|
+
return names.some((name) => sameKeyName(text, name));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Key ids are loose about case because hosts spell them differently
|
|
282
|
+
// (`pageDown` vs `pagedown`); only a shifted letter whose unshifted twin is a
|
|
283
|
+
// live binding (`J` next to `j`) stays case-sensitive.
|
|
284
|
+
function sameKeyName(left, right) {
|
|
285
|
+
const a = String(left);
|
|
286
|
+
const b = String(right);
|
|
287
|
+
if (a === b) return true;
|
|
288
|
+
const shiftedLetter = (text) => text.length === 1 && text >= 'A' && text <= 'Z';
|
|
289
|
+
if (shiftedLetter(a) && !shiftedLetter(b) && SHIFTED_TWINS.has(b.toLowerCase())) return false;
|
|
290
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function isKeyRelease(helpers, data) {
|
|
294
|
+
try {
|
|
295
|
+
if (helpers && typeof helpers.isKeyRelease === 'function') return helpers.isKeyRelease(data) === true;
|
|
296
|
+
} catch {
|
|
297
|
+
// A throwing classifier never releases; normal handling follows.
|
|
298
|
+
}
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// S12: case-insensitive alphabetical order by display name, with the raw name
|
|
303
|
+
// as the deterministic tie-break for names that differ only in case. Plain
|
|
304
|
+
// code-unit comparison, never `localeCompare`: the painted order must not
|
|
305
|
+
// change with the host locale.
|
|
306
|
+
function compareByName(left, right) {
|
|
307
|
+
const a = String((left && left.name) || '');
|
|
308
|
+
const b = String((right && right.name) || '');
|
|
309
|
+
const folded = a.toLowerCase();
|
|
310
|
+
const other = b.toLowerCase();
|
|
311
|
+
if (folded !== other) return folded < other ? -1 : 1;
|
|
312
|
+
if (a === b) return 0;
|
|
313
|
+
return a < b ? -1 : 1;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function normalizeLoad(result) {
|
|
317
|
+
if (!result || typeof result !== 'object') return { ok: false, error: 'document unreadable' };
|
|
318
|
+
if (result.ok === true && typeof result.text === 'string') return { ok: true, text: result.text };
|
|
319
|
+
if (result.ok === false) return { ok: false, error: String(result.error || 'document unreadable') };
|
|
320
|
+
return { ok: false, error: 'document unreadable' };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function createOddView(options = {}) {
|
|
324
|
+
const {
|
|
325
|
+
helpers = null,
|
|
326
|
+
theme = null,
|
|
327
|
+
rowsAvailable = null,
|
|
328
|
+
requestRender = null,
|
|
329
|
+
done = null,
|
|
330
|
+
loadDoc = null,
|
|
331
|
+
reloadDocs = null,
|
|
332
|
+
openExternal = null,
|
|
333
|
+
} = options;
|
|
334
|
+
// S10b: `options.assigned`, `options.saveAssignment` and
|
|
335
|
+
// `options.assignmentEnabled` are deliberately not read: the association
|
|
336
|
+
// belongs to the worktree detail, and this view stays read-only.
|
|
337
|
+
let docs = Array.isArray(options.docs) ? options.docs.slice() : [];
|
|
338
|
+
const entries = new Map();
|
|
339
|
+
const state = { index: 0, scroll: 0, query: '', searching: false, listStart: 0, order: ORDER.RECENT };
|
|
340
|
+
let openError = null;
|
|
341
|
+
// S9: 'idle' | 'reloaded' | 'failed' — the outcome of the last `r` press.
|
|
342
|
+
let reloadState = 'idle';
|
|
343
|
+
let bodyRows = 1;
|
|
344
|
+
// S23: the per-instance document-name memo (see createLabelRows).
|
|
345
|
+
const labelRows = createLabelRows(helpers);
|
|
346
|
+
// The list pane width of the last frame, so a key press pages against the
|
|
347
|
+
// layout the reader is actually looking at.
|
|
348
|
+
let listWidthCache = DEFAULT_LIST_WIDTH;
|
|
349
|
+
|
|
350
|
+
// S24/S25: the title is the viewer and the count, nothing more. The
|
|
351
|
+
// checkout/branch identity is not repeated here (it is the session's own
|
|
352
|
+
// checkout) and neither is the selected document — that rides the status
|
|
353
|
+
// row next to the preview. `checkoutName` stays accepted for the existing
|
|
354
|
+
// callers but is deliberately not rendered. S11/S13: the two parts are
|
|
355
|
+
// separate tokens so the header can paint the viewer with the theme's
|
|
356
|
+
// secondary title role and the count muted; `titleText` joins them for the
|
|
357
|
+
// plain fallback line.
|
|
358
|
+
const TITLE_NAME = '✎ Odd';
|
|
359
|
+
|
|
360
|
+
function titleParts() {
|
|
361
|
+
return { name: TITLE_NAME, count: `${docs.length} documents` };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function titleText() {
|
|
365
|
+
const parts = titleParts();
|
|
366
|
+
return `${parts.name} · ${parts.count}`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// The visible list: the filter narrows by document name
|
|
370
|
+
// (case-insensitive substring), exactly like the issues viewer keeps its
|
|
371
|
+
// query after leaving the box — selection indexes into this list. S12: the
|
|
372
|
+
// ACTIVE order decides the sequence of the filtered rows; the recency order
|
|
373
|
+
// is whatever the loader returned, so this view only ever sorts a copy.
|
|
374
|
+
function shown() {
|
|
375
|
+
const q = state.query.trim().toLowerCase();
|
|
376
|
+
const matched = q === ''
|
|
377
|
+
? docs
|
|
378
|
+
: docs.filter((docEntry) => String((docEntry && docEntry.name) || '').toLowerCase().includes(q));
|
|
379
|
+
if (state.order !== ORDER.NAME) return matched;
|
|
380
|
+
return matched.slice().sort(compareByName);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function availableRows() {
|
|
384
|
+
try {
|
|
385
|
+
if (typeof rowsAvailable === 'function') {
|
|
386
|
+
const value = Math.floor(rowsAvailable());
|
|
387
|
+
if (Number.isFinite(value) && value > 0) return value;
|
|
388
|
+
}
|
|
389
|
+
} catch {
|
|
390
|
+
// A hostile rows source never breaks the view.
|
|
391
|
+
}
|
|
392
|
+
return 24;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function repaint() {
|
|
396
|
+
try {
|
|
397
|
+
if (typeof requestRender === 'function') requestRender();
|
|
398
|
+
} catch {
|
|
399
|
+
// A repaint failure never breaks navigation.
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function current() {
|
|
404
|
+
const list = shown();
|
|
405
|
+
return list[state.index] || null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function renderLines(entry, text, width) {
|
|
409
|
+
entry.lines = renderOddMarkdown(text, {
|
|
410
|
+
width,
|
|
411
|
+
paint: (role, value) => paint(theme, role, value),
|
|
412
|
+
emphasis: (value) => emphasize(theme, value),
|
|
413
|
+
measure: (value) => measure(helpers, value),
|
|
414
|
+
});
|
|
415
|
+
entry.text = text;
|
|
416
|
+
entry.width = width;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// One read per document per view: the entry keeps its text and rendered
|
|
420
|
+
// lines, so re-selecting a document never re-reads or blocks a frame.
|
|
421
|
+
function ensureEntry(docEntry) {
|
|
422
|
+
if (!docEntry) return null;
|
|
423
|
+
const key = docEntry.path || docEntry.name;
|
|
424
|
+
let entry = entries.get(key);
|
|
425
|
+
if (entry) return entry;
|
|
426
|
+
entry = { status: 'loading', lines: [], text: '', width: 0 };
|
|
427
|
+
entries.set(key, entry);
|
|
428
|
+
if (typeof loadDoc !== 'function') {
|
|
429
|
+
entry.status = 'error';
|
|
430
|
+
entry.error = 'document unreadable';
|
|
431
|
+
return entry;
|
|
432
|
+
}
|
|
433
|
+
let pending = null;
|
|
434
|
+
try {
|
|
435
|
+
pending = loadDoc(docEntry, key);
|
|
436
|
+
} catch (err) {
|
|
437
|
+
entry.status = 'error';
|
|
438
|
+
entry.error = (err && err.message) || 'document unreadable';
|
|
439
|
+
return entry;
|
|
440
|
+
}
|
|
441
|
+
const settle = (result) => {
|
|
442
|
+
const normalized = normalizeLoad(result);
|
|
443
|
+
if (normalized.ok) {
|
|
444
|
+
entry.status = 'ready';
|
|
445
|
+
renderLines(entry, normalized.text, entry.width || MIN_WIDTH);
|
|
446
|
+
} else {
|
|
447
|
+
entry.status = 'error';
|
|
448
|
+
entry.error = normalized.error;
|
|
449
|
+
}
|
|
450
|
+
repaint();
|
|
451
|
+
};
|
|
452
|
+
// A synchronous reader (this package reads a local file) settles before
|
|
453
|
+
// the next frame, so selecting a document never shows a placeholder for
|
|
454
|
+
// a read that already finished.
|
|
455
|
+
if (pending && typeof pending.then === 'function') {
|
|
456
|
+
pending.then(settle).catch((err) => {
|
|
457
|
+
entry.status = 'error';
|
|
458
|
+
entry.error = (err && err.message) || 'document unreadable';
|
|
459
|
+
repaint();
|
|
460
|
+
});
|
|
461
|
+
} else {
|
|
462
|
+
settle(pending);
|
|
463
|
+
}
|
|
464
|
+
return entry;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function clampScroll() {
|
|
468
|
+
const entry = current() ? ensureEntry(current()) : null;
|
|
469
|
+
const total = entry && entry.status === 'ready' ? entry.lines.length : 0;
|
|
470
|
+
const max = Math.max(0, total - Math.max(1, bodyRows));
|
|
471
|
+
if (state.scroll > max) state.scroll = max;
|
|
472
|
+
if (state.scroll < 0) state.scroll = 0;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// One place moves the document window, so every input path (keyboard,
|
|
476
|
+
// wheel event, wheel bytes) clamps and repaints identically and none of
|
|
477
|
+
// them writes or reads outside the view.
|
|
478
|
+
function scrollBy(delta) {
|
|
479
|
+
if (!Number.isFinite(delta) || delta === 0) return;
|
|
480
|
+
state.scroll += delta;
|
|
481
|
+
clampScroll();
|
|
482
|
+
repaint();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// The host delivers a wheel notch as a TuiMouseEvent (type `wheel`, a
|
|
486
|
+
// signed `wheelDelta`). The preview is the reading surface, so the wheel
|
|
487
|
+
// moves it from anywhere in the frame and never touches the list
|
|
488
|
+
// selection. Only the wheel is consumed; other events stay unhandled so
|
|
489
|
+
// the host keeps its own mouse behavior.
|
|
490
|
+
function handleMouse(event) {
|
|
491
|
+
if (!event || event.type !== 'wheel') return undefined;
|
|
492
|
+
const delta = Number(event.wheelDelta);
|
|
493
|
+
if (!Number.isFinite(delta) || delta === 0) return undefined;
|
|
494
|
+
scrollBy(delta);
|
|
495
|
+
return { handled: true, render: true };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// S3: the list is ONE continuous window whose capacity follows the rows each
|
|
499
|
+
// wrapped name actually needs, never a fixed document count. (The older "para
|
|
500
|
+
// odd igual, si son muchas igual se pagina" page marker went with the pager.)
|
|
501
|
+
function listCosts() {
|
|
502
|
+
return shown().map((docEntry) => labelRows(String((docEntry && docEntry.name) || ''), listWidthCache, '▸ ', ' ').length);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// The window the list paints: bounded by the body budget and always holding
|
|
506
|
+
// the focused document.
|
|
507
|
+
function listWindow() {
|
|
508
|
+
return layout.scrollWindow(listCosts(), {
|
|
509
|
+
budget: Math.max(1, bodyRows),
|
|
510
|
+
index: state.index,
|
|
511
|
+
start: state.listStart,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// S12: the order token names the ACTIVE order, so the ONE footer row says
|
|
516
|
+
// which sequence the list is in as well as which key flips it. It is built
|
|
517
|
+
// per frame (the order changes) and keeps the key/description split the rest
|
|
518
|
+
// of the palette uses: `s` is the key run, ` sort: …` its description.
|
|
519
|
+
function orderKey() {
|
|
520
|
+
return {
|
|
521
|
+
key: ORDER_KEY,
|
|
522
|
+
text: `${ORDER_KEY} sort: ${state.order === ORDER.NAME ? 'name' : 'recent'}`,
|
|
523
|
+
short: ORDER_KEY,
|
|
524
|
+
priority: 50,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// S1: the reader legend is the static keys plus the scroll hints the shared
|
|
529
|
+
// binding helper says this terminal can actually deliver.
|
|
530
|
+
//
|
|
531
|
+
// S14: the document pane's hints — the page pair, the half-page pair and the
|
|
532
|
+
// printable fallback — all scroll the SAME target, so they collapse into the
|
|
533
|
+
// ONE token that says so ("ctrl+j/k scroll"). Only the HINT collapses: the
|
|
534
|
+
// handler keeps resolving every one of those keys, so no binding is lost with
|
|
535
|
+
// its alias.
|
|
536
|
+
function contentKeys() {
|
|
537
|
+
const documentScroll = palette.scrollHint(scrollKeys.previewScrollKeys(), 'scroll');
|
|
538
|
+
// S12 and S9 splice their live-state tokens in beside the actions: the
|
|
539
|
+
// order toggle next to the search hint, the reload outcome where `r` sits.
|
|
540
|
+
const [selectKey, searchKey, ...actionKeys] = CONTENT_KEYS;
|
|
541
|
+
return [
|
|
542
|
+
selectKey,
|
|
543
|
+
documentScroll,
|
|
544
|
+
orderKey(),
|
|
545
|
+
searchKey,
|
|
546
|
+
reloadKey(),
|
|
547
|
+
...actionKeys,
|
|
548
|
+
].filter((entry) => entry !== null && entry !== undefined);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// S9 («el r en odd no hace nada pareceria»): a reload that finds the same
|
|
552
|
+
// directory changes no row, so the only way the key can report that it ran is
|
|
553
|
+
// its own token. Like `s sort: …` it names live state — the outcome of the
|
|
554
|
+
// LAST reload — and it never claims a success the reload did not report.
|
|
555
|
+
//
|
|
556
|
+
// The token keeps two spellings: the full one (`r reload failed`) wherever the
|
|
557
|
+
// whole legend fits, and the compact one (`r reloaded`, `r failed`) for the
|
|
558
|
+
// short tier, because the reported ~60-column row has room for the outcome only
|
|
559
|
+
// from `o open`'s cells (see RELOAD_PRIORITY).
|
|
560
|
+
function reloadKey() {
|
|
561
|
+
let text = `${RELOAD_KEY} reload`;
|
|
562
|
+
let short = RELOAD_KEY;
|
|
563
|
+
if (reloadState === 'reloaded') {
|
|
564
|
+
text = `${RELOAD_KEY} reloaded`;
|
|
565
|
+
short = text;
|
|
566
|
+
} else if (reloadState === 'failed') {
|
|
567
|
+
text = `${RELOAD_KEY} reload failed`;
|
|
568
|
+
short = `${RELOAD_KEY} failed`;
|
|
569
|
+
}
|
|
570
|
+
return { key: RELOAD_KEY, text, short, priority: RELOAD_PRIORITY };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// S3: ←/→ scroll the list a screenful. There is no page to move to, so the
|
|
574
|
+
// window simply moves over the one list and the focused document follows it
|
|
575
|
+
// into the new screen when it scrolled out of view.
|
|
576
|
+
function scrollList(step) {
|
|
577
|
+
const costs = listCosts();
|
|
578
|
+
if (costs.length === 0) return;
|
|
579
|
+
const budget = Math.max(1, bodyRows);
|
|
580
|
+
const target = layout.shiftWindow(costs, { budget, start: state.listStart, step });
|
|
581
|
+
if (target === state.listStart) return;
|
|
582
|
+
state.listStart = target;
|
|
583
|
+
const win = layout.scrollWindow(costs, { budget, index: target, start: target });
|
|
584
|
+
state.listStart = win.start;
|
|
585
|
+
if (state.index < win.start || state.index >= win.end) {
|
|
586
|
+
state.index = win.start;
|
|
587
|
+
state.scroll = 0;
|
|
588
|
+
ensureEntry(current());
|
|
589
|
+
}
|
|
590
|
+
repaint();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// S12: `s` alternates the loader's recency order with a case-insensitive
|
|
594
|
+
// alphabetical one. The focused document is remembered by identity and
|
|
595
|
+
// re-found in the new sequence, so the reader keeps reading the SAME document
|
|
596
|
+
// instead of whatever lands at the old index; the document's own reading
|
|
597
|
+
// position is untouched, because it is still the same document. The list
|
|
598
|
+
// window is re-anchored on the new position so the focus is painted even when
|
|
599
|
+
// its alphabetical place is far from the old one.
|
|
600
|
+
function toggleOrder() {
|
|
601
|
+
const focused = current();
|
|
602
|
+
const focusedKey = focused ? (focused.path || focused.name) : null;
|
|
603
|
+
state.order = state.order === ORDER.NAME ? ORDER.RECENT : ORDER.NAME;
|
|
604
|
+
const list = shown();
|
|
605
|
+
let next = Math.min(state.index, Math.max(0, list.length - 1));
|
|
606
|
+
if (focusedKey !== null) {
|
|
607
|
+
const at = list.findIndex((docEntry) => docEntry && (docEntry.path || docEntry.name) === focusedKey);
|
|
608
|
+
if (at >= 0) next = at;
|
|
609
|
+
}
|
|
610
|
+
state.index = Math.max(0, next);
|
|
611
|
+
const win = layout.scrollWindow(listCosts(), { budget: Math.max(1, bodyRows), index: state.index, start: 0 });
|
|
612
|
+
state.listStart = win.start;
|
|
613
|
+
ensureEntry(current());
|
|
614
|
+
repaint();
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function render(width) {
|
|
618
|
+
const w = typeof width === 'number' && Number.isFinite(width) ? Math.floor(width) : 0;
|
|
619
|
+
if (w <= 0) return [];
|
|
620
|
+
const height = availableRows();
|
|
621
|
+
const title = titleText();
|
|
622
|
+
if (w < MIN_WIDTH || height < 4) {
|
|
623
|
+
return [pad(helpers, fit(helpers, `${title} — enlarge the terminal`, w), w)].slice(0, Math.max(1, Math.min(height, 1)));
|
|
624
|
+
}
|
|
625
|
+
const list = shown();
|
|
626
|
+
if (state.index >= list.length) state.index = Math.max(0, list.length - 1);
|
|
627
|
+
// S3: the shared 30/70 reader rule owns this geometry, so ODD matches the
|
|
628
|
+
// issues reader with the smaller list index and the larger reading pane.
|
|
629
|
+
// CARD_PADDING covers the frame's own `│ ` and ` │` cells, so this inner
|
|
630
|
+
// width fills the terminal exactly: the right border belongs in the last
|
|
631
|
+
// cell (S24/S27 — no trailing slack, corners and edges stay aligned).
|
|
632
|
+
const inner = layout.innerWidth(w);
|
|
633
|
+
const panes = layout.paneWidths(inner, { listShare: layout.READER_LIST_SHARE });
|
|
634
|
+
const listWidth = panes.left;
|
|
635
|
+
listWidthCache = listWidth;
|
|
636
|
+
const rightWidth = panes.right;
|
|
637
|
+
// S8: the fixed chrome is reserved FIRST — top rule, search row, the ONE
|
|
638
|
+
// footer legend and the bottom rule — and whatever rows remain is the
|
|
639
|
+
// body, so page capacity is the real viewport height, not a guessed 20.
|
|
640
|
+
bodyRows = Math.max(1, height - 4);
|
|
641
|
+
const docEntry = current();
|
|
642
|
+
const entry = docEntry ? ensureEntry(docEntry) : null;
|
|
643
|
+
// The document reflows to the pane width: a resize re-renders it rather
|
|
644
|
+
// than clipping an earlier, wider layout.
|
|
645
|
+
if (entry && entry.status === 'ready' && entry.width !== rightWidth) {
|
|
646
|
+
renderLines(entry, entry.text, rightWidth);
|
|
647
|
+
}
|
|
648
|
+
clampScroll();
|
|
649
|
+
const totalLines = entry && entry.status === 'ready' ? entry.lines.length : 0;
|
|
650
|
+
const border = (text) => paint(theme, 'border', text);
|
|
651
|
+
const divider = border('│');
|
|
652
|
+
const lines = [];
|
|
653
|
+
// S11/S13: the header uses the palette — the title takes the theme's
|
|
654
|
+
// `dim` title role (bold), the `·` separator is the dim label role —
|
|
655
|
+
// while the COUNT alone is muted, so a number never competes with the title
|
|
656
|
+
// for attention and the title never competes with the accent a button owns.
|
|
657
|
+
const parts = titleParts();
|
|
658
|
+
const topLeft = `${border('╭─')} ${palette.title(theme, parts.name)}${palette.label(theme, ' · ')}${palette.count(theme, parts.count)} `;
|
|
659
|
+
// S27: no top-right `esc` text (the /changes frame has none) — the corner
|
|
660
|
+
// and the right border stay in the terminal's last cell.
|
|
661
|
+
const topRight = border('─╮');
|
|
662
|
+
lines.push(`${topLeft}${border('─'.repeat(Math.max(0, w - measure(helpers, topLeft) - measure(helpers, topRight))))}${topRight}`);
|
|
663
|
+
|
|
664
|
+
// S13/S26: one row carries the search widget and the reading position. S4:
|
|
665
|
+
// the key that opens the box lives in the footer now, never in this row, and
|
|
666
|
+
// S3 removed the page marker with the pager. S11: the `›` prompt and the
|
|
667
|
+
// idle `Search` label paint the dim LABEL role while a live query is the
|
|
668
|
+
// VALUE role, in and out of the box.
|
|
669
|
+
const searchText = state.searching
|
|
670
|
+
? `${palette.label(theme, '› ')}${palette.value(theme, `${state.query}█`)}`
|
|
671
|
+
: (state.query
|
|
672
|
+
? `${palette.label(theme, '› ')}${palette.value(theme, state.query)}`
|
|
673
|
+
: `${palette.label(theme, '› ')}${palette.label(theme, 'Search')}`);
|
|
674
|
+
// S14: the reading position of an open document keeps the right edge.
|
|
675
|
+
//
|
|
676
|
+
// S10 («pagina/linea ... sale el 1-, esta demas»): the position names how
|
|
677
|
+
// far this screen reaches against the document total. The old
|
|
678
|
+
// `start-end/total` range restated the window's first line — always the line
|
|
679
|
+
// just above the pane, and `1` at the top — so the useful number is kept and
|
|
680
|
+
// the redundant one dropped.
|
|
681
|
+
const visibleThrough = Math.min(totalLines, Math.max(0, state.scroll) + Math.max(1, bodyRows));
|
|
682
|
+
const scrollMark = totalLines > 0 ? `${visibleThrough}/${totalLines}` : '';
|
|
683
|
+
const searchCell = withRight(helpers, searchText, scrollMark, inner);
|
|
684
|
+
lines.push(`${border('│')} ${pad(helpers, searchCell, inner)} ${border('│')}`);
|
|
685
|
+
|
|
686
|
+
const listLines = [];
|
|
687
|
+
if (docs.length === 0) {
|
|
688
|
+
listLines.push(paint(theme, 'muted', EMPTY_LIST));
|
|
689
|
+
listLines.push(paint(theme, 'dim', EMPTY_HINT));
|
|
690
|
+
} else if (list.length === 0) {
|
|
691
|
+
listLines.push(paint(theme, 'muted', '(no matches)'));
|
|
692
|
+
} else {
|
|
693
|
+
// S3/S5/S17: each name wraps over the rows it needs — at spaces and at
|
|
694
|
+
// its own hyphens — and the ONE window paints the documents whose wrapped
|
|
695
|
+
// rows fit the body budget, keeping the focused document inside it.
|
|
696
|
+
const win = listWindow();
|
|
697
|
+
state.listStart = win.start;
|
|
698
|
+
for (let at = win.start; at < win.end; at += 1) {
|
|
699
|
+
const focused = at === state.index;
|
|
700
|
+
const rows = labelRows(String((list[at] && list[at].name) || ''), listWidth, focused ? '▸ ' : ' ', ' ');
|
|
701
|
+
for (const row of rows) {
|
|
702
|
+
listLines.push(highlight(theme, pad(helpers, row, listWidth), focused));
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
const contentLines = [];
|
|
707
|
+
if (!docEntry) {
|
|
708
|
+
if (docs.length === 0) {
|
|
709
|
+
contentLines.push(paint(theme, 'muted', EMPTY_LIST));
|
|
710
|
+
contentLines.push(paint(theme, 'dim', EMPTY_HINT));
|
|
711
|
+
} else {
|
|
712
|
+
contentLines.push(paint(theme, 'muted', `(no matches for filter "${state.query}")`));
|
|
713
|
+
}
|
|
714
|
+
} else if (entry.status === 'error') {
|
|
715
|
+
contentLines.push(paint(theme, 'error', `unreadable: ${entry.error}`));
|
|
716
|
+
} else if (entry.status === 'ready' && entry.lines.length === 0) {
|
|
717
|
+
contentLines.push(paint(theme, 'muted', '(empty document)'));
|
|
718
|
+
} else if (entry.status === 'ready') {
|
|
719
|
+
contentLines.push(...entry.lines.slice(state.scroll, state.scroll + bodyRows));
|
|
720
|
+
} else {
|
|
721
|
+
contentLines.push(paint(theme, 'muted', 'loading…'));
|
|
722
|
+
}
|
|
723
|
+
// A failed open is shown, never claimed as success: the error rides
|
|
724
|
+
// above the document so the untouched selection it explains stays visible.
|
|
725
|
+
if (openError) {
|
|
726
|
+
contentLines.unshift(paint(theme, 'error', `open failed: ${openError}`));
|
|
727
|
+
}
|
|
728
|
+
for (let row = 0; row < bodyRows; row += 1) {
|
|
729
|
+
const left = pad(helpers, listLines[row] ?? '', listWidth);
|
|
730
|
+
const right = pad(helpers, contentLines[row] ?? '', rightWidth);
|
|
731
|
+
lines.push(`${border('│')} ${left} ${divider} ${right} ${border('│')}`);
|
|
732
|
+
}
|
|
733
|
+
lines.push(`${border('│')} ${pad(helpers, palette.legendRow(theme, state.searching ? SEARCH_KEYS : contentKeys(), inner, (text) => measure(helpers, text)), inner)} ${border('│')}`);
|
|
734
|
+
lines.push(border(`╰${'─'.repeat(Math.max(0, w - 2))}╯`));
|
|
735
|
+
return lines.map((line) => pad(helpers, line, w));
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function selectNext(step) {
|
|
739
|
+
const list = shown();
|
|
740
|
+
if (list.length === 0) return;
|
|
741
|
+
state.index = (state.index + step + list.length) % list.length;
|
|
742
|
+
state.scroll = 0;
|
|
743
|
+
ensureEntry(current());
|
|
744
|
+
repaint();
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// The `T`/`/` name filter (key-consistency K3/S5, same behaviour as
|
|
748
|
+
// the wizard's search box and the issues viewer): typing narrows the
|
|
749
|
+
// list live, Backspace edits, Enter/Esc leaves the box with the filter
|
|
750
|
+
// kept. Anything else is ignored while filtering.
|
|
751
|
+
function handleSearchInput(data) {
|
|
752
|
+
if (keyMatches(helpers, data, ['enter', 'return'])) {
|
|
753
|
+
state.searching = false;
|
|
754
|
+
state.index = 0;
|
|
755
|
+
state.listStart = 0;
|
|
756
|
+
state.scroll = 0;
|
|
757
|
+
repaint();
|
|
758
|
+
return true;
|
|
759
|
+
}
|
|
760
|
+
if (keyMatches(helpers, data, ['escape'])) {
|
|
761
|
+
state.searching = false;
|
|
762
|
+
state.index = 0;
|
|
763
|
+
state.listStart = 0;
|
|
764
|
+
repaint();
|
|
765
|
+
return true;
|
|
766
|
+
}
|
|
767
|
+
if (keyMatches(helpers, data, ['backspace'])) {
|
|
768
|
+
state.query = state.query.slice(0, -1);
|
|
769
|
+
state.index = 0;
|
|
770
|
+
state.listStart = 0;
|
|
771
|
+
state.scroll = 0;
|
|
772
|
+
repaint();
|
|
773
|
+
return true;
|
|
774
|
+
}
|
|
775
|
+
// S4: Delete clears the WHOLE query while the search input is active;
|
|
776
|
+
// Backspace above keeps deleting one character. The box stays open so a
|
|
777
|
+
// new query needs no extra keypress.
|
|
778
|
+
if (keyMatches(helpers, data, ['delete'])) {
|
|
779
|
+
state.query = '';
|
|
780
|
+
state.index = 0;
|
|
781
|
+
state.listStart = 0;
|
|
782
|
+
state.scroll = 0;
|
|
783
|
+
repaint();
|
|
784
|
+
return true;
|
|
785
|
+
}
|
|
786
|
+
const typed = typeof data === 'string' ? data : (data && (data.name || data.key)) || '';
|
|
787
|
+
if (typeof typed === 'string' && [...typed].length === 1) {
|
|
788
|
+
state.query += typed;
|
|
789
|
+
state.index = 0;
|
|
790
|
+
state.listStart = 0;
|
|
791
|
+
state.scroll = 0;
|
|
792
|
+
repaint();
|
|
793
|
+
return true;
|
|
794
|
+
}
|
|
795
|
+
return false;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function handleInput(data) {
|
|
799
|
+
if (isKeyRelease(helpers, data)) return;
|
|
800
|
+
// The wheel can also arrive as raw SGR/X10 bytes on hosts that do not
|
|
801
|
+
// dispatch mouse events: both paths scroll the preview, never the list.
|
|
802
|
+
const wheel = wheelDeltaFromInput(data);
|
|
803
|
+
if (wheel !== undefined) {
|
|
804
|
+
scrollBy(wheel);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
// While filtering, only the search keys answer: Enter/Esc leaves the
|
|
808
|
+
// box with the filter kept, typing narrows live. Everything else —
|
|
809
|
+
// arrows, toggles, `o` — is ignored until the box is left.
|
|
810
|
+
if (state.searching) {
|
|
811
|
+
if (handleSearchInput(data)) return;
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
// S1: the shared binding helper owns the preview scroll keys, so this
|
|
815
|
+
// handler and the footer legend can never disagree about which pair is
|
|
816
|
+
// live. ctrl+j/k page wherever the host reports them apart from Enter,
|
|
817
|
+
// ctrl+shift+j/k and the printable J/K are working alternates, ctrl+d/u
|
|
818
|
+
// keep their half-page step, and bare LF/ctrl+j is not a scroll key — a
|
|
819
|
+
// legacy terminal sends it for Enter too.
|
|
820
|
+
const scrollAction = scrollKeys.resolveScrollAction(helpers, data);
|
|
821
|
+
if (scrollAction) {
|
|
822
|
+
const page = Math.max(1, bodyRows - 1);
|
|
823
|
+
const halved = scrollAction === scrollKeys.ACTIONS.HALF_DOWN || scrollAction === scrollKeys.ACTIONS.HALF_UP;
|
|
824
|
+
const step = halved ? Math.max(1, Math.floor(page / 2)) : page;
|
|
825
|
+
const down = scrollAction === scrollKeys.ACTIONS.PAGE_DOWN || scrollAction === scrollKeys.ACTIONS.HALF_DOWN;
|
|
826
|
+
scrollBy(down ? step : -step);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (keyMatches(helpers, data, ['escape', 'ctrl+c', 'q'])) {
|
|
830
|
+
if (typeof done === 'function') done(null);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
// `t`/`T` or `/` opens the name filter on the current query, for editing.
|
|
834
|
+
if (keyMatches(helpers, data, ['t', 'T', '/'])) {
|
|
835
|
+
state.searching = true;
|
|
836
|
+
repaint();
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
// S12: `s` alternates the list order (recency ↔ alphabetical). The filter
|
|
840
|
+
// box returns above, so the letter stays a query character while it is
|
|
841
|
+
// open — the order never flips under someone typing.
|
|
842
|
+
if (keyMatches(helpers, data, [ORDER_KEY])) {
|
|
843
|
+
toggleOrder();
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
// `o` opens the focused document outside Pi through $VISUAL/$EDITOR:
|
|
847
|
+
// the injected opener reports honestly, so a missing editor or a
|
|
848
|
+
// failed spawn shows its error instead of failing silently.
|
|
849
|
+
if (keyMatches(helpers, data, ['o'])) {
|
|
850
|
+
const focused = current();
|
|
851
|
+
if (!focused) return;
|
|
852
|
+
if (typeof openExternal !== 'function') {
|
|
853
|
+
openError = 'the viewer has no external opener';
|
|
854
|
+
repaint();
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
const target = (typeof focused.path === 'string' && focused.path.length > 0)
|
|
858
|
+
? focused.path
|
|
859
|
+
: focused.name;
|
|
860
|
+
let result = null;
|
|
861
|
+
try {
|
|
862
|
+
result = openExternal(target);
|
|
863
|
+
} catch (err) {
|
|
864
|
+
result = { ok: false, error: (err && err.message) || 'open failed' };
|
|
865
|
+
}
|
|
866
|
+
Promise.resolve(result).then((settled) => {
|
|
867
|
+
if (settled && settled.ok === true) openError = null;
|
|
868
|
+
else openError = (settled && settled.error) || 'open failed';
|
|
869
|
+
repaint();
|
|
870
|
+
}).catch((err) => {
|
|
871
|
+
openError = (err && err.message) || 'open failed';
|
|
872
|
+
repaint();
|
|
873
|
+
});
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
if (keyMatches(helpers, data, ['down', 'j'])) {
|
|
877
|
+
selectNext(1);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
if (keyMatches(helpers, data, ['up', 'k'])) {
|
|
881
|
+
selectNext(-1);
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
// D3: left/right scroll the ACTIVE list and nothing else (S3: a screenful of
|
|
885
|
+
// the one list, never a page); Tab never touches a single-dataset reader.
|
|
886
|
+
if (keyMatches(helpers, data, ['right', ']'])) {
|
|
887
|
+
scrollList(1);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
if (keyMatches(helpers, data, ['left', '['])) {
|
|
891
|
+
scrollList(-1);
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
if (keyMatches(helpers, data, ['end'])) {
|
|
895
|
+
state.scroll = Number.MAX_SAFE_INTEGER;
|
|
896
|
+
clampScroll();
|
|
897
|
+
repaint();
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
if (keyMatches(helpers, data, ['home'])) {
|
|
901
|
+
state.scroll = 0;
|
|
902
|
+
repaint();
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
// S9: `r` re-lists AND re-reads. The listing is refreshed from disk, every
|
|
906
|
+
// cached document is dropped so the open one is read again (an edit cannot
|
|
907
|
+
// hide behind a cached body), and the outcome rides the footer token so a
|
|
908
|
+
// reload that changes no row is still visible. A reload that cannot re-read
|
|
909
|
+
// the directory keeps the documents it already has and reports the failure
|
|
910
|
+
// instead of appearing to do nothing. S21: `reloadDocs` may answer a
|
|
911
|
+
// PROMISE — the interactive caller re-lists through the awaited Git reader,
|
|
912
|
+
// so a slow history walk is never a blocking read inside this key handler
|
|
913
|
+
// (the view keeps painting and the outcome lands when the listing does).
|
|
914
|
+
if (keyMatches(helpers, data, [RELOAD_KEY])) {
|
|
915
|
+
if (typeof reloadDocs !== 'function') return;
|
|
916
|
+
let result = null;
|
|
917
|
+
try {
|
|
918
|
+
result = reloadDocs();
|
|
919
|
+
} catch {
|
|
920
|
+
reloadState = 'failed';
|
|
921
|
+
repaint();
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
const settle = (next) => {
|
|
925
|
+
if (!next || next.ok !== true || !Array.isArray(next.docs)) {
|
|
926
|
+
reloadState = 'failed';
|
|
927
|
+
repaint();
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
docs = next.docs.slice();
|
|
931
|
+
entries.clear();
|
|
932
|
+
reloadState = 'reloaded';
|
|
933
|
+
// The filter survives a reload: clamp the selection into the
|
|
934
|
+
// still-visible list, exactly like the issues viewer keeps its query.
|
|
935
|
+
if (state.index >= shown().length) state.index = Math.max(0, shown().length - 1);
|
|
936
|
+
state.scroll = 0;
|
|
937
|
+
ensureEntry(current());
|
|
938
|
+
repaint();
|
|
939
|
+
};
|
|
940
|
+
if (result && typeof result.then === 'function') {
|
|
941
|
+
Promise.resolve(result).then(settle).catch(() => {
|
|
942
|
+
reloadState = 'failed';
|
|
943
|
+
repaint();
|
|
944
|
+
});
|
|
945
|
+
} else {
|
|
946
|
+
settle(result);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function invalidate() {}
|
|
952
|
+
|
|
953
|
+
// S21: the list can be handed to an OPEN view a second time — the command
|
|
954
|
+
// paints the cheap local scan at once and applies the git-ranked order when
|
|
955
|
+
// the checkout's history walk answers, so the viewer never waits on git to
|
|
956
|
+
// appear and a slow walk never freezes it. This behaves like the `s` order
|
|
957
|
+
// toggle, never like a reload: the focused document is re-found by IDENTITY
|
|
958
|
+
// (the reader keeps reading the same file instead of whatever lands at the old
|
|
959
|
+
// index), the window re-anchors on its new position, and the rendered
|
|
960
|
+
// documents are KEPT — only the order moved, so re-reading them would cost the
|
|
961
|
+
// reader the pane they are already reading (and could only surface a
|
|
962
|
+
// difference the settled order never claimed). An answer that is not a list
|
|
963
|
+
// leaves the open view exactly as it is.
|
|
964
|
+
function applyDocs(next) {
|
|
965
|
+
if (!Array.isArray(next)) return;
|
|
966
|
+
const focused = current();
|
|
967
|
+
const focusedKey = focused ? (focused.path || focused.name) : null;
|
|
968
|
+
docs = next.slice();
|
|
969
|
+
const list = shown();
|
|
970
|
+
let target = Math.min(state.index, Math.max(0, list.length - 1));
|
|
971
|
+
if (focusedKey !== null) {
|
|
972
|
+
const at = list.findIndex((docEntry) => docEntry && (docEntry.path || docEntry.name) === focusedKey);
|
|
973
|
+
if (at >= 0) target = at;
|
|
974
|
+
}
|
|
975
|
+
state.index = Math.max(0, target);
|
|
976
|
+
const win = layout.scrollWindow(listCosts(), { budget: Math.max(1, bodyRows), index: state.index, start: 0 });
|
|
977
|
+
state.listStart = win.start;
|
|
978
|
+
ensureEntry(current());
|
|
979
|
+
repaint();
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// The selected document starts loading as soon as the view exists, so the
|
|
983
|
+
// first frame the host paints already carries content instead of a
|
|
984
|
+
// placeholder.
|
|
985
|
+
ensureEntry(current());
|
|
986
|
+
|
|
987
|
+
return { render, handleInput, invalidate, handleMouse, applyDocs, state };
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
module.exports = { createOddView, CONTENT_KEYS, SEARCH_KEYS, EMPTY_LIST, EMPTY_HINT };
|