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.
Files changed (60) hide show
  1. package/README.md +234 -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,216 @@
1
+ 'use strict';
2
+ // T2 (issues-panel S1,S3): the migrated issue preview formatter,
3
+ // lib/issue-preview.js. In the source package (S16) one formatter served the
4
+ // worktree detail/edit pane and the Issues viewer; it is migrated whole, so its
5
+ // field set cannot shrink in the move. These tests pin the formatter's own
6
+ // contract (field order, omitted empties, pending read, injected clock) so the
7
+ // `/issues` wiring that lands later renders exactly what the source did.
8
+
9
+ const { test, describe } = require('node:test');
10
+ const assert = require('node:assert/strict');
11
+
12
+ const { issueDetailLines, relativeTime, wrapText } = require('../lib/issue-preview.js');
13
+
14
+ const plain = (_role, text) => String(text);
15
+ const bold = (text) => String(text);
16
+ const NOW = Date.parse('2026-03-01T00:00:00.000Z');
17
+
18
+ function render(detail, comments, options = {}) {
19
+ return issueDetailLines(detail, comments, {
20
+ width: 80,
21
+ paint: plain,
22
+ emphasis: bold,
23
+ now: NOW,
24
+ ...options,
25
+ }).join('\n');
26
+ }
27
+
28
+ const FULL = {
29
+ number: 77,
30
+ title: 'parity title',
31
+ state: 'open',
32
+ author: 'octo',
33
+ labels: [{ name: 'bug' }, 'docs'],
34
+ assignees: [{ login: 'ana' }, 'beto'],
35
+ createdAt: '2026-02-25T00:00:00.000Z',
36
+ updatedAt: '2026-02-26T00:00:00.000Z',
37
+ commentsCount: 2,
38
+ reactions: { plusOne: 3 },
39
+ projects: [{ title: 'Roadmap' }],
40
+ linkedPullRequestCount: 2,
41
+ body: 'the body',
42
+ };
43
+
44
+ describe('issue preview formatter (S16)', () => {
45
+ test('renders every present field and every comment', () => {
46
+ const text = render(FULL, [
47
+ { author: 'ana', body: 'first comment', createdAt: '2026-02-27T00:00:00.000Z' },
48
+ { author: 'beto', body: 'second comment', createdAt: '2026-02-28T00:00:00.000Z' },
49
+ ]);
50
+ assert.match(text, /^#77 parity title/);
51
+ assert.match(text, /\[open\]/);
52
+ assert.match(text, /labels: bug, docs/);
53
+ assert.match(text, /assignees: ana, beto/);
54
+ assert.match(text, /by octo · created 4d ago · updated 3d ago/);
55
+ assert.match(text, /👍 3 · 2 comments/);
56
+ assert.match(text, /projects: Roadmap/);
57
+ assert.match(text, /linked PRs: 2/);
58
+ assert.match(text, /the body/);
59
+ assert.match(text, /─ ana · 2d ago ─/);
60
+ assert.match(text, /first comment/);
61
+ assert.match(text, /second comment/);
62
+ assert.equal((text.match(/\(end of issue\)/g) || []).length, 1, 'the end marker closes the last comment');
63
+ });
64
+
65
+ test('omits empty optional metadata instead of printing a zero or "none"', () => {
66
+ const text = render(
67
+ { ...FULL, assignees: [], projects: [], linkedPullRequestCount: 0, commentsCount: 0, reactions: { plusOne: 0 } },
68
+ [],
69
+ );
70
+ assert.doesNotMatch(text, /assignees:/);
71
+ assert.doesNotMatch(text, /projects:/);
72
+ assert.doesNotMatch(text, /linked PRs:/);
73
+ assert.match(text, /👍 0 · 0 comments/, 'the reaction/comment counter is one honest line');
74
+ assert.match(text, /\(end of issue\)/, 'an uncommented issue still ends');
75
+ });
76
+
77
+ test('a pending read paints the known fields and names the read', () => {
78
+ const text = render({ number: 12, title: 'issue title 12', labels: [{ name: 'bug' }] }, [], { pending: true });
79
+ assert.match(text, /#12 issue title 12/);
80
+ assert.match(text, /labels: bug/);
81
+ assert.match(text, /loading…/);
82
+ assert.doesNotMatch(text, /the body|no description|\(end of issue\)/);
83
+ });
84
+
85
+ test('the caller note rides the state row and never replaces a fact', () => {
86
+ const text = render(FULL, [], { note: 'assigned to this worktree' });
87
+ assert.match(text, /\[open\] · assigned to this worktree/);
88
+ assert.match(text, /labels: bug, docs/);
89
+ });
90
+
91
+ test('ages come from the injected clock, so no assertion depends on the host time', () => {
92
+ assert.equal(relativeTime('2026-02-28T23:59:30.000Z', NOW), 'just now');
93
+ assert.equal(relativeTime('2026-02-28T23:00:00.000Z', NOW), '1h ago');
94
+ assert.equal(relativeTime('2026-02-01T00:00:00.000Z', NOW), '28d ago');
95
+ assert.equal(relativeTime('2025-01-01T00:00:00.000Z', NOW), '1y ago');
96
+ assert.equal(relativeTime('not a date', NOW), 'unknown time');
97
+ });
98
+
99
+ test('a narrow pane wraps every field instead of dropping it', () => {
100
+ const lines = issueDetailLines({ ...FULL, body: 'alpha beta gamma delta' }, [], {
101
+ width: 12,
102
+ paint: plain,
103
+ emphasis: bold,
104
+ now: NOW,
105
+ });
106
+ // The two chrome markers (the comment rule and `(end of issue)`) are single
107
+ // tokens the caller's own padding clips; every content line is wrapped.
108
+ const chrome = (line) => /^─ /.test(line) || line === '(end of issue)';
109
+ for (const line of lines.filter((entry) => !chrome(entry))) {
110
+ assert.ok([...line].length <= 12, `"${line}" fits 12 cells`);
111
+ }
112
+ assert.match(lines.join('\n'), /assignees:/);
113
+ assert.match(lines.join('\n'), /ana, beto/);
114
+ assert.match(lines.join('\n'), /delta/);
115
+ assert.equal(wrapText('', 20).length, 1, 'an empty body is one blank row, not zero rows');
116
+ });
117
+
118
+ test('a comment without an author still names itself honestly', () => {
119
+ const text = render({ number: 5, title: 't' }, [{ body: 'orphan' }]);
120
+ assert.match(text, /─ unknown · unknown time ─/);
121
+ assert.match(text, /orphan/);
122
+ });
123
+
124
+ test('a long comment retains its complete body beyond 2000 characters', () => {
125
+ const body = `${'complete comment '.repeat(180)}TAIL_AFTER_2000`;
126
+ const text = render({ number: 5, title: 't' }, [{ body }]);
127
+ assert.ok(text.includes('TAIL_AFTER_2000'), 'sharing the formatter must not truncate a comment');
128
+ const flattened = text.replace(/\s+/g, ' ');
129
+ assert.ok(flattened.includes(body), 'every word survives, not just the final marker');
130
+ });
131
+
132
+ test('wrapping a token wider than the preview preserves all its characters', () => {
133
+ const token = 'https://example.test/' + 'x'.repeat(180);
134
+ const lines = wrapText(token, 12);
135
+ assert.equal(lines.join(''), token, 'long tokens wrap rather than losing their suffix');
136
+ assert.ok(lines.every((line) => line.length <= 12));
137
+ });
138
+ });
139
+
140
+ // T9 (S13): the preview's OWN TITLE is a title, not a control. It asks the
141
+ // injected paint seam for the SAME role the shared table gives every other
142
+ // title — `customMessageLabel`, the role the Pi Agents view paints its own frame
143
+ // title with (`gentle-pi/lib/agents-view.ts:42`, painted at line 437) — so the Issues reading
144
+ // pane, the two readers' frames and the plugin's panels cannot drift into two
145
+ // title colours. Every non-title role stays exactly where it was.
146
+ describe('the preview title follows the shared palette (S13)', () => {
147
+ // A paint seam that RECORDS which role each string was painted with, so the
148
+ // assertion is about the ROLE behind the title instead of a colour.
149
+ function spyPaint() {
150
+ const calls = [];
151
+ const paint = (role, text) => {
152
+ calls.push({ role, text: String(text) });
153
+ return String(text);
154
+ };
155
+ return { calls, paint };
156
+ }
157
+
158
+ function renderSpy(detail, comments, options = {}) {
159
+ const spy = spyPaint();
160
+ const emphasised = [];
161
+ const lines = issueDetailLines(detail, comments, {
162
+ width: 80,
163
+ now: NOW,
164
+ paint: spy.paint,
165
+ emphasis: (text) => {
166
+ emphasised.push(String(text));
167
+ return String(text);
168
+ },
169
+ ...options,
170
+ });
171
+ return { ...spy, emphasised, lines };
172
+ }
173
+
174
+ test('the title asks for the Agents view title role, never the button accent', () => {
175
+ const { calls } = renderSpy(FULL, []);
176
+ const titleCall = calls.find((call) => call.text === '#77 parity title');
177
+ assert.ok(titleCall, 'the title is painted through the caller seam');
178
+ assert.equal(titleCall.role, 'customMessageLabel',
179
+ 'the title uses the role the Pi Agents view paints its own title with');
180
+ assert.notEqual(titleCall.role, 'accent',
181
+ 'the title is never painted with the button role');
182
+ assert.notEqual(titleCall.role, 'mdHeading',
183
+ 'a markdown heading role is not a UI title role');
184
+ });
185
+
186
+ test('the title keeps its emphasis and every metadata role is unchanged', () => {
187
+ const { calls, emphasised, lines } = renderSpy(FULL, [{ author: 'ana', body: 'first comment', createdAt: '2026-02-27T00:00:00.000Z' }]);
188
+ assert.ok(emphasised.includes('#77 parity title'), 'the title is still emphasised through the theme');
189
+ const roleOf = (needle) => {
190
+ const call = calls.find((entry) => entry.text.includes(needle));
191
+ assert.ok(call, `no painted call carries ${JSON.stringify(needle)}`);
192
+ return call.role;
193
+ };
194
+ assert.equal(roleOf('[open]'), 'accent', 'the open state keeps its own role');
195
+ assert.equal(roleOf('(end of issue)'), 'dim', 'the end marker stays dim');
196
+ assert.equal(roleOf('─ ana · 2d ago ─'), 'border', 'the comment rule stays the border role');
197
+ assert.equal(roleOf('#77 parity title'), 'customMessageLabel', 'the title is a title');
198
+ // The remaining fields are the formatter's plain text: no role is invented
199
+ // for labels, assignees, counters or the body.
200
+ const bodyLine = lines.find((line) => line.includes('the body'));
201
+ assert.ok(bodyLine !== undefined, 'the body still renders');
202
+ assert.ok(!calls.some((call) => call.text.includes('the body')), 'the body is painted by the caller, not roled here');
203
+ assert.ok(!calls.some((call) => call.text.startsWith('labels:')), 'labels stay plain text');
204
+ assert.ok(!calls.some((call) => call.text.startsWith('👍')), 'the counter stays plain text');
205
+ });
206
+
207
+ test('a closed issue and an unreadable-metadata note keep their own roles', () => {
208
+ const { calls } = renderSpy({ ...FULL, state: 'closed', metadataError: 'boom' }, []);
209
+ assert.ok(calls.some((call) => call.role === 'muted' && call.text === '[closed]'),
210
+ 'a closed state is muted, not the title role');
211
+ assert.ok(calls.some((call) => call.role === 'muted' && call.text === 'metadata unavailable'),
212
+ 'the unreadable-metadata note stays muted');
213
+ assert.ok(calls.some((call) => call.role === 'muted' && call.text === 'loading…') === false,
214
+ 'a ready read paints no pending word');
215
+ });
216
+ });