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,273 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// ONE issue preview, migrated from the source package's issue panel (S16), where
|
|
3
|
+
// it was shared by every surface that shows an issue beside a list and was
|
|
4
|
+
// extracted whole so a field added once showed up everywhere. That second,
|
|
5
|
+
// smaller formatter had left the detail pane without assignees, comments or
|
|
6
|
+
// projects. Here `/issues` renders through this formatter once it is wired, and
|
|
7
|
+
// the field set is the original one whole — nothing shrank in the move.
|
|
8
|
+
//
|
|
9
|
+
// Purity: plain data in, styled strings out. The caller injects `paint`,
|
|
10
|
+
// `emphasis`, its own `wrap` and, for deterministic tests, the clock. No
|
|
11
|
+
// filesystem, network or registry access happens here.
|
|
12
|
+
//
|
|
13
|
+
// Contract
|
|
14
|
+
// issueDetailLines(detail, comments, options) -> string[]
|
|
15
|
+
// detail an issue as `gh` normalizes it (number, title, state, author,
|
|
16
|
+
// labels, assignees, createdAt, updatedAt, commentsCount,
|
|
17
|
+
// reactions{plusOne}, projects, linkedPullRequestCount,
|
|
18
|
+
// metadataError, body). Every field is optional.
|
|
19
|
+
// comments an array of `{ author, body, createdAt }`; anything else is
|
|
20
|
+
// treated as "no comments yet".
|
|
21
|
+
// options { width, paint, emphasis, wrap, cache, note, pending, now }
|
|
22
|
+
// `note` an extra short fact for the state row (a caller may
|
|
23
|
+
// pass a worktree's assignment state); it never
|
|
24
|
+
// replaces a fact of the issue itself.
|
|
25
|
+
// `pending` true while a richer read is still in flight: the
|
|
26
|
+
// fields already known render, the read is named, and
|
|
27
|
+
// the body/comments are NOT invented from the row.
|
|
28
|
+
// `now` epoch ms for the relative ages.
|
|
29
|
+
// `cache` a wrap memo from `createWrapCache()`; the default is
|
|
30
|
+
// the module's shared instance (S23).
|
|
31
|
+
//
|
|
32
|
+
// S23: the wrap is memoized. A repaint at an unchanged width used to re-wrap
|
|
33
|
+
// the whole body and every comment of the issue on screen; the memo is keyed
|
|
34
|
+
// by the EXACT text and the width it was wrapped for, so an unchanged repaint
|
|
35
|
+
// wraps nothing while a changed body or pane always misses — a stale row can
|
|
36
|
+
// never be served.
|
|
37
|
+
//
|
|
38
|
+
// Absent metadata is omitted rather than printed as "none" or "0": an empty
|
|
39
|
+
// assignee/project list or a zero PR count does not add an optional row;
|
|
40
|
+
// reaction/comment counters remain explicit facts, including measured zeros.
|
|
41
|
+
//
|
|
42
|
+
// S13: the title is a TITLE, not a control. Its role comes from the shared
|
|
43
|
+
// table in `lib/tui-paint.js` (`ROLES.title`) instead of a role name written
|
|
44
|
+
// here, so this preview cannot drift into its own title colour: the `/issues`
|
|
45
|
+
// reading pane, the reader frames and the warp/create panels all paint their
|
|
46
|
+
// titles with the one role, and a change to that table moves every one of them
|
|
47
|
+
// at once. That role is `customMessageLabel` — the one the
|
|
48
|
+
// Pi Agents view paints its own frame title with (`gentle-pi/lib/agents-view.ts:42`
|
|
49
|
+
// maps `TITLE: "customMessageLabel"`, painted at line 437). The rest of the formatter keeps the
|
|
50
|
+
// roles it already had (`accent` state, `muted` notes, `border` comment rule,
|
|
51
|
+
// `dim` end marker) and everything else stays the caller's plain text.
|
|
52
|
+
|
|
53
|
+
const { ROLES: PALETTE_ROLES } = require('./tui-paint.js');
|
|
54
|
+
|
|
55
|
+
// The one role a title is painted with. Read once from the shared table: a
|
|
56
|
+
// surface that injects its own `paint` still resolves the ROLE (never a colour)
|
|
57
|
+
// through its own theme.
|
|
58
|
+
const TITLE_ROLE = PALETTE_ROLES.title;
|
|
59
|
+
|
|
60
|
+
function labelName(label) {
|
|
61
|
+
if (typeof label === 'string') return label;
|
|
62
|
+
if (label && typeof label.name === 'string') return label.name;
|
|
63
|
+
return '';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function assigneeName(entry) {
|
|
67
|
+
if (typeof entry === 'string') return entry;
|
|
68
|
+
if (entry && typeof entry.login === 'string') return entry.login;
|
|
69
|
+
return '';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function commentAuthor(comment) {
|
|
73
|
+
if (typeof comment.author === 'string') return comment.author;
|
|
74
|
+
if (comment.author && typeof comment.author.login === 'string') return comment.author.login;
|
|
75
|
+
return 'unknown';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The package's one clock style (issues view, warp facts): just now, minutes,
|
|
79
|
+
// hours, days, months, years, always with the `ago` suffix so two surfaces
|
|
80
|
+
// never describe the same instant two different ways.
|
|
81
|
+
function relativeTime(iso, nowMs) {
|
|
82
|
+
const when = Date.parse(iso);
|
|
83
|
+
if (!Number.isFinite(when)) return 'unknown time';
|
|
84
|
+
const diff = (Number.isFinite(nowMs) ? nowMs : Date.now()) - when;
|
|
85
|
+
if (!Number.isFinite(diff) || diff < 0) return 'just now';
|
|
86
|
+
const minutes = Math.floor(diff / 60000);
|
|
87
|
+
if (minutes < 1) return 'just now';
|
|
88
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
89
|
+
const hours = Math.floor(minutes / 60);
|
|
90
|
+
if (hours < 24) return `${hours}h ago`;
|
|
91
|
+
const days = Math.floor(hours / 24);
|
|
92
|
+
if (days < 30) return `${days}d ago`;
|
|
93
|
+
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
|
|
94
|
+
return `${Math.floor(days / 365)}y ago`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Word wrap that never splits a word unless the word alone is wider than the
|
|
98
|
+
// pane, and that keeps blank lines as blank rows.
|
|
99
|
+
function wrapText(text, width) {
|
|
100
|
+
const room = Math.max(1, Math.floor(Number(width) || 1));
|
|
101
|
+
const out = [];
|
|
102
|
+
for (const paragraph of String(text ?? '').split('\n')) {
|
|
103
|
+
if (paragraph.trim() === '') {
|
|
104
|
+
out.push('');
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const words = paragraph.split(/\s+/).filter(Boolean);
|
|
108
|
+
let line = '';
|
|
109
|
+
for (const word of words) {
|
|
110
|
+
if (line !== '' && (line + ' ' + word).length <= room) {
|
|
111
|
+
line += ' ' + word;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (line !== '') out.push(line);
|
|
115
|
+
let tail = word;
|
|
116
|
+
while (tail.length > room) {
|
|
117
|
+
out.push(tail.slice(0, room));
|
|
118
|
+
tail = tail.slice(room);
|
|
119
|
+
}
|
|
120
|
+
line = tail;
|
|
121
|
+
}
|
|
122
|
+
out.push(line);
|
|
123
|
+
}
|
|
124
|
+
return out.length === 0 ? [''] : out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// One wrap memo entry holds the rows of ONE `wrap` call (a body, a comment),
|
|
128
|
+
// and the smallest real caller adds a handful of short lines per paint, so this
|
|
129
|
+
// budget covers several issues at both pane widths instead of thrashing.
|
|
130
|
+
const WRAP_CACHE_ENTRIES = 400;
|
|
131
|
+
// A pathological result (a very narrow pane over a huge body) is returned
|
|
132
|
+
// without being stored, so one odd paint cannot pin an enormous row set.
|
|
133
|
+
const WRAP_CACHE_ROWS = 20000;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A bounded memo of finished wraps, keyed by width + the exact text.
|
|
137
|
+
*
|
|
138
|
+
* Both halves of the key are the whole input of `wrapText`, so a hit can only
|
|
139
|
+
* return rows that the same text at the same width already produced: the memo
|
|
140
|
+
* changes cost, never output. Callers treat the returned rows as read-only —
|
|
141
|
+
* `issueDetailLines` spreads them into its own array.
|
|
142
|
+
*
|
|
143
|
+
* `stats.wraps` counts the wraps actually performed and `stats.hits` the
|
|
144
|
+
* reuses, which is what makes "a repaint re-wraps nothing" observable.
|
|
145
|
+
*/
|
|
146
|
+
function createWrapCache(limit = WRAP_CACHE_ENTRIES) {
|
|
147
|
+
const max = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : WRAP_CACHE_ENTRIES;
|
|
148
|
+
const entries = new Map();
|
|
149
|
+
const stats = { wraps: 0, hits: 0 };
|
|
150
|
+
|
|
151
|
+
function wrap(text, width) {
|
|
152
|
+
const room = Math.max(1, Math.floor(Number(width) || 1));
|
|
153
|
+
const source = String(text ?? '');
|
|
154
|
+
const key = `${room}\u0000${source}`;
|
|
155
|
+
const cached = entries.get(key);
|
|
156
|
+
if (cached) {
|
|
157
|
+
stats.hits += 1;
|
|
158
|
+
// Re-insert, so the Map's own order is the eviction order.
|
|
159
|
+
entries.delete(key);
|
|
160
|
+
entries.set(key, cached);
|
|
161
|
+
return cached;
|
|
162
|
+
}
|
|
163
|
+
stats.wraps += 1;
|
|
164
|
+
const lines = wrapText(source, room);
|
|
165
|
+
if (lines.length <= WRAP_CACHE_ROWS) {
|
|
166
|
+
entries.set(key, lines);
|
|
167
|
+
while (entries.size > max) {
|
|
168
|
+
const oldest = entries.keys().next();
|
|
169
|
+
if (oldest.done) break;
|
|
170
|
+
entries.delete(oldest.value);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return lines;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { wrap, stats };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Every caller that does not inject its own `wrap` shares this instance — every
|
|
180
|
+
// reading surface relies on the default — so the memo lives here instead of
|
|
181
|
+
// being wired twice.
|
|
182
|
+
const defaultWrapCache = createWrapCache();
|
|
183
|
+
|
|
184
|
+
function identity(_role, text) {
|
|
185
|
+
return String(text);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function bold(text) {
|
|
189
|
+
return String(text);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The preview pane for ONE issue: identity, state, labels, assignees, author
|
|
194
|
+
* and ages, reaction/comment counts, projects, linked PRs, the body and every
|
|
195
|
+
* comment, in that order (S16).
|
|
196
|
+
*
|
|
197
|
+
* @returns {string[]} styled lines, already wrapped to `options.width`
|
|
198
|
+
*/
|
|
199
|
+
function issueDetailLines(detail, comments, options = {}) {
|
|
200
|
+
const entry = detail && typeof detail === 'object' ? detail : {};
|
|
201
|
+
const settings = options && typeof options === 'object' ? options : {};
|
|
202
|
+
const width = Math.max(1, Math.floor(Number(settings.width) || 1));
|
|
203
|
+
const paint = typeof settings.paint === 'function' ? settings.paint : identity;
|
|
204
|
+
const emphasize = typeof settings.emphasis === 'function' ? settings.emphasis : bold;
|
|
205
|
+
const cache = settings.cache && typeof settings.cache.wrap === 'function' ? settings.cache : defaultWrapCache;
|
|
206
|
+
const wrap = typeof settings.wrap === 'function' ? settings.wrap : (text) => cache.wrap(text, width);
|
|
207
|
+
const list = Array.isArray(comments) ? comments : [];
|
|
208
|
+
const nowMs = Number.isFinite(settings.now) ? settings.now : Date.now();
|
|
209
|
+
|
|
210
|
+
const lines = [];
|
|
211
|
+
// S13: the title asks for the shared title role, and keeps its emphasis.
|
|
212
|
+
lines.push(emphasize(paint(TITLE_ROLE, `#${entry.number} ${entry.title || '(no title)'}`)));
|
|
213
|
+
const state = String(entry.state || 'open');
|
|
214
|
+
const stateLine = [paint(state === 'closed' ? 'muted' : 'accent', `[${state}]`)];
|
|
215
|
+
if (typeof settings.note === 'string' && settings.note.length > 0) {
|
|
216
|
+
stateLine.push(paint('muted', settings.note));
|
|
217
|
+
}
|
|
218
|
+
lines.push(stateLine.join(' · '));
|
|
219
|
+
|
|
220
|
+
const labels = (Array.isArray(entry.labels) ? entry.labels : []).map(labelName).filter(Boolean);
|
|
221
|
+
lines.push(labels.length > 0 ? `labels: ${labels.join(', ')}` : paint('muted', '(no labels)'));
|
|
222
|
+
const assignees = (Array.isArray(entry.assignees) ? entry.assignees : []).map(assigneeName).filter(Boolean);
|
|
223
|
+
if (assignees.length > 0) lines.push(`assignees: ${assignees.join(', ')}`);
|
|
224
|
+
|
|
225
|
+
// S16: while a richer read is in flight the row's own facts are already on
|
|
226
|
+
// screen and the read is NAMED — an empty-looking pane must not read as
|
|
227
|
+
// "this issue has no body". The counters and the body are not invented from
|
|
228
|
+
// a row that never carried them.
|
|
229
|
+
if (settings.pending === true) {
|
|
230
|
+
const pending = [];
|
|
231
|
+
for (const line of lines) pending.push(...wrap(line));
|
|
232
|
+
pending.push(paint('muted', 'loading…'));
|
|
233
|
+
return pending;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
lines.push(
|
|
237
|
+
`by ${entry.author || 'unknown'} · created ${relativeTime(entry.createdAt, nowMs)} · updated ${relativeTime(entry.updatedAt, nowMs)}`,
|
|
238
|
+
);
|
|
239
|
+
const plusOne = entry.reactions && Number.isFinite(Number(entry.reactions.plusOne))
|
|
240
|
+
? Number(entry.reactions.plusOne)
|
|
241
|
+
: 0;
|
|
242
|
+
const stored = Number.isFinite(Number(entry.commentsCount)) ? Number(entry.commentsCount) : 0;
|
|
243
|
+
lines.push(`👍 ${plusOne} · ${Math.max(stored, list.length)} comments`);
|
|
244
|
+
// S9/S6: only data the issue actually has. An empty relation is omitted, not
|
|
245
|
+
// rendered as "none", and an unreadable relation is one short status instead
|
|
246
|
+
// of the scopes or reasons behind it.
|
|
247
|
+
const projects = (Array.isArray(entry.projects) ? entry.projects : [])
|
|
248
|
+
.map((project) => (project && project.title ? String(project.title) : ''))
|
|
249
|
+
.filter(Boolean);
|
|
250
|
+
if (projects.length > 0) lines.push(`projects: ${projects.join(', ')}`);
|
|
251
|
+
const linkedPrs = Number(entry.linkedPullRequestCount);
|
|
252
|
+
if (Number.isFinite(linkedPrs) && linkedPrs > 0) lines.push(`linked PRs: ${linkedPrs}`);
|
|
253
|
+
if (entry.metadataError) lines.push(paint('muted', 'metadata unavailable'));
|
|
254
|
+
|
|
255
|
+
const out = [];
|
|
256
|
+
lines.push('');
|
|
257
|
+
for (const line of lines) out.push(...wrap(line));
|
|
258
|
+
out.push(...wrap(entry.body || '(no description)'));
|
|
259
|
+
list.forEach((comment, at) => {
|
|
260
|
+
out.push('');
|
|
261
|
+
out.push(paint('border', `─ ${commentAuthor(comment)} · ${relativeTime(comment.createdAt, nowMs)} ─`));
|
|
262
|
+
const body = typeof comment.body === 'string' ? comment.body : '';
|
|
263
|
+
out.push(...wrap(body));
|
|
264
|
+
if (at === list.length - 1) out.push(paint('dim', '(end of issue)'));
|
|
265
|
+
});
|
|
266
|
+
if (list.length === 0) {
|
|
267
|
+
out.push('');
|
|
268
|
+
out.push(paint('dim', '(end of issue)'));
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = { issueDetailLines, relativeTime, wrapText, createWrapCache, defaultWrapCache };
|