diff --git a/electron/ipc/channels.ts b/electron/ipc/channels.ts index 7cc9225d..98cd203b 100644 --- a/electron/ipc/channels.ts +++ b/electron/ipc/channels.ts @@ -21,7 +21,7 @@ export enum IPC { GetAllFileDiffsFromBranch = 'get_all_file_diffs_from_branch', GetFileDiff = 'get_file_diff', GetFileDiffFromBranch = 'get_file_diff_from_branch', - GetGitignoredDirs = 'get_gitignored_dirs', + ListProjectEntries = 'list_project_entries', GetWorktreeStatus = 'get_worktree_status', CheckMergeStatus = 'check_merge_status', MergeTask = 'merge_task', @@ -82,6 +82,9 @@ export enum IPC { PlanContent = 'plan_content', ReadPlanContent = 'read_plan_content', + // Setup + RunSetupCommands = 'run_setup_commands', + // Ask about code AskAboutCode = 'ask_about_code', CancelAskAboutCode = 'cancel_ask_about_code', diff --git a/electron/ipc/git.ts b/electron/ipc/git.ts index e633324f..dc6f71fd 100644 --- a/electron/ipc/git.ts +++ b/electron/ipc/git.ts @@ -64,23 +64,6 @@ function withWorktreeLock(key: string, fn: () => Promise): Promise { return next; } -// --- Symlink candidates --- - -const SYMLINK_CANDIDATES = [ - '.claude', - '.cursor', - '.aider', - '.copilot', - '.codeium', - '.continue', - '.windsurf', - '.env', - 'node_modules', -]; - -/** Entries inside `.claude` that must NOT be symlinked (kept per-worktree). */ -const CLAUDE_DIR_EXCLUDE = new Set(['plans', 'settings.local.json']); - // --- Internal helpers --- async function detectMainBranch(repoRoot: string): Promise { @@ -303,33 +286,6 @@ async function computeBranchDiffStats( return { linesAdded, linesRemoved }; } -/** - * "Shallow-symlink" a directory: create a real directory at `target` and - * symlink each entry from `source` into it, EXCEPT entries in `exclude`. - */ -function shallowSymlinkDir(source: string, target: string, exclude: Set): void { - fs.mkdirSync(target, { recursive: true }); - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(source, { withFileTypes: true }); - } catch (err) { - console.warn(`Failed to read directory ${source} for shallow-symlink:`, err); - return; - } - for (const entry of entries) { - if (exclude.has(entry.name)) continue; - const src = path.join(source, entry.name); - const dst = path.join(target, entry.name); - try { - if (!fs.existsSync(dst)) { - fs.symlinkSync(src, dst); - } - } catch { - /* ignore */ - } - } -} - // --- Public functions (used by tasks.ts and register.ts) --- export async function createWorktree( @@ -365,21 +321,19 @@ export async function createWorktree( await exec('git', ['worktree', 'add', '-b', branchName, worktreePath], { cwd: repoRoot }); // Symlink selected directories + const resolvedRoot = path.resolve(repoRoot) + path.sep; + const resolvedWorktree = path.resolve(worktreePath) + path.sep; for (const name of symlinkDirs) { - // Reject names that could escape the worktree directory - if (name.includes('/') || name.includes('\\') || name.includes('..') || name === '.') continue; + if (name.includes('..')) continue; const source = path.join(repoRoot, name); const target = path.join(worktreePath, name); + if (!path.resolve(source).startsWith(resolvedRoot)) continue; + if (!path.resolve(target).startsWith(resolvedWorktree)) continue; try { if (!fs.existsSync(source)) continue; if (fs.existsSync(target)) continue; - - if (name === '.claude') { - // Shallow-symlink: real dir with per-entry symlinks, excluding per-worktree entries - shallowSymlinkDir(source, target, CLAUDE_DIR_EXCLUDE); - } else { - fs.symlinkSync(source, target); - } + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.symlinkSync(source, target); } catch { /* ignore */ } @@ -425,23 +379,24 @@ export async function removeWorktree( // --- IPC command functions --- -export async function getGitIgnoredDirs(projectRoot: string): Promise { - const results: string[] = []; - for (const name of SYMLINK_CANDIDATES) { - const dirPath = path.join(projectRoot, name); - try { - fs.statSync(dirPath); // throws if entry doesn't exist - } catch { - continue; - } - try { - await exec('git', ['check-ignore', '-q', name], { cwd: projectRoot }); - results.push(name); - } catch { - /* not ignored */ - } +export function listProjectEntries( + projectRoot: string, + subpath?: string, +): { name: string; isDir: boolean }[] { + const HIDDEN = new Set(['.git', '.worktrees']); + const dir = subpath ? path.join(projectRoot, subpath) : projectRoot; + // Prevent traversal outside project root + const resolvedDir = path.resolve(dir); + const resolvedRoot = path.resolve(projectRoot); + if (resolvedDir !== resolvedRoot && !resolvedDir.startsWith(resolvedRoot + path.sep)) return []; + try { + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((e) => !HIDDEN.has(e.name)) + .map((e) => ({ name: e.name, isDir: e.isDirectory() })); + } catch { + return []; } - return results; } export async function getMainBranch(projectRoot: string): Promise { diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index 1a054d6d..0fa12945 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -16,7 +16,7 @@ import { import { ensurePlansDirectory, startPlanWatcher, readPlanForWorktree } from './plans.js'; import { startRemoteServer } from '../remote/server.js'; import { - getGitIgnoredDirs, + listProjectEntries, getMainBranch, getCurrentBranch, getChangedFiles, @@ -41,6 +41,7 @@ import { listAgents } from './agents.js'; import { saveAppState, loadAppState } from './persistence.js'; import { spawn } from 'child_process'; import { askAboutCode, cancelAskAboutCode } from './ask-code.js'; +import { runSetupCommands } from './setup.js'; import path from 'path'; import { assertString, @@ -173,9 +174,9 @@ export function registerAllHandlers(win: BrowserWindow): void { validateRelativePath(args.filePath, 'filePath'); return getFileDiffFromBranch(args.projectRoot, args.branchName, args.filePath); }); - ipcMain.handle(IPC.GetGitignoredDirs, (_e, args) => { + ipcMain.handle(IPC.ListProjectEntries, (_e, args) => { validatePath(args.projectRoot, 'projectRoot'); - return getGitIgnoredDirs(args.projectRoot); + return listProjectEntries(args.projectRoot, args.subpath); }); ipcMain.handle(IPC.GetWorktreeStatus, (_e, args) => { validatePath(args.worktreePath, 'worktreePath'); @@ -304,6 +305,20 @@ export function registerAllHandlers(win: BrowserWindow): void { return readPlanForWorktree(args.worktreePath, fileName); }); + // --- Setup commands --- + ipcMain.handle(IPC.RunSetupCommands, (_e, args) => { + validatePath(args.worktreePath, 'worktreePath'); + validatePath(args.projectRoot, 'projectRoot'); + assertStringArray(args.commands, 'commands'); + assertString(args.onOutput?.__CHANNEL_ID__, 'channelId'); + return runSetupCommands(win, { + worktreePath: args.worktreePath, + projectRoot: args.projectRoot, + commands: args.commands, + channelId: args.onOutput.__CHANNEL_ID__, + }); + }); + // --- Ask about code --- ipcMain.handle(IPC.AskAboutCode, (_e, args) => { assertString(args.requestId, 'requestId'); diff --git a/electron/ipc/setup.ts b/electron/ipc/setup.ts new file mode 100644 index 00000000..75280412 --- /dev/null +++ b/electron/ipc/setup.ts @@ -0,0 +1,56 @@ +import { spawn } from 'child_process'; +import type { BrowserWindow } from 'electron'; + +export async function runSetupCommands( + win: BrowserWindow, + args: { worktreePath: string; projectRoot: string; commands: string[]; channelId: string }, +): Promise { + const { worktreePath, projectRoot, commands, channelId } = args; + + const expandVars = (cmd: string): string => + cmd + .replace(/\$\{PROJECT_ROOT\}|\$PROJECT_ROOT\b/g, () => projectRoot) + .replace(/\$\{WORKTREE\}|\$WORKTREE\b/g, () => worktreePath); + + const send = (msg: string) => { + if (!win.isDestroyed()) { + win.webContents.send(`channel:${channelId}`, msg); + } + }; + + for (const raw of commands) { + const cmd = expandVars(raw); + send(`$ ${cmd}\n`); + await new Promise((resolve, reject) => { + const proc = spawn(cmd, { + shell: true, + cwd: worktreePath, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + proc.stdout?.on('data', (chunk: Buffer) => { + send(chunk.toString('utf8')); + }); + + proc.stderr?.on('data', (chunk: Buffer) => { + send(chunk.toString('utf8')); + }); + + let settled = false; + proc.on('close', (code) => { + if (settled) return; + settled = true; + if (code !== 0) { + reject(new Error(`Command "${cmd}" exited with code ${code}`)); + } else { + resolve(); + } + }); + proc.on('error', (err) => { + if (settled) return; + settled = true; + reject(new Error(`Failed to run "${cmd}": ${err.message}`)); + }); + }); + } +} diff --git a/electron/preload.cjs b/electron/preload.cjs index eaf4f9eb..142918f5 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -24,7 +24,7 @@ const ALLOWED_CHANNELS = new Set([ 'get_file_diff_from_branch', 'get_all_file_diffs', 'get_all_file_diffs_from_branch', - 'get_gitignored_dirs', + 'list_project_entries', 'get_worktree_status', 'commit_all', 'discard_uncommitted', @@ -76,6 +76,8 @@ const ALLOWED_CHANNELS = new Set([ 'get_remote_status', // Plan 'plan_content', + // Setup + 'run_setup_commands', // Ask about code 'ask_about_code', 'cancel_ask_about_code', diff --git a/src/components/CommandListEditor.tsx b/src/components/CommandListEditor.tsx new file mode 100644 index 00000000..8df8ad1b --- /dev/null +++ b/src/components/CommandListEditor.tsx @@ -0,0 +1,229 @@ +import { createSignal, For, Show } from 'solid-js'; +import { theme } from '../lib/theme'; + +export interface CommandVariable { + name: string; + description: string; + example: string; +} + +interface CommandListEditorProps { + label: string; + description?: string; + placeholder: string; + items: string[]; + onAdd: (item: string) => void; + onRemove: (index: number) => void; + variables?: CommandVariable[]; +} + +export function CommandListEditor(props: CommandListEditorProps) { + const [newItem, setNewItem] = createSignal(''); + let inputRef: HTMLInputElement | undefined; + + function add() { + const v = newItem().trim(); + if (!v) return; + props.onAdd(v); + setNewItem(''); + } + + function insertVariable(varName: string) { + if (!inputRef) return; + const token = `$${varName}`; + const start = inputRef.selectionStart ?? inputRef.value.length; + const end = inputRef.selectionEnd ?? start; + const before = inputRef.value.slice(0, start); + const after = inputRef.value.slice(end); + const updated = before + token + after; + setNewItem(updated); + // Restore cursor position after the inserted token + requestAnimationFrame(() => { + inputRef?.focus(); + const pos = start + token.length; + inputRef?.setSelectionRange(pos, pos); + }); + } + + return ( +
+ + + {props.description} + + 0}> +
+ + {(item, i) => ( +
+ + {item} + + +
+ )} +
+
+
+
+ setNewItem(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + add(); + } + }} + placeholder={props.placeholder} + style={{ + flex: '1', + background: theme.bgInput, + border: `1px solid ${theme.border}`, + 'border-radius': '8px', + padding: '8px 12px', + color: theme.fg, + 'font-size': '12px', + 'font-family': "'JetBrains Mono', monospace", + outline: 'none', + }} + /> + +
+ 0}> +
+ Variables: + + {(v) => insertVariable(v.name)} />} + +
+
+
+ ); +} + +function VariableChip(props: { variable: CommandVariable; onInsert: () => void }) { + const [showTooltip, setShowTooltip] = createSignal(false); + + return ( +
+ + +
+ {props.variable.description} + + e.g. {props.variable.example} + +
+
+
+ ); +} diff --git a/src/components/EditProjectDialog.tsx b/src/components/EditProjectDialog.tsx index 350d9688..c76fc36e 100644 --- a/src/components/EditProjectDialog.tsx +++ b/src/components/EditProjectDialog.tsx @@ -9,7 +9,9 @@ import { } from '../store/store'; import { sanitizeBranchPrefix, toBranchName } from '../lib/branch-name'; import { theme } from '../lib/theme'; -import type { Project, TerminalBookmark } from '../store/types'; +import { CommandListEditor, type CommandVariable } from './CommandListEditor'; +import { PathSelector } from './PathSelector'; +import type { Project } from '../store/types'; interface EditProjectDialogProps { project: Project | null; @@ -27,8 +29,10 @@ export function EditProjectDialog(props: EditProjectDialogProps) { const [branchPrefix, setBranchPrefix] = createSignal('task'); const [deleteBranchOnClose, setDeleteBranchOnClose] = createSignal(true); const [defaultDirectMode, setDefaultDirectMode] = createSignal(false); - const [bookmarks, setBookmarks] = createSignal([]); - const [newCommand, setNewCommand] = createSignal(''); + const [bookmarks, setBookmarks] = createSignal([]); + const [setupCommands, setSetupCommands] = createSignal([]); + const [teardownCommands, setTeardownCommands] = createSignal([]); + const [symlinkDirs, setSymlinkDirs] = createSignal([]); let nameRef!: HTMLInputElement; // Sync signals when project prop changes @@ -40,26 +44,25 @@ export function EditProjectDialog(props: EditProjectDialogProps) { setBranchPrefix(sanitizeBranchPrefix(p.branchPrefix ?? 'task')); setDeleteBranchOnClose(p.deleteBranchOnClose ?? true); setDefaultDirectMode(p.defaultDirectMode ?? false); - setBookmarks(p.terminalBookmarks ? [...p.terminalBookmarks] : []); - setNewCommand(''); + setBookmarks(p.terminalBookmarks ? p.terminalBookmarks.map((b) => b.command) : []); + setSetupCommands(p.setupCommands ? [...p.setupCommands] : []); + setTeardownCommands(p.teardownCommands ? [...p.teardownCommands] : []); + setSymlinkDirs(p.defaultSymlinkDirs ? [...p.defaultSymlinkDirs] : []); requestAnimationFrame(() => nameRef?.focus()); }); - function addBookmark() { - const cmd = newCommand().trim(); - if (!cmd) return; - const existing = bookmarks(); - const bookmark: TerminalBookmark = { - id: crypto.randomUUID(), - command: cmd, - }; - setBookmarks([...existing, bookmark]); - setNewCommand(''); - } - - function removeBookmark(id: string) { - setBookmarks(bookmarks().filter((b) => b.id !== id)); - } + const commandVariables = (projectPath: string): CommandVariable[] => [ + { + name: 'PROJECT_ROOT', + description: 'Root repository path', + example: projectPath, + }, + { + name: 'WORKTREE', + description: 'Current task worktree path', + example: `${projectPath}/.worktrees/task/my-task`, + }, + ]; const canSave = () => name().trim().length > 0; @@ -72,7 +75,10 @@ export function EditProjectDialog(props: EditProjectDialogProps) { branchPrefix: sanitizedPrefix, deleteBranchOnClose: deleteBranchOnClose(), defaultDirectMode: defaultDirectMode(), - terminalBookmarks: bookmarks(), + terminalBookmarks: bookmarks().map((cmd) => ({ id: crypto.randomUUID(), command: cmd })), + setupCommands: setupCommands().length > 0 ? setupCommands() : undefined, + teardownCommands: teardownCommands().length > 0 ? teardownCommands() : undefined, + defaultSymlinkDirs: symlinkDirs().length > 0 ? [...symlinkDirs()] : undefined, }); props.onClose(); } @@ -367,112 +373,45 @@ export function EditProjectDialog(props: EditProjectDialogProps) { {/* Command Bookmarks */} -
- - 0}> -
- - {(bookmark) => ( -
- - {bookmark.command} - - -
- )} -
-
-
-
- setNewCommand(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - addBookmark(); - } - }} - placeholder="e.g. npm run dev" - style={{ - flex: '1', - background: theme.bgInput, - border: `1px solid ${theme.border}`, - 'border-radius': '8px', - padding: '8px 12px', - color: theme.fg, - 'font-size': '12px', - 'font-family': "'JetBrains Mono', monospace", - outline: 'none', - }} - /> - -
-
+ setBookmarks([...bookmarks(), cmd])} + onRemove={(i) => setBookmarks(bookmarks().filter((_, idx) => idx !== i))} + /> + + {/* Setup Commands */} + setSetupCommands([...setupCommands(), cmd])} + onRemove={(i) => setSetupCommands(setupCommands().filter((_, idx) => idx !== i))} + variables={commandVariables(project().path)} + /> + + {/* Teardown Commands */} + setTeardownCommands([...teardownCommands(), cmd])} + onRemove={(i) => + setTeardownCommands(teardownCommands().filter((_, idx) => idx !== i)) + } + variables={commandVariables(project().path)} + /> + + {/* Default Symlink Dirs */} + setSymlinkDirs([...symlinkDirs(), dir])} + onRemove={(i) => setSymlinkDirs(symlinkDirs().filter((_, idx) => idx !== i))} + /> {/* Buttons */}
(null); const [error, setError] = createSignal(''); const [loading, setLoading] = createSignal(false); - const [ignoredDirs, setIgnoredDirs] = createSignal([]); - const [selectedDirs, setSelectedDirs] = createSignal>(new Set()); + const [symlinkDirs, setSymlinkDirs] = createSignal([]); const [directMode, setDirectMode] = createSignal(false); const [skipPermissions, setSkipPermissions] = createSignal(false); const [branchPrefix, setBranchPrefix] = createSignal(''); @@ -158,34 +158,18 @@ export function NewTaskDialog(props: NewTaskDialogProps) { }); }); - // Fetch gitignored dirs when project changes + // Load symlink dirs from project defaults createEffect(() => { const pid = selectedProjectId(); - const path = pid ? getProjectPath(pid) : undefined; - let cancelled = false; + const projectPath = pid ? getProjectPath(pid) : undefined; - if (!path) { - setIgnoredDirs([]); - setSelectedDirs(new Set()); + if (!projectPath) { + setSymlinkDirs([]); return; } - void (async () => { - try { - const dirs = await invoke(IPC.GetGitignoredDirs, { projectRoot: path }); - if (cancelled) return; - setIgnoredDirs(dirs); - setSelectedDirs(new Set(dirs)); // all checked by default - } catch { - if (cancelled) return; - setIgnoredDirs([]); - setSelectedDirs(new Set()); - } - })(); - - onCleanup(() => { - cancelled = true; - }); + const defaults = pid ? getProjectDefaultSymlinkDirs(pid) : undefined; + setSymlinkDirs(defaults ? [...defaults] : []); }); // Sync branch prefix when project changes @@ -302,7 +286,7 @@ export function NewTaskDialog(props: NewTaskDialogProps) { name: n, agentDef: agent, projectId, - symlinkDirs: [...selectedDirs()], + symlinkDirs: [...symlinkDirs()], initialPrompt: isFromDrop ? undefined : p, branchPrefixOverride: prefix, githubUrl: ghUrl, @@ -589,16 +573,12 @@ export function NewTaskDialog(props: NewTaskDialogProps) {
- 0 && !directMode()}> - { - const next = new Set(selectedDirs()); - if (next.has(dir)) next.delete(dir); - else next.add(dir); - setSelectedDirs(next); - }} + + setSymlinkDirs([...symlinkDirs(), dir])} + onRemove={(i) => setSymlinkDirs(symlinkDirs().filter((_, idx) => idx !== i))} /> diff --git a/src/components/PathSelector.tsx b/src/components/PathSelector.tsx new file mode 100644 index 00000000..453c0d35 --- /dev/null +++ b/src/components/PathSelector.tsx @@ -0,0 +1,304 @@ +import { createSignal, createEffect, createMemo, For, Show, onCleanup } from 'solid-js'; +import { invoke } from '../lib/ipc'; +import { IPC } from '../../electron/ipc/channels'; +import { theme } from '../lib/theme'; + +interface Entry { + name: string; + isDir: boolean; +} + +interface PathSelectorProps { + dirs: string[]; + projectRoot: string | undefined; + onAdd: (dir: string) => void; + onRemove: (index: number) => void; +} + +export function PathSelector(props: PathSelectorProps) { + const [query, setQuery] = createSignal(''); + const [showSuggestions, setShowSuggestions] = createSignal(false); + const [selectedIdx, setSelectedIdx] = createSignal(-1); + const [entries, setEntries] = createSignal([]); + const [dropdownPos, setDropdownPos] = createSignal({ top: 0, left: 0, width: 0 }); + let inputRef!: HTMLInputElement; + let suppressBlur = false; + + // Split query into directory prefix and filter part + const queryParts = () => { + const q = query(); + const lastSlash = q.lastIndexOf('/'); + if (lastSlash === -1) return { prefix: '', filter: q }; + return { prefix: q.slice(0, lastSlash + 1), filter: q.slice(lastSlash + 1) }; + }; + + // Fetch entries when prefix changes + createEffect(() => { + const root = props.projectRoot; + if (!root) { + setEntries([]); + return; + } + const { prefix } = queryParts(); + const subpath = prefix ? prefix.replace(/\/$/, '') : undefined; + let cancelled = false; + + void (async () => { + try { + const result = await invoke(IPC.ListProjectEntries, { + projectRoot: root, + subpath, + }); + if (!cancelled) setEntries(result); + } catch { + if (!cancelled) setEntries([]); + } + })(); + + onCleanup(() => { + cancelled = true; + }); + }); + + const filtered = createMemo(() => { + const { prefix, filter } = queryParts(); + const f = filter.toLowerCase(); + const added = new Set(props.dirs); + return entries() + .filter((e) => { + const fullPath = prefix + e.name; + return !added.has(fullPath) && (!f || e.name.toLowerCase().includes(f)); + }) + .sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name); + }) + .map((e) => ({ ...e, fullPath: prefix + e.name })); + }); + + function updateDropdownPos() { + if (!inputRef) return; + const rect = inputRef.getBoundingClientRect(); + setDropdownPos({ top: rect.bottom + 2, left: rect.left, width: rect.width }); + } + + function addDir(name: string) { + const trimmed = name.trim().replace(/\/$/, ''); + if (!trimmed || props.dirs.includes(trimmed)) return; + props.onAdd(trimmed); + setQuery(''); + setShowSuggestions(false); + setSelectedIdx(-1); + inputRef?.focus(); + } + + function selectItem(item: { fullPath: string; isDir: boolean }) { + if (item.isDir) { + setQuery(item.fullPath + '/'); + setSelectedIdx(-1); + setShowSuggestions(true); + updateDropdownPos(); + } else { + addDir(item.fullPath); + } + } + + function handleKeyDown(e: KeyboardEvent) { + const items = filtered(); + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIdx((i) => Math.min(i + 1, items.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIdx((i) => Math.max(i - 1, -1)); + } else if (e.key === 'Tab') { + const idx = selectedIdx(); + const item = idx >= 0 && idx < items.length ? items[idx] : items[0]; + if (item) { + e.preventDefault(); + suppressBlur = true; + selectItem(item); + requestAnimationFrame(() => { + inputRef?.focus(); + suppressBlur = false; + }); + } + } else if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + const idx = selectedIdx(); + if (idx >= 0 && idx < items.length) { + selectItem(items[idx]); + } else if (query().trim()) { + addDir(query()); + } + } else if (e.key === 'Escape') { + setShowSuggestions(false); + setSelectedIdx(-1); + } + } + + return ( +
+ +
+ + {(dir, index) => ( +
+ + {dir} + + +
+ )} +
+
+ { + setQuery(e.currentTarget.value); + setShowSuggestions(true); + setSelectedIdx(-1); + updateDropdownPos(); + }} + onFocus={() => { + updateDropdownPos(); + setShowSuggestions(true); + }} + onBlur={() => { + if (suppressBlur) return; + setTimeout(() => setShowSuggestions(false), 150); + }} + onKeyDown={handleKeyDown} + placeholder="Add path…" + style={{ + width: '100%', + 'box-sizing': 'border-box', + background: theme.bgInput, + border: `1px solid ${theme.border}`, + 'border-radius': '6px', + padding: '6px 10px', + color: theme.fg, + 'font-size': '11px', + 'font-family': "'JetBrains Mono', monospace", + outline: 'none', + }} + /> + 0}> +
+ + {(item, index) => ( +
{ + e.preventDefault(); + selectItem(item); + inputRef?.focus(); + }} + style={{ + padding: '6px 10px', + 'font-size': '11px', + 'font-family': "'JetBrains Mono', monospace", + color: theme.fg, + cursor: 'pointer', + background: index() === selectedIdx() ? theme.bgInput : 'transparent', + display: 'flex', + 'align-items': 'center', + gap: '6px', + }} + onMouseEnter={() => setSelectedIdx(index())} + > + + {item.isDir ? '/' : ''} + + {item.name} +
+ )} +
+
+
+
+
+
+ ); +} diff --git a/src/components/SetupBanner.tsx b/src/components/SetupBanner.tsx new file mode 100644 index 00000000..c2541e76 --- /dev/null +++ b/src/components/SetupBanner.tsx @@ -0,0 +1,117 @@ +import { Show, createEffect } from 'solid-js'; +import { theme } from '../lib/theme'; +import { retrySetup, skipSetup } from '../store/store'; +import type { Task } from '../store/types'; + +interface SetupBannerProps { + task: Task; +} + +export function SetupBanner(props: SetupBannerProps) { + let logRef: HTMLPreElement | undefined; + + // Auto-scroll log to bottom when content changes + createEffect(() => { + void props.task.setupLog; // track + if (logRef) logRef.scrollTop = logRef.scrollHeight; + }); + + return ( + +
+
+ + + Running setup commands... + + + Setup failed + + + {props.task.setupError} + + +
+ + +
+
+
+ +
+            {props.task.setupLog}
+          
+
+
+ +
+ ); +} diff --git a/src/components/SymlinkDirPicker.tsx b/src/components/SymlinkDirPicker.tsx deleted file mode 100644 index a0c4e8f3..00000000 --- a/src/components/SymlinkDirPicker.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { For } from 'solid-js'; -import { theme } from '../lib/theme'; - -interface SymlinkDirPickerProps { - dirs: string[]; - selectedDirs: Set; - onToggle: (dir: string) => void; -} - -export function SymlinkDirPicker(props: SymlinkDirPickerProps) { - return ( -
- -
- - {(dir) => { - const checked = () => props.selectedDirs.has(dir); - return ( - - ); - }} - -
-
- ); -} diff --git a/src/components/TaskPanel.tsx b/src/components/TaskPanel.tsx index 18f26aad..3fdeedce 100644 --- a/src/components/TaskPanel.tsx +++ b/src/components/TaskPanel.tsx @@ -44,6 +44,7 @@ import { PushDialog } from './PushDialog'; import { DiffViewerDialog } from './DiffViewerDialog'; import { PlanViewerDialog } from './PlanViewerDialog'; import { EditProjectDialog } from './EditProjectDialog'; +import { SetupBanner } from './SetupBanner'; import { theme } from '../lib/theme'; import { sf } from '../lib/fontScale'; import { mod, isMac } from '../lib/platform'; @@ -1033,6 +1034,7 @@ export function TaskPanel(props: TaskPanelProps) { : 'No prompts sent'} +
{(a) => ( diff --git a/src/store/projects.ts b/src/store/projects.ts index 3ce7cefd..a2263624 100644 --- a/src/store/projects.ts +++ b/src/store/projects.ts @@ -64,6 +64,9 @@ export function updateProject( | 'deleteBranchOnClose' | 'defaultDirectMode' | 'terminalBookmarks' + | 'setupCommands' + | 'teardownCommands' + | 'defaultSymlinkDirs' > >, ): void { @@ -81,6 +84,12 @@ export function updateProject( s.projects[idx].defaultDirectMode = updates.defaultDirectMode; if (updates.terminalBookmarks !== undefined) s.projects[idx].terminalBookmarks = updates.terminalBookmarks; + if (updates.setupCommands !== undefined) + s.projects[idx].setupCommands = updates.setupCommands; + if (updates.teardownCommands !== undefined) + s.projects[idx].teardownCommands = updates.teardownCommands; + if (updates.defaultSymlinkDirs !== undefined) + s.projects[idx].defaultSymlinkDirs = updates.defaultSymlinkDirs; }), ); } @@ -168,6 +177,20 @@ export async function relinkProject(projectId: string): Promise { return exists; } +export function getProjectTeardownCommands(projectId: string): string[] | undefined { + const cmds = store.projects.find((p) => p.id === projectId)?.teardownCommands; + return cmds && cmds.length > 0 ? cmds : undefined; +} + +export function getProjectSetupCommands(projectId: string): string[] | undefined { + const cmds = store.projects.find((p) => p.id === projectId)?.setupCommands; + return cmds && cmds.length > 0 ? cmds : undefined; +} + +export function getProjectDefaultSymlinkDirs(projectId: string): string[] | undefined { + return store.projects.find((p) => p.id === projectId)?.defaultSymlinkDirs; +} + export function isProjectMissing(projectId: string): boolean { return projectId in store.missingProjectIds; } diff --git a/src/store/store.ts b/src/store/store.ts index af9079d5..fc2675f5 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -12,6 +12,9 @@ export { validateProjectPaths, relinkProject, isProjectMissing, + getProjectSetupCommands, + getProjectTeardownCommands, + getProjectDefaultSymlinkDirs, PASTEL_HUES, } from './projects'; export { @@ -49,6 +52,8 @@ export { setNewTaskDropUrl, setNewTaskPrefillPrompt, setPlanContent, + retrySetup, + skipSetup, } from './tasks'; export { setActiveTask, diff --git a/src/store/tasks.ts b/src/store/tasks.ts index 2bf3168d..44de4cd6 100644 --- a/src/store/tasks.ts +++ b/src/store/tasks.ts @@ -3,7 +3,14 @@ import { invoke, Channel } from '../lib/ipc'; import { IPC } from '../../electron/ipc/channels'; import { store, setStore, updateWindowTitle, cleanupPanelEntries } from './core'; import { setTaskFocusedPanel } from './focus'; -import { getProject, getProjectPath, getProjectBranchPrefix, isProjectMissing } from './projects'; +import { + getProject, + getProjectPath, + getProjectBranchPrefix, + getProjectSetupCommands, + getProjectTeardownCommands, + isProjectMissing, +} from './projects'; import { setPendingShellCommand } from '../lib/bookmarks'; import { markAgentSpawned, @@ -17,6 +24,9 @@ import type { AgentDef, CreateTaskResult, MergeResult } from '../ipc/types'; import { parseGitHubUrl, taskNameFromGitHubUrl } from '../lib/github-url'; import type { Agent, Task } from './types'; +// Map of taskId -> stashed initialPrompt (held while setup is running) +const stashedPrompts = new Map(); + const AGENT_WRITE_READY_TIMEOUT_MS = 8_000; const AGENT_WRITE_RETRY_MS = 50; @@ -126,6 +136,10 @@ export async function createTask(opts: CreateTaskOptions): Promise { markAgentSpawned(agentId); rescheduleTaskStatusPolling(); updateWindowTitle(name); + + // Run setup commands if configured in project settings + runSetupForTask(result.id, result.worktree_path, projectId); + return result.id; } @@ -223,6 +237,21 @@ export async function closeTask(taskId: string): Promise { // Skip git cleanup for direct mode (no worktree/branch to remove) if (!task.directMode) { + // Run teardown commands before removing worktree (best-effort) + const teardownCmds = getProjectTeardownCommands(task.projectId); + if (teardownCmds) { + try { + await invoke(IPC.RunSetupCommands, { + worktreePath: task.worktreePath, + projectRoot: projectRoot || task.worktreePath, + commands: teardownCmds, + onOutput: new Channel(), + }); + } catch (err) { + console.warn('Teardown commands failed:', err); + } + } + // Remove worktree + branch await invoke(IPC.DeleteTask, { agentIds: [...agentIds, ...shellAgentIds], @@ -601,6 +630,83 @@ export function setNewTaskPrefillPrompt(prompt: string, projectId: string | null setStore('newTaskPrefillPrompt', { prompt, projectId }); } +// --- Setup commands --- + +function runSetupForTask(taskId: string, worktreePath: string, projectId: string): void { + const task = store.tasks[taskId]; + if (!task || task.directMode) return; + + const commands = getProjectSetupCommands(projectId); + if (!commands) return; // nothing configured — skip silently + + // Stash initialPrompt so agent doesn't send it during setup + if (task.initialPrompt) { + stashedPrompts.set(taskId, task.initialPrompt); + setStore('tasks', taskId, 'initialPrompt', undefined); + } + + setStore('tasks', taskId, 'setupStatus', 'running'); + setStore('tasks', taskId, 'setupLog', ''); + setStore('tasks', taskId, 'setupError', undefined); + + const channel = new Channel(); + let logBuffer = ''; + let flushScheduled = false; + channel.onmessage = (msg) => { + logBuffer += msg; + if (!flushScheduled) { + flushScheduled = true; + requestAnimationFrame(() => { + flushScheduled = false; + const current = store.tasks[taskId]?.setupLog ?? ''; + setStore('tasks', taskId, 'setupLog', current + logBuffer); + logBuffer = ''; + }); + } + }; + + const projectRoot = getProjectPath(projectId) ?? worktreePath; + + invoke(IPC.RunSetupCommands, { + worktreePath, + projectRoot, + commands, + onOutput: channel, + }) + .then(() => { + setStore('tasks', taskId, 'setupStatus', 'done'); + }) + .catch((err: unknown) => { + setStore('tasks', taskId, 'setupStatus', 'failed'); + setStore('tasks', taskId, 'setupError', String(err)); + }) + .finally(() => { + restoreStashedPrompt(taskId); + channel.cleanup?.(); + }); +} + +function restoreStashedPrompt(taskId: string): void { + const prompt = stashedPrompts.get(taskId); + if (prompt) { + stashedPrompts.delete(taskId); + setStore('tasks', taskId, 'initialPrompt', prompt); + } +} + +export function retrySetup(taskId: string): void { + const task = store.tasks[taskId]; + if (!task) return; + runSetupForTask(taskId, task.worktreePath, task.projectId); +} + +export function skipSetup(taskId: string): void { + setStore('tasks', taskId, 'setupStatus', undefined); + setStore('tasks', taskId, 'setupLog', undefined); + setStore('tasks', taskId, 'setupError', undefined); + restoreStashedPrompt(taskId); +} + export function setPlanContent( taskId: string, content: string | null, diff --git a/src/store/types.ts b/src/store/types.ts index 3aa0df36..fb589e50 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -16,6 +16,9 @@ export interface Project { deleteBranchOnClose?: boolean; // default true if unset defaultDirectMode?: boolean; // default false if unset terminalBookmarks?: TerminalBookmark[]; + setupCommands?: string[]; + teardownCommands?: string[]; + defaultSymlinkDirs?: string[]; } export interface Agent { @@ -52,6 +55,9 @@ export interface Task { savedAgentDef?: AgentDef; planContent?: string; planFileName?: string; + setupStatus?: 'running' | 'done' | 'failed'; + setupLog?: string; + setupError?: string; } export interface Terminal {