@ohzw/worktree-command-tui 0.1.0 → 0.1.2
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 +5 -0
- package/dist/app.d.ts +3 -2
- package/dist/app.js +458 -42
- package/dist/components/ActionPanel.d.ts +6 -2
- package/dist/components/ActionPanel.js +141 -82
- package/dist/components/ContextBar.d.ts +4 -1
- package/dist/components/ContextBar.js +37 -4
- package/dist/components/FloatingLogWindow.d.ts +7 -0
- package/dist/components/FloatingLogWindow.js +5 -0
- package/dist/components/HelpWindow.d.ts +7 -0
- package/dist/components/HelpWindow.js +29 -0
- package/dist/components/LogPanel.d.ts +22 -0
- package/dist/components/LogPanel.js +260 -0
- package/dist/components/WorktreeList.d.ts +3 -1
- package/dist/components/WorktreeList.js +25 -30
- package/dist/core/command-runner.d.ts +11 -0
- package/dist/core/command-runner.js +44 -0
- package/dist/core/config-lifecycle.d.ts +25 -0
- package/dist/core/config-lifecycle.js +143 -0
- package/dist/core/config.d.ts +2 -3
- package/dist/core/config.js +0 -48
- package/dist/core/git-metadata.d.ts +25 -0
- package/dist/core/git-metadata.js +84 -0
- package/dist/core/git-worktrees.d.ts +2 -1
- package/dist/core/git-worktrees.js +30 -11
- package/dist/core/github-metadata.d.ts +14 -0
- package/dist/core/github-metadata.js +137 -0
- package/dist/core/init.d.ts +3 -2
- package/dist/core/init.js +9 -57
- package/dist/core/log-reader.d.ts +7 -0
- package/dist/core/log-reader.js +43 -0
- package/dist/core/runtime-state.d.ts +42 -0
- package/dist/core/runtime-state.js +125 -0
- package/dist/core/runtime.d.ts +20 -33
- package/dist/core/runtime.js +116 -173
- package/dist/core/tui-interaction.d.ts +31 -0
- package/dist/core/tui-interaction.js +59 -0
- package/dist/core/worktree-projection.d.ts +76 -0
- package/dist/core/worktree-projection.js +124 -0
- package/dist/main.js +24 -2
- package/dist/render-options.d.ts +1 -0
- package/dist/render-options.js +1 -0
- package/dist/repro.d.ts +1 -0
- package/dist/repro.js +13 -0
- package/dist/terminal/viewport.d.ts +15 -0
- package/dist/terminal/viewport.js +49 -0
- package/dist/ui-theme.d.ts +3 -0
- package/dist/ui-theme.js +38 -0
- package/package.json +2 -1
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { getScrollbarThumbRows, sliceTailViewport } from '../terminal/viewport.js';
|
|
4
|
+
const ESCAPE = '\u001B';
|
|
5
|
+
const CSI = '\u009B';
|
|
6
|
+
const BEL = '\u0007';
|
|
7
|
+
const STRING_TERMINATOR_PREFIX = '\u001B';
|
|
8
|
+
const STRING_TERMINATOR_SUFFIX = '\\';
|
|
9
|
+
const STRING_TERMINATOR = '\u009C';
|
|
10
|
+
const DEVICE_CONTROL_STRING = '\u0090';
|
|
11
|
+
const START_OF_STRING = '\u0098';
|
|
12
|
+
const OPERATING_SYSTEM_COMMAND = '\u009D';
|
|
13
|
+
const PRIVACY_MESSAGE = '\u009E';
|
|
14
|
+
const APPLICATION_PROGRAM_COMMAND = '\u009F';
|
|
15
|
+
const ANSI_FOREGROUND_COLORS = new Map([
|
|
16
|
+
[30, 'black'],
|
|
17
|
+
[31, 'red'],
|
|
18
|
+
[32, 'green'],
|
|
19
|
+
[33, 'yellow'],
|
|
20
|
+
[34, 'blue'],
|
|
21
|
+
[35, 'magenta'],
|
|
22
|
+
[36, 'cyan'],
|
|
23
|
+
[37, 'white'],
|
|
24
|
+
[90, 'gray'],
|
|
25
|
+
[91, 'red'],
|
|
26
|
+
[92, 'green'],
|
|
27
|
+
[93, 'yellow'],
|
|
28
|
+
[94, 'blue'],
|
|
29
|
+
[95, 'magenta'],
|
|
30
|
+
[96, 'cyan'],
|
|
31
|
+
[97, 'white'],
|
|
32
|
+
]);
|
|
33
|
+
function sanitizeTextChunk(value) {
|
|
34
|
+
return value
|
|
35
|
+
.replace(/[\r\t\u2028\u2029]+/g, ' ')
|
|
36
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '')
|
|
37
|
+
.replace(/\p{Cf}/gu, '');
|
|
38
|
+
}
|
|
39
|
+
function sameStyle(a, b) {
|
|
40
|
+
return a.color === b.color && a.dimColor === b.dimColor && a.bold === b.bold;
|
|
41
|
+
}
|
|
42
|
+
function appendSegment(segments, text, style) {
|
|
43
|
+
if (text.length === 0) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const last = segments.at(-1);
|
|
47
|
+
if (last && sameStyle(last, style)) {
|
|
48
|
+
last.text += text;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
segments.push({ ...style, text });
|
|
52
|
+
}
|
|
53
|
+
function skipExtendedColor(codes, index) {
|
|
54
|
+
const mode = Number(codes[index + 1] ?? Number.NaN);
|
|
55
|
+
if (mode === 5) {
|
|
56
|
+
return index + 2;
|
|
57
|
+
}
|
|
58
|
+
if (mode === 2) {
|
|
59
|
+
return index + 4;
|
|
60
|
+
}
|
|
61
|
+
return index;
|
|
62
|
+
}
|
|
63
|
+
function applyAnsiSgr(style, parameters) {
|
|
64
|
+
const codes = parameters.length === 0 ? ['0'] : parameters.split(';');
|
|
65
|
+
for (let index = 0; index < codes.length; index += 1) {
|
|
66
|
+
const rawCode = codes[index] ?? '0';
|
|
67
|
+
if (rawCode.includes(':')) {
|
|
68
|
+
if (Number(rawCode.split(':', 1)[0]) === 38) {
|
|
69
|
+
delete style.color;
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const code = Number(rawCode || 0);
|
|
74
|
+
if (code === 0) {
|
|
75
|
+
delete style.color;
|
|
76
|
+
delete style.dimColor;
|
|
77
|
+
delete style.bold;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (code === 1) {
|
|
81
|
+
style.bold = true;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (code === 2) {
|
|
85
|
+
style.dimColor = true;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (code === 22) {
|
|
89
|
+
delete style.bold;
|
|
90
|
+
delete style.dimColor;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (code === 38) {
|
|
94
|
+
delete style.color;
|
|
95
|
+
index = skipExtendedColor(codes, index);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (code === 48 || code === 58) {
|
|
99
|
+
index = skipExtendedColor(codes, index);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (code === 39) {
|
|
103
|
+
delete style.color;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const color = ANSI_FOREGROUND_COLORS.get(code);
|
|
107
|
+
if (color) {
|
|
108
|
+
style.color = color;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function trimLineEnd(segments) {
|
|
113
|
+
const result = [...segments];
|
|
114
|
+
while (result.length > 0) {
|
|
115
|
+
const last = result.at(-1);
|
|
116
|
+
const trimmed = last.text.trimEnd();
|
|
117
|
+
if (trimmed.length > 0) {
|
|
118
|
+
if (trimmed.length !== last.text.length) {
|
|
119
|
+
result[result.length - 1] = { ...last, text: trimmed };
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
result.pop();
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
function pushLine(lines, segments) {
|
|
128
|
+
const trimmedSegments = trimLineEnd(segments);
|
|
129
|
+
lines.push({ segments: trimmedSegments.length > 0 ? trimmedSegments : [{ text: ' ' }] });
|
|
130
|
+
}
|
|
131
|
+
function isAnsiFinalByte(value) {
|
|
132
|
+
const code = value.charCodeAt(0);
|
|
133
|
+
return code >= 0x40 && code <= 0x7E;
|
|
134
|
+
}
|
|
135
|
+
function isAnsiIntermediateByte(value) {
|
|
136
|
+
const code = value.charCodeAt(0);
|
|
137
|
+
return code >= 0x20 && code <= 0x2F;
|
|
138
|
+
}
|
|
139
|
+
function consumeCsi(value, index, style) {
|
|
140
|
+
let cursor = index;
|
|
141
|
+
while (cursor < value.length && !isAnsiFinalByte(value[cursor])) {
|
|
142
|
+
cursor += 1;
|
|
143
|
+
}
|
|
144
|
+
if (cursor >= value.length) {
|
|
145
|
+
return value.length;
|
|
146
|
+
}
|
|
147
|
+
const finalByte = value[cursor];
|
|
148
|
+
const sequence = value.slice(index, cursor);
|
|
149
|
+
const hasIntermediate = Array.from(sequence).some(isAnsiIntermediateByte);
|
|
150
|
+
if (!hasIntermediate && finalByte === 'm') {
|
|
151
|
+
applyAnsiSgr(style, sequence);
|
|
152
|
+
}
|
|
153
|
+
return cursor + 1;
|
|
154
|
+
}
|
|
155
|
+
function consumeStringControl(value, index, allowBelTerminator) {
|
|
156
|
+
let cursor = index;
|
|
157
|
+
while (cursor < value.length) {
|
|
158
|
+
if ((allowBelTerminator && value[cursor] === BEL) || value[cursor] === STRING_TERMINATOR) {
|
|
159
|
+
return cursor + 1;
|
|
160
|
+
}
|
|
161
|
+
if (value[cursor] === STRING_TERMINATOR_PREFIX && value[cursor + 1] === STRING_TERMINATOR_SUFFIX) {
|
|
162
|
+
return cursor + 2;
|
|
163
|
+
}
|
|
164
|
+
cursor += 1;
|
|
165
|
+
}
|
|
166
|
+
return value.length;
|
|
167
|
+
}
|
|
168
|
+
function isStringControl(value) {
|
|
169
|
+
return value === DEVICE_CONTROL_STRING
|
|
170
|
+
|| value === START_OF_STRING
|
|
171
|
+
|| value === OPERATING_SYSTEM_COMMAND
|
|
172
|
+
|| value === PRIVACY_MESSAGE
|
|
173
|
+
|| value === APPLICATION_PROGRAM_COMMAND;
|
|
174
|
+
}
|
|
175
|
+
function consumeEscape(value, index, style) {
|
|
176
|
+
if (value[index] === CSI) {
|
|
177
|
+
return consumeCsi(value, index + 1, style);
|
|
178
|
+
}
|
|
179
|
+
if (isStringControl(value[index])) {
|
|
180
|
+
return consumeStringControl(value, index + 1, value[index] === OPERATING_SYSTEM_COMMAND);
|
|
181
|
+
}
|
|
182
|
+
const next = value[index + 1];
|
|
183
|
+
if (next === undefined) {
|
|
184
|
+
return index + 1;
|
|
185
|
+
}
|
|
186
|
+
if (next === '[') {
|
|
187
|
+
return consumeCsi(value, index + 2, style);
|
|
188
|
+
}
|
|
189
|
+
if (next === ']') {
|
|
190
|
+
return consumeStringControl(value, index + 2, true);
|
|
191
|
+
}
|
|
192
|
+
if (next === 'P' || next === '^' || next === '_' || next === 'X') {
|
|
193
|
+
return consumeStringControl(value, index + 2, false);
|
|
194
|
+
}
|
|
195
|
+
let cursor = index + 1;
|
|
196
|
+
while (cursor < value.length && isAnsiIntermediateByte(value[cursor])) {
|
|
197
|
+
cursor += 1;
|
|
198
|
+
}
|
|
199
|
+
return Math.min(cursor + 1, value.length);
|
|
200
|
+
}
|
|
201
|
+
function parseLogContent(value) {
|
|
202
|
+
const lines = [];
|
|
203
|
+
let segments = [];
|
|
204
|
+
const style = {};
|
|
205
|
+
let textStart = 0;
|
|
206
|
+
let index = 0;
|
|
207
|
+
while (index < value.length) {
|
|
208
|
+
const character = value[index];
|
|
209
|
+
if (character === '\n') {
|
|
210
|
+
appendSegment(segments, sanitizeTextChunk(value.slice(textStart, index)), style);
|
|
211
|
+
pushLine(lines, segments);
|
|
212
|
+
segments = [];
|
|
213
|
+
index += 1;
|
|
214
|
+
textStart = index;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (character === ESCAPE || character === CSI || isStringControl(character)) {
|
|
218
|
+
appendSegment(segments, sanitizeTextChunk(value.slice(textStart, index)), style);
|
|
219
|
+
index = consumeEscape(value, index, style);
|
|
220
|
+
textStart = index;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
index += 1;
|
|
224
|
+
}
|
|
225
|
+
appendSegment(segments, sanitizeTextChunk(value.slice(textStart)), style);
|
|
226
|
+
pushLine(lines, segments);
|
|
227
|
+
return lines;
|
|
228
|
+
}
|
|
229
|
+
function plainLine(text, style = {}) {
|
|
230
|
+
return { segments: [{ ...style, text }] };
|
|
231
|
+
}
|
|
232
|
+
function getLineText(line) {
|
|
233
|
+
return line.segments.map(segment => segment.text).join('');
|
|
234
|
+
}
|
|
235
|
+
export function buildLogLines(logs) {
|
|
236
|
+
if (logs.length === 0) {
|
|
237
|
+
return [plainLine('No *.log files yet.', { dimColor: true })];
|
|
238
|
+
}
|
|
239
|
+
const lines = [];
|
|
240
|
+
for (const [index, log] of logs.entries()) {
|
|
241
|
+
if (index > 0) {
|
|
242
|
+
lines.push(plainLine(' ', { dimColor: true }));
|
|
243
|
+
}
|
|
244
|
+
lines.push(plainLine(`[${log.name}]`, { color: 'cyan' }));
|
|
245
|
+
lines.push(...parseLogContent(log.content.length > 0 ? log.content : '(empty)'));
|
|
246
|
+
}
|
|
247
|
+
return lines;
|
|
248
|
+
}
|
|
249
|
+
export function LogPanel({ logs, width, height, scrollOffset = 0, title = 'Logs (*.log · tail 120)', }) {
|
|
250
|
+
const lines = buildLogLines(logs);
|
|
251
|
+
const viewport = sliceTailViewport(lines, height === undefined ? lines.length : height - 3, scrollOffset);
|
|
252
|
+
const contentViewportHeight = viewport.viewportHeight;
|
|
253
|
+
const startIndex = viewport.startIndex;
|
|
254
|
+
const visibleLines = viewport.visibleItems;
|
|
255
|
+
const showScrollbar = height !== undefined && lines.length > contentViewportHeight;
|
|
256
|
+
const scrollbarThumbRows = showScrollbar
|
|
257
|
+
? getScrollbarThumbRows(lines.length, contentViewportHeight, viewport.topScrollOffset)
|
|
258
|
+
: new Set();
|
|
259
|
+
return (_jsxs(Box, { width: width, height: height, borderStyle: "round", borderColor: "yellow", flexDirection: "column", paddingX: 1, overflow: "hidden", children: [_jsx(Text, { bold: true, color: "yellow", wrap: "truncate-end", children: title }), _jsx(Box, { height: contentViewportHeight, flexDirection: "column", overflow: "hidden", children: visibleLines.map((line, index) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexGrow: 1, flexShrink: 1, children: _jsx(Text, { wrap: "truncate-end", children: line.segments.map((segment, segmentIndex) => (_jsx(Text, { color: segment.color, dimColor: segment.dimColor, bold: segment.bold, children: segment.text }, `${segmentIndex}-${segment.text}`))) }) }), showScrollbar ? (_jsx(Text, { color: scrollbarThumbRows.has(index) ? 'yellow' : 'gray', dimColor: !scrollbarThumbRows.has(index), children: scrollbarThumbRows.has(index) ? '█' : '│' })) : null] }, `${startIndex + index}-${getLineText(line)}`))) })] }));
|
|
260
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { AppRow } from '../core/runtime.js';
|
|
2
|
-
export declare function WorktreeList({ rows, selectedIndex, width, stacked, }: {
|
|
2
|
+
export declare function WorktreeList({ rows, selectedIndex, width, height, stacked, scrollOffset, }: {
|
|
3
3
|
rows: AppRow[];
|
|
4
4
|
selectedIndex: number;
|
|
5
5
|
width?: number;
|
|
6
|
+
height?: number;
|
|
6
7
|
stacked: boolean;
|
|
8
|
+
scrollOffset?: number;
|
|
7
9
|
}): import("react").JSX.Element;
|
|
@@ -1,45 +1,30 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
+
import { projectWorktreeListRow, sanitizeInlineText } from '../core/worktree-projection.js';
|
|
4
|
+
import { getScrollbarThumbRows, sliceListViewport } from '../terminal/viewport.js';
|
|
3
5
|
const MIN_BRANCH_WIDTH = 24;
|
|
4
|
-
function
|
|
5
|
-
|
|
6
|
-
.replace(/[\r\n\t\u2028\u2029]+/g, ' ')
|
|
7
|
-
.replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
|
|
8
|
-
.replace(/\p{Cf}/gu, '')
|
|
9
|
-
.replace(/\s+/g, ' ')
|
|
10
|
-
.trim();
|
|
11
|
-
}
|
|
12
|
-
function getIndicator(row) {
|
|
13
|
-
if (row.tags.includes('active')) {
|
|
6
|
+
function getIndicator(state) {
|
|
7
|
+
if (state === 'active') {
|
|
14
8
|
return '*';
|
|
15
9
|
}
|
|
16
|
-
if (
|
|
10
|
+
if (state === 'invalid') {
|
|
17
11
|
return '!';
|
|
18
12
|
}
|
|
19
|
-
if (
|
|
13
|
+
if (state === 'external') {
|
|
20
14
|
return '^';
|
|
21
15
|
}
|
|
22
|
-
if (row.tags.includes('main')) {
|
|
23
|
-
return '#';
|
|
24
|
-
}
|
|
25
16
|
return '-';
|
|
26
17
|
}
|
|
27
|
-
function getRowColor(
|
|
28
|
-
if (
|
|
18
|
+
function getRowColor(projection) {
|
|
19
|
+
if (projection.state === 'active') {
|
|
29
20
|
return 'green';
|
|
30
21
|
}
|
|
31
|
-
if (isSelected) {
|
|
22
|
+
if (projection.isSelected) {
|
|
32
23
|
return 'cyan';
|
|
33
24
|
}
|
|
34
|
-
if (
|
|
25
|
+
if (projection.state === 'invalid') {
|
|
35
26
|
return 'red';
|
|
36
27
|
}
|
|
37
|
-
if (row.tags.includes('external')) {
|
|
38
|
-
return 'yellow';
|
|
39
|
-
}
|
|
40
|
-
if (row.tags.includes('main')) {
|
|
41
|
-
return 'blue';
|
|
42
|
-
}
|
|
43
28
|
return undefined;
|
|
44
29
|
}
|
|
45
30
|
function truncateLabel(value, width) {
|
|
@@ -48,11 +33,21 @@ function truncateLabel(value, width) {
|
|
|
48
33
|
}
|
|
49
34
|
return `${value.slice(0, Math.max(width - 1, 0))}…`;
|
|
50
35
|
}
|
|
51
|
-
export function WorktreeList({ rows, selectedIndex, width, stacked, }) {
|
|
36
|
+
export function WorktreeList({ rows, selectedIndex, width, height, stacked, scrollOffset = 0, }) {
|
|
52
37
|
const branchWidth = Math.max(MIN_BRANCH_WIDTH, (width ?? 34) - 7);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
38
|
+
const viewport = sliceListViewport(rows, height === undefined ? rows.length : height - 3, scrollOffset);
|
|
39
|
+
const contentViewportHeight = viewport.viewportHeight;
|
|
40
|
+
const effectiveScrollOffset = viewport.scrollOffset;
|
|
41
|
+
const visibleRows = viewport.visibleItems;
|
|
42
|
+
const showScrollbar = height !== undefined && rows.length > contentViewportHeight;
|
|
43
|
+
const scrollbarThumbRows = showScrollbar ? getScrollbarThumbRows(rows.length, contentViewportHeight, effectiveScrollOffset) : new Set();
|
|
44
|
+
return (_jsxs(Box, { width: width, height: height, flexGrow: stacked ? 0 : 1, marginRight: stacked ? 0 : 1, borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, overflowY: "hidden", children: [_jsx(Text, { bold: true, color: "cyan", children: "Worktrees" }), visibleRows.map((row, index) => {
|
|
45
|
+
const isSelected = index + effectiveScrollOffset === selectedIndex;
|
|
46
|
+
const projection = projectWorktreeListRow(row, isSelected);
|
|
47
|
+
const tagSuffix = projection.isMain ? ' [root]' : '';
|
|
48
|
+
const color = getRowColor(projection);
|
|
49
|
+
const branchText = sanitizeInlineText(row.branch);
|
|
50
|
+
const line = `${isSelected ? '>' : ' '} ${getIndicator(projection.state)} ${truncateLabel(branchText, Math.max(1, branchWidth - tagSuffix.length))}${tagSuffix}`;
|
|
51
|
+
return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexGrow: 1, flexShrink: 1, children: _jsx(Text, { color: color, dimColor: !isSelected && color === undefined, bold: projection.state === 'active', wrap: "truncate-end", children: line }, row.path) }), showScrollbar ? (_jsx(Text, { color: scrollbarThumbRows.has(index) ? 'cyan' : 'gray', dimColor: !scrollbarThumbRows.has(index), children: scrollbarThumbRows.has(index) ? '█' : '│' })) : null] }, row.path));
|
|
57
52
|
})] }));
|
|
58
53
|
}
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
interface CommandLogOptions {
|
|
2
|
+
command: string[];
|
|
3
|
+
cwd: string;
|
|
4
|
+
logsDir: string;
|
|
5
|
+
logFileBase: string;
|
|
6
|
+
errorLabel?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function runCommandToLog({ command, cwd, logsDir, logFileBase, errorLabel, }: CommandLogOptions): Promise<{
|
|
9
|
+
logPath: string;
|
|
10
|
+
}>;
|
|
1
11
|
export declare function startDetachedCommand({ command, cwd, logsDir, logFileBase, }: {
|
|
2
12
|
command: string[];
|
|
3
13
|
cwd: string;
|
|
@@ -8,3 +18,4 @@ export declare function startDetachedCommand({ command, cwd, logsDir, logFileBas
|
|
|
8
18
|
pgid: number;
|
|
9
19
|
logPath: string;
|
|
10
20
|
}>;
|
|
21
|
+
export {};
|
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
import { closeSync, mkdirSync, openSync } from 'node:fs';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
function getLogEnvironment() {
|
|
5
|
+
return {
|
|
6
|
+
...process.env,
|
|
7
|
+
FORCE_COLOR: process.env.FORCE_COLOR ?? '1',
|
|
8
|
+
CLICOLOR_FORCE: process.env.CLICOLOR_FORCE ?? '1',
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export function runCommandToLog({ command, cwd, logsDir, logFileBase, errorLabel = 'setup command', }) {
|
|
12
|
+
mkdirSync(logsDir, { recursive: true });
|
|
13
|
+
const logPath = path.join(logsDir, `${logFileBase}.log`);
|
|
14
|
+
const fd = openSync(logPath, 'a');
|
|
15
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
16
|
+
let settled = false;
|
|
17
|
+
const finalize = () => {
|
|
18
|
+
if (settled) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
settled = true;
|
|
22
|
+
closeSync(fd);
|
|
23
|
+
};
|
|
24
|
+
const child = spawn(command[0], command.slice(1), {
|
|
25
|
+
cwd,
|
|
26
|
+
env: getLogEnvironment(),
|
|
27
|
+
stdio: ['ignore', fd, fd],
|
|
28
|
+
});
|
|
29
|
+
child.once('error', error => {
|
|
30
|
+
finalize();
|
|
31
|
+
reject(error);
|
|
32
|
+
});
|
|
33
|
+
child.once('exit', (code, signal) => {
|
|
34
|
+
finalize();
|
|
35
|
+
if (code === 0) {
|
|
36
|
+
resolve({ logPath });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (code !== null) {
|
|
40
|
+
reject(new Error(`${errorLabel} exited with code ${code}; see ${logPath}`));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
reject(new Error(`${errorLabel} exited due to signal ${signal ?? 'unknown'}; see ${logPath}`));
|
|
44
|
+
});
|
|
45
|
+
return promise;
|
|
46
|
+
}
|
|
4
47
|
export function startDetachedCommand({ command, cwd, logsDir, logFileBase, }) {
|
|
5
48
|
mkdirSync(logsDir, { recursive: true });
|
|
6
49
|
const logPath = path.join(logsDir, `${logFileBase}.log`);
|
|
@@ -10,6 +53,7 @@ export function startDetachedCommand({ command, cwd, logsDir, logFileBase, }) {
|
|
|
10
53
|
const child = spawn(command[0], command.slice(1), {
|
|
11
54
|
cwd,
|
|
12
55
|
detached: true,
|
|
56
|
+
env: getLogEnvironment(),
|
|
13
57
|
stdio: ['ignore', fd, fd],
|
|
14
58
|
});
|
|
15
59
|
const finalize = () => {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type ToolConfig } from './config.js';
|
|
2
|
+
export interface DefaultToolConfigOptions {
|
|
3
|
+
namespaceSeed: string;
|
|
4
|
+
packageManager: 'bun' | 'pnpm' | 'yarn' | 'npm';
|
|
5
|
+
script: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ConfigInitResult {
|
|
8
|
+
path: string;
|
|
9
|
+
config: ToolConfig;
|
|
10
|
+
}
|
|
11
|
+
export interface ConfigInitOptions {
|
|
12
|
+
workspaceRoot: string;
|
|
13
|
+
force: boolean;
|
|
14
|
+
config: ToolConfig;
|
|
15
|
+
}
|
|
16
|
+
export declare function isSafeNamespace(value: unknown): value is string;
|
|
17
|
+
export declare function toSafeNamespace(value: string): string;
|
|
18
|
+
export declare function validateToolConfig(raw: unknown): ToolConfig;
|
|
19
|
+
export declare function createDefaultToolConfig(options: DefaultToolConfigOptions): ToolConfig;
|
|
20
|
+
export declare function loadToolConfig({ repoRoot }: {
|
|
21
|
+
repoRoot: string;
|
|
22
|
+
}): Promise<ToolConfig>;
|
|
23
|
+
export declare function renderConfigJsonc(config: ToolConfig): string;
|
|
24
|
+
export declare function findExistingConfigPath(workspaceRoot: string, fileExists: (filePath: string) => Promise<boolean>): Promise<string | null>;
|
|
25
|
+
export declare function writeToolConfigForRepo({ workspaceRoot, force, config }: ConfigInitOptions, fileExists: (filePath: string) => Promise<boolean>): Promise<ConfigInitResult>;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { CONFIG_FILE_NAME, CONFIG_FILE_NAMES, parseJsonc } from './config.js';
|
|
4
|
+
const SAFE_NAMESPACE_PATTERN = /^[A-Za-z0-9._-]+$/u;
|
|
5
|
+
const UNSAFE_NAMESPACE_RUN_PATTERN = /[^A-Za-z0-9._-]+/gu;
|
|
6
|
+
const LEADING_NAMESPACE_HYPHENS_PATTERN = /^-+/u;
|
|
7
|
+
const TRAILING_NAMESPACE_HYPHENS_PATTERN = /-+$/u;
|
|
8
|
+
const SAFE_NAMESPACE_DESCRIPTION = '[A-Za-z0-9._-]+';
|
|
9
|
+
const DEFAULT_NAMESPACE = 'worktree-command-tui';
|
|
10
|
+
function isNonEmptyString(value) {
|
|
11
|
+
return typeof value === 'string' && value.length > 0;
|
|
12
|
+
}
|
|
13
|
+
export function isSafeNamespace(value) {
|
|
14
|
+
return isNonEmptyString(value) && SAFE_NAMESPACE_PATTERN.test(value);
|
|
15
|
+
}
|
|
16
|
+
export function toSafeNamespace(value) {
|
|
17
|
+
const replaced = value.replace(UNSAFE_NAMESPACE_RUN_PATTERN, '-');
|
|
18
|
+
const trimmed = replaced.replace(LEADING_NAMESPACE_HYPHENS_PATTERN, '').replace(TRAILING_NAMESPACE_HYPHENS_PATTERN, '');
|
|
19
|
+
return isSafeNamespace(trimmed) ? trimmed : DEFAULT_NAMESPACE;
|
|
20
|
+
}
|
|
21
|
+
function readStringList(value, fieldName) {
|
|
22
|
+
if (value === undefined) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
if (!Array.isArray(value) || value.some(item => !isNonEmptyString(item))) {
|
|
26
|
+
throw new Error(`${fieldName} must be a string array`);
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
function readRequiredCommand(value, fieldName) {
|
|
31
|
+
if (!Array.isArray(value) || value.length === 0 || value.some(part => !isNonEmptyString(part))) {
|
|
32
|
+
throw new Error(`${fieldName} must be a non-empty string array`);
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
function readOptionalCommand(value, fieldName) {
|
|
37
|
+
if (value === undefined) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
if (!Array.isArray(value) || value.length === 0 || value.some(part => !isNonEmptyString(part))) {
|
|
41
|
+
throw new Error(`${fieldName} must be a non-empty string array when set`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
function readPort(value) {
|
|
46
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535) {
|
|
47
|
+
throw new Error('port must be an integer between 1 and 65535');
|
|
48
|
+
}
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
export function validateToolConfig(raw) {
|
|
52
|
+
const config = (raw ?? {});
|
|
53
|
+
const command = readRequiredCommand(config.command, 'command');
|
|
54
|
+
if (!isSafeNamespace(config.namespace)) {
|
|
55
|
+
throw new Error(`namespace must match ${SAFE_NAMESPACE_DESCRIPTION}`);
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
namespace: config.namespace,
|
|
59
|
+
command,
|
|
60
|
+
setupCommand: readOptionalCommand(config.setupCommand, 'setupCommand'),
|
|
61
|
+
editorCommand: readOptionalCommand(config.editorCommand, 'editorCommand'),
|
|
62
|
+
port: readPort(config.port),
|
|
63
|
+
requiredFiles: readStringList(config.requiredFiles, 'requiredFiles'),
|
|
64
|
+
orphanMatchers: readStringList(config.orphanMatchers, 'orphanMatchers'),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export function createDefaultToolConfig(options) {
|
|
68
|
+
return validateToolConfig({
|
|
69
|
+
namespace: toSafeNamespace(options.namespaceSeed),
|
|
70
|
+
command: [options.packageManager, 'run', options.script],
|
|
71
|
+
setupCommand: [options.packageManager, 'install'],
|
|
72
|
+
editorCommand: ['code'],
|
|
73
|
+
port: 3000,
|
|
74
|
+
requiredFiles: ['package.json'],
|
|
75
|
+
orphanMatchers: [],
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
async function readFirstConfig(repoRoot) {
|
|
79
|
+
let firstError;
|
|
80
|
+
for (const fileName of CONFIG_FILE_NAMES) {
|
|
81
|
+
try {
|
|
82
|
+
return await readFile(path.join(repoRoot, fileName), 'utf8');
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
firstError ??= error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
throw firstError;
|
|
89
|
+
}
|
|
90
|
+
export async function loadToolConfig({ repoRoot }) {
|
|
91
|
+
return validateToolConfig(parseJsonc(await readFirstConfig(repoRoot)));
|
|
92
|
+
}
|
|
93
|
+
export function renderConfigJsonc(config) {
|
|
94
|
+
const setupCommandSection = config.setupCommand === undefined ? '' : `
|
|
95
|
+
// Optional command run manually with the setup key in the selected worktree.
|
|
96
|
+
// Useful for first-time dependency installation without doing it on every switch.
|
|
97
|
+
"setupCommand": ${JSON.stringify(config.setupCommand)},
|
|
98
|
+
`;
|
|
99
|
+
const editorCommandSection = config.editorCommand === undefined ? '' : `
|
|
100
|
+
// Optional command that opens the selected worktree path in an editor.
|
|
101
|
+
// The selected worktree path is appended as the final argv entry.
|
|
102
|
+
"editorCommand": ${JSON.stringify(config.editorCommand)},
|
|
103
|
+
`;
|
|
104
|
+
return `{
|
|
105
|
+
// Session namespace used for git-common-dir state files and logs.
|
|
106
|
+
// Keep this filesystem-safe: letters, numbers, dots, underscores, and hyphens only.
|
|
107
|
+
"namespace": ${JSON.stringify(config.namespace)},
|
|
108
|
+
|
|
109
|
+
// Command launched in the selected worktree.
|
|
110
|
+
// Use argv form so spaces and shell metacharacters are passed safely.
|
|
111
|
+
"command": ${JSON.stringify(config.command)},
|
|
112
|
+
${setupCommandSection}${editorCommandSection}
|
|
113
|
+
// TCP port owned by the command, used when stopping stale/orphaned processes.
|
|
114
|
+
"port": ${JSON.stringify(config.port)},
|
|
115
|
+
|
|
116
|
+
// Files that must exist in a worktree before the command can be started there.
|
|
117
|
+
"requiredFiles": ${JSON.stringify(config.requiredFiles)},
|
|
118
|
+
|
|
119
|
+
// Extra process command-line substrings treated as orphans for cleanup.
|
|
120
|
+
// Example: ["node --watch", "vite --host 0.0.0.0"]
|
|
121
|
+
"orphanMatchers": ${JSON.stringify(config.orphanMatchers)},
|
|
122
|
+
}
|
|
123
|
+
`;
|
|
124
|
+
}
|
|
125
|
+
export async function findExistingConfigPath(workspaceRoot, fileExists) {
|
|
126
|
+
for (const fileName of CONFIG_FILE_NAMES) {
|
|
127
|
+
const configPath = path.join(workspaceRoot, fileName);
|
|
128
|
+
if (await fileExists(configPath)) {
|
|
129
|
+
return configPath;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
export async function writeToolConfigForRepo({ workspaceRoot, force, config }, fileExists) {
|
|
135
|
+
const configPath = path.join(workspaceRoot, CONFIG_FILE_NAME);
|
|
136
|
+
const existingConfigPath = await findExistingConfigPath(workspaceRoot, fileExists);
|
|
137
|
+
if (!force && existingConfigPath) {
|
|
138
|
+
throw new Error(`Config file already exists: ${existingConfigPath}`);
|
|
139
|
+
}
|
|
140
|
+
const validatedConfig = validateToolConfig(config);
|
|
141
|
+
await writeFile(configPath, renderConfigJsonc(validatedConfig), 'utf8');
|
|
142
|
+
return { path: configPath, config: validatedConfig };
|
|
143
|
+
}
|
package/dist/core/config.d.ts
CHANGED
|
@@ -4,11 +4,10 @@ export declare const CONFIG_FILE_NAMES: readonly [".worktree-command-tui.jsonc",
|
|
|
4
4
|
export interface ToolConfig {
|
|
5
5
|
namespace: string;
|
|
6
6
|
command: string[];
|
|
7
|
+
setupCommand?: string[];
|
|
8
|
+
editorCommand?: string[];
|
|
7
9
|
port: number;
|
|
8
10
|
requiredFiles: string[];
|
|
9
11
|
orphanMatchers: string[];
|
|
10
12
|
}
|
|
11
13
|
export declare function parseJsonc(source: string): unknown;
|
|
12
|
-
export declare function loadToolConfig({ repoRoot }: {
|
|
13
|
-
repoRoot: string;
|
|
14
|
-
}): Promise<ToolConfig>;
|
package/dist/core/config.js
CHANGED
|
@@ -1,23 +1,6 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
1
|
export const CONFIG_FILE_NAME = '.worktree-command-tui.jsonc';
|
|
4
2
|
export const LEGACY_CONFIG_FILE_NAME = '.worktree-command-tui.json';
|
|
5
3
|
export const CONFIG_FILE_NAMES = [CONFIG_FILE_NAME, LEGACY_CONFIG_FILE_NAME];
|
|
6
|
-
function isNonEmptyString(value) {
|
|
7
|
-
return typeof value === 'string' && value.length > 0;
|
|
8
|
-
}
|
|
9
|
-
function isSafeNamespace(value) {
|
|
10
|
-
return isNonEmptyString(value) && /^[A-Za-z0-9._-]+$/u.test(value);
|
|
11
|
-
}
|
|
12
|
-
function readStringList(value, fieldName) {
|
|
13
|
-
if (value === undefined) {
|
|
14
|
-
return [];
|
|
15
|
-
}
|
|
16
|
-
if (!Array.isArray(value) || value.some(item => !isNonEmptyString(item))) {
|
|
17
|
-
throw new Error(`${fieldName} must be a string array`);
|
|
18
|
-
}
|
|
19
|
-
return value;
|
|
20
|
-
}
|
|
21
4
|
function stripJsoncComments(source) {
|
|
22
5
|
let result = '';
|
|
23
6
|
let inString = false;
|
|
@@ -119,34 +102,3 @@ function stripTrailingCommas(source) {
|
|
|
119
102
|
export function parseJsonc(source) {
|
|
120
103
|
return JSON.parse(stripTrailingCommas(stripJsoncComments(source)));
|
|
121
104
|
}
|
|
122
|
-
async function readFirstConfig(repoRoot) {
|
|
123
|
-
let firstError;
|
|
124
|
-
for (const fileName of CONFIG_FILE_NAMES) {
|
|
125
|
-
try {
|
|
126
|
-
return await readFile(path.join(repoRoot, fileName), 'utf8');
|
|
127
|
-
}
|
|
128
|
-
catch (error) {
|
|
129
|
-
firstError ??= error;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
throw firstError;
|
|
133
|
-
}
|
|
134
|
-
export async function loadToolConfig({ repoRoot }) {
|
|
135
|
-
const raw = parseJsonc(await readFirstConfig(repoRoot));
|
|
136
|
-
if (!Array.isArray(raw.command) || raw.command.length === 0 || raw.command.some(part => !isNonEmptyString(part))) {
|
|
137
|
-
throw new Error('command must be a non-empty string array');
|
|
138
|
-
}
|
|
139
|
-
if (!isSafeNamespace(raw.namespace)) {
|
|
140
|
-
throw new Error('namespace must match [A-Za-z0-9._-]+');
|
|
141
|
-
}
|
|
142
|
-
if (typeof raw.port !== 'number' || !Number.isInteger(raw.port) || raw.port < 1 || raw.port > 65535) {
|
|
143
|
-
throw new Error('port must be an integer between 1 and 65535');
|
|
144
|
-
}
|
|
145
|
-
return {
|
|
146
|
-
namespace: raw.namespace,
|
|
147
|
-
command: raw.command,
|
|
148
|
-
port: raw.port,
|
|
149
|
-
requiredFiles: readStringList(raw.requiredFiles, 'requiredFiles'),
|
|
150
|
-
orphanMatchers: readStringList(raw.orphanMatchers, 'orphanMatchers'),
|
|
151
|
-
};
|
|
152
|
-
}
|