pi-odd-worktree 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +223 -2
  2. package/extensions/odd-worktree-tools.ts +7593 -0
  3. package/lib/checkout-facts.js +598 -0
  4. package/lib/clipboard.js +226 -0
  5. package/lib/gh-issues.js +1555 -0
  6. package/lib/issue-preview.js +273 -0
  7. package/lib/issues-view.js +2274 -0
  8. package/lib/odd-docs.js +505 -0
  9. package/lib/odd-markdown.js +413 -0
  10. package/lib/odd-view.js +990 -0
  11. package/lib/open-external.js +106 -0
  12. package/lib/preview-scroll-keys.js +192 -0
  13. package/lib/session-auto-name.js +292 -0
  14. package/lib/session-worktree-register.js +62 -0
  15. package/lib/tui-paint.js +273 -0
  16. package/lib/view-layout.js +368 -0
  17. package/lib/warp-create-panel.js +722 -0
  18. package/lib/warp-create-picker.js +906 -0
  19. package/lib/warp-delete-confirm.js +465 -0
  20. package/lib/warp-selector-view.js +1265 -0
  21. package/lib/worktree-card.js +1117 -0
  22. package/lib/worktree-changes.js +1395 -0
  23. package/lib/worktree-close-herdr.js +73 -0
  24. package/lib/worktree-git.js +1048 -0
  25. package/lib/worktree-hooks.js +738 -0
  26. package/lib/worktree-model-result.js +291 -0
  27. package/lib/worktree-registry.js +886 -0
  28. package/lib/worktree-session-guard.js +595 -0
  29. package/lib/worktree-symlinks.js +336 -0
  30. package/lib/worktree-warp-sessions.js +566 -0
  31. package/package.json +36 -4
  32. package/tools/commands.test.js +176 -0
  33. package/tools/fixtures/gh-api-user.json +1 -0
  34. package/tools/fixtures/gh-issue-list.json +1 -0
  35. package/tools/fixtures/gh-issue-view.json +1 -0
  36. package/tools/fixtures/gh-label-list.json +1 -0
  37. package/tools/fixtures/gh-repo-view.json +1 -0
  38. package/tools/fixtures/load-extension.js +66 -0
  39. package/tools/gh-issues-feedback.test.js +359 -0
  40. package/tools/gh-issues.test.js +1502 -0
  41. package/tools/issue-preview.test.js +216 -0
  42. package/tools/issues-command.test.js +1217 -0
  43. package/tools/issues-view.test.js +4401 -0
  44. package/tools/odd-docs.test.js +803 -0
  45. package/tools/odd-markdown.test.js +236 -0
  46. package/tools/odd-view.test.js +2328 -0
  47. package/tools/package-surface.test.js +138 -0
  48. package/tools/t5-session-identity.test.js +306 -0
  49. package/tools/warp-create-panel.test.js +185 -0
  50. package/tools/warp-create-picker.test.js +1357 -0
  51. package/tools/warp-delete-confirm.test.js +616 -0
  52. package/tools/warp-selector-view.test.js +1463 -0
  53. package/tools/worktree-changes.test.js +951 -0
  54. package/tools/worktree-close-herdr-async.test.js +204 -0
  55. package/tools/worktree-odd-migration.test.js +224 -0
  56. package/tools/worktree-registry-cache.test.js +163 -0
  57. package/tools/worktree-registry-odd.test.js +231 -0
  58. package/tools/worktree-registry.test.js +244 -0
  59. package/tools/worktree-session-guard.test.js +507 -0
  60. package/tools/worktree-tools.test.js +434 -0
@@ -0,0 +1,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 };