From 05d96a7d6eaa0acb3022373aebfec4e85b49b989 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sat, 10 Jan 2026 22:19:34 +0100 Subject: [PATCH] feat: Implement worktree initialization script functionality This commit introduces a new feature for managing worktree initialization scripts, allowing users to configure and execute scripts upon worktree creation. Key changes include: 1. **New API Endpoints**: Added endpoints for getting, setting, and deleting init scripts. 2. **Worktree Routes**: Updated worktree routes to include init script handling. 3. **Init Script Service**: Created a service to execute the init scripts asynchronously, with support for cross-platform compatibility. 4. **UI Components**: Added UI components for displaying and editing init scripts, including a dedicated section in the settings view. 5. **Event Handling**: Implemented event handling for init script execution status, providing real-time feedback in the UI. This enhancement improves the user experience by allowing automated setup processes for new worktrees, streamlining project workflows. --- apps/server/src/index.ts | 2 +- apps/server/src/lib/worktree-metadata.ts | 6 + apps/server/src/routes/worktree/index.ts | 15 +- .../src/routes/worktree/routes/create.ts | 25 +- .../src/routes/worktree/routes/init-script.ts | 162 +++++++++++ .../src/services/init-script-service.ts | 258 +++++++++++++++++ apps/ui/package.json | 2 + apps/ui/src/components/ui/ansi-output.tsx | 262 ++++++++++++++++++ .../src/components/ui/shell-syntax-editor.tsx | 141 ++++++++++ apps/ui/src/components/views/board-view.tsx | 8 + .../board-view/init-script-indicator.tsx | 140 ++++++++++ .../ui/src/components/views/settings-view.tsx | 10 +- .../views/settings-view/config/navigation.ts | 3 + .../feature-defaults-section.tsx | 31 --- .../settings-view/hooks/use-settings-view.ts | 1 + .../views/settings-view/worktrees/index.ts | 1 + .../worktrees/worktrees-section.tsx | 238 ++++++++++++++++ apps/ui/src/hooks/use-init-script-events.ts | 79 ++++++ apps/ui/src/lib/http-api-client.ts | 29 ++ apps/ui/src/store/app-store.ts | 56 ++++ docs/worktree-init-script-example.sh | 26 ++ libs/types/src/event.ts | 5 +- package-lock.json | 27 +- 23 files changed, 1481 insertions(+), 46 deletions(-) create mode 100644 apps/server/src/routes/worktree/routes/init-script.ts create mode 100644 apps/server/src/services/init-script-service.ts create mode 100644 apps/ui/src/components/ui/ansi-output.tsx create mode 100644 apps/ui/src/components/ui/shell-syntax-editor.tsx create mode 100644 apps/ui/src/components/views/board-view/init-script-indicator.tsx create mode 100644 apps/ui/src/components/views/settings-view/worktrees/index.ts create mode 100644 apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx create mode 100644 apps/ui/src/hooks/use-init-script-events.ts create mode 100644 docs/worktree-init-script-example.sh diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index caa4dd6a..dabbc712 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -217,7 +217,7 @@ app.use('/api/sessions', createSessionsRoutes(agentService)); app.use('/api/features', createFeaturesRoutes(featureLoader)); app.use('/api/auto-mode', createAutoModeRoutes(autoModeService)); app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService)); -app.use('/api/worktree', createWorktreeRoutes()); +app.use('/api/worktree', createWorktreeRoutes(events)); app.use('/api/git', createGitRoutes()); app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService)); app.use('/api/models', createModelsRoutes()); diff --git a/apps/server/src/lib/worktree-metadata.ts b/apps/server/src/lib/worktree-metadata.ts index edeadc5b..3f7ea60d 100644 --- a/apps/server/src/lib/worktree-metadata.ts +++ b/apps/server/src/lib/worktree-metadata.ts @@ -21,6 +21,12 @@ export interface WorktreeMetadata { branch: string; createdAt: string; pr?: WorktreePRInfo; + /** Whether the init script has been executed for this worktree */ + initScriptRan?: boolean; + /** Status of the init script execution */ + initScriptStatus?: 'running' | 'success' | 'failed'; + /** Error message if init script failed */ + initScriptError?: string; } /** diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 7fef5c6e..abb56833 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -3,6 +3,7 @@ */ import { Router } from 'express'; +import type { EventEmitter } from '../../lib/events.js'; import { validatePathParams } from '../../middleware/validate-paths.js'; import { requireValidWorktree, requireValidProject, requireGitRepoOnly } from './middleware.js'; import { createInfoHandler } from './routes/info.js'; @@ -30,8 +31,13 @@ import { createMigrateHandler } from './routes/migrate.js'; import { createStartDevHandler } from './routes/start-dev.js'; import { createStopDevHandler } from './routes/stop-dev.js'; import { createListDevServersHandler } from './routes/list-dev-servers.js'; +import { + createGetInitScriptHandler, + createPutInitScriptHandler, + createDeleteInitScriptHandler, +} from './routes/init-script.js'; -export function createWorktreeRoutes(): Router { +export function createWorktreeRoutes(events: EventEmitter): Router { const router = Router(); router.post('/info', validatePathParams('projectPath'), createInfoHandler()); @@ -45,7 +51,7 @@ export function createWorktreeRoutes(): Router { requireValidProject, createMergeHandler() ); - router.post('/create', validatePathParams('projectPath'), createCreateHandler()); + router.post('/create', validatePathParams('projectPath'), createCreateHandler(events)); router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler()); router.post('/create-pr', createCreatePRHandler()); router.post('/pr-info', createPRInfoHandler()); @@ -87,5 +93,10 @@ export function createWorktreeRoutes(): Router { router.post('/stop-dev', createStopDevHandler()); router.post('/list-dev-servers', createListDevServersHandler()); + // Init script routes + router.post('/init-script', validatePathParams('projectPath'), createGetInitScriptHandler()); + router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler()); + router.delete('/init-script', validatePathParams('projectPath'), createDeleteInitScriptHandler()); + return router; } diff --git a/apps/server/src/routes/worktree/routes/create.ts b/apps/server/src/routes/worktree/routes/create.ts index b8e07570..ecc3b9b0 100644 --- a/apps/server/src/routes/worktree/routes/create.ts +++ b/apps/server/src/routes/worktree/routes/create.ts @@ -12,6 +12,7 @@ import { exec } from 'child_process'; import { promisify } from 'util'; import path from 'path'; import * as secureFs from '../../../lib/secure-fs.js'; +import type { EventEmitter } from '../../../lib/events.js'; import { isGitRepo, getErrorMessage, @@ -21,6 +22,8 @@ import { } from '../common.js'; import { trackBranch } from './branch-tracking.js'; import { createLogger } from '@automaker/utils'; +import { runInitScript, getInitScriptPath, hasInitScriptRun } from '../../../services/init-script-service.js'; +import fs from 'fs'; const logger = createLogger('Worktree'); @@ -77,7 +80,7 @@ async function findExistingWorktreeForBranch( } } -export function createCreateHandler() { +export function createCreateHandler(events: EventEmitter) { return async (req: Request, res: Response): Promise => { try { const { projectPath, branchName, baseBranch } = req.body as { @@ -177,6 +180,13 @@ export function createCreateHandler() { // Resolve to absolute path for cross-platform compatibility // normalizePath converts to forward slashes for API consistency const absoluteWorktreePath = path.resolve(worktreePath); + + // Check if init script exists and should be run (only for new worktrees) + const initScriptPath = getInitScriptPath(projectPath); + const hasInitScript = fs.existsSync(initScriptPath); + const alreadyRan = await hasInitScriptRun(projectPath, branchName); + + // Respond immediately (non-blocking) res.json({ success: true, worktree: { @@ -185,6 +195,19 @@ export function createCreateHandler() { isNew: !branchExists, }, }); + + // Trigger init script asynchronously after response + if (hasInitScript && !alreadyRan) { + logger.info(`Triggering init script for worktree: ${branchName}`); + runInitScript({ + projectPath, + worktreePath: absoluteWorktreePath, + branch: branchName, + emitter: events, + }).catch((err) => { + logger.error(`Init script failed for ${branchName}:`, err); + }); + } } catch (error) { logError(error, 'Create worktree failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); diff --git a/apps/server/src/routes/worktree/routes/init-script.ts b/apps/server/src/routes/worktree/routes/init-script.ts new file mode 100644 index 00000000..89c32456 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/init-script.ts @@ -0,0 +1,162 @@ +/** + * Init Script routes - Read/write the worktree-init.sh file + * + * GET /init-script - Read the init script content + * PUT /init-script - Write content to the init script file + * DELETE /init-script - Delete the init script file + */ + +import type { Request, Response } from 'express'; +import path from 'path'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { getErrorMessage, logError } from '../common.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('InitScript'); + +/** Fixed path for init script within .automaker directory */ +const INIT_SCRIPT_FILENAME = 'worktree-init.sh'; + +/** + * Get the full path to the init script for a project + */ +function getInitScriptPath(projectPath: string): string { + return path.join(projectPath, '.automaker', INIT_SCRIPT_FILENAME); +} + +/** + * GET /init-script - Read the init script content + */ +export function createGetInitScriptHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + const scriptPath = getInitScriptPath(projectPath); + + try { + const content = await secureFs.readFile(scriptPath, 'utf-8'); + res.json({ + success: true, + exists: true, + content: content as string, + path: scriptPath, + }); + } catch { + // File doesn't exist + res.json({ + success: true, + exists: false, + content: '', + path: scriptPath, + }); + } + } catch (error) { + logError(error, 'Read init script failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} + +/** + * PUT /init-script - Write content to the init script file + */ +export function createPutInitScriptHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, content } = req.body as { + projectPath: string; + content: string; + }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + if (typeof content !== 'string') { + res.status(400).json({ + success: false, + error: 'content must be a string', + }); + return; + } + + const scriptPath = getInitScriptPath(projectPath); + const automakerDir = path.dirname(scriptPath); + + // Ensure .automaker directory exists + await secureFs.mkdir(automakerDir, { recursive: true }); + + // Write the script content + await secureFs.writeFile(scriptPath, content, 'utf-8'); + + logger.info(`Wrote init script to ${scriptPath}`); + + res.json({ + success: true, + path: scriptPath, + }); + } catch (error) { + logError(error, 'Write init script failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} + +/** + * DELETE /init-script - Delete the init script file + */ +export function createDeleteInitScriptHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + const scriptPath = getInitScriptPath(projectPath); + + try { + await secureFs.rm(scriptPath, { force: true }); + logger.info(`Deleted init script at ${scriptPath}`); + res.json({ + success: true, + }); + } catch { + // File doesn't exist - still success + res.json({ + success: true, + }); + } + } catch (error) { + logError(error, 'Delete init script failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/apps/server/src/services/init-script-service.ts b/apps/server/src/services/init-script-service.ts new file mode 100644 index 00000000..25a36fcb --- /dev/null +++ b/apps/server/src/services/init-script-service.ts @@ -0,0 +1,258 @@ +/** + * Init Script Service - Executes worktree initialization scripts + * + * Runs the .automaker/worktree-init.sh script after worktree creation. + * Uses Git Bash on Windows for cross-platform shell script compatibility. + */ + +import { spawn } from 'child_process'; +import path from 'path'; +import fs from 'fs'; +import { createLogger } from '@automaker/utils'; +import type { EventEmitter } from '../lib/events.js'; +import { + readWorktreeMetadata, + writeWorktreeMetadata, +} from '../lib/worktree-metadata.js'; + +const logger = createLogger('InitScript'); + +/** Common Git Bash installation paths on Windows */ +const GIT_BASH_PATHS = [ + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', + path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Git', 'bin', 'bash.exe'), + path.join(process.env.USERPROFILE || '', 'scoop', 'apps', 'git', 'current', 'bin', 'bash.exe'), +]; + +/** + * Find Git Bash executable on Windows + */ +function findGitBash(): string | null { + if (process.platform !== 'win32') { + return null; + } + + for (const bashPath of GIT_BASH_PATHS) { + if (fs.existsSync(bashPath)) { + return bashPath; + } + } + + return null; +} + +/** + * Get the shell command for running scripts + * Returns [shellPath, shellArgs] for cross-platform compatibility + */ +function getShellCommand(): { shell: string; args: string[] } | null { + if (process.platform === 'win32') { + const gitBash = findGitBash(); + if (!gitBash) { + return null; + } + return { shell: gitBash, args: [] }; + } + + // Unix-like systems: prefer bash, fall back to sh + if (fs.existsSync('/bin/bash')) { + return { shell: '/bin/bash', args: [] }; + } + if (fs.existsSync('/bin/sh')) { + return { shell: '/bin/sh', args: [] }; + } + + return null; +} + +export interface InitScriptOptions { + /** Absolute path to the project root */ + projectPath: string; + /** Absolute path to the worktree directory */ + worktreePath: string; + /** Branch name for this worktree */ + branch: string; + /** Event emitter for streaming output */ + emitter: EventEmitter; +} + +/** + * Check if init script exists for a project + */ +export function getInitScriptPath(projectPath: string): string { + return path.join(projectPath, '.automaker', 'worktree-init.sh'); +} + +/** + * Check if the init script has already been run for a worktree + */ +export async function hasInitScriptRun( + projectPath: string, + branch: string +): Promise { + const metadata = await readWorktreeMetadata(projectPath, branch); + return metadata?.initScriptRan === true; +} + +/** + * Run the worktree initialization script + * Non-blocking - returns immediately after spawning + */ +export async function runInitScript(options: InitScriptOptions): Promise { + const { projectPath, worktreePath, branch, emitter } = options; + + const scriptPath = getInitScriptPath(projectPath); + + // Check if script exists + if (!fs.existsSync(scriptPath)) { + logger.debug(`No init script found at ${scriptPath}`); + return; + } + + // Check if already run + if (await hasInitScriptRun(projectPath, branch)) { + logger.info(`Init script already ran for branch "${branch}", skipping`); + return; + } + + // Get shell command + const shellCmd = getShellCommand(); + if (!shellCmd) { + const error = + process.platform === 'win32' + ? 'Git Bash not found. Please install Git for Windows to run init scripts.' + : 'No shell found (/bin/bash or /bin/sh)'; + logger.error(error); + + // Update metadata with error + await writeWorktreeMetadata(projectPath, branch, { + branch, + createdAt: new Date().toISOString(), + initScriptRan: true, + initScriptStatus: 'failed', + initScriptError: error, + }); + + emitter.emit('worktree:init-completed', { + projectPath, + worktreePath, + branch, + success: false, + error, + }); + return; + } + + logger.info(`Running init script for branch "${branch}" in ${worktreePath}`); + + // Update metadata to mark as running + const existingMetadata = await readWorktreeMetadata(projectPath, branch); + await writeWorktreeMetadata(projectPath, branch, { + branch, + createdAt: existingMetadata?.createdAt || new Date().toISOString(), + pr: existingMetadata?.pr, + initScriptRan: false, + initScriptStatus: 'running', + }); + + // Emit started event + emitter.emit('worktree:init-started', { + projectPath, + worktreePath, + branch, + }); + + // Spawn the script + const child = spawn(shellCmd.shell, [...shellCmd.args, scriptPath], { + cwd: worktreePath, + env: { + ...process.env, + // Provide useful env vars to the script + AUTOMAKER_PROJECT_PATH: projectPath, + AUTOMAKER_WORKTREE_PATH: worktreePath, + AUTOMAKER_BRANCH: branch, + // Force color output even though we're not a TTY + FORCE_COLOR: '1', + npm_config_color: 'always', + CLICOLOR_FORCE: '1', + // Git colors + GIT_TERMINAL_PROMPT: '0', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + // Stream stdout + child.stdout?.on('data', (data: Buffer) => { + const content = data.toString(); + emitter.emit('worktree:init-output', { + projectPath, + branch, + type: 'stdout', + content, + }); + }); + + // Stream stderr + child.stderr?.on('data', (data: Buffer) => { + const content = data.toString(); + emitter.emit('worktree:init-output', { + projectPath, + branch, + type: 'stderr', + content, + }); + }); + + // Handle completion + child.on('exit', async (code) => { + const success = code === 0; + const status = success ? 'success' : 'failed'; + + logger.info(`Init script for branch "${branch}" ${status} with exit code ${code}`); + + // Update metadata + const metadata = await readWorktreeMetadata(projectPath, branch); + await writeWorktreeMetadata(projectPath, branch, { + branch, + createdAt: metadata?.createdAt || new Date().toISOString(), + pr: metadata?.pr, + initScriptRan: true, + initScriptStatus: status, + initScriptError: success ? undefined : `Exit code: ${code}`, + }); + + // Emit completion event + emitter.emit('worktree:init-completed', { + projectPath, + worktreePath, + branch, + success, + exitCode: code, + }); + }); + + child.on('error', async (error) => { + logger.error(`Init script error for branch "${branch}":`, error); + + // Update metadata + const metadata = await readWorktreeMetadata(projectPath, branch); + await writeWorktreeMetadata(projectPath, branch, { + branch, + createdAt: metadata?.createdAt || new Date().toISOString(), + pr: metadata?.pr, + initScriptRan: true, + initScriptStatus: 'failed', + initScriptError: error.message, + }); + + // Emit completion with error + emitter.emit('worktree:init-completed', { + projectPath, + worktreePath, + branch, + success: false, + error: error.message, + }); + }); +} diff --git a/apps/ui/package.json b/apps/ui/package.json index f5b5aa6e..167734d3 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -42,6 +42,8 @@ "@automaker/dependency-resolver": "1.0.0", "@automaker/types": "1.0.0", "@codemirror/lang-xml": "6.1.0", + "@codemirror/language": "^6.12.1", + "@codemirror/legacy-modes": "^6.5.2", "@codemirror/theme-one-dark": "6.1.3", "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "10.0.0", diff --git a/apps/ui/src/components/ui/ansi-output.tsx b/apps/ui/src/components/ui/ansi-output.tsx new file mode 100644 index 00000000..af9f8667 --- /dev/null +++ b/apps/ui/src/components/ui/ansi-output.tsx @@ -0,0 +1,262 @@ +import { useMemo } from 'react'; +import { cn } from '@/lib/utils'; + +interface AnsiOutputProps { + text: string; + className?: string; +} + +// ANSI color codes to CSS color mappings +const ANSI_COLORS: Record = { + // Standard colors + 30: '#6b7280', // Black (use gray for visibility on dark bg) + 31: '#ef4444', // Red + 32: '#22c55e', // Green + 33: '#eab308', // Yellow + 34: '#3b82f6', // Blue + 35: '#a855f7', // Magenta + 36: '#06b6d4', // Cyan + 37: '#d1d5db', // White + // Bright colors + 90: '#9ca3af', // Bright Black (Gray) + 91: '#f87171', // Bright Red + 92: '#4ade80', // Bright Green + 93: '#facc15', // Bright Yellow + 94: '#60a5fa', // Bright Blue + 95: '#c084fc', // Bright Magenta + 96: '#22d3ee', // Bright Cyan + 97: '#ffffff', // Bright White +}; + +const ANSI_BG_COLORS: Record = { + 40: 'transparent', + 41: '#ef4444', + 42: '#22c55e', + 43: '#eab308', + 44: '#3b82f6', + 45: '#a855f7', + 46: '#06b6d4', + 47: '#f3f4f6', + // Bright backgrounds + 100: '#374151', + 101: '#f87171', + 102: '#4ade80', + 103: '#facc15', + 104: '#60a5fa', + 105: '#c084fc', + 106: '#22d3ee', + 107: '#ffffff', +}; + +interface TextSegment { + text: string; + style: { + color?: string; + backgroundColor?: string; + fontWeight?: string; + fontStyle?: string; + textDecoration?: string; + }; +} + +/** + * Strip hyperlink escape sequences (OSC 8) + * Format: ESC]8;;url ESC\ text ESC]8;; ESC\ + */ +function stripHyperlinks(text: string): string { + // Remove OSC 8 hyperlink sequences + // eslint-disable-next-line no-control-regex + return text.replace(/\x1b\]8;;[^\x07\x1b]*(?:\x07|\x1b\\)/g, ''); +} + +/** + * Strip other OSC sequences (title, etc.) + */ +function stripOtherOSC(text: string): string { + // Remove OSC sequences (ESC ] ... BEL or ESC ] ... ST) + // eslint-disable-next-line no-control-regex + return text.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, ''); +} + +function parseAnsi(text: string): TextSegment[] { + // Pre-process: strip hyperlinks and other OSC sequences + let processedText = stripHyperlinks(text); + processedText = stripOtherOSC(processedText); + + const segments: TextSegment[] = []; + + // Match ANSI escape sequences: ESC[...m (SGR - Select Graphic Rendition) + // Also handle ESC[K (erase line) and other CSI sequences by stripping them + // The ESC character can be \x1b, \033, \u001b + // eslint-disable-next-line no-control-regex + const ansiRegex = /\x1b\[([0-9;]*)([a-zA-Z])/g; + + let currentStyle: TextSegment['style'] = {}; + let lastIndex = 0; + let match; + + while ((match = ansiRegex.exec(processedText)) !== null) { + // Add text before this escape sequence + if (match.index > lastIndex) { + const content = processedText.slice(lastIndex, match.index); + if (content) { + segments.push({ text: content, style: { ...currentStyle } }); + } + } + + const params = match[1]; + const command = match[2]; + + // Only process 'm' command (SGR - graphics/color) + // Ignore other commands like K (erase), H (cursor), J (clear), etc. + if (command === 'm') { + // Parse the escape sequence codes + const codes = params ? params.split(';').map((c) => parseInt(c, 10) || 0) : [0]; + + for (let i = 0; i < codes.length; i++) { + const code = codes[i]; + + if (code === 0) { + // Reset all attributes + currentStyle = {}; + } else if (code === 1) { + // Bold + currentStyle.fontWeight = 'bold'; + } else if (code === 2) { + // Dim/faint + currentStyle.color = 'var(--muted-foreground)'; + } else if (code === 3) { + // Italic + currentStyle.fontStyle = 'italic'; + } else if (code === 4) { + // Underline + currentStyle.textDecoration = 'underline'; + } else if (code === 22) { + // Normal intensity (not bold, not dim) + currentStyle.fontWeight = undefined; + } else if (code === 23) { + // Not italic + currentStyle.fontStyle = undefined; + } else if (code === 24) { + // Not underlined + currentStyle.textDecoration = undefined; + } else if (code === 38) { + // Extended foreground color + if (codes[i + 1] === 5 && codes[i + 2] !== undefined) { + // 256 color mode: 38;5;n + const colorIndex = codes[i + 2]; + currentStyle.color = get256Color(colorIndex); + i += 2; + } else if (codes[i + 1] === 2 && codes[i + 4] !== undefined) { + // RGB mode: 38;2;r;g;b + const r = codes[i + 2]; + const g = codes[i + 3]; + const b = codes[i + 4]; + currentStyle.color = `rgb(${r}, ${g}, ${b})`; + i += 4; + } + } else if (code === 48) { + // Extended background color + if (codes[i + 1] === 5 && codes[i + 2] !== undefined) { + // 256 color mode: 48;5;n + const colorIndex = codes[i + 2]; + currentStyle.backgroundColor = get256Color(colorIndex); + i += 2; + } else if (codes[i + 1] === 2 && codes[i + 4] !== undefined) { + // RGB mode: 48;2;r;g;b + const r = codes[i + 2]; + const g = codes[i + 3]; + const b = codes[i + 4]; + currentStyle.backgroundColor = `rgb(${r}, ${g}, ${b})`; + i += 4; + } + } else if (ANSI_COLORS[code]) { + // Standard foreground color (30-37, 90-97) + currentStyle.color = ANSI_COLORS[code]; + } else if (ANSI_BG_COLORS[code]) { + // Standard background color (40-47, 100-107) + currentStyle.backgroundColor = ANSI_BG_COLORS[code]; + } else if (code === 39) { + // Default foreground + currentStyle.color = undefined; + } else if (code === 49) { + // Default background + currentStyle.backgroundColor = undefined; + } + } + } + + lastIndex = match.index + match[0].length; + } + + // Add remaining text after last escape sequence + if (lastIndex < processedText.length) { + const content = processedText.slice(lastIndex); + if (content) { + segments.push({ text: content, style: { ...currentStyle } }); + } + } + + // If no segments were created (no ANSI codes), return the whole text + if (segments.length === 0 && processedText) { + segments.push({ text: processedText, style: {} }); + } + + return segments; +} + +/** + * Convert 256-color palette index to CSS color + */ +function get256Color(index: number): string { + // 0-15: Standard colors + if (index < 16) { + const standardColors = [ + '#000000', '#cd0000', '#00cd00', '#cdcd00', '#0000ee', '#cd00cd', '#00cdcd', '#e5e5e5', + '#7f7f7f', '#ff0000', '#00ff00', '#ffff00', '#5c5cff', '#ff00ff', '#00ffff', '#ffffff', + ]; + return standardColors[index]; + } + + // 16-231: 6x6x6 color cube + if (index < 232) { + const n = index - 16; + const b = n % 6; + const g = Math.floor(n / 6) % 6; + const r = Math.floor(n / 36); + const toHex = (v: number) => (v === 0 ? 0 : 55 + v * 40); + return `rgb(${toHex(r)}, ${toHex(g)}, ${toHex(b)})`; + } + + // 232-255: Grayscale + const gray = 8 + (index - 232) * 10; + return `rgb(${gray}, ${gray}, ${gray})`; +} + +export function AnsiOutput({ text, className }: AnsiOutputProps) { + const segments = useMemo(() => parseAnsi(text), [text]); + + return ( +
+      {segments.map((segment, index) => (
+        
+          {segment.text}
+        
+      ))}
+    
+ ); +} diff --git a/apps/ui/src/components/ui/shell-syntax-editor.tsx b/apps/ui/src/components/ui/shell-syntax-editor.tsx new file mode 100644 index 00000000..03675539 --- /dev/null +++ b/apps/ui/src/components/ui/shell-syntax-editor.tsx @@ -0,0 +1,141 @@ +import CodeMirror from '@uiw/react-codemirror'; +import { StreamLanguage } from '@codemirror/language'; +import { shell } from '@codemirror/legacy-modes/mode/shell'; +import { EditorView } from '@codemirror/view'; +import { Extension } from '@codemirror/state'; +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { tags as t } from '@lezer/highlight'; +import { cn } from '@/lib/utils'; + +interface ShellSyntaxEditorProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; + minHeight?: string; + 'data-testid'?: string; +} + +// Syntax highlighting using CSS variables for theme compatibility +const syntaxColors = HighlightStyle.define([ + // Keywords (if, then, else, fi, for, while, do, done, case, esac, etc.) + { tag: t.keyword, color: 'var(--chart-4, oklch(0.7 0.15 280))' }, + + // Strings (single and double quoted) + { tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' }, + + // Comments + { tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' }, + + // Variables ($VAR, ${VAR}) + { tag: t.variableName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' }, + + // Operators + { tag: t.operator, color: 'var(--muted-foreground)' }, + + // Numbers + { tag: t.number, color: 'var(--chart-3, oklch(0.7 0.15 150))' }, + + // Function names / commands + { tag: t.function(t.variableName), color: 'var(--primary)' }, + { tag: t.attributeName, color: 'var(--chart-5, oklch(0.65 0.2 30))' }, + + // Default text + { tag: t.content, color: 'var(--foreground)' }, +]); + +// Editor theme using CSS variables +const editorTheme = EditorView.theme({ + '&': { + height: '100%', + fontSize: '0.875rem', + fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', + backgroundColor: 'transparent', + color: 'var(--foreground)', + }, + '.cm-scroller': { + overflow: 'auto', + fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', + }, + '.cm-content': { + padding: '0.75rem', + minHeight: '100%', + caretColor: 'var(--primary)', + }, + '.cm-cursor, .cm-dropCursor': { + borderLeftColor: 'var(--primary)', + }, + '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': { + backgroundColor: 'oklch(0.55 0.25 265 / 0.3)', + }, + '.cm-activeLine': { + backgroundColor: 'var(--accent)', + opacity: '0.3', + }, + '.cm-line': { + padding: '0 0.25rem', + }, + '&.cm-focused': { + outline: 'none', + }, + '.cm-gutters': { + backgroundColor: 'transparent', + color: 'var(--muted-foreground)', + border: 'none', + paddingRight: '0.5rem', + }, + '.cm-lineNumbers .cm-gutterElement': { + minWidth: '2rem', + textAlign: 'right', + paddingRight: '0.5rem', + }, + '.cm-placeholder': { + color: 'var(--muted-foreground)', + fontStyle: 'italic', + }, +}); + +// Combine all extensions +const extensions: Extension[] = [ + StreamLanguage.define(shell), + syntaxHighlighting(syntaxColors), + editorTheme, +]; + +export function ShellSyntaxEditor({ + value, + onChange, + placeholder, + className, + minHeight = '200px', + 'data-testid': testId, +}: ShellSyntaxEditorProps) { + return ( +
+ +
+ ); +} diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 810d1a91..b4c050f6 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -78,6 +78,8 @@ import { } from './board-view/hooks'; import { SelectionActionBar } from './board-view/components'; import { MassEditDialog } from './board-view/dialogs'; +import { InitScriptIndicator } from './board-view/init-script-indicator'; +import { useInitScriptEvents } from '@/hooks/use-init-script-events'; // Stable empty array to avoid infinite loop in selector const EMPTY_WORKTREES: ReturnType['getWorktrees']> = []; @@ -255,6 +257,9 @@ export function BoardView() { // Window state hook for compact dialog mode const { isMaximized } = useWindowState(); + // Init script events hook - subscribe to worktree init script events + useInitScriptEvents(currentProject?.path ?? null); + // Keyboard shortcuts hook will be initialized after actions hook // Prevent hydration issues @@ -1570,6 +1575,9 @@ export function BoardView() { setSelectedWorktreeForAction(null); }} /> + + {/* Init Script Indicator - floating overlay for worktree init script status */} + ); } diff --git a/apps/ui/src/components/views/board-view/init-script-indicator.tsx b/apps/ui/src/components/views/board-view/init-script-indicator.tsx new file mode 100644 index 00000000..ca108dbd --- /dev/null +++ b/apps/ui/src/components/views/board-view/init-script-indicator.tsx @@ -0,0 +1,140 @@ +import { useState, useRef, useEffect } from 'react'; +import { Terminal, Check, X, Loader2, ChevronDown, ChevronUp } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useAppStore, type InitScriptState } from '@/store/app-store'; +import { AnsiOutput } from '@/components/ui/ansi-output'; + +interface InitScriptIndicatorProps { + projectPath: string; +} + +export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) { + const initScriptState = useAppStore((s) => s.initScriptState[projectPath]); + const clearInitScriptState = useAppStore((s) => s.clearInitScriptState); + const [showLogs, setShowLogs] = useState(false); + const [dismissed, setDismissed] = useState(false); + const logsEndRef = useRef(null); + + // Auto-scroll to bottom when new output arrives + useEffect(() => { + if (showLogs && logsEndRef.current) { + logsEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [initScriptState?.output, showLogs]); + + // Reset dismissed state when a new script starts + useEffect(() => { + if (initScriptState?.status === 'running') { + setDismissed(false); + setShowLogs(true); + } + }, [initScriptState?.status]); + + if (!initScriptState || dismissed) return null; + if (initScriptState.status === 'idle') return null; + + const { status, output, branch, error } = initScriptState; + + const handleDismiss = () => { + setDismissed(true); + // Clear state after a delay to allow for future scripts + setTimeout(() => { + clearInitScriptState(projectPath); + }, 100); + }; + + return ( +
+ {/* Header */} +
+
+ {status === 'running' && ( + + )} + {status === 'success' && } + {status === 'failed' && } + + Init Script{' '} + {status === 'running' + ? 'Running' + : status === 'success' + ? 'Completed' + : 'Failed'} + +
+
+ + {status !== 'running' && ( + + )} +
+
+ + {/* Branch info */} +
+ + Branch: {branch} +
+ + {/* Logs (collapsible) */} + {showLogs && ( +
+
+ {output.length > 0 ? ( + + ) : ( +
+ {status === 'running' ? 'Waiting for output...' : 'No output'} +
+ )} + {error && ( +
+ Error: {error} +
+ )} +
+
+
+ )} + + {/* Status bar for completed states */} + {status !== 'running' && ( +
+ {status === 'success' + ? 'Initialization completed successfully' + : 'Initialization failed - worktree is still usable'} +
+ )} +
+ ); +} diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index 27cc7703..52e09b1a 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -15,6 +15,7 @@ import { TerminalSection } from './settings-view/terminal/terminal-section'; import { AudioSection } from './settings-view/audio/audio-section'; import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section'; import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section'; +import { WorktreesSection } from './settings-view/worktrees'; import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section'; import { AccountSection } from './settings-view/account'; import { SecuritySection } from './settings-view/security'; @@ -149,17 +150,22 @@ export function SettingsView() { defaultSkipTests={defaultSkipTests} enableDependencyBlocking={enableDependencyBlocking} skipVerificationInAutoMode={skipVerificationInAutoMode} - useWorktrees={useWorktrees} defaultPlanningMode={defaultPlanningMode} defaultRequirePlanApproval={defaultRequirePlanApproval} onDefaultSkipTestsChange={setDefaultSkipTests} onEnableDependencyBlockingChange={setEnableDependencyBlocking} onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode} - onUseWorktreesChange={setUseWorktrees} onDefaultPlanningModeChange={setDefaultPlanningMode} onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval} /> ); + case 'worktrees': + return ( + + ); case 'account': return ; case 'security': diff --git a/apps/ui/src/components/views/settings-view/config/navigation.ts b/apps/ui/src/components/views/settings-view/config/navigation.ts index f7f2b9f6..f63d0494 100644 --- a/apps/ui/src/components/views/settings-view/config/navigation.ts +++ b/apps/ui/src/components/views/settings-view/config/navigation.ts @@ -14,6 +14,8 @@ import { MessageSquareText, User, Shield, + Cpu, + GitBranch, } from 'lucide-react'; import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon'; import type { SettingsViewId } from '../hooks/use-settings-view'; @@ -37,6 +39,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [ items: [ { id: 'model-defaults', label: 'Model Defaults', icon: Workflow }, { id: 'defaults', label: 'Feature Defaults', icon: FlaskConical }, + { id: 'worktrees', label: 'Worktrees', icon: GitBranch }, { id: 'prompts', label: 'Prompt Customization', icon: MessageSquareText }, { id: 'api-keys', label: 'API Keys', icon: Key }, { diff --git a/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx b/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx index a0f9ab36..5a75f903 100644 --- a/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx +++ b/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx @@ -3,7 +3,6 @@ import { Checkbox } from '@/components/ui/checkbox'; import { FlaskConical, TestTube, - GitBranch, AlertCircle, Zap, ClipboardList, @@ -27,13 +26,11 @@ interface FeatureDefaultsSectionProps { defaultSkipTests: boolean; enableDependencyBlocking: boolean; skipVerificationInAutoMode: boolean; - useWorktrees: boolean; defaultPlanningMode: PlanningMode; defaultRequirePlanApproval: boolean; onDefaultSkipTestsChange: (value: boolean) => void; onEnableDependencyBlockingChange: (value: boolean) => void; onSkipVerificationInAutoModeChange: (value: boolean) => void; - onUseWorktreesChange: (value: boolean) => void; onDefaultPlanningModeChange: (value: PlanningMode) => void; onDefaultRequirePlanApprovalChange: (value: boolean) => void; } @@ -42,13 +39,11 @@ export function FeatureDefaultsSection({ defaultSkipTests, enableDependencyBlocking, skipVerificationInAutoMode, - useWorktrees, defaultPlanningMode, defaultRequirePlanApproval, onDefaultSkipTestsChange, onEnableDependencyBlockingChange, onSkipVerificationInAutoModeChange, - onUseWorktreesChange, onDefaultPlanningModeChange, onDefaultRequirePlanApprovalChange, }: FeatureDefaultsSectionProps) { @@ -257,32 +252,6 @@ export function FeatureDefaultsSection({
- {/* Separator */} -
- - {/* Worktree Isolation Setting */} -
- onUseWorktreesChange(checked === true)} - className="mt-1" - data-testid="use-worktrees-checkbox" - /> -
- -

- Creates isolated git branches for each feature. When disabled, agents work directly in - the main project directory. -

-
-
); diff --git a/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts b/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts index 1dc0208f..29d29ea2 100644 --- a/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts +++ b/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts @@ -16,6 +16,7 @@ export type SettingsViewId = | 'keyboard' | 'audio' | 'defaults' + | 'worktrees' | 'account' | 'security' | 'danger'; diff --git a/apps/ui/src/components/views/settings-view/worktrees/index.ts b/apps/ui/src/components/views/settings-view/worktrees/index.ts new file mode 100644 index 00000000..a240bd72 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/worktrees/index.ts @@ -0,0 +1 @@ +export { WorktreesSection } from './worktrees-section'; diff --git a/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx b/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx new file mode 100644 index 00000000..1af4c755 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx @@ -0,0 +1,238 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor'; +import { GitBranch, Terminal, FileCode, Check, Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { apiPost, apiPut } from '@/lib/api-fetch'; +import { toast } from 'sonner'; +import { useAppStore } from '@/store/app-store'; + +interface WorktreesSectionProps { + useWorktrees: boolean; + onUseWorktreesChange: (value: boolean) => void; +} + +interface InitScriptResponse { + success: boolean; + exists: boolean; + content: string; + path: string; + error?: string; +} + +export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) { + const currentProject = useAppStore((s) => s.currentProject); + const [scriptContent, setScriptContent] = useState(''); + const [scriptPath, setScriptPath] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [showSaved, setShowSaved] = useState(false); + const saveTimeoutRef = useRef | null>(null); + const savedTimeoutRef = useRef | null>(null); + + // Load init script content when project changes + useEffect(() => { + if (!currentProject?.path) { + setScriptContent(''); + setScriptPath(''); + setIsLoading(false); + return; + } + + const loadInitScript = async () => { + setIsLoading(true); + try { + const response = await apiPost('/api/worktree/init-script', { + projectPath: currentProject.path, + }); + if (response.success) { + setScriptContent(response.content || ''); + setScriptPath(response.path || ''); + } + } catch (error) { + console.error('Failed to load init script:', error); + } finally { + setIsLoading(false); + } + }; + + loadInitScript(); + }, [currentProject?.path]); + + // Debounced save function + const saveScript = useCallback( + async (content: string) => { + if (!currentProject?.path) return; + + setIsSaving(true); + try { + const response = await apiPut<{ success: boolean; error?: string }>( + '/api/worktree/init-script', + { + projectPath: currentProject.path, + content, + } + ); + if (response.success) { + setShowSaved(true); + savedTimeoutRef.current = setTimeout(() => setShowSaved(false), 2000); + } else { + toast.error('Failed to save init script', { + description: response.error, + }); + } + } catch (error) { + console.error('Failed to save init script:', error); + toast.error('Failed to save init script'); + } finally { + setIsSaving(false); + } + }, + [currentProject?.path] + ); + + // Handle content change with debounce + const handleContentChange = useCallback( + (value: string) => { + setScriptContent(value); + setShowSaved(false); + + // Clear existing timeouts + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + if (savedTimeoutRef.current) { + clearTimeout(savedTimeoutRef.current); + } + + // Debounce save + saveTimeoutRef.current = setTimeout(() => { + saveScript(value); + }, 1000); + }, + [saveScript] + ); + + // Cleanup timeouts + useEffect(() => { + return () => { + if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); + if (savedTimeoutRef.current) clearTimeout(savedTimeoutRef.current); + }; + }, []); + + return ( +
+
+
+
+ +
+

Worktrees

+
+

+ Configure git worktree isolation and initialization scripts. +

+
+
+ {/* Enable Worktrees Toggle */} +
+ onUseWorktreesChange(checked === true)} + className="mt-1" + data-testid="use-worktrees-checkbox" + /> +
+ +

+ Creates isolated git branches for each feature. When disabled, agents work directly in + the main project directory. +

+
+
+ + {/* Separator */} +
+ + {/* Init Script Section */} +
+
+
+ + +
+
+ {isSaving && ( + + + Saving... + + )} + {showSaved && !isSaving && ( + + + Saved + + )} +
+
+

+ Shell commands to run after a worktree is created. Runs once per worktree. Uses Git + Bash on Windows for cross-platform compatibility. +

+ + {currentProject ? ( + <> + {/* File path indicator */} +
+ + .automaker/worktree-init.sh +
+ + {isLoading ? ( +
+ +
+ ) : ( + + )} + + ) : ( +
+ Select a project to configure the init script. +
+ )} +
+
+
+ ); +} diff --git a/apps/ui/src/hooks/use-init-script-events.ts b/apps/ui/src/hooks/use-init-script-events.ts new file mode 100644 index 00000000..b95b485d --- /dev/null +++ b/apps/ui/src/hooks/use-init-script-events.ts @@ -0,0 +1,79 @@ +import { useEffect } from 'react'; +import { useAppStore } from '@/store/app-store'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { pathsEqual } from '@/lib/utils'; + +interface InitScriptStartedPayload { + projectPath: string; + worktreePath: string; + branch: string; +} + +interface InitScriptOutputPayload { + projectPath: string; + branch: string; + type: 'stdout' | 'stderr'; + content: string; +} + +interface InitScriptCompletedPayload { + projectPath: string; + worktreePath: string; + branch: string; + success: boolean; + exitCode?: number; + error?: string; +} + +/** + * Hook to subscribe to init script WebSocket events and update the store. + * Should be used in a component that's always mounted (e.g., board-view). + */ +export function useInitScriptEvents(projectPath: string | null) { + const setInitScriptState = useAppStore((s) => s.setInitScriptState); + const appendInitScriptOutput = useAppStore((s) => s.appendInitScriptOutput); + + useEffect(() => { + if (!projectPath) return; + + const api = getHttpApiClient(); + + const unsubscribe = api.worktree.onInitScriptEvent((event) => { + const payload = event.payload as + | InitScriptStartedPayload + | InitScriptOutputPayload + | InitScriptCompletedPayload; + + // Only handle events for the current project (use pathsEqual for cross-platform path comparison) + if (!pathsEqual(payload.projectPath, projectPath)) return; + + switch (event.type) { + case 'worktree:init-started': { + const startPayload = payload as InitScriptStartedPayload; + setInitScriptState(projectPath, { + status: 'running', + branch: startPayload.branch, + output: [], + error: undefined, + }); + break; + } + case 'worktree:init-output': { + const outputPayload = payload as InitScriptOutputPayload; + appendInitScriptOutput(projectPath, outputPayload.content); + break; + } + case 'worktree:init-completed': { + const completePayload = payload as InitScriptCompletedPayload; + setInitScriptState(projectPath, { + status: completePayload.success ? 'success' : 'failed', + error: completePayload.error, + }); + break; + } + } + }); + + return unsubscribe; + }, [projectPath, setInitScriptState, appendInitScriptOutput]); +} diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 85392000..7e81703c 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1608,6 +1608,35 @@ export class HttpApiClient implements ElectronAPI { listDevServers: () => this.post('/api/worktree/list-dev-servers', {}), getPRInfo: (worktreePath: string, branchName: string) => this.post('/api/worktree/pr-info', { worktreePath, branchName }), + // Init script methods + getInitScript: (projectPath: string) => + this.post('/api/worktree/init-script', { projectPath }), + setInitScript: (projectPath: string, content: string) => + this.put('/api/worktree/init-script', { projectPath, content }), + deleteInitScript: (projectPath: string) => + this.delete('/api/worktree/init-script', { projectPath }), + onInitScriptEvent: ( + callback: (event: { + type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed'; + payload: unknown; + }) => void + ) => { + // Note: subscribeToEvent callback receives (payload) not (_, payload) + const unsub1 = this.subscribeToEvent('worktree:init-started', (payload) => + callback({ type: 'worktree:init-started', payload }) + ); + const unsub2 = this.subscribeToEvent('worktree:init-output', (payload) => + callback({ type: 'worktree:init-output', payload }) + ); + const unsub3 = this.subscribeToEvent('worktree:init-completed', (payload) => + callback({ type: 'worktree:init-completed', payload }) + ); + return () => { + unsub1(); + unsub2(); + unsub3(); + }; + }, }; // Git API diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index fa1daf4c..5479c015 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -459,6 +459,14 @@ export interface PersistedTerminalSettings { maxSessions: number; } +/** State for worktree init script execution */ +export interface InitScriptState { + status: 'idle' | 'running' | 'success' | 'failed'; + branch: string; + output: string[]; + error?: string; +} + export interface AppState { // Project state projects: Project[]; @@ -664,6 +672,9 @@ export interface AppState { lastProjectDir: string; /** Recently accessed folders for quick access */ recentFolders: string[]; + + // Init Script State (per-project, keyed by project path) + initScriptState: Record; } // Claude Usage interface matching the server response @@ -1095,6 +1106,12 @@ export interface AppActions { }> ) => void; + // Init Script State actions + setInitScriptState: (projectPath: string, state: Partial) => void; + appendInitScriptOutput: (projectPath: string, content: string) => void; + clearInitScriptState: (projectPath: string) => void; + getInitScriptState: (projectPath: string) => InitScriptState | null; + // Reset reset: () => void; } @@ -1195,6 +1212,7 @@ const initialState: AppState = { worktreePanelCollapsed: false, lastProjectDir: '', recentFolders: [], + initScriptState: {}, }; export const useAppStore = create()((set, get) => ({ @@ -3119,6 +3137,44 @@ export const useAppStore = create()((set, get) => ({ set({ recentFolders: updated }); }, + // Init Script State actions + setInitScriptState: (projectPath, state) => { + const current = get().initScriptState[projectPath] || { + status: 'idle', + branch: '', + output: [], + }; + set({ + initScriptState: { + ...get().initScriptState, + [projectPath]: { ...current, ...state }, + }, + }); + }, + + appendInitScriptOutput: (projectPath, content) => { + const current = get().initScriptState[projectPath]; + if (!current) return; + set({ + initScriptState: { + ...get().initScriptState, + [projectPath]: { + ...current, + output: [...current.output, content], + }, + }, + }); + }, + + clearInitScriptState: (projectPath) => { + const { [projectPath]: _, ...rest } = get().initScriptState; + set({ initScriptState: rest }); + }, + + getInitScriptState: (projectPath) => { + return get().initScriptState[projectPath] || null; + }, + // Reset reset: () => set(initialState), })); diff --git a/docs/worktree-init-script-example.sh b/docs/worktree-init-script-example.sh new file mode 100644 index 00000000..6e57389f --- /dev/null +++ b/docs/worktree-init-script-example.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Example worktree init script for Automaker +# Copy this content to Settings > Worktrees > Init Script +# Or save directly as .automaker/worktree-init.sh in your project + +echo "==========================================" +echo " Worktree Init Script Starting..." +echo "==========================================" +echo "" +echo "Current directory: $(pwd)" +echo "Branch: $(git branch --show-current 2>/dev/null || echo 'unknown')" +echo "" + +# Install dependencies +echo "[1/1] Installing npm dependencies..." +if [ -f "package.json" ]; then + npm install + echo "Dependencies installed successfully!" +else + echo "No package.json found, skipping npm install" +fi +echo "" + +echo "==========================================" +echo " Worktree initialization complete!" +echo "==========================================" diff --git a/libs/types/src/event.ts b/libs/types/src/event.ts index 6692f0f0..092c80bd 100644 --- a/libs/types/src/event.ts +++ b/libs/types/src/event.ts @@ -39,6 +39,9 @@ export type EventType = | 'ideation:idea-created' | 'ideation:idea-updated' | 'ideation:idea-deleted' - | 'ideation:idea-converted'; + | 'ideation:idea-converted' + | 'worktree:init-started' + | 'worktree:init-output' + | 'worktree:init-completed'; export type EventCallback = (type: EventType, payload: unknown) => void; diff --git a/package-lock.json b/package-lock.json index f358ed5d..8c7f0355 100644 --- a/package-lock.json +++ b/package-lock.json @@ -87,6 +87,8 @@ "@automaker/dependency-resolver": "1.0.0", "@automaker/types": "1.0.0", "@codemirror/lang-xml": "6.1.0", + "@codemirror/language": "^6.12.1", + "@codemirror/legacy-modes": "^6.5.2", "@codemirror/theme-one-dark": "6.1.3", "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "10.0.0", @@ -1199,19 +1201,28 @@ } }, "node_modules/@codemirror/language": { - "version": "6.11.3", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", - "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", + "version": "6.12.1", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz", + "integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", - "@lezer/common": "^1.1.0", + "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz", + "integrity": "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, "node_modules/@codemirror/lint": { "version": "6.9.2", "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.2.tgz", @@ -1468,7 +1479,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT", @@ -3604,9 +3615,9 @@ } }, "node_modules/@lezer/common": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.4.0.tgz", - "integrity": "sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.0.tgz", + "integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==", "license": "MIT" }, "node_modules/@lezer/highlight": {