Skip to content

Commit 1a07371

Browse files
committed
feat(setup): add setup/teardown commands to project settings
Allow configuring shell commands that run automatically in each new worktree (setup) and before worktree removal (teardown). Commands support $PROJECT_ROOT and $WORKTREE variable substitution. A banner in TaskPanel shows live output and allows retry/skip on failure.
1 parent 514737b commit 1a07371

9 files changed

Lines changed: 611 additions & 128 deletions

File tree

electron/ipc/setup.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { spawn } from 'child_process';
2+
import type { BrowserWindow } from 'electron';
3+
4+
export async function runSetupCommands(
5+
win: BrowserWindow,
6+
args: { worktreePath: string; projectRoot: string; commands: string[]; channelId: string },
7+
): Promise<void> {
8+
const { worktreePath, projectRoot, commands, channelId } = args;
9+
10+
const expandVars = (cmd: string): string =>
11+
cmd
12+
.replace(/\$\{PROJECT_ROOT\}|\$PROJECT_ROOT\b/g, () => projectRoot)
13+
.replace(/\$\{WORKTREE\}|\$WORKTREE\b/g, () => worktreePath);
14+
15+
const send = (msg: string) => {
16+
if (!win.isDestroyed()) {
17+
win.webContents.send(`channel:${channelId}`, msg);
18+
}
19+
};
20+
21+
for (const raw of commands) {
22+
const cmd = expandVars(raw);
23+
send(`$ ${cmd}\n`);
24+
await new Promise<void>((resolve, reject) => {
25+
const proc = spawn(cmd, {
26+
shell: true,
27+
cwd: worktreePath,
28+
stdio: ['ignore', 'pipe', 'pipe'],
29+
});
30+
31+
proc.stdout?.on('data', (chunk: Buffer) => {
32+
send(chunk.toString('utf8'));
33+
});
34+
35+
proc.stderr?.on('data', (chunk: Buffer) => {
36+
send(chunk.toString('utf8'));
37+
});
38+
39+
let settled = false;
40+
proc.on('close', (code) => {
41+
if (settled) return;
42+
settled = true;
43+
if (code !== 0) {
44+
reject(new Error(`Command "${cmd}" exited with code ${code}`));
45+
} else {
46+
resolve();
47+
}
48+
});
49+
proc.on('error', (err) => {
50+
if (settled) return;
51+
settled = true;
52+
reject(new Error(`Failed to run "${cmd}": ${err.message}`));
53+
});
54+
});
55+
}
56+
}
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { createSignal, For, Show } from 'solid-js';
2+
import { theme } from '../lib/theme';
3+
4+
export interface CommandVariable {
5+
name: string;
6+
description: string;
7+
example: string;
8+
}
9+
10+
interface CommandListEditorProps {
11+
label: string;
12+
description?: string;
13+
placeholder: string;
14+
items: string[];
15+
onAdd: (item: string) => void;
16+
onRemove: (index: number) => void;
17+
variables?: CommandVariable[];
18+
}
19+
20+
export function CommandListEditor(props: CommandListEditorProps) {
21+
const [newItem, setNewItem] = createSignal('');
22+
let inputRef: HTMLInputElement | undefined;
23+
24+
function add() {
25+
const v = newItem().trim();
26+
if (!v) return;
27+
props.onAdd(v);
28+
setNewItem('');
29+
}
30+
31+
function insertVariable(varName: string) {
32+
if (!inputRef) return;
33+
const token = `$${varName}`;
34+
const start = inputRef.selectionStart ?? inputRef.value.length;
35+
const end = inputRef.selectionEnd ?? start;
36+
const before = inputRef.value.slice(0, start);
37+
const after = inputRef.value.slice(end);
38+
const updated = before + token + after;
39+
setNewItem(updated);
40+
// Restore cursor position after the inserted token
41+
requestAnimationFrame(() => {
42+
inputRef?.focus();
43+
const pos = start + token.length;
44+
inputRef?.setSelectionRange(pos, pos);
45+
});
46+
}
47+
48+
return (
49+
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '8px' }}>
50+
<label
51+
style={{
52+
'font-size': '11px',
53+
color: theme.fgMuted,
54+
'text-transform': 'uppercase',
55+
'letter-spacing': '0.05em',
56+
}}
57+
>
58+
{props.label}
59+
</label>
60+
<Show when={props.description}>
61+
<span style={{ 'font-size': '11px', color: theme.fgSubtle }}>{props.description}</span>
62+
</Show>
63+
<Show when={props.items.length > 0}>
64+
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '4px' }}>
65+
<For each={props.items}>
66+
{(item, i) => (
67+
<div
68+
style={{
69+
display: 'flex',
70+
'align-items': 'center',
71+
gap: '8px',
72+
padding: '4px 8px',
73+
background: theme.bgInput,
74+
'border-radius': '6px',
75+
border: `1px solid ${theme.border}`,
76+
}}
77+
>
78+
<span
79+
style={{
80+
flex: '1',
81+
'font-size': '11px',
82+
'font-family': "'JetBrains Mono', monospace",
83+
color: theme.fgSubtle,
84+
overflow: 'hidden',
85+
'text-overflow': 'ellipsis',
86+
'white-space': 'nowrap',
87+
}}
88+
>
89+
{item}
90+
</span>
91+
<button
92+
type="button"
93+
onClick={() => props.onRemove(i())}
94+
style={{
95+
background: 'transparent',
96+
border: 'none',
97+
color: theme.fgSubtle,
98+
cursor: 'pointer',
99+
padding: '2px',
100+
'line-height': '1',
101+
'flex-shrink': '0',
102+
}}
103+
title="Remove"
104+
>
105+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
106+
<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" />
107+
</svg>
108+
</button>
109+
</div>
110+
)}
111+
</For>
112+
</div>
113+
</Show>
114+
<div style={{ display: 'flex', gap: '6px' }}>
115+
<input
116+
ref={inputRef}
117+
class="input-field"
118+
type="text"
119+
value={newItem()}
120+
onInput={(e) => setNewItem(e.currentTarget.value)}
121+
onKeyDown={(e) => {
122+
if (e.key === 'Enter') {
123+
e.preventDefault();
124+
add();
125+
}
126+
}}
127+
placeholder={props.placeholder}
128+
style={{
129+
flex: '1',
130+
background: theme.bgInput,
131+
border: `1px solid ${theme.border}`,
132+
'border-radius': '8px',
133+
padding: '8px 12px',
134+
color: theme.fg,
135+
'font-size': '12px',
136+
'font-family': "'JetBrains Mono', monospace",
137+
outline: 'none',
138+
}}
139+
/>
140+
<button
141+
type="button"
142+
onClick={add}
143+
disabled={!newItem().trim()}
144+
style={{
145+
padding: '8px 14px',
146+
background: theme.bgInput,
147+
border: `1px solid ${theme.border}`,
148+
'border-radius': '8px',
149+
color: newItem().trim() ? theme.fg : theme.fgSubtle,
150+
cursor: newItem().trim() ? 'pointer' : 'not-allowed',
151+
'font-size': '12px',
152+
'flex-shrink': '0',
153+
}}
154+
>
155+
Add
156+
</button>
157+
</div>
158+
<Show when={props.variables && props.variables.length > 0}>
159+
<div style={{ display: 'flex', 'flex-wrap': 'wrap', gap: '6px', 'align-items': 'center' }}>
160+
<span style={{ 'font-size': '10px', color: theme.fgSubtle }}>Variables:</span>
161+
<For each={props.variables}>
162+
{(v) => <VariableChip variable={v} onInsert={() => insertVariable(v.name)} />}
163+
</For>
164+
</div>
165+
</Show>
166+
</div>
167+
);
168+
}
169+
170+
function VariableChip(props: { variable: CommandVariable; onInsert: () => void }) {
171+
const [showTooltip, setShowTooltip] = createSignal(false);
172+
173+
return (
174+
<div style={{ position: 'relative', display: 'inline-block' }}>
175+
<button
176+
type="button"
177+
onMouseEnter={() => setShowTooltip(true)}
178+
onMouseLeave={() => setShowTooltip(false)}
179+
onClick={(e) => {
180+
e.preventDefault();
181+
props.onInsert();
182+
}}
183+
style={{
184+
background: theme.bgInput,
185+
border: `1px solid ${theme.border}`,
186+
'border-radius': '4px',
187+
padding: '2px 6px',
188+
color: theme.accent,
189+
cursor: 'pointer',
190+
'font-size': '10px',
191+
'font-family': "'JetBrains Mono', monospace",
192+
'line-height': '1.4',
193+
}}
194+
>
195+
{'$' + props.variable.name}
196+
</button>
197+
<Show when={showTooltip()}>
198+
<div
199+
style={{
200+
position: 'absolute',
201+
bottom: 'calc(100% + 6px)',
202+
left: '0',
203+
background: theme.bgElevated,
204+
border: `1px solid ${theme.border}`,
205+
'border-radius': '6px',
206+
padding: '8px 10px',
207+
'z-index': '1000',
208+
'white-space': 'nowrap',
209+
'box-shadow': '0 4px 12px rgba(0,0,0,0.3)',
210+
display: 'flex',
211+
'flex-direction': 'column',
212+
gap: '4px',
213+
}}
214+
>
215+
<span style={{ 'font-size': '11px', color: theme.fg }}>{props.variable.description}</span>
216+
<span
217+
style={{
218+
'font-size': '10px',
219+
color: theme.fgSubtle,
220+
'font-family': "'JetBrains Mono', monospace",
221+
}}
222+
>
223+
e.g. {props.variable.example}
224+
</span>
225+
</div>
226+
</Show>
227+
</div>
228+
);
229+
}

0 commit comments

Comments
 (0)