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,226 @@
1
+ 'use strict';
2
+ // System clipboard writer for the issues viewer (key-consistency S6/K4:
3
+ // `y` copies the issue number, `Y` its URL).
4
+ //
5
+ // Tries the platform clipboard tools in order with fixed argv arrays and
6
+ // NO shell string — the text travels on stdin, never interpolated into a
7
+ // command line. Injectable runner seam: `setClipboardRunner(fn)` replaces
8
+ // the default runner with `fn(bin, argv, {text})` (may be sync or async).
9
+ // The runner may resolve to a result `{status, stdout, stderr, error}`
10
+ // like the gh-issues runner, or to a still-running child with
11
+ // `{stdin, unref(), on()}` (e.g. wl-copy, which accepts the text and then
12
+ // stays alive serving the selection). Pass `null` to restore the default.
13
+ //
14
+ // `writeText(text)` returns `{ok:true, via}` naming the tool that took the
15
+ // text, or `{ok:false, error}`. Success means the binary resolved, started,
16
+ // and accepted the text on stdin — it never waits for the helper to EXIT,
17
+ // so a serving helper is detached (unref'd) instead of timing out. A
18
+ // missing tool (ENOENT) falls through to the next candidate; when none
19
+ // exists the miss is reported honestly — the caller must say so, never
20
+ // pretend it copied.
21
+
22
+ const { spawn } = require('node:child_process');
23
+
24
+ const CLIPBOARD_TOOLS = [
25
+ { bin: 'pbcopy', argv: [] },
26
+ { bin: 'wl-copy', argv: [] },
27
+ { bin: 'xclip', argv: ['-selection', 'clipboard'] },
28
+ { bin: 'xsel', argv: ['--clipboard', '--input'] },
29
+ ];
30
+
31
+ let runnerOverride = null;
32
+
33
+ // Test seam: inject `fn(bin, argv, {text})` (may be sync or async), or
34
+ // `null`/`undefined` to restore the default runner.
35
+ function setClipboardRunner(fn) {
36
+ if (fn === null || fn === undefined) {
37
+ runnerOverride = null;
38
+ return;
39
+ }
40
+ if (typeof fn !== 'function') throw new TypeError('setClipboardRunner requires a function or null');
41
+ runnerOverride = fn;
42
+ }
43
+
44
+ function isMissingTool(error) {
45
+ return typeof error === 'string' && /ENOENT/i.test(error);
46
+ }
47
+
48
+ function isChildLike(value) {
49
+ return Boolean(value)
50
+ && typeof value === 'object'
51
+ && Boolean(value.stdin)
52
+ && (typeof value.stdin.write === 'function' || typeof value.stdin.end === 'function');
53
+ }
54
+
55
+ // Default runner: argv array, no shell, text on stdin. Resolves to
56
+ // {status, stdout, stderr, error} once the text is accepted — never waits
57
+ // for the helper to exit. The child is detached (stdio ignored except the
58
+ // piped stdin, then unref'd) so a serving helper such as wl-copy neither
59
+ // blocks the event loop nor becomes a waited-on zombie. Never throws.
60
+ function defaultRunner(bin, argv, runOpts = {}) {
61
+ const text = runOpts && typeof runOpts.text === 'string' ? runOpts.text : '';
62
+ return new Promise((resolve) => {
63
+ let settled = false;
64
+ const settle = (value) => {
65
+ if (!settled) {
66
+ settled = true;
67
+ resolve(value);
68
+ }
69
+ };
70
+ let child;
71
+ try {
72
+ child = spawn(bin, argv, { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true });
73
+ } catch (err) {
74
+ settle({ status: null, stdout: '', stderr: '', error: (err && err.message) || String(err) });
75
+ return;
76
+ }
77
+ child.on('error', (err) => {
78
+ settle({ status: null, stdout: '', stderr: '', error: (err && err.message) || String(err) });
79
+ });
80
+ try {
81
+ child.stdin.write(text, (writeErr) => {
82
+ if (writeErr) {
83
+ settle({
84
+ status: null,
85
+ stdout: '',
86
+ stderr: '',
87
+ error: (writeErr && writeErr.message) || String(writeErr),
88
+ });
89
+ return;
90
+ }
91
+ try {
92
+ child.stdin.end();
93
+ } catch (err) {
94
+ settle({ status: null, stdout: '', stderr: '', error: (err && err.message) || String(err) });
95
+ return;
96
+ }
97
+ // Accepted: detach and report success without waiting for exit.
98
+ try {
99
+ if (typeof child.unref === 'function') child.unref();
100
+ } catch (err) {
101
+ settle({ status: null, stdout: '', stderr: '', error: (err && err.message) || String(err) });
102
+ return;
103
+ }
104
+ settle({ status: 0, stdout: '', stderr: '', error: null });
105
+ });
106
+ } catch (err) {
107
+ settle({ status: null, stdout: '', stderr: '', error: (err && err.message) || String(err) });
108
+ }
109
+ });
110
+ }
111
+
112
+ // Feed the text to an injected still-running child (the wl-copy shape).
113
+ // Resolves to `{type:'ok'}` once the write is accepted (ending stdin and
114
+ // unrefing the child), `{type:'write-error', message}` when the write
115
+ // itself fails, or `{type:'child-error', message}` when the child reports
116
+ // an error first (e.g. a missing binary). Never waits for exit.
117
+ function feedChild(child, text) {
118
+ return new Promise((resolve) => {
119
+ let settled = false;
120
+ const settle = (value) => {
121
+ if (!settled) {
122
+ settled = true;
123
+ resolve(value);
124
+ }
125
+ };
126
+ if (child && typeof child.on === 'function') {
127
+ try {
128
+ child.on('error', (err) => {
129
+ settle({ type: 'child-error', message: (err && err.message) || String(err) });
130
+ });
131
+ } catch (err) {
132
+ settle({ type: 'write-error', message: (err && err.message) || String(err) });
133
+ return;
134
+ }
135
+ }
136
+ const stdin = child.stdin;
137
+ const finishWrite = () => {
138
+ try {
139
+ if (stdin && typeof stdin.end === 'function') stdin.end();
140
+ } catch (err) {
141
+ settle({ type: 'write-error', message: (err && err.message) || String(err) });
142
+ return;
143
+ }
144
+ try {
145
+ if (typeof child.unref === 'function') child.unref();
146
+ } catch (err) {
147
+ settle({ type: 'write-error', message: (err && err.message) || String(err) });
148
+ return;
149
+ }
150
+ settle({ type: 'ok' });
151
+ };
152
+ try {
153
+ if (!stdin || typeof stdin.write !== 'function') {
154
+ if (stdin && typeof stdin.end === 'function') {
155
+ stdin.end(text, finishWrite);
156
+ return;
157
+ }
158
+ settle({ type: 'write-error', message: 'clipboard helper stdin is not writable' });
159
+ return;
160
+ }
161
+ // A write implementation that ignores callbacks signals acceptance by
162
+ // returning without throwing; only callback-style writes settle async.
163
+ if (stdin.write.length < 2) {
164
+ stdin.write(text);
165
+ finishWrite();
166
+ return;
167
+ }
168
+ stdin.write(text, (writeErr) => {
169
+ if (writeErr) {
170
+ settle({ type: 'write-error', message: (writeErr && writeErr.message) || String(writeErr) });
171
+ return;
172
+ }
173
+ finishWrite();
174
+ });
175
+ } catch (err) {
176
+ settle({ type: 'write-error', message: (err && err.message) || String(err) });
177
+ }
178
+ });
179
+ }
180
+
181
+ async function writeText(text) {
182
+ if (typeof text !== 'string' || text === '') {
183
+ return { ok: false, error: 'nothing to copy: refusing an empty clipboard write' };
184
+ }
185
+ const run = runnerOverride || defaultRunner;
186
+ const tried = [];
187
+ for (const tool of CLIPBOARD_TOOLS) {
188
+ let raw;
189
+ try {
190
+ raw = await run(tool.bin, tool.argv.slice(), { text });
191
+ } catch (err) {
192
+ // A thrown ENOENT means the binary is missing: try the next tool.
193
+ // Any other throw is a genuine failure of a present helper.
194
+ const message = (err && err.message) || String(err);
195
+ if (isMissingTool(message)) {
196
+ tried.push(tool.bin);
197
+ continue;
198
+ }
199
+ return { ok: false, error: `clipboard copy failed (${tool.bin}): ${message}` };
200
+ }
201
+ if (isChildLike(raw)) {
202
+ const fed = await feedChild(raw, text);
203
+ if (fed.type === 'ok') return { ok: true, via: tool.bin };
204
+ if (fed.type === 'child-error' && isMissingTool(fed.message)) {
205
+ tried.push(tool.bin);
206
+ continue;
207
+ }
208
+ return { ok: false, error: `clipboard copy failed (${tool.bin}): ${fed.message}` };
209
+ }
210
+ const outcome = raw && typeof raw === 'object' ? raw : {};
211
+ const error = outcome.error === null || outcome.error === undefined ? null : String(outcome.error);
212
+ const status = typeof outcome.status === 'number' ? outcome.status : null;
213
+ if (status === 0 && error === null) return { ok: true, via: tool.bin };
214
+ if (error !== null && isMissingTool(error)) {
215
+ tried.push(tool.bin);
216
+ continue;
217
+ }
218
+ return { ok: false, error: `clipboard copy failed (${tool.bin}): ${error || `exit status ${status}`}` };
219
+ }
220
+ return {
221
+ ok: false,
222
+ error: `no clipboard tool available (tried ${tried.join(', ')}): install pbcopy, wl-copy, xclip or xsel to copy`,
223
+ };
224
+ }
225
+
226
+ module.exports = { setClipboardRunner, writeText };