mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
Merge pull request #409 from AutoMaker-Org/feat/worktrees-init-script
feat: worktrees init script
This commit is contained in:
@@ -217,7 +217,7 @@ app.use('/api/sessions', createSessionsRoutes(agentService));
|
|||||||
app.use('/api/features', createFeaturesRoutes(featureLoader));
|
app.use('/api/features', createFeaturesRoutes(featureLoader));
|
||||||
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
|
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
|
||||||
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
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/git', createGitRoutes());
|
||||||
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
|
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
|
||||||
app.use('/api/models', createModelsRoutes());
|
app.use('/api/models', createModelsRoutes());
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ export interface WorktreeMetadata {
|
|||||||
branch: string;
|
branch: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
pr?: WorktreePRInfo;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,11 +12,22 @@ const featureLoader = new FeatureLoader();
|
|||||||
export function createApplyHandler() {
|
export function createApplyHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, plan } = req.body as {
|
const {
|
||||||
|
projectPath,
|
||||||
|
plan,
|
||||||
|
branchName: rawBranchName,
|
||||||
|
} = req.body as {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
plan: BacklogPlanResult;
|
plan: BacklogPlanResult;
|
||||||
|
branchName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Validate branchName: must be undefined or a non-empty trimmed string
|
||||||
|
const branchName =
|
||||||
|
typeof rawBranchName === 'string' && rawBranchName.trim().length > 0
|
||||||
|
? rawBranchName.trim()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (!projectPath) {
|
if (!projectPath) {
|
||||||
res.status(400).json({ success: false, error: 'projectPath required' });
|
res.status(400).json({ success: false, error: 'projectPath required' });
|
||||||
return;
|
return;
|
||||||
@@ -82,6 +93,7 @@ export function createApplyHandler() {
|
|||||||
dependencies: change.feature.dependencies,
|
dependencies: change.feature.dependencies,
|
||||||
priority: change.feature.priority,
|
priority: change.feature.priority,
|
||||||
status: 'backlog',
|
status: 'backlog',
|
||||||
|
branchName,
|
||||||
});
|
});
|
||||||
|
|
||||||
appliedChanges.push(`added:${newFeature.id}`);
|
appliedChanges.push(`added:${newFeature.id}`);
|
||||||
|
|||||||
@@ -3,15 +3,51 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import { spawnProcess } from '@automaker/platform';
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import path from 'path';
|
|
||||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
|
||||||
|
|
||||||
const logger = createLogger('Worktree');
|
const logger = createLogger('Worktree');
|
||||||
export const execAsync = promisify(exec);
|
export const execAsync = promisify(exec);
|
||||||
const featureLoader = new FeatureLoader();
|
|
||||||
|
// ============================================================================
|
||||||
|
// Secure Command Execution
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute git command with array arguments to prevent command injection.
|
||||||
|
* Uses spawnProcess from @automaker/platform for secure, cross-platform execution.
|
||||||
|
*
|
||||||
|
* @param args - Array of git command arguments (e.g., ['worktree', 'add', path])
|
||||||
|
* @param cwd - Working directory to execute the command in
|
||||||
|
* @returns Promise resolving to stdout output
|
||||||
|
* @throws Error with stderr message if command fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Safe: no injection possible
|
||||||
|
* await execGitCommand(['branch', '-D', branchName], projectPath);
|
||||||
|
*
|
||||||
|
* // Instead of unsafe:
|
||||||
|
* // await execAsync(`git branch -D ${branchName}`, { cwd });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function execGitCommand(args: string[], cwd: string): Promise<string> {
|
||||||
|
const result = await spawnProcess({
|
||||||
|
command: 'git',
|
||||||
|
args,
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
|
|
||||||
|
// spawnProcess returns { stdout, stderr, exitCode }
|
||||||
|
if (result.exitCode === 0) {
|
||||||
|
return result.stdout;
|
||||||
|
} else {
|
||||||
|
const errorMessage = result.stderr || `Git command failed with code ${result.exitCode}`;
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Constants
|
// Constants
|
||||||
@@ -99,18 +135,6 @@ export function normalizePath(p: string): string {
|
|||||||
return p.replace(/\\/g, '/');
|
return p.replace(/\\/g, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a path is a git repo
|
|
||||||
*/
|
|
||||||
export async function isGitRepo(repoPath: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await execAsync('git rev-parse --is-inside-work-tree', { cwd: repoPath });
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a git repository has at least one commit (i.e., HEAD exists)
|
* Check if a git repository has at least one commit (i.e., HEAD exists)
|
||||||
* Returns false for freshly initialized repos with no commits
|
* Returns false for freshly initialized repos with no commits
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import type { EventEmitter } from '../../lib/events.js';
|
||||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||||
import { requireValidWorktree, requireValidProject, requireGitRepoOnly } from './middleware.js';
|
import { requireValidWorktree, requireValidProject, requireGitRepoOnly } from './middleware.js';
|
||||||
import { createInfoHandler } from './routes/info.js';
|
import { createInfoHandler } from './routes/info.js';
|
||||||
@@ -32,8 +33,14 @@ import { createMigrateHandler } from './routes/migrate.js';
|
|||||||
import { createStartDevHandler } from './routes/start-dev.js';
|
import { createStartDevHandler } from './routes/start-dev.js';
|
||||||
import { createStopDevHandler } from './routes/stop-dev.js';
|
import { createStopDevHandler } from './routes/stop-dev.js';
|
||||||
import { createListDevServersHandler } from './routes/list-dev-servers.js';
|
import { createListDevServersHandler } from './routes/list-dev-servers.js';
|
||||||
|
import {
|
||||||
|
createGetInitScriptHandler,
|
||||||
|
createPutInitScriptHandler,
|
||||||
|
createDeleteInitScriptHandler,
|
||||||
|
createRunInitScriptHandler,
|
||||||
|
} from './routes/init-script.js';
|
||||||
|
|
||||||
export function createWorktreeRoutes(): Router {
|
export function createWorktreeRoutes(events: EventEmitter): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post('/info', validatePathParams('projectPath'), createInfoHandler());
|
router.post('/info', validatePathParams('projectPath'), createInfoHandler());
|
||||||
@@ -47,7 +54,7 @@ export function createWorktreeRoutes(): Router {
|
|||||||
requireValidProject,
|
requireValidProject,
|
||||||
createMergeHandler()
|
createMergeHandler()
|
||||||
);
|
);
|
||||||
router.post('/create', validatePathParams('projectPath'), createCreateHandler());
|
router.post('/create', validatePathParams('projectPath'), createCreateHandler(events));
|
||||||
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
|
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
|
||||||
router.post('/create-pr', createCreatePRHandler());
|
router.post('/create-pr', createCreatePRHandler());
|
||||||
router.post('/pr-info', createPRInfoHandler());
|
router.post('/pr-info', createPRInfoHandler());
|
||||||
@@ -91,5 +98,15 @@ export function createWorktreeRoutes(): Router {
|
|||||||
router.post('/stop-dev', createStopDevHandler());
|
router.post('/stop-dev', createStopDevHandler());
|
||||||
router.post('/list-dev-servers', createListDevServersHandler());
|
router.post('/list-dev-servers', createListDevServersHandler());
|
||||||
|
|
||||||
|
// Init script routes
|
||||||
|
router.get('/init-script', createGetInitScriptHandler());
|
||||||
|
router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler());
|
||||||
|
router.delete('/init-script', validatePathParams('projectPath'), createDeleteInitScriptHandler());
|
||||||
|
router.post(
|
||||||
|
'/run-init-script',
|
||||||
|
validatePathParams('projectPath', 'worktreePath'),
|
||||||
|
createRunInitScriptHandler(events)
|
||||||
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
import { isGitRepo, hasCommits } from './common.js';
|
import { isGitRepo } from '@automaker/git-utils';
|
||||||
|
import { hasCommits } from './common.js';
|
||||||
|
|
||||||
interface ValidationOptions {
|
interface ValidationOptions {
|
||||||
/** Check if the path is a git repository (default: true) */
|
/** Check if the path is a git repository (default: true) */
|
||||||
|
|||||||
@@ -12,15 +12,19 @@ import { exec } from 'child_process';
|
|||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import * as secureFs from '../../../lib/secure-fs.js';
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
|
import type { EventEmitter } from '../../../lib/events.js';
|
||||||
|
import { isGitRepo } from '@automaker/git-utils';
|
||||||
import {
|
import {
|
||||||
isGitRepo,
|
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
logError,
|
logError,
|
||||||
normalizePath,
|
normalizePath,
|
||||||
ensureInitialCommit,
|
ensureInitialCommit,
|
||||||
|
isValidBranchName,
|
||||||
|
execGitCommand,
|
||||||
} from '../common.js';
|
} from '../common.js';
|
||||||
import { trackBranch } from './branch-tracking.js';
|
import { trackBranch } from './branch-tracking.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import { runInitScript } from '../../../services/init-script-service.js';
|
||||||
|
|
||||||
const logger = createLogger('Worktree');
|
const logger = createLogger('Worktree');
|
||||||
|
|
||||||
@@ -77,7 +81,7 @@ async function findExistingWorktreeForBranch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCreateHandler() {
|
export function createCreateHandler(events: EventEmitter) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, branchName, baseBranch } = req.body as {
|
const { projectPath, branchName, baseBranch } = req.body as {
|
||||||
@@ -94,6 +98,26 @@ export function createCreateHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate branch name to prevent command injection
|
||||||
|
if (!isValidBranchName(branchName)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
'Invalid branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate base branch if provided
|
||||||
|
if (baseBranch && !isValidBranchName(baseBranch) && baseBranch !== 'HEAD') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
'Invalid base branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!(await isGitRepo(projectPath))) {
|
if (!(await isGitRepo(projectPath))) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -143,30 +167,28 @@ export function createCreateHandler() {
|
|||||||
// Create worktrees directory if it doesn't exist
|
// Create worktrees directory if it doesn't exist
|
||||||
await secureFs.mkdir(worktreesDir, { recursive: true });
|
await secureFs.mkdir(worktreesDir, { recursive: true });
|
||||||
|
|
||||||
// Check if branch exists
|
// Check if branch exists (using array arguments to prevent injection)
|
||||||
let branchExists = false;
|
let branchExists = false;
|
||||||
try {
|
try {
|
||||||
await execAsync(`git rev-parse --verify ${branchName}`, {
|
await execGitCommand(['rev-parse', '--verify', branchName], projectPath);
|
||||||
cwd: projectPath,
|
|
||||||
});
|
|
||||||
branchExists = true;
|
branchExists = true;
|
||||||
} catch {
|
} catch {
|
||||||
// Branch doesn't exist
|
// Branch doesn't exist
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create worktree
|
// Create worktree (using array arguments to prevent injection)
|
||||||
let createCmd: string;
|
|
||||||
if (branchExists) {
|
if (branchExists) {
|
||||||
// Use existing branch
|
// Use existing branch
|
||||||
createCmd = `git worktree add "${worktreePath}" ${branchName}`;
|
await execGitCommand(['worktree', 'add', worktreePath, branchName], projectPath);
|
||||||
} else {
|
} else {
|
||||||
// Create new branch from base or HEAD
|
// Create new branch from base or HEAD
|
||||||
const base = baseBranch || 'HEAD';
|
const base = baseBranch || 'HEAD';
|
||||||
createCmd = `git worktree add -b ${branchName} "${worktreePath}" ${base}`;
|
await execGitCommand(
|
||||||
|
['worktree', 'add', '-b', branchName, worktreePath, base],
|
||||||
|
projectPath
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await execAsync(createCmd, { cwd: projectPath });
|
|
||||||
|
|
||||||
// Note: We intentionally do NOT symlink .automaker to worktrees
|
// Note: We intentionally do NOT symlink .automaker to worktrees
|
||||||
// Features and config are always accessed from the main project path
|
// Features and config are always accessed from the main project path
|
||||||
// This avoids symlink loop issues when activating worktrees
|
// This avoids symlink loop issues when activating worktrees
|
||||||
@@ -177,6 +199,8 @@ export function createCreateHandler() {
|
|||||||
// Resolve to absolute path for cross-platform compatibility
|
// Resolve to absolute path for cross-platform compatibility
|
||||||
// normalizePath converts to forward slashes for API consistency
|
// normalizePath converts to forward slashes for API consistency
|
||||||
const absoluteWorktreePath = path.resolve(worktreePath);
|
const absoluteWorktreePath = path.resolve(worktreePath);
|
||||||
|
|
||||||
|
// Respond immediately (non-blocking)
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
worktree: {
|
worktree: {
|
||||||
@@ -185,6 +209,17 @@ export function createCreateHandler() {
|
|||||||
isNew: !branchExists,
|
isNew: !branchExists,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Trigger init script asynchronously after response
|
||||||
|
// runInitScript internally checks if script exists and hasn't already run
|
||||||
|
runInitScript({
|
||||||
|
projectPath,
|
||||||
|
worktreePath: absoluteWorktreePath,
|
||||||
|
branch: branchName,
|
||||||
|
emitter: events,
|
||||||
|
}).catch((err) => {
|
||||||
|
logger.error(`Init script failed for ${branchName}:`, err);
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Create worktree failed');
|
logError(error, 'Create worktree failed');
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import type { Request, Response } from 'express';
|
|||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { isGitRepo } from '@automaker/git-utils';
|
import { isGitRepo } from '@automaker/git-utils';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
const logger = createLogger('Worktree');
|
||||||
|
|
||||||
export function createDeleteHandler() {
|
export function createDeleteHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
@@ -46,22 +48,25 @@ export function createDeleteHandler() {
|
|||||||
// Could not get branch name
|
// Could not get branch name
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the worktree
|
// Remove the worktree (using array arguments to prevent injection)
|
||||||
try {
|
try {
|
||||||
await execAsync(`git worktree remove "${worktreePath}" --force`, {
|
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
|
||||||
cwd: projectPath,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Try with prune if remove fails
|
// Try with prune if remove fails
|
||||||
await execAsync('git worktree prune', { cwd: projectPath });
|
await execGitCommand(['worktree', 'prune'], projectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally delete the branch
|
// Optionally delete the branch
|
||||||
if (deleteBranch && branchName && branchName !== 'main' && branchName !== 'master') {
|
if (deleteBranch && branchName && branchName !== 'main' && branchName !== 'master') {
|
||||||
try {
|
// Validate branch name to prevent command injection
|
||||||
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
|
if (!isValidBranchName(branchName)) {
|
||||||
} catch {
|
logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
|
||||||
// Branch deletion failed, not critical
|
} else {
|
||||||
|
try {
|
||||||
|
await execGitCommand(['branch', '-D', branchName], projectPath);
|
||||||
|
} catch {
|
||||||
|
// Branch deletion failed, not critical
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
270
apps/server/src/routes/worktree/routes/init-script.ts
Normal file
270
apps/server/src/routes/worktree/routes/init-script.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* Init Script routes - Read/write/run the worktree-init.sh file
|
||||||
|
*
|
||||||
|
* POST /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
|
||||||
|
* POST /run-init-script - Run the init script for a worktree
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import path from 'path';
|
||||||
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
|
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import type { EventEmitter } from '../../../lib/events.js';
|
||||||
|
import { forceRunInitScript } from '../../../services/init-script-service.js';
|
||||||
|
|
||||||
|
const logger = createLogger('InitScript');
|
||||||
|
|
||||||
|
/** Fixed path for init script within .automaker directory */
|
||||||
|
const INIT_SCRIPT_FILENAME = 'worktree-init.sh';
|
||||||
|
|
||||||
|
/** Maximum allowed size for init scripts (1MB) */
|
||||||
|
const MAX_SCRIPT_SIZE_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> => {
|
||||||
|
try {
|
||||||
|
const projectPath = req.query.projectPath as string;
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'projectPath query parameter 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<void> => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate script size to prevent disk exhaustion
|
||||||
|
const sizeBytes = Buffer.byteLength(content, 'utf-8');
|
||||||
|
if (sizeBytes > MAX_SCRIPT_SIZE_BYTES) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Script size (${Math.round(sizeBytes / 1024)}KB) exceeds maximum allowed size (${Math.round(MAX_SCRIPT_SIZE_BYTES / 1024)}KB)`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log warning if potentially dangerous patterns are detected (non-blocking)
|
||||||
|
const dangerousPatterns = [
|
||||||
|
/rm\s+-rf\s+\/(?!\s*\$)/i, // rm -rf / (not followed by variable)
|
||||||
|
/curl\s+.*\|\s*(?:bash|sh)/i, // curl | bash
|
||||||
|
/wget\s+.*\|\s*(?:bash|sh)/i, // wget | sh
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of dangerousPatterns) {
|
||||||
|
if (pattern.test(content)) {
|
||||||
|
logger.warn(
|
||||||
|
`Init script contains potentially dangerous pattern: ${pattern.source}. User responsibility to verify script safety.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> => {
|
||||||
|
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);
|
||||||
|
|
||||||
|
await secureFs.rm(scriptPath, { force: true });
|
||||||
|
logger.info(`Deleted init script at ${scriptPath}`);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Delete init script failed');
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /run-init-script - Run (or re-run) the init script for a worktree
|
||||||
|
*/
|
||||||
|
export function createRunInitScriptHandler(events: EventEmitter) {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { projectPath, worktreePath, branch } = req.body as {
|
||||||
|
projectPath: string;
|
||||||
|
worktreePath: string;
|
||||||
|
branch: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'projectPath is required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!worktreePath) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'worktreePath is required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!branch) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'branch is required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate branch name to prevent injection via environment variables
|
||||||
|
if (!isValidBranchName(branch)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
'Invalid branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptPath = getInitScriptPath(projectPath);
|
||||||
|
|
||||||
|
// Check if script exists
|
||||||
|
try {
|
||||||
|
await secureFs.access(scriptPath);
|
||||||
|
} catch {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'No init script found. Create one in Settings > Worktrees.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Running init script for branch "${branch}" (forced)`);
|
||||||
|
|
||||||
|
// Run the script asynchronously (non-blocking)
|
||||||
|
forceRunInitScript({
|
||||||
|
projectPath,
|
||||||
|
worktreePath,
|
||||||
|
branch,
|
||||||
|
emitter: events,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return immediately - progress will be streamed via WebSocket events
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Init script started',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Run init script failed');
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
360
apps/server/src/services/init-script-service.ts
Normal file
360
apps/server/src/services/init-script-service.ts
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
/**
|
||||||
|
* 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 { createLogger } from '@automaker/utils';
|
||||||
|
import { systemPathExists, getShellPaths, findGitBashPath } from '@automaker/platform';
|
||||||
|
import { findCommand } from '../lib/cli-detection.js';
|
||||||
|
import type { EventEmitter } from '../lib/events.js';
|
||||||
|
import { readWorktreeMetadata, writeWorktreeMetadata } from '../lib/worktree-metadata.js';
|
||||||
|
import * as secureFs from '../lib/secure-fs.js';
|
||||||
|
|
||||||
|
const logger = createLogger('InitScript');
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShellCommand {
|
||||||
|
shell: string;
|
||||||
|
args: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Init Script Service
|
||||||
|
*
|
||||||
|
* Handles execution of worktree initialization scripts with cross-platform
|
||||||
|
* shell detection and proper streaming of output via WebSocket events.
|
||||||
|
*/
|
||||||
|
export class InitScriptService {
|
||||||
|
private cachedShellCommand: ShellCommand | null | undefined = undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the path to the init script for a project
|
||||||
|
*/
|
||||||
|
getInitScriptPath(projectPath: string): string {
|
||||||
|
return path.join(projectPath, '.automaker', 'worktree-init.sh');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the init script has already been run for a worktree
|
||||||
|
*/
|
||||||
|
async hasInitScriptRun(projectPath: string, branch: string): Promise<boolean> {
|
||||||
|
const metadata = await readWorktreeMetadata(projectPath, branch);
|
||||||
|
return metadata?.initScriptRan === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the appropriate shell for running scripts
|
||||||
|
* Uses findGitBashPath() on Windows to avoid WSL bash, then falls back to PATH
|
||||||
|
*/
|
||||||
|
async findShellCommand(): Promise<ShellCommand | null> {
|
||||||
|
// Return cached result if available
|
||||||
|
if (this.cachedShellCommand !== undefined) {
|
||||||
|
return this.cachedShellCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
// On Windows, prioritize Git Bash over WSL bash (C:\Windows\System32\bash.exe)
|
||||||
|
// WSL bash may not be properly configured and causes ENOENT errors
|
||||||
|
|
||||||
|
// First try known Git Bash installation paths
|
||||||
|
const gitBashPath = await findGitBashPath();
|
||||||
|
if (gitBashPath) {
|
||||||
|
logger.debug(`Found Git Bash at: ${gitBashPath}`);
|
||||||
|
this.cachedShellCommand = { shell: gitBashPath, args: [] };
|
||||||
|
return this.cachedShellCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to finding bash in PATH, but skip WSL bash
|
||||||
|
const bashInPath = await findCommand(['bash']);
|
||||||
|
if (bashInPath && !bashInPath.toLowerCase().includes('system32')) {
|
||||||
|
logger.debug(`Found bash in PATH at: ${bashInPath}`);
|
||||||
|
this.cachedShellCommand = { shell: bashInPath, args: [] };
|
||||||
|
return this.cachedShellCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('Git Bash not found. WSL bash was skipped to avoid compatibility issues.');
|
||||||
|
this.cachedShellCommand = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unix-like systems: use getShellPaths() and check existence
|
||||||
|
const shellPaths = getShellPaths();
|
||||||
|
const posixShells = shellPaths.filter(
|
||||||
|
(p) => p.includes('bash') || p === '/bin/sh' || p === '/usr/bin/sh'
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const shellPath of posixShells) {
|
||||||
|
try {
|
||||||
|
if (systemPathExists(shellPath)) {
|
||||||
|
this.cachedShellCommand = { shell: shellPath, args: [] };
|
||||||
|
return this.cachedShellCommand;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Path not allowed or doesn't exist, continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ultimate fallback
|
||||||
|
if (systemPathExists('/bin/sh')) {
|
||||||
|
this.cachedShellCommand = { shell: '/bin/sh', args: [] };
|
||||||
|
return this.cachedShellCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cachedShellCommand = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the worktree initialization script
|
||||||
|
* Non-blocking - returns immediately after spawning
|
||||||
|
*/
|
||||||
|
async runInitScript(options: InitScriptOptions): Promise<void> {
|
||||||
|
const { projectPath, worktreePath, branch, emitter } = options;
|
||||||
|
|
||||||
|
const scriptPath = this.getInitScriptPath(projectPath);
|
||||||
|
|
||||||
|
// Check if script exists using secureFs (respects ALLOWED_ROOT_DIRECTORY)
|
||||||
|
try {
|
||||||
|
await secureFs.access(scriptPath);
|
||||||
|
} catch {
|
||||||
|
logger.debug(`No init script found at ${scriptPath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already run
|
||||||
|
if (await this.hasInitScriptRun(projectPath, branch)) {
|
||||||
|
logger.info(`Init script already ran for branch "${branch}", skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get shell command
|
||||||
|
const shellCmd = await this.findShellCommand();
|
||||||
|
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, preserving existing metadata
|
||||||
|
const existingMetadata = await readWorktreeMetadata(projectPath, branch);
|
||||||
|
await writeWorktreeMetadata(projectPath, branch, {
|
||||||
|
branch,
|
||||||
|
createdAt: existingMetadata?.createdAt || new Date().toISOString(),
|
||||||
|
pr: existingMetadata?.pr,
|
||||||
|
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}`);
|
||||||
|
logger.debug(`Using shell: ${shellCmd.shell}`);
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build safe environment - only pass necessary variables, not all of process.env
|
||||||
|
// This prevents exposure of sensitive credentials like ANTHROPIC_API_KEY
|
||||||
|
const safeEnv: Record<string, string> = {
|
||||||
|
// Automaker-specific variables
|
||||||
|
AUTOMAKER_PROJECT_PATH: projectPath,
|
||||||
|
AUTOMAKER_WORKTREE_PATH: worktreePath,
|
||||||
|
AUTOMAKER_BRANCH: branch,
|
||||||
|
|
||||||
|
// Essential system variables
|
||||||
|
PATH: process.env.PATH || '',
|
||||||
|
HOME: process.env.HOME || '',
|
||||||
|
USER: process.env.USER || '',
|
||||||
|
TMPDIR: process.env.TMPDIR || process.env.TEMP || process.env.TMP || '/tmp',
|
||||||
|
|
||||||
|
// Shell and locale
|
||||||
|
SHELL: process.env.SHELL || '',
|
||||||
|
LANG: process.env.LANG || 'en_US.UTF-8',
|
||||||
|
LC_ALL: process.env.LC_ALL || '',
|
||||||
|
|
||||||
|
// Force color output even though we're not a TTY
|
||||||
|
FORCE_COLOR: '1',
|
||||||
|
npm_config_color: 'always',
|
||||||
|
CLICOLOR_FORCE: '1',
|
||||||
|
|
||||||
|
// Git configuration
|
||||||
|
GIT_TERMINAL_PROMPT: '0',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Platform-specific additions
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
safeEnv.USERPROFILE = process.env.USERPROFILE || '';
|
||||||
|
safeEnv.APPDATA = process.env.APPDATA || '';
|
||||||
|
safeEnv.LOCALAPPDATA = process.env.LOCALAPPDATA || '';
|
||||||
|
safeEnv.SystemRoot = process.env.SystemRoot || 'C:\\Windows';
|
||||||
|
safeEnv.TEMP = process.env.TEMP || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn the script with safe environment
|
||||||
|
const child = spawn(shellCmd.shell, [...shellCmd.args, scriptPath], {
|
||||||
|
cwd: worktreePath,
|
||||||
|
env: safeEnv,
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force re-run the worktree initialization script
|
||||||
|
* Ignores the initScriptRan flag - useful for testing or re-setup
|
||||||
|
*/
|
||||||
|
async forceRunInitScript(options: InitScriptOptions): Promise<void> {
|
||||||
|
const { projectPath, branch } = options;
|
||||||
|
|
||||||
|
// Reset the initScriptRan flag so the script will run
|
||||||
|
const metadata = await readWorktreeMetadata(projectPath, branch);
|
||||||
|
if (metadata) {
|
||||||
|
await writeWorktreeMetadata(projectPath, branch, {
|
||||||
|
...metadata,
|
||||||
|
initScriptRan: false,
|
||||||
|
initScriptStatus: undefined,
|
||||||
|
initScriptError: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now run the script
|
||||||
|
await this.runInitScript(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance for convenience
|
||||||
|
let initScriptService: InitScriptService | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the singleton InitScriptService instance
|
||||||
|
*/
|
||||||
|
export function getInitScriptService(): InitScriptService {
|
||||||
|
if (!initScriptService) {
|
||||||
|
initScriptService = new InitScriptService();
|
||||||
|
}
|
||||||
|
return initScriptService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export convenience functions that use the singleton
|
||||||
|
export const getInitScriptPath = (projectPath: string) =>
|
||||||
|
getInitScriptService().getInitScriptPath(projectPath);
|
||||||
|
|
||||||
|
export const hasInitScriptRun = (projectPath: string, branch: string) =>
|
||||||
|
getInitScriptService().hasInitScriptRun(projectPath, branch);
|
||||||
|
|
||||||
|
export const runInitScript = (options: InitScriptOptions) =>
|
||||||
|
getInitScriptService().runInitScript(options);
|
||||||
|
|
||||||
|
export const forceRunInitScript = (options: InitScriptOptions) =>
|
||||||
|
getInitScriptService().forceRunInitScript(options);
|
||||||
@@ -42,6 +42,8 @@
|
|||||||
"@automaker/dependency-resolver": "1.0.0",
|
"@automaker/dependency-resolver": "1.0.0",
|
||||||
"@automaker/types": "1.0.0",
|
"@automaker/types": "1.0.0",
|
||||||
"@codemirror/lang-xml": "6.1.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",
|
"@codemirror/theme-one-dark": "6.1.3",
|
||||||
"@dnd-kit/core": "6.3.1",
|
"@dnd-kit/core": "6.3.1",
|
||||||
"@dnd-kit/sortable": "10.0.0",
|
"@dnd-kit/sortable": "10.0.0",
|
||||||
|
|||||||
276
apps/ui/src/components/ui/ansi-output.tsx
Normal file
276
apps/ui/src/components/ui/ansi-output.tsx
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
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<number, string> = {
|
||||||
|
// 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<number, string> = {
|
||||||
|
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 (
|
||||||
|
<pre
|
||||||
|
className={cn(
|
||||||
|
'font-mono text-xs whitespace-pre-wrap break-words text-muted-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{segments.map((segment, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
color: segment.style.color,
|
||||||
|
backgroundColor: segment.style.backgroundColor,
|
||||||
|
fontWeight: segment.style.fontWeight,
|
||||||
|
fontStyle: segment.style.fontStyle,
|
||||||
|
textDecoration: segment.style.textDecoration,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{segment.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
apps/ui/src/components/ui/shell-syntax-editor.tsx
Normal file
142
apps/ui/src/components/ui/shell-syntax-editor.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
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;
|
||||||
|
maxHeight?: 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',
|
||||||
|
maxHeight,
|
||||||
|
'data-testid': testId,
|
||||||
|
}: ShellSyntaxEditorProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('w-full rounded-lg border border-border bg-muted/30', className)}
|
||||||
|
style={{ minHeight }}
|
||||||
|
data-testid={testId}
|
||||||
|
>
|
||||||
|
<CodeMirror
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
extensions={extensions}
|
||||||
|
theme="none"
|
||||||
|
placeholder={placeholder}
|
||||||
|
height={maxHeight}
|
||||||
|
minHeight={minHeight}
|
||||||
|
className="[&_.cm-editor]:min-h-[inherit]"
|
||||||
|
basicSetup={{
|
||||||
|
lineNumbers: true,
|
||||||
|
foldGutter: false,
|
||||||
|
highlightActiveLine: true,
|
||||||
|
highlightSelectionMatches: true,
|
||||||
|
autocompletion: false,
|
||||||
|
bracketMatching: true,
|
||||||
|
indentOnInput: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -75,6 +75,8 @@ import {
|
|||||||
} from './board-view/hooks';
|
} from './board-view/hooks';
|
||||||
import { SelectionActionBar } from './board-view/components';
|
import { SelectionActionBar } from './board-view/components';
|
||||||
import { MassEditDialog } from './board-view/dialogs';
|
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
|
// Stable empty array to avoid infinite loop in selector
|
||||||
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
|
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
|
||||||
@@ -99,6 +101,8 @@ export function BoardView() {
|
|||||||
useWorktrees,
|
useWorktrees,
|
||||||
enableDependencyBlocking,
|
enableDependencyBlocking,
|
||||||
skipVerificationInAutoMode,
|
skipVerificationInAutoMode,
|
||||||
|
planUseSelectedWorktreeBranch,
|
||||||
|
addFeatureUseSelectedWorktreeBranch,
|
||||||
isPrimaryWorktreeBranch,
|
isPrimaryWorktreeBranch,
|
||||||
getPrimaryWorktreeBranch,
|
getPrimaryWorktreeBranch,
|
||||||
setPipelineConfig,
|
setPipelineConfig,
|
||||||
@@ -107,6 +111,12 @@ export function BoardView() {
|
|||||||
const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject);
|
const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject);
|
||||||
// Subscribe to worktreePanelVisibleByProject to trigger re-renders when it changes
|
// Subscribe to worktreePanelVisibleByProject to trigger re-renders when it changes
|
||||||
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
|
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
|
||||||
|
// Subscribe to showInitScriptIndicatorByProject to trigger re-renders when it changes
|
||||||
|
const showInitScriptIndicatorByProject = useAppStore(
|
||||||
|
(state) => state.showInitScriptIndicatorByProject
|
||||||
|
);
|
||||||
|
const getShowInitScriptIndicator = useAppStore((state) => state.getShowInitScriptIndicator);
|
||||||
|
const getDefaultDeleteBranch = useAppStore((state) => state.getDefaultDeleteBranch);
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
const {
|
const {
|
||||||
features: hookFeatures,
|
features: hookFeatures,
|
||||||
@@ -252,6 +262,9 @@ export function BoardView() {
|
|||||||
// Window state hook for compact dialog mode
|
// Window state hook for compact dialog mode
|
||||||
const { isMaximized } = useWindowState();
|
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
|
// Keyboard shortcuts hook will be initialized after actions hook
|
||||||
|
|
||||||
// Prevent hydration issues
|
// Prevent hydration issues
|
||||||
@@ -1361,6 +1374,14 @@ export function BoardView() {
|
|||||||
isMaximized={isMaximized}
|
isMaximized={isMaximized}
|
||||||
parentFeature={spawnParentFeature}
|
parentFeature={spawnParentFeature}
|
||||||
allFeatures={hookFeatures}
|
allFeatures={hookFeatures}
|
||||||
|
// When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
|
||||||
|
selectedNonMainWorktreeBranch={
|
||||||
|
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
|
||||||
|
? currentWorktreeBranch || undefined
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
// When the worktree setting is disabled, force 'current' branch mode
|
||||||
|
forceCurrentBranchMode={!addFeatureUseSelectedWorktreeBranch}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Edit Feature Dialog */}
|
{/* Edit Feature Dialog */}
|
||||||
@@ -1440,6 +1461,7 @@ export function BoardView() {
|
|||||||
setPendingPlanResult={setPendingBacklogPlan}
|
setPendingPlanResult={setPendingBacklogPlan}
|
||||||
isGeneratingPlan={isGeneratingPlan}
|
isGeneratingPlan={isGeneratingPlan}
|
||||||
setIsGeneratingPlan={setIsGeneratingPlan}
|
setIsGeneratingPlan={setIsGeneratingPlan}
|
||||||
|
currentBranch={planUseSelectedWorktreeBranch ? selectedWorktreeBranch : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Plan Approval Dialog */}
|
{/* Plan Approval Dialog */}
|
||||||
@@ -1507,6 +1529,7 @@ export function BoardView() {
|
|||||||
? hookFeatures.filter((f) => f.branchName === selectedWorktreeForAction.branch).length
|
? hookFeatures.filter((f) => f.branchName === selectedWorktreeForAction.branch).length
|
||||||
: 0
|
: 0
|
||||||
}
|
}
|
||||||
|
defaultDeleteBranch={getDefaultDeleteBranch(currentProject.path)}
|
||||||
onDeleted={(deletedWorktree, _deletedBranch) => {
|
onDeleted={(deletedWorktree, _deletedBranch) => {
|
||||||
// Reset features that were assigned to the deleted worktree (by branch)
|
// Reset features that were assigned to the deleted worktree (by branch)
|
||||||
hookFeatures.forEach((feature) => {
|
hookFeatures.forEach((feature) => {
|
||||||
@@ -1574,6 +1597,11 @@ export function BoardView() {
|
|||||||
setSelectedWorktreeForAction(null);
|
setSelectedWorktreeForAction(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Init Script Indicator - floating overlay for worktree init script status */}
|
||||||
|
{getShowInitScriptIndicator(currentProject.path) && (
|
||||||
|
<InitScriptIndicator projectPath={currentProject.path} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { UsagePopover } from '@/components/usage-popover';
|
|||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
|
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
|
||||||
|
import { WorktreeSettingsDialog } from './dialogs/worktree-settings-dialog';
|
||||||
|
import { PlanSettingsDialog } from './dialogs/plan-settings-dialog';
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import { BoardSearchBar } from './board-search-bar';
|
import { BoardSearchBar } from './board-search-bar';
|
||||||
import { BoardControls } from './board-controls';
|
import { BoardControls } from './board-controls';
|
||||||
@@ -55,10 +57,22 @@ export function BoardHeader({
|
|||||||
completedCount,
|
completedCount,
|
||||||
}: BoardHeaderProps) {
|
}: BoardHeaderProps) {
|
||||||
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
|
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
|
||||||
|
const [showWorktreeSettings, setShowWorktreeSettings] = useState(false);
|
||||||
|
const [showPlanSettings, setShowPlanSettings] = useState(false);
|
||||||
const apiKeys = useAppStore((state) => state.apiKeys);
|
const apiKeys = useAppStore((state) => state.apiKeys);
|
||||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||||
const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode);
|
const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode);
|
||||||
const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode);
|
const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode);
|
||||||
|
const planUseSelectedWorktreeBranch = useAppStore((state) => state.planUseSelectedWorktreeBranch);
|
||||||
|
const setPlanUseSelectedWorktreeBranch = useAppStore(
|
||||||
|
(state) => state.setPlanUseSelectedWorktreeBranch
|
||||||
|
);
|
||||||
|
const addFeatureUseSelectedWorktreeBranch = useAppStore(
|
||||||
|
(state) => state.addFeatureUseSelectedWorktreeBranch
|
||||||
|
);
|
||||||
|
const setAddFeatureUseSelectedWorktreeBranch = useAppStore(
|
||||||
|
(state) => state.setAddFeatureUseSelectedWorktreeBranch
|
||||||
|
);
|
||||||
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
||||||
|
|
||||||
// Worktree panel visibility (per-project)
|
// Worktree panel visibility (per-project)
|
||||||
@@ -132,9 +146,25 @@ export function BoardHeader({
|
|||||||
onCheckedChange={handleWorktreePanelToggle}
|
onCheckedChange={handleWorktreePanelToggle}
|
||||||
data-testid="worktrees-toggle"
|
data-testid="worktrees-toggle"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowWorktreeSettings(true)}
|
||||||
|
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||||
|
title="Worktree Settings"
|
||||||
|
data-testid="worktree-settings-button"
|
||||||
|
>
|
||||||
|
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Worktree Settings Dialog */}
|
||||||
|
<WorktreeSettingsDialog
|
||||||
|
open={showWorktreeSettings}
|
||||||
|
onOpenChange={setShowWorktreeSettings}
|
||||||
|
addFeatureUseSelectedWorktreeBranch={addFeatureUseSelectedWorktreeBranch}
|
||||||
|
onAddFeatureUseSelectedWorktreeBranchChange={setAddFeatureUseSelectedWorktreeBranch}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Concurrency Control - only show after mount to prevent hydration issues */}
|
{/* Concurrency Control - only show after mount to prevent hydration issues */}
|
||||||
{isMounted && (
|
{isMounted && (
|
||||||
<Popover>
|
<Popover>
|
||||||
@@ -209,15 +239,33 @@ export function BoardHeader({
|
|||||||
onSkipVerificationChange={setSkipVerificationInAutoMode}
|
onSkipVerificationChange={setSkipVerificationInAutoMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
{/* Plan Button with Settings */}
|
||||||
size="sm"
|
<div className={controlContainerClass} data-testid="plan-button-container">
|
||||||
variant="outline"
|
<button
|
||||||
onClick={onOpenPlanDialog}
|
onClick={onOpenPlanDialog}
|
||||||
data-testid="plan-backlog-button"
|
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
|
||||||
>
|
data-testid="plan-backlog-button"
|
||||||
<Wand2 className="w-4 h-4 mr-2" />
|
>
|
||||||
Plan
|
<Wand2 className="w-4 h-4 text-muted-foreground" />
|
||||||
</Button>
|
<span className="text-sm font-medium">Plan</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPlanSettings(true)}
|
||||||
|
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||||
|
title="Plan Settings"
|
||||||
|
data-testid="plan-settings-button"
|
||||||
|
>
|
||||||
|
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plan Settings Dialog */}
|
||||||
|
<PlanSettingsDialog
|
||||||
|
open={showPlanSettings}
|
||||||
|
onOpenChange={setShowPlanSettings}
|
||||||
|
planUseSelectedWorktreeBranch={planUseSelectedWorktreeBranch}
|
||||||
|
onPlanUseSelectedWorktreeBranchChange={setPlanUseSelectedWorktreeBranch}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -56,6 +56,32 @@ import {
|
|||||||
|
|
||||||
const logger = createLogger('AddFeatureDialog');
|
const logger = createLogger('AddFeatureDialog');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the default work mode based on global settings and current worktree selection.
|
||||||
|
*
|
||||||
|
* Priority:
|
||||||
|
* 1. If forceCurrentBranchMode is true, always defaults to 'current' (work on current branch)
|
||||||
|
* 2. If a non-main worktree is selected in the board header, defaults to 'custom' (use that branch)
|
||||||
|
* 3. If useWorktrees global setting is enabled, defaults to 'auto' (automatic worktree creation)
|
||||||
|
* 4. Otherwise, defaults to 'current' (work on current branch without isolation)
|
||||||
|
*/
|
||||||
|
const getDefaultWorkMode = (
|
||||||
|
useWorktrees: boolean,
|
||||||
|
selectedNonMainWorktreeBranch?: string,
|
||||||
|
forceCurrentBranchMode?: boolean
|
||||||
|
): WorkMode => {
|
||||||
|
// If force current branch mode is enabled (worktree setting is off), always use 'current'
|
||||||
|
if (forceCurrentBranchMode) {
|
||||||
|
return 'current';
|
||||||
|
}
|
||||||
|
// If a non-main worktree is selected, default to 'custom' mode with that branch
|
||||||
|
if (selectedNonMainWorktreeBranch) {
|
||||||
|
return 'custom';
|
||||||
|
}
|
||||||
|
// Otherwise, respect the global worktree setting
|
||||||
|
return useWorktrees ? 'auto' : 'current';
|
||||||
|
};
|
||||||
|
|
||||||
type FeatureData = {
|
type FeatureData = {
|
||||||
title: string;
|
title: string;
|
||||||
category: string;
|
category: string;
|
||||||
@@ -89,6 +115,16 @@ interface AddFeatureDialogProps {
|
|||||||
isMaximized: boolean;
|
isMaximized: boolean;
|
||||||
parentFeature?: Feature | null;
|
parentFeature?: Feature | null;
|
||||||
allFeatures?: Feature[];
|
allFeatures?: Feature[];
|
||||||
|
/**
|
||||||
|
* When a non-main worktree is selected in the board header, this will be set to that worktree's branch.
|
||||||
|
* When set, the dialog will default to 'custom' work mode with this branch pre-filled.
|
||||||
|
*/
|
||||||
|
selectedNonMainWorktreeBranch?: string;
|
||||||
|
/**
|
||||||
|
* When true, forces the dialog to default to 'current' work mode (work on current branch).
|
||||||
|
* This is used when the "Use selected worktree branch" setting is disabled.
|
||||||
|
*/
|
||||||
|
forceCurrentBranchMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,6 +148,8 @@ export function AddFeatureDialog({
|
|||||||
isMaximized,
|
isMaximized,
|
||||||
parentFeature = null,
|
parentFeature = null,
|
||||||
allFeatures = [],
|
allFeatures = [],
|
||||||
|
selectedNonMainWorktreeBranch,
|
||||||
|
forceCurrentBranchMode,
|
||||||
}: AddFeatureDialogProps) {
|
}: AddFeatureDialogProps) {
|
||||||
const isSpawnMode = !!parentFeature;
|
const isSpawnMode = !!parentFeature;
|
||||||
const [workMode, setWorkMode] = useState<WorkMode>('current');
|
const [workMode, setWorkMode] = useState<WorkMode>('current');
|
||||||
@@ -149,7 +187,7 @@ export function AddFeatureDialog({
|
|||||||
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
|
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Get defaults from store
|
// Get defaults from store
|
||||||
const { defaultPlanningMode, defaultRequirePlanApproval } = useAppStore();
|
const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore();
|
||||||
|
|
||||||
// Track previous open state to detect when dialog opens
|
// Track previous open state to detect when dialog opens
|
||||||
const wasOpenRef = useRef(false);
|
const wasOpenRef = useRef(false);
|
||||||
@@ -161,8 +199,12 @@ export function AddFeatureDialog({
|
|||||||
|
|
||||||
if (justOpened) {
|
if (justOpened) {
|
||||||
setSkipTests(defaultSkipTests);
|
setSkipTests(defaultSkipTests);
|
||||||
setBranchName(defaultBranch || '');
|
// When a non-main worktree is selected, use its branch name for custom mode
|
||||||
setWorkMode('current');
|
// Otherwise, use the default branch
|
||||||
|
setBranchName(selectedNonMainWorktreeBranch || defaultBranch || '');
|
||||||
|
setWorkMode(
|
||||||
|
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
|
||||||
|
);
|
||||||
setPlanningMode(defaultPlanningMode);
|
setPlanningMode(defaultPlanningMode);
|
||||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||||
setModelEntry({ model: 'opus' });
|
setModelEntry({ model: 'opus' });
|
||||||
@@ -186,6 +228,9 @@ export function AddFeatureDialog({
|
|||||||
defaultBranch,
|
defaultBranch,
|
||||||
defaultPlanningMode,
|
defaultPlanningMode,
|
||||||
defaultRequirePlanApproval,
|
defaultRequirePlanApproval,
|
||||||
|
useWorktrees,
|
||||||
|
selectedNonMainWorktreeBranch,
|
||||||
|
forceCurrentBranchMode,
|
||||||
parentFeature,
|
parentFeature,
|
||||||
allFeatures,
|
allFeatures,
|
||||||
]);
|
]);
|
||||||
@@ -270,10 +315,13 @@ export function AddFeatureDialog({
|
|||||||
setImagePaths([]);
|
setImagePaths([]);
|
||||||
setTextFilePaths([]);
|
setTextFilePaths([]);
|
||||||
setSkipTests(defaultSkipTests);
|
setSkipTests(defaultSkipTests);
|
||||||
setBranchName('');
|
// When a non-main worktree is selected, use its branch name for custom mode
|
||||||
|
setBranchName(selectedNonMainWorktreeBranch || '');
|
||||||
setPriority(2);
|
setPriority(2);
|
||||||
setModelEntry({ model: 'opus' });
|
setModelEntry({ model: 'opus' });
|
||||||
setWorkMode('current');
|
setWorkMode(
|
||||||
|
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
|
||||||
|
);
|
||||||
setPlanningMode(defaultPlanningMode);
|
setPlanningMode(defaultPlanningMode);
|
||||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||||
setPreviewMap(new Map());
|
setPreviewMap(new Map());
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ interface BacklogPlanDialogProps {
|
|||||||
setPendingPlanResult: (result: BacklogPlanResult | null) => void;
|
setPendingPlanResult: (result: BacklogPlanResult | null) => void;
|
||||||
isGeneratingPlan: boolean;
|
isGeneratingPlan: boolean;
|
||||||
setIsGeneratingPlan: (generating: boolean) => void;
|
setIsGeneratingPlan: (generating: boolean) => void;
|
||||||
|
// Branch to use for created features (defaults to main if not provided)
|
||||||
|
currentBranch?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DialogMode = 'input' | 'review' | 'applying';
|
type DialogMode = 'input' | 'review' | 'applying';
|
||||||
@@ -76,6 +78,7 @@ export function BacklogPlanDialog({
|
|||||||
setPendingPlanResult,
|
setPendingPlanResult,
|
||||||
isGeneratingPlan,
|
isGeneratingPlan,
|
||||||
setIsGeneratingPlan,
|
setIsGeneratingPlan,
|
||||||
|
currentBranch,
|
||||||
}: BacklogPlanDialogProps) {
|
}: BacklogPlanDialogProps) {
|
||||||
const [mode, setMode] = useState<DialogMode>('input');
|
const [mode, setMode] = useState<DialogMode>('input');
|
||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
@@ -167,7 +170,7 @@ export function BacklogPlanDialog({
|
|||||||
}) || [],
|
}) || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await api.backlogPlan.apply(projectPath, filteredPlanResult);
|
const result = await api.backlogPlan.apply(projectPath, filteredPlanResult, currentBranch);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(`Applied ${result.appliedChanges?.length || 0} changes`);
|
toast.success(`Applied ${result.appliedChanges?.length || 0} changes`);
|
||||||
setPendingPlanResult(null);
|
setPendingPlanResult(null);
|
||||||
@@ -184,6 +187,7 @@ export function BacklogPlanDialog({
|
|||||||
setPendingPlanResult,
|
setPendingPlanResult,
|
||||||
onPlanApplied,
|
onPlanApplied,
|
||||||
onClose,
|
onClose,
|
||||||
|
currentBranch,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleDiscard = useCallback(() => {
|
const handleDiscard = useCallback(() => {
|
||||||
|
|||||||
@@ -10,10 +10,73 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { GitBranch, Loader2 } from 'lucide-react';
|
import { GitBranch, Loader2, AlertCircle } from 'lucide-react';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse git/worktree error messages and return user-friendly versions
|
||||||
|
*/
|
||||||
|
function parseWorktreeError(error: string): { title: string; description?: string } {
|
||||||
|
const errorLower = error.toLowerCase();
|
||||||
|
|
||||||
|
// Worktree already exists
|
||||||
|
if (errorLower.includes('already exists') && errorLower.includes('worktree')) {
|
||||||
|
return {
|
||||||
|
title: 'A worktree with this name already exists',
|
||||||
|
description: 'Try a different branch name or delete the existing worktree first.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Branch already checked out in another worktree
|
||||||
|
if (
|
||||||
|
errorLower.includes('already checked out') ||
|
||||||
|
errorLower.includes('is already used by worktree')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
title: 'This branch is already in use',
|
||||||
|
description: 'The branch is checked out in another worktree. Use a different branch name.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Branch name conflicts with existing branch
|
||||||
|
if (errorLower.includes('already exists') && errorLower.includes('branch')) {
|
||||||
|
return {
|
||||||
|
title: 'A branch with this name already exists',
|
||||||
|
description: 'The worktree will use the existing branch, or try a different name.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not a git repository
|
||||||
|
if (errorLower.includes('not a git repository')) {
|
||||||
|
return {
|
||||||
|
title: 'Not a git repository',
|
||||||
|
description: 'Initialize git in this project first with "git init".',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock file exists (another git operation in progress)
|
||||||
|
if (errorLower.includes('.lock') || errorLower.includes('lock file')) {
|
||||||
|
return {
|
||||||
|
title: 'Another git operation is in progress',
|
||||||
|
description: 'Wait for it to complete or remove stale lock files.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission denied
|
||||||
|
if (errorLower.includes('permission denied') || errorLower.includes('access denied')) {
|
||||||
|
return {
|
||||||
|
title: 'Permission denied',
|
||||||
|
description: 'Check file permissions for the project directory.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: return original error but cleaned up
|
||||||
|
return {
|
||||||
|
title: error.replace(/^(fatal|error):\s*/i, '').split('\n')[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface CreatedWorktreeInfo {
|
interface CreatedWorktreeInfo {
|
||||||
path: string;
|
path: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
@@ -34,20 +97,21 @@ export function CreateWorktreeDialog({
|
|||||||
}: CreateWorktreeDialogProps) {
|
}: CreateWorktreeDialogProps) {
|
||||||
const [branchName, setBranchName] = useState('');
|
const [branchName, setBranchName] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<{ title: string; description?: string } | null>(null);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!branchName.trim()) {
|
if (!branchName.trim()) {
|
||||||
setError('Branch name is required');
|
setError({ title: 'Branch name is required' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate branch name (git-compatible)
|
// Validate branch name (git-compatible)
|
||||||
const validBranchRegex = /^[a-zA-Z0-9._/-]+$/;
|
const validBranchRegex = /^[a-zA-Z0-9._/-]+$/;
|
||||||
if (!validBranchRegex.test(branchName)) {
|
if (!validBranchRegex.test(branchName)) {
|
||||||
setError(
|
setError({
|
||||||
'Invalid branch name. Use only letters, numbers, dots, underscores, hyphens, and slashes.'
|
title: 'Invalid branch name',
|
||||||
);
|
description: 'Use only letters, numbers, dots, underscores, hyphens, and slashes.',
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +121,7 @@ export function CreateWorktreeDialog({
|
|||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.worktree?.create) {
|
if (!api?.worktree?.create) {
|
||||||
setError('Worktree API not available');
|
setError({ title: 'Worktree API not available' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await api.worktree.create(projectPath, branchName);
|
const result = await api.worktree.create(projectPath, branchName);
|
||||||
@@ -70,10 +134,12 @@ export function CreateWorktreeDialog({
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
setBranchName('');
|
setBranchName('');
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || 'Failed to create worktree');
|
setError(parseWorktreeError(result.error || 'Failed to create worktree'));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to create worktree');
|
setError(
|
||||||
|
parseWorktreeError(err instanceof Error ? err.message : 'Failed to create worktree')
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -114,7 +180,17 @@ export function CreateWorktreeDialog({
|
|||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 p-3 rounded-md bg-destructive/10 border border-destructive/20">
|
||||||
|
<AlertCircle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-destructive">{error.title}</p>
|
||||||
|
{error.description && (
|
||||||
|
<p className="text-xs text-destructive/80">{error.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-muted-foreground space-y-1">
|
<div className="text-xs text-muted-foreground space-y-1">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -30,6 +30,8 @@ interface DeleteWorktreeDialogProps {
|
|||||||
onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
|
onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
|
||||||
/** Number of features assigned to this worktree's branch */
|
/** Number of features assigned to this worktree's branch */
|
||||||
affectedFeatureCount?: number;
|
affectedFeatureCount?: number;
|
||||||
|
/** Default value for the "delete branch" checkbox */
|
||||||
|
defaultDeleteBranch?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeleteWorktreeDialog({
|
export function DeleteWorktreeDialog({
|
||||||
@@ -39,10 +41,18 @@ export function DeleteWorktreeDialog({
|
|||||||
worktree,
|
worktree,
|
||||||
onDeleted,
|
onDeleted,
|
||||||
affectedFeatureCount = 0,
|
affectedFeatureCount = 0,
|
||||||
|
defaultDeleteBranch = false,
|
||||||
}: DeleteWorktreeDialogProps) {
|
}: DeleteWorktreeDialogProps) {
|
||||||
const [deleteBranch, setDeleteBranch] = useState(false);
|
const [deleteBranch, setDeleteBranch] = useState(defaultDeleteBranch);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Reset deleteBranch to default when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setDeleteBranch(defaultDeleteBranch);
|
||||||
|
}
|
||||||
|
}, [open, defaultDeleteBranch]);
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!worktree) return;
|
if (!worktree) return;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { GitBranch, Settings2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface PlanSettingsDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
planUseSelectedWorktreeBranch: boolean;
|
||||||
|
onPlanUseSelectedWorktreeBranchChange: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlanSettingsDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
planUseSelectedWorktreeBranch,
|
||||||
|
onPlanUseSelectedWorktreeBranchChange,
|
||||||
|
}: PlanSettingsDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md" data-testid="plan-settings-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Settings2 className="w-5 h-5" />
|
||||||
|
Plan Settings
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure how the Plan feature creates and organizes new features.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{/* Use Selected Worktree Branch Setting */}
|
||||||
|
<div className="flex items-start space-x-3 p-3 rounded-lg bg-secondary/50">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label
|
||||||
|
htmlFor="plan-worktree-branch-toggle"
|
||||||
|
className="text-sm font-medium cursor-pointer flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<GitBranch className="w-4 h-4 text-brand-500" />
|
||||||
|
Use selected worktree branch
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="plan-worktree-branch-toggle"
|
||||||
|
checked={planUseSelectedWorktreeBranch}
|
||||||
|
onCheckedChange={onPlanUseSelectedWorktreeBranchChange}
|
||||||
|
data-testid="plan-worktree-branch-toggle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
When enabled, features created via the Plan dialog will be assigned to the currently
|
||||||
|
selected worktree branch. When disabled, features will be added to the main branch.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { GitBranch, Settings2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface WorktreeSettingsDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
addFeatureUseSelectedWorktreeBranch: boolean;
|
||||||
|
onAddFeatureUseSelectedWorktreeBranchChange: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorktreeSettingsDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
addFeatureUseSelectedWorktreeBranch,
|
||||||
|
onAddFeatureUseSelectedWorktreeBranchChange,
|
||||||
|
}: WorktreeSettingsDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md" data-testid="worktree-settings-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Settings2 className="w-5 h-5" />
|
||||||
|
Worktree Settings
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure how worktrees affect feature creation and organization.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{/* Use Selected Worktree Branch Setting */}
|
||||||
|
<div className="flex items-start space-x-3 p-3 rounded-lg bg-secondary/50">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label
|
||||||
|
htmlFor="worktree-branch-toggle"
|
||||||
|
className="text-sm font-medium cursor-pointer flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<GitBranch className="w-4 h-4 text-brand-500" />
|
||||||
|
Use selected worktree branch
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="worktree-branch-toggle"
|
||||||
|
checked={addFeatureUseSelectedWorktreeBranch}
|
||||||
|
onCheckedChange={onAddFeatureUseSelectedWorktreeBranchChange}
|
||||||
|
data-testid="worktree-branch-toggle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
When enabled, the Add Feature dialog will default to custom branch mode with the
|
||||||
|
currently selected worktree branch pre-filled.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SingleIndicatorProps {
|
||||||
|
stateKey: string;
|
||||||
|
state: InitScriptState;
|
||||||
|
onDismiss: (key: string) => void;
|
||||||
|
isOnlyOne: boolean; // Whether this is the only indicator shown
|
||||||
|
autoDismiss: boolean; // Whether to auto-dismiss after completion
|
||||||
|
}
|
||||||
|
|
||||||
|
function SingleIndicator({
|
||||||
|
stateKey,
|
||||||
|
state,
|
||||||
|
onDismiss,
|
||||||
|
isOnlyOne,
|
||||||
|
autoDismiss,
|
||||||
|
}: SingleIndicatorProps) {
|
||||||
|
const [showLogs, setShowLogs] = useState(false);
|
||||||
|
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { status, output, branch, error } = state;
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when new output arrives
|
||||||
|
useEffect(() => {
|
||||||
|
if (showLogs && logsEndRef.current) {
|
||||||
|
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [output, showLogs]);
|
||||||
|
|
||||||
|
// Auto-expand logs when script starts (only if it's the only one or running)
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'running' && isOnlyOne) {
|
||||||
|
setShowLogs(true);
|
||||||
|
}
|
||||||
|
}, [status, isOnlyOne]);
|
||||||
|
|
||||||
|
// Auto-dismiss after completion (5 seconds)
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoDismiss && (status === 'success' || status === 'failed')) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
onDismiss(stateKey);
|
||||||
|
}, 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [status, autoDismiss, stateKey, onDismiss]);
|
||||||
|
|
||||||
|
if (status === 'idle') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'bg-card border border-border rounded-lg shadow-lg',
|
||||||
|
'min-w-[350px] max-w-[500px]',
|
||||||
|
'animate-in slide-in-from-right-5 duration-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-3 border-b border-border/50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{status === 'running' && <Loader2 className="w-4 h-4 animate-spin text-blue-500" />}
|
||||||
|
{status === 'success' && <Check className="w-4 h-4 text-green-500" />}
|
||||||
|
{status === 'failed' && <X className="w-4 h-4 text-red-500" />}
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
Init Script{' '}
|
||||||
|
{status === 'running' ? 'Running' : status === 'success' ? 'Completed' : 'Failed'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLogs(!showLogs)}
|
||||||
|
className="p-1 hover:bg-accent rounded transition-colors"
|
||||||
|
title={showLogs ? 'Hide logs' : 'Show logs'}
|
||||||
|
>
|
||||||
|
{showLogs ? (
|
||||||
|
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{status !== 'running' && (
|
||||||
|
<button
|
||||||
|
onClick={() => onDismiss(stateKey)}
|
||||||
|
className="p-1 hover:bg-accent rounded transition-colors"
|
||||||
|
title="Dismiss"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Branch info */}
|
||||||
|
<div className="px-3 py-2 text-xs text-muted-foreground flex items-center gap-2">
|
||||||
|
<Terminal className="w-3.5 h-3.5" />
|
||||||
|
<span>Branch: {branch}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logs (collapsible) */}
|
||||||
|
{showLogs && (
|
||||||
|
<div className="border-t border-border/50">
|
||||||
|
<div className="p-3 max-h-[300px] overflow-y-auto">
|
||||||
|
{output.length > 0 ? (
|
||||||
|
<AnsiOutput text={output.join('')} />
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-muted-foreground/60 text-center py-2">
|
||||||
|
{status === 'running' ? 'Waiting for output...' : 'No output'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <div className="mt-2 text-red-500 text-xs font-medium">Error: {error}</div>}
|
||||||
|
<div ref={logsEndRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status bar for completed states */}
|
||||||
|
{status !== 'running' && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-2 text-xs',
|
||||||
|
status === 'success' ? 'bg-green-500/10 text-green-600' : 'bg-red-500/10 text-red-600'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status === 'success'
|
||||||
|
? 'Initialization completed successfully'
|
||||||
|
: 'Initialization failed - worktree is still usable'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) {
|
||||||
|
const getInitScriptStatesForProject = useAppStore((s) => s.getInitScriptStatesForProject);
|
||||||
|
const clearInitScriptState = useAppStore((s) => s.clearInitScriptState);
|
||||||
|
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
|
||||||
|
const [dismissedKeys, setDismissedKeys] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Get auto-dismiss setting
|
||||||
|
const autoDismiss = getAutoDismissInitScriptIndicator(projectPath);
|
||||||
|
|
||||||
|
// Get all init script states for this project
|
||||||
|
const allStates = getInitScriptStatesForProject(projectPath);
|
||||||
|
|
||||||
|
// Filter out dismissed and idle states
|
||||||
|
const activeStates = allStates.filter(
|
||||||
|
({ key, state }) => !dismissedKeys.has(key) && state.status !== 'idle'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset dismissed keys when a new script starts for a branch
|
||||||
|
useEffect(() => {
|
||||||
|
const runningKeys = allStates
|
||||||
|
.filter(({ state }) => state.status === 'running')
|
||||||
|
.map(({ key }) => key);
|
||||||
|
|
||||||
|
if (runningKeys.length > 0) {
|
||||||
|
setDismissedKeys((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
runningKeys.forEach((key) => newSet.delete(key));
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [allStates]);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
setDismissedKeys((prev) => new Set(prev).add(key));
|
||||||
|
// Extract branch from key (format: "projectPath::branch")
|
||||||
|
const branch = key.split('::')[1];
|
||||||
|
if (branch) {
|
||||||
|
// Clear state after a delay to allow for future scripts
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInitScriptState(projectPath, branch);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectPath, clearInitScriptState]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (activeStates.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'fixed bottom-4 right-4 z-50 flex flex-col gap-2',
|
||||||
|
'max-h-[calc(100vh-120px)] overflow-y-auto',
|
||||||
|
'scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{activeStates.map(({ key, state }) => (
|
||||||
|
<SingleIndicator
|
||||||
|
key={key}
|
||||||
|
stateKey={key}
|
||||||
|
state={state}
|
||||||
|
onDismiss={handleDismiss}
|
||||||
|
isOnlyOne={activeStates.length === 1}
|
||||||
|
autoDismiss={autoDismiss}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
GitMerge,
|
GitMerge,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
RefreshCw,
|
||||||
Copy,
|
Copy,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -55,6 +56,8 @@ interface WorktreeActionsDropdownProps {
|
|||||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||||
|
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||||
|
hasInitScript: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorktreeActionsDropdown({
|
export function WorktreeActionsDropdown({
|
||||||
@@ -80,6 +83,8 @@ export function WorktreeActionsDropdown({
|
|||||||
onStartDevServer,
|
onStartDevServer,
|
||||||
onStopDevServer,
|
onStopDevServer,
|
||||||
onOpenDevServerUrl,
|
onOpenDevServerUrl,
|
||||||
|
onRunInitScript,
|
||||||
|
hasInitScript,
|
||||||
}: WorktreeActionsDropdownProps) {
|
}: WorktreeActionsDropdownProps) {
|
||||||
// Get available editors for the "Open In" submenu
|
// Get available editors for the "Open In" submenu
|
||||||
const { editors } = useAvailableEditors();
|
const { editors } = useAvailableEditors();
|
||||||
@@ -266,6 +271,12 @@ export function WorktreeActionsDropdown({
|
|||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
)}
|
)}
|
||||||
|
{!worktree.isMain && hasInitScript && (
|
||||||
|
<DropdownMenuItem onClick={() => onRunInitScript(worktree)} className="text-xs">
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Re-run Init Script
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{worktree.hasChanges && (
|
{worktree.hasChanges && (
|
||||||
<TooltipWrapper
|
<TooltipWrapper
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ interface WorktreeTabProps {
|
|||||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||||
|
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||||
|
hasInitScript: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorktreeTab({
|
export function WorktreeTab({
|
||||||
@@ -85,6 +87,8 @@ export function WorktreeTab({
|
|||||||
onStartDevServer,
|
onStartDevServer,
|
||||||
onStopDevServer,
|
onStopDevServer,
|
||||||
onOpenDevServerUrl,
|
onOpenDevServerUrl,
|
||||||
|
onRunInitScript,
|
||||||
|
hasInitScript,
|
||||||
}: WorktreeTabProps) {
|
}: WorktreeTabProps) {
|
||||||
let prBadge: JSX.Element | null = null;
|
let prBadge: JSX.Element | null = null;
|
||||||
if (worktree.pr) {
|
if (worktree.pr) {
|
||||||
@@ -333,6 +337,8 @@ export function WorktreeTab({
|
|||||||
onStartDevServer={onStartDevServer}
|
onStartDevServer={onStartDevServer}
|
||||||
onStopDevServer={onStopDevServer}
|
onStopDevServer={onStopDevServer}
|
||||||
onOpenDevServerUrl={onOpenDevServerUrl}
|
onOpenDevServerUrl={onOpenDevServerUrl}
|
||||||
|
onRunInitScript={onRunInitScript}
|
||||||
|
hasInitScript={hasInitScript}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
|
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
|
||||||
import { cn, pathsEqual } from '@/lib/utils';
|
import { cn, pathsEqual } from '@/lib/utils';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import type { WorktreePanelProps, WorktreeInfo } from './types';
|
import type { WorktreePanelProps, WorktreeInfo } from './types';
|
||||||
import {
|
import {
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
@@ -79,6 +81,28 @@ export function WorktreePanel({
|
|||||||
features,
|
features,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Track whether init script exists for the project
|
||||||
|
const [hasInitScript, setHasInitScript] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectPath) {
|
||||||
|
setHasInitScript(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkInitScript = async () => {
|
||||||
|
try {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
const result = await api.worktree.getInitScript(projectPath);
|
||||||
|
setHasInitScript(result.success && result.exists);
|
||||||
|
} catch {
|
||||||
|
setHasInitScript(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkInitScript();
|
||||||
|
}, [projectPath]);
|
||||||
|
|
||||||
// Periodic interval check (5 seconds) to detect branch changes on disk
|
// Periodic interval check (5 seconds) to detect branch changes on disk
|
||||||
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
|
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
|
||||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
@@ -113,6 +137,33 @@ export function WorktreePanel({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRunInitScript = useCallback(
|
||||||
|
async (worktree: WorktreeInfo) => {
|
||||||
|
if (!projectPath) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
const result = await api.worktree.runInitScript(
|
||||||
|
projectPath,
|
||||||
|
worktree.path,
|
||||||
|
worktree.branch
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error('Failed to run init script', {
|
||||||
|
description: result.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Success feedback will come via WebSocket events (init-started, init-output, init-completed)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to run init script', {
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectPath]
|
||||||
|
);
|
||||||
|
|
||||||
const mainWorktree = worktrees.find((w) => w.isMain);
|
const mainWorktree = worktrees.find((w) => w.isMain);
|
||||||
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
|
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
|
||||||
|
|
||||||
@@ -162,6 +213,8 @@ export function WorktreePanel({
|
|||||||
onStartDevServer={handleStartDevServer}
|
onStartDevServer={handleStartDevServer}
|
||||||
onStopDevServer={handleStopDevServer}
|
onStopDevServer={handleStopDevServer}
|
||||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
|
onRunInitScript={handleRunInitScript}
|
||||||
|
hasInitScript={hasInitScript}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -216,6 +269,8 @@ export function WorktreePanel({
|
|||||||
onStartDevServer={handleStartDevServer}
|
onStartDevServer={handleStartDevServer}
|
||||||
onStopDevServer={handleStopDevServer}
|
onStopDevServer={handleStopDevServer}
|
||||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
|
onRunInitScript={handleRunInitScript}
|
||||||
|
hasInitScript={hasInitScript}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { TerminalSection } from './settings-view/terminal/terminal-section';
|
|||||||
import { AudioSection } from './settings-view/audio/audio-section';
|
import { AudioSection } from './settings-view/audio/audio-section';
|
||||||
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
|
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
|
||||||
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-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 { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
|
||||||
import { AccountSection } from './settings-view/account';
|
import { AccountSection } from './settings-view/account';
|
||||||
import { SecuritySection } from './settings-view/security';
|
import { SecuritySection } from './settings-view/security';
|
||||||
@@ -149,17 +150,19 @@ export function SettingsView() {
|
|||||||
defaultSkipTests={defaultSkipTests}
|
defaultSkipTests={defaultSkipTests}
|
||||||
enableDependencyBlocking={enableDependencyBlocking}
|
enableDependencyBlocking={enableDependencyBlocking}
|
||||||
skipVerificationInAutoMode={skipVerificationInAutoMode}
|
skipVerificationInAutoMode={skipVerificationInAutoMode}
|
||||||
useWorktrees={useWorktrees}
|
|
||||||
defaultPlanningMode={defaultPlanningMode}
|
defaultPlanningMode={defaultPlanningMode}
|
||||||
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
||||||
onDefaultSkipTestsChange={setDefaultSkipTests}
|
onDefaultSkipTestsChange={setDefaultSkipTests}
|
||||||
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
||||||
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
|
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
|
||||||
onUseWorktreesChange={setUseWorktrees}
|
|
||||||
onDefaultPlanningModeChange={setDefaultPlanningMode}
|
onDefaultPlanningModeChange={setDefaultPlanningMode}
|
||||||
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case 'worktrees':
|
||||||
|
return (
|
||||||
|
<WorktreesSection useWorktrees={useWorktrees} onUseWorktreesChange={setUseWorktrees} />
|
||||||
|
);
|
||||||
case 'account':
|
case 'account':
|
||||||
return <AccountSection />;
|
return <AccountSection />;
|
||||||
case 'security':
|
case 'security':
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
User,
|
User,
|
||||||
Shield,
|
Shield,
|
||||||
|
Cpu,
|
||||||
|
GitBranch,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
|
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
|
||||||
import type { SettingsViewId } from '../hooks/use-settings-view';
|
import type { SettingsViewId } from '../hooks/use-settings-view';
|
||||||
@@ -37,6 +39,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ id: 'model-defaults', label: 'Model Defaults', icon: Workflow },
|
{ id: 'model-defaults', label: 'Model Defaults', icon: Workflow },
|
||||||
{ id: 'defaults', label: 'Feature Defaults', icon: FlaskConical },
|
{ id: 'defaults', label: 'Feature Defaults', icon: FlaskConical },
|
||||||
|
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
||||||
{ id: 'prompts', label: 'Prompt Customization', icon: MessageSquareText },
|
{ id: 'prompts', label: 'Prompt Customization', icon: MessageSquareText },
|
||||||
{ id: 'api-keys', label: 'API Keys', icon: Key },
|
{ id: 'api-keys', label: 'API Keys', icon: Key },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import {
|
import {
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
TestTube,
|
TestTube,
|
||||||
GitBranch,
|
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Zap,
|
Zap,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
@@ -27,13 +26,11 @@ interface FeatureDefaultsSectionProps {
|
|||||||
defaultSkipTests: boolean;
|
defaultSkipTests: boolean;
|
||||||
enableDependencyBlocking: boolean;
|
enableDependencyBlocking: boolean;
|
||||||
skipVerificationInAutoMode: boolean;
|
skipVerificationInAutoMode: boolean;
|
||||||
useWorktrees: boolean;
|
|
||||||
defaultPlanningMode: PlanningMode;
|
defaultPlanningMode: PlanningMode;
|
||||||
defaultRequirePlanApproval: boolean;
|
defaultRequirePlanApproval: boolean;
|
||||||
onDefaultSkipTestsChange: (value: boolean) => void;
|
onDefaultSkipTestsChange: (value: boolean) => void;
|
||||||
onEnableDependencyBlockingChange: (value: boolean) => void;
|
onEnableDependencyBlockingChange: (value: boolean) => void;
|
||||||
onSkipVerificationInAutoModeChange: (value: boolean) => void;
|
onSkipVerificationInAutoModeChange: (value: boolean) => void;
|
||||||
onUseWorktreesChange: (value: boolean) => void;
|
|
||||||
onDefaultPlanningModeChange: (value: PlanningMode) => void;
|
onDefaultPlanningModeChange: (value: PlanningMode) => void;
|
||||||
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
|
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
@@ -42,13 +39,11 @@ export function FeatureDefaultsSection({
|
|||||||
defaultSkipTests,
|
defaultSkipTests,
|
||||||
enableDependencyBlocking,
|
enableDependencyBlocking,
|
||||||
skipVerificationInAutoMode,
|
skipVerificationInAutoMode,
|
||||||
useWorktrees,
|
|
||||||
defaultPlanningMode,
|
defaultPlanningMode,
|
||||||
defaultRequirePlanApproval,
|
defaultRequirePlanApproval,
|
||||||
onDefaultSkipTestsChange,
|
onDefaultSkipTestsChange,
|
||||||
onEnableDependencyBlockingChange,
|
onEnableDependencyBlockingChange,
|
||||||
onSkipVerificationInAutoModeChange,
|
onSkipVerificationInAutoModeChange,
|
||||||
onUseWorktreesChange,
|
|
||||||
onDefaultPlanningModeChange,
|
onDefaultPlanningModeChange,
|
||||||
onDefaultRequirePlanApprovalChange,
|
onDefaultRequirePlanApprovalChange,
|
||||||
}: FeatureDefaultsSectionProps) {
|
}: FeatureDefaultsSectionProps) {
|
||||||
@@ -256,33 +251,6 @@ export function FeatureDefaultsSection({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Separator */}
|
|
||||||
<div className="border-t border-border/30" />
|
|
||||||
|
|
||||||
{/* Worktree Isolation Setting */}
|
|
||||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
|
||||||
<Checkbox
|
|
||||||
id="use-worktrees"
|
|
||||||
checked={useWorktrees}
|
|
||||||
onCheckedChange={(checked) => onUseWorktreesChange(checked === true)}
|
|
||||||
className="mt-1"
|
|
||||||
data-testid="use-worktrees-checkbox"
|
|
||||||
/>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label
|
|
||||||
htmlFor="use-worktrees"
|
|
||||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<GitBranch className="w-4 h-4 text-brand-500" />
|
|
||||||
Enable Git Worktree Isolation
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
|
||||||
Creates isolated git branches for each feature. When disabled, agents work directly in
|
|
||||||
the main project directory.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export type SettingsViewId =
|
|||||||
| 'keyboard'
|
| 'keyboard'
|
||||||
| 'audio'
|
| 'audio'
|
||||||
| 'defaults'
|
| 'defaults'
|
||||||
|
| 'worktrees'
|
||||||
| 'account'
|
| 'account'
|
||||||
| 'security'
|
| 'security'
|
||||||
| 'danger';
|
| 'danger';
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { WorktreesSection } from './worktrees-section';
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
|
||||||
|
import {
|
||||||
|
GitBranch,
|
||||||
|
Terminal,
|
||||||
|
FileCode,
|
||||||
|
Save,
|
||||||
|
RotateCcw,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
PanelBottomClose,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
|
||||||
|
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 getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator);
|
||||||
|
const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator);
|
||||||
|
const getDefaultDeleteBranch = useAppStore((s) => s.getDefaultDeleteBranch);
|
||||||
|
const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch);
|
||||||
|
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
|
||||||
|
const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator);
|
||||||
|
const [scriptContent, setScriptContent] = useState('');
|
||||||
|
const [originalContent, setOriginalContent] = useState('');
|
||||||
|
const [scriptExists, setScriptExists] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
// Get the current show indicator setting
|
||||||
|
const showIndicator = currentProject?.path
|
||||||
|
? getShowInitScriptIndicator(currentProject.path)
|
||||||
|
: true;
|
||||||
|
|
||||||
|
// Get the default delete branch setting
|
||||||
|
const defaultDeleteBranch = currentProject?.path
|
||||||
|
? getDefaultDeleteBranch(currentProject.path)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
// Get the auto-dismiss setting
|
||||||
|
const autoDismiss = currentProject?.path
|
||||||
|
? getAutoDismissInitScriptIndicator(currentProject.path)
|
||||||
|
: true;
|
||||||
|
|
||||||
|
// Check if there are unsaved changes
|
||||||
|
const hasChanges = scriptContent !== originalContent;
|
||||||
|
|
||||||
|
// Load init script content when project changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentProject?.path) {
|
||||||
|
setScriptContent('');
|
||||||
|
setOriginalContent('');
|
||||||
|
setScriptExists(false);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadInitScript = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiGet<InitScriptResponse>(
|
||||||
|
`/api/worktree/init-script?projectPath=${encodeURIComponent(currentProject.path)}`
|
||||||
|
);
|
||||||
|
if (response.success) {
|
||||||
|
const content = response.content || '';
|
||||||
|
setScriptContent(content);
|
||||||
|
setOriginalContent(content);
|
||||||
|
setScriptExists(response.exists);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load init script:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadInitScript();
|
||||||
|
}, [currentProject?.path]);
|
||||||
|
|
||||||
|
// Save script
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!currentProject?.path) return;
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const response = await apiPut<{ success: boolean; error?: string }>(
|
||||||
|
'/api/worktree/init-script',
|
||||||
|
{
|
||||||
|
projectPath: currentProject.path,
|
||||||
|
content: scriptContent,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (response.success) {
|
||||||
|
setOriginalContent(scriptContent);
|
||||||
|
setScriptExists(true);
|
||||||
|
toast.success('Init script saved');
|
||||||
|
} 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, scriptContent]);
|
||||||
|
|
||||||
|
// Reset to original content
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
setScriptContent(originalContent);
|
||||||
|
}, [originalContent]);
|
||||||
|
|
||||||
|
// Delete script
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!currentProject?.path) return;
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
const response = await apiDelete<{ success: boolean; error?: string }>(
|
||||||
|
'/api/worktree/init-script',
|
||||||
|
{
|
||||||
|
body: { projectPath: currentProject.path },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (response.success) {
|
||||||
|
setScriptContent('');
|
||||||
|
setOriginalContent('');
|
||||||
|
setScriptExists(false);
|
||||||
|
toast.success('Init script deleted');
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to delete init script', {
|
||||||
|
description: response.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete init script:', error);
|
||||||
|
toast.error('Failed to delete init script');
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
}, [currentProject?.path]);
|
||||||
|
|
||||||
|
// Handle content change (no auto-save)
|
||||||
|
const handleContentChange = useCallback((value: string) => {
|
||||||
|
setScriptContent(value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-2xl overflow-hidden',
|
||||||
|
'border border-border/50',
|
||||||
|
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||||
|
'shadow-sm shadow-black/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||||
|
<GitBranch className="w-5 h-5 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground tracking-tight">Worktrees</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||||
|
Configure git worktree isolation and initialization scripts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
{/* Enable Worktrees Toggle */}
|
||||||
|
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||||
|
<Checkbox
|
||||||
|
id="use-worktrees"
|
||||||
|
checked={useWorktrees}
|
||||||
|
onCheckedChange={(checked) => onUseWorktreesChange(checked === true)}
|
||||||
|
className="mt-1"
|
||||||
|
data-testid="use-worktrees-checkbox"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="use-worktrees"
|
||||||
|
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<GitBranch className="w-4 h-4 text-brand-500" />
|
||||||
|
Enable Git Worktree Isolation
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||||
|
Creates isolated git branches for each feature. When disabled, agents work directly in
|
||||||
|
the main project directory.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show Init Script Indicator Toggle */}
|
||||||
|
{currentProject && (
|
||||||
|
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 mt-4">
|
||||||
|
<Checkbox
|
||||||
|
id="show-init-script-indicator"
|
||||||
|
checked={showIndicator}
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
if (currentProject?.path) {
|
||||||
|
const value = checked === true;
|
||||||
|
setShowInitScriptIndicator(currentProject.path, value);
|
||||||
|
// Persist to server
|
||||||
|
try {
|
||||||
|
const httpClient = getHttpApiClient();
|
||||||
|
await httpClient.settings.updateProject(currentProject.path, {
|
||||||
|
showInitScriptIndicator: value,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to persist showInitScriptIndicator:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="show-init-script-indicator"
|
||||||
|
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<PanelBottomClose className="w-4 h-4 text-brand-500" />
|
||||||
|
Show Init Script Indicator
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||||
|
Display a floating panel in the bottom-right corner showing init script execution
|
||||||
|
status and output when a worktree is created.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Auto-dismiss Init Script Indicator Toggle */}
|
||||||
|
{currentProject && showIndicator && (
|
||||||
|
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 ml-6">
|
||||||
|
<Checkbox
|
||||||
|
id="auto-dismiss-indicator"
|
||||||
|
checked={autoDismiss}
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
if (currentProject?.path) {
|
||||||
|
const value = checked === true;
|
||||||
|
setAutoDismissInitScriptIndicator(currentProject.path, value);
|
||||||
|
// Persist to server
|
||||||
|
try {
|
||||||
|
const httpClient = getHttpApiClient();
|
||||||
|
await httpClient.settings.updateProject(currentProject.path, {
|
||||||
|
autoDismissInitScriptIndicator: value,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to persist autoDismissInitScriptIndicator:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="auto-dismiss-indicator"
|
||||||
|
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||||
|
>
|
||||||
|
Auto-dismiss After Completion
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||||
|
Automatically hide the indicator 5 seconds after the script completes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Default Delete Branch Toggle */}
|
||||||
|
{currentProject && (
|
||||||
|
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||||
|
<Checkbox
|
||||||
|
id="default-delete-branch"
|
||||||
|
checked={defaultDeleteBranch}
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
if (currentProject?.path) {
|
||||||
|
const value = checked === true;
|
||||||
|
setDefaultDeleteBranch(currentProject.path, value);
|
||||||
|
// Persist to server
|
||||||
|
try {
|
||||||
|
const httpClient = getHttpApiClient();
|
||||||
|
await httpClient.settings.updateProject(currentProject.path, {
|
||||||
|
defaultDeleteBranch: value,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to persist defaultDeleteBranch:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="default-delete-branch"
|
||||||
|
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 text-brand-500" />
|
||||||
|
Delete Branch by Default
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||||
|
When deleting a worktree, automatically check the "Also delete the branch" option.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<div className="border-t border-border/30" />
|
||||||
|
|
||||||
|
{/* Init Script Section */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Terminal className="w-4 h-4 text-brand-500" />
|
||||||
|
<Label className="text-foreground font-medium">Initialization Script</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||||
|
Shell commands to run after a worktree is created. Runs once per worktree. Uses Git Bash
|
||||||
|
on Windows for cross-platform compatibility.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{currentProject ? (
|
||||||
|
<>
|
||||||
|
{/* File path indicator */}
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground/60">
|
||||||
|
<FileCode className="w-3.5 h-3.5" />
|
||||||
|
<code className="font-mono">.automaker/worktree-init.sh</code>
|
||||||
|
{hasChanges && (
|
||||||
|
<span className="text-amber-500 font-medium">(unsaved changes)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ShellSyntaxEditor
|
||||||
|
value={scriptContent}
|
||||||
|
onChange={handleContentChange}
|
||||||
|
placeholder={`# Example initialization commands
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Or use pnpm
|
||||||
|
# pnpm install
|
||||||
|
|
||||||
|
# Copy environment file
|
||||||
|
# cp .env.example .env`}
|
||||||
|
minHeight="200px"
|
||||||
|
maxHeight="500px"
|
||||||
|
data-testid="init-script-editor"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={!hasChanges || isSaving || isDeleting}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={!scriptExists || isSaving || isDeleting}
|
||||||
|
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges || isSaving || isDeleting}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground/60 py-4 text-center">
|
||||||
|
Select a project to configure the init script.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
apps/ui/src/hooks/use-init-script-events.ts
Normal file
79
apps/ui/src/hooks/use-init-script-events.ts
Normal file
@@ -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, startPayload.branch, {
|
||||||
|
status: 'running',
|
||||||
|
branch: startPayload.branch,
|
||||||
|
output: [],
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'worktree:init-output': {
|
||||||
|
const outputPayload = payload as InitScriptOutputPayload;
|
||||||
|
appendInitScriptOutput(projectPath, outputPayload.branch, outputPayload.content);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'worktree:init-completed': {
|
||||||
|
const completePayload = payload as InitScriptCompletedPayload;
|
||||||
|
setInitScriptState(projectPath, completePayload.branch, {
|
||||||
|
status: completePayload.success ? 'success' : 'failed',
|
||||||
|
error: completePayload.error,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, [projectPath, setInitScriptState, appendInitScriptOutput]);
|
||||||
|
}
|
||||||
@@ -18,6 +18,11 @@ export function useProjectSettingsLoader() {
|
|||||||
const setCardBorderOpacity = useAppStore((state) => state.setCardBorderOpacity);
|
const setCardBorderOpacity = useAppStore((state) => state.setCardBorderOpacity);
|
||||||
const setHideScrollbar = useAppStore((state) => state.setHideScrollbar);
|
const setHideScrollbar = useAppStore((state) => state.setHideScrollbar);
|
||||||
const setWorktreePanelVisible = useAppStore((state) => state.setWorktreePanelVisible);
|
const setWorktreePanelVisible = useAppStore((state) => state.setWorktreePanelVisible);
|
||||||
|
const setShowInitScriptIndicator = useAppStore((state) => state.setShowInitScriptIndicator);
|
||||||
|
const setDefaultDeleteBranch = useAppStore((state) => state.setDefaultDeleteBranch);
|
||||||
|
const setAutoDismissInitScriptIndicator = useAppStore(
|
||||||
|
(state) => state.setAutoDismissInitScriptIndicator
|
||||||
|
);
|
||||||
|
|
||||||
const loadingRef = useRef<string | null>(null);
|
const loadingRef = useRef<string | null>(null);
|
||||||
const currentProjectRef = useRef<string | null>(null);
|
const currentProjectRef = useRef<string | null>(null);
|
||||||
@@ -78,6 +83,27 @@ export function useProjectSettingsLoader() {
|
|||||||
if (result.settings.worktreePanelVisible !== undefined) {
|
if (result.settings.worktreePanelVisible !== undefined) {
|
||||||
setWorktreePanelVisible(requestedProjectPath, result.settings.worktreePanelVisible);
|
setWorktreePanelVisible(requestedProjectPath, result.settings.worktreePanelVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply showInitScriptIndicator if present
|
||||||
|
if (result.settings.showInitScriptIndicator !== undefined) {
|
||||||
|
setShowInitScriptIndicator(
|
||||||
|
requestedProjectPath,
|
||||||
|
result.settings.showInitScriptIndicator
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply defaultDeleteBranch if present
|
||||||
|
if (result.settings.defaultDeleteBranch !== undefined) {
|
||||||
|
setDefaultDeleteBranch(requestedProjectPath, result.settings.defaultDeleteBranch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply autoDismissInitScriptIndicator if present
|
||||||
|
if (result.settings.autoDismissInitScriptIndicator !== undefined) {
|
||||||
|
setAutoDismissInitScriptIndicator(
|
||||||
|
requestedProjectPath,
|
||||||
|
result.settings.autoDismissInitScriptIndicator
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load project settings:', error);
|
console.error('Failed to load project settings:', error);
|
||||||
|
|||||||
@@ -651,7 +651,8 @@ export interface ElectronAPI {
|
|||||||
removedDependencies: string[];
|
removedDependencies: string[];
|
||||||
addedDependencies: string[];
|
addedDependencies: string[];
|
||||||
}>;
|
}>;
|
||||||
}
|
},
|
||||||
|
branchName?: string
|
||||||
) => Promise<{ success: boolean; appliedChanges?: string[]; error?: string }>;
|
) => Promise<{ success: boolean; appliedChanges?: string[]; error?: string }>;
|
||||||
onEvent: (callback: (data: unknown) => void) => () => void;
|
onEvent: (callback: (data: unknown) => void) => () => void;
|
||||||
};
|
};
|
||||||
@@ -1769,6 +1770,47 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getInitScript: async (projectPath: string) => {
|
||||||
|
console.log('[Mock] Getting init script:', { projectPath });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
exists: false,
|
||||||
|
content: '',
|
||||||
|
path: `${projectPath}/.automaker/worktree-init.sh`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
setInitScript: async (projectPath: string, content: string) => {
|
||||||
|
console.log('[Mock] Setting init script:', { projectPath, content });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
path: `${projectPath}/.automaker/worktree-init.sh`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteInitScript: async (projectPath: string) => {
|
||||||
|
console.log('[Mock] Deleting init script:', { projectPath });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
runInitScript: async (projectPath: string, worktreePath: string, branch: string) => {
|
||||||
|
console.log('[Mock] Running init script:', { projectPath, worktreePath, branch });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Init script started (mock)',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
onInitScriptEvent: (callback) => {
|
||||||
|
console.log('[Mock] Subscribing to init script events');
|
||||||
|
// Return unsubscribe function
|
||||||
|
return () => {
|
||||||
|
console.log('[Mock] Unsubscribing from init script events');
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -507,7 +507,10 @@ type EventType =
|
|||||||
| 'issue-validation:event'
|
| 'issue-validation:event'
|
||||||
| 'backlog-plan:event'
|
| 'backlog-plan:event'
|
||||||
| 'ideation:stream'
|
| 'ideation:stream'
|
||||||
| 'ideation:analysis';
|
| 'ideation:analysis'
|
||||||
|
| 'worktree:init-started'
|
||||||
|
| 'worktree:init-output'
|
||||||
|
| 'worktree:init-completed';
|
||||||
|
|
||||||
type EventCallback = (payload: unknown) => void;
|
type EventCallback = (payload: unknown) => void;
|
||||||
|
|
||||||
@@ -846,13 +849,14 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async httpDelete<T>(endpoint: string): Promise<T> {
|
private async httpDelete<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||||
// Ensure API key is initialized before making request
|
// Ensure API key is initialized before making request
|
||||||
await waitForApiKeyInit();
|
await waitForApiKeyInit();
|
||||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
credentials: 'include', // Include cookies for session auth
|
credentials: 'include', // Include cookies for session auth
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
@@ -1647,6 +1651,37 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
listDevServers: () => this.post('/api/worktree/list-dev-servers', {}),
|
listDevServers: () => this.post('/api/worktree/list-dev-servers', {}),
|
||||||
getPRInfo: (worktreePath: string, branchName: string) =>
|
getPRInfo: (worktreePath: string, branchName: string) =>
|
||||||
this.post('/api/worktree/pr-info', { worktreePath, branchName }),
|
this.post('/api/worktree/pr-info', { worktreePath, branchName }),
|
||||||
|
// Init script methods
|
||||||
|
getInitScript: (projectPath: string) =>
|
||||||
|
this.get(`/api/worktree/init-script?projectPath=${encodeURIComponent(projectPath)}`),
|
||||||
|
setInitScript: (projectPath: string, content: string) =>
|
||||||
|
this.put('/api/worktree/init-script', { projectPath, content }),
|
||||||
|
deleteInitScript: (projectPath: string) =>
|
||||||
|
this.httpDelete('/api/worktree/init-script', { projectPath }),
|
||||||
|
runInitScript: (projectPath: string, worktreePath: string, branch: string) =>
|
||||||
|
this.post('/api/worktree/run-init-script', { projectPath, worktreePath, branch }),
|
||||||
|
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
|
// Git API
|
||||||
@@ -2167,9 +2202,10 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
removedDependencies: string[];
|
removedDependencies: string[];
|
||||||
addedDependencies: string[];
|
addedDependencies: string[];
|
||||||
}>;
|
}>;
|
||||||
}
|
},
|
||||||
|
branchName?: string
|
||||||
): Promise<{ success: boolean; appliedChanges?: string[]; error?: string }> =>
|
): Promise<{ success: boolean; appliedChanges?: string[]; error?: string }> =>
|
||||||
this.post('/api/backlog-plan/apply', { projectPath, plan }),
|
this.post('/api/backlog-plan/apply', { projectPath, plan, branchName }),
|
||||||
|
|
||||||
onEvent: (callback: (data: unknown) => void): (() => void) => {
|
onEvent: (callback: (data: unknown) => void): (() => void) => {
|
||||||
return this.subscribeToEvent('backlog-plan:event', callback as EventCallback);
|
return this.subscribeToEvent('backlog-plan:event', callback as EventCallback);
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ export type ThemeMode =
|
|||||||
// LocalStorage key for theme persistence (fallback when server settings aren't available)
|
// LocalStorage key for theme persistence (fallback when server settings aren't available)
|
||||||
export const THEME_STORAGE_KEY = 'automaker:theme';
|
export const THEME_STORAGE_KEY = 'automaker:theme';
|
||||||
|
|
||||||
|
// Maximum number of output lines to keep in init script state (prevents unbounded memory growth)
|
||||||
|
export const MAX_INIT_OUTPUT_LINES = 500;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the theme from localStorage as a fallback
|
* Get the theme from localStorage as a fallback
|
||||||
* Used before server settings are loaded (e.g., on login/setup pages)
|
* Used before server settings are loaded (e.g., on login/setup pages)
|
||||||
@@ -469,6 +472,14 @@ export interface PersistedTerminalSettings {
|
|||||||
maxSessions: number;
|
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 {
|
export interface AppState {
|
||||||
// Project state
|
// Project state
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
@@ -522,6 +533,8 @@ export interface AppState {
|
|||||||
defaultSkipTests: boolean; // Default value for skip tests when creating new features
|
defaultSkipTests: boolean; // Default value for skip tests when creating new features
|
||||||
enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true)
|
enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true)
|
||||||
skipVerificationInAutoMode: boolean; // When true, auto-mode grabs features even if dependencies are not verified (only checks they're not running)
|
skipVerificationInAutoMode: boolean; // When true, auto-mode grabs features even if dependencies are not verified (only checks they're not running)
|
||||||
|
planUseSelectedWorktreeBranch: boolean; // When true, Plan dialog creates features on the currently selected worktree branch
|
||||||
|
addFeatureUseSelectedWorktreeBranch: boolean; // When true, Add Feature dialog defaults to custom mode with selected worktree branch
|
||||||
|
|
||||||
// Worktree Settings
|
// Worktree Settings
|
||||||
useWorktrees: boolean; // Whether to use git worktree isolation for features (default: true)
|
useWorktrees: boolean; // Whether to use git worktree isolation for features (default: true)
|
||||||
@@ -670,6 +683,18 @@ export interface AppState {
|
|||||||
// Whether the worktree panel row is visible (default: true)
|
// Whether the worktree panel row is visible (default: true)
|
||||||
worktreePanelVisibleByProject: Record<string, boolean>;
|
worktreePanelVisibleByProject: Record<string, boolean>;
|
||||||
|
|
||||||
|
// Init Script Indicator Visibility (per-project, keyed by project path)
|
||||||
|
// Whether to show the floating init script indicator panel (default: true)
|
||||||
|
showInitScriptIndicatorByProject: Record<string, boolean>;
|
||||||
|
|
||||||
|
// Default Delete Branch With Worktree (per-project, keyed by project path)
|
||||||
|
// Whether to default the "delete branch" checkbox when deleting a worktree (default: false)
|
||||||
|
defaultDeleteBranchByProject: Record<string, boolean>;
|
||||||
|
|
||||||
|
// Auto-dismiss Init Script Indicator (per-project, keyed by project path)
|
||||||
|
// Whether to auto-dismiss the indicator after completion (default: true)
|
||||||
|
autoDismissInitScriptIndicatorByProject: Record<string, boolean>;
|
||||||
|
|
||||||
// UI State (previously in localStorage, now synced via API)
|
// UI State (previously in localStorage, now synced via API)
|
||||||
/** Whether worktree panel is collapsed in board view */
|
/** Whether worktree panel is collapsed in board view */
|
||||||
worktreePanelCollapsed: boolean;
|
worktreePanelCollapsed: boolean;
|
||||||
@@ -677,6 +702,9 @@ export interface AppState {
|
|||||||
lastProjectDir: string;
|
lastProjectDir: string;
|
||||||
/** Recently accessed folders for quick access */
|
/** Recently accessed folders for quick access */
|
||||||
recentFolders: string[];
|
recentFolders: string[];
|
||||||
|
|
||||||
|
// Init Script State (keyed by "projectPath::branch" to support concurrent scripts)
|
||||||
|
initScriptState: Record<string, InitScriptState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Claude Usage interface matching the server response
|
// Claude Usage interface matching the server response
|
||||||
@@ -890,6 +918,8 @@ export interface AppActions {
|
|||||||
setDefaultSkipTests: (skip: boolean) => void;
|
setDefaultSkipTests: (skip: boolean) => void;
|
||||||
setEnableDependencyBlocking: (enabled: boolean) => void;
|
setEnableDependencyBlocking: (enabled: boolean) => void;
|
||||||
setSkipVerificationInAutoMode: (enabled: boolean) => Promise<void>;
|
setSkipVerificationInAutoMode: (enabled: boolean) => Promise<void>;
|
||||||
|
setPlanUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
|
||||||
|
setAddFeatureUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
|
||||||
|
|
||||||
// Worktree Settings actions
|
// Worktree Settings actions
|
||||||
setUseWorktrees: (enabled: boolean) => void;
|
setUseWorktrees: (enabled: boolean) => void;
|
||||||
@@ -1083,6 +1113,18 @@ export interface AppActions {
|
|||||||
setWorktreePanelVisible: (projectPath: string, visible: boolean) => void;
|
setWorktreePanelVisible: (projectPath: string, visible: boolean) => void;
|
||||||
getWorktreePanelVisible: (projectPath: string) => boolean;
|
getWorktreePanelVisible: (projectPath: string) => boolean;
|
||||||
|
|
||||||
|
// Init Script Indicator Visibility actions (per-project)
|
||||||
|
setShowInitScriptIndicator: (projectPath: string, visible: boolean) => void;
|
||||||
|
getShowInitScriptIndicator: (projectPath: string) => boolean;
|
||||||
|
|
||||||
|
// Default Delete Branch actions (per-project)
|
||||||
|
setDefaultDeleteBranch: (projectPath: string, deleteBranch: boolean) => void;
|
||||||
|
getDefaultDeleteBranch: (projectPath: string) => boolean;
|
||||||
|
|
||||||
|
// Auto-dismiss Init Script Indicator actions (per-project)
|
||||||
|
setAutoDismissInitScriptIndicator: (projectPath: string, autoDismiss: boolean) => void;
|
||||||
|
getAutoDismissInitScriptIndicator: (projectPath: string) => boolean;
|
||||||
|
|
||||||
// UI State actions (previously in localStorage, now synced via API)
|
// UI State actions (previously in localStorage, now synced via API)
|
||||||
setWorktreePanelCollapsed: (collapsed: boolean) => void;
|
setWorktreePanelCollapsed: (collapsed: boolean) => void;
|
||||||
setLastProjectDir: (dir: string) => void;
|
setLastProjectDir: (dir: string) => void;
|
||||||
@@ -1111,6 +1153,19 @@ export interface AppActions {
|
|||||||
}>
|
}>
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
|
// Init Script State actions (keyed by projectPath::branch to support concurrent scripts)
|
||||||
|
setInitScriptState: (
|
||||||
|
projectPath: string,
|
||||||
|
branch: string,
|
||||||
|
state: Partial<InitScriptState>
|
||||||
|
) => void;
|
||||||
|
appendInitScriptOutput: (projectPath: string, branch: string, content: string) => void;
|
||||||
|
clearInitScriptState: (projectPath: string, branch: string) => void;
|
||||||
|
getInitScriptState: (projectPath: string, branch: string) => InitScriptState | null;
|
||||||
|
getInitScriptStatesForProject: (
|
||||||
|
projectPath: string
|
||||||
|
) => Array<{ key: string; state: InitScriptState }>;
|
||||||
|
|
||||||
// Reset
|
// Reset
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
@@ -1143,6 +1198,8 @@ const initialState: AppState = {
|
|||||||
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
||||||
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
|
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
|
||||||
skipVerificationInAutoMode: false, // Default to disabled (require dependencies to be verified)
|
skipVerificationInAutoMode: false, // Default to disabled (require dependencies to be verified)
|
||||||
|
planUseSelectedWorktreeBranch: true, // Default to enabled (Plan creates features on selected worktree branch)
|
||||||
|
addFeatureUseSelectedWorktreeBranch: false, // Default to disabled (Add Feature uses normal defaults)
|
||||||
useWorktrees: true, // Default to enabled (git worktree isolation)
|
useWorktrees: true, // Default to enabled (git worktree isolation)
|
||||||
currentWorktreeByProject: {},
|
currentWorktreeByProject: {},
|
||||||
worktreesByProject: {},
|
worktreesByProject: {},
|
||||||
@@ -1208,10 +1265,14 @@ const initialState: AppState = {
|
|||||||
codexModelsLastFetched: null,
|
codexModelsLastFetched: null,
|
||||||
pipelineConfigByProject: {},
|
pipelineConfigByProject: {},
|
||||||
worktreePanelVisibleByProject: {},
|
worktreePanelVisibleByProject: {},
|
||||||
|
showInitScriptIndicatorByProject: {},
|
||||||
|
defaultDeleteBranchByProject: {},
|
||||||
|
autoDismissInitScriptIndicatorByProject: {},
|
||||||
// UI State (previously in localStorage, now synced via API)
|
// UI State (previously in localStorage, now synced via API)
|
||||||
worktreePanelCollapsed: false,
|
worktreePanelCollapsed: false,
|
||||||
lastProjectDir: '',
|
lastProjectDir: '',
|
||||||
recentFolders: [],
|
recentFolders: [],
|
||||||
|
initScriptState: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||||
@@ -1764,6 +1825,30 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||||
await syncSettingsToServer();
|
await syncSettingsToServer();
|
||||||
},
|
},
|
||||||
|
setPlanUseSelectedWorktreeBranch: async (enabled) => {
|
||||||
|
const previous = get().planUseSelectedWorktreeBranch;
|
||||||
|
set({ planUseSelectedWorktreeBranch: enabled });
|
||||||
|
// Sync to server settings file
|
||||||
|
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||||
|
const ok = await syncSettingsToServer();
|
||||||
|
if (!ok) {
|
||||||
|
logger.error('Failed to sync planUseSelectedWorktreeBranch setting to server - reverting');
|
||||||
|
set({ planUseSelectedWorktreeBranch: previous });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setAddFeatureUseSelectedWorktreeBranch: async (enabled) => {
|
||||||
|
const previous = get().addFeatureUseSelectedWorktreeBranch;
|
||||||
|
set({ addFeatureUseSelectedWorktreeBranch: enabled });
|
||||||
|
// Sync to server settings file
|
||||||
|
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||||
|
const ok = await syncSettingsToServer();
|
||||||
|
if (!ok) {
|
||||||
|
logger.error(
|
||||||
|
'Failed to sync addFeatureUseSelectedWorktreeBranch setting to server - reverting'
|
||||||
|
);
|
||||||
|
set({ addFeatureUseSelectedWorktreeBranch: previous });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Worktree Settings actions
|
// Worktree Settings actions
|
||||||
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
|
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
|
||||||
@@ -3136,6 +3221,51 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
return get().worktreePanelVisibleByProject[projectPath] ?? true;
|
return get().worktreePanelVisibleByProject[projectPath] ?? true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Init Script Indicator Visibility actions (per-project)
|
||||||
|
setShowInitScriptIndicator: (projectPath, visible) => {
|
||||||
|
set({
|
||||||
|
showInitScriptIndicatorByProject: {
|
||||||
|
...get().showInitScriptIndicatorByProject,
|
||||||
|
[projectPath]: visible,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getShowInitScriptIndicator: (projectPath) => {
|
||||||
|
// Default to true (visible) if not set
|
||||||
|
return get().showInitScriptIndicatorByProject[projectPath] ?? true;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Default Delete Branch actions (per-project)
|
||||||
|
setDefaultDeleteBranch: (projectPath, deleteBranch) => {
|
||||||
|
set({
|
||||||
|
defaultDeleteBranchByProject: {
|
||||||
|
...get().defaultDeleteBranchByProject,
|
||||||
|
[projectPath]: deleteBranch,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getDefaultDeleteBranch: (projectPath) => {
|
||||||
|
// Default to false (don't delete branch) if not set
|
||||||
|
return get().defaultDeleteBranchByProject[projectPath] ?? false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Auto-dismiss Init Script Indicator actions (per-project)
|
||||||
|
setAutoDismissInitScriptIndicator: (projectPath, autoDismiss) => {
|
||||||
|
set({
|
||||||
|
autoDismissInitScriptIndicatorByProject: {
|
||||||
|
...get().autoDismissInitScriptIndicatorByProject,
|
||||||
|
[projectPath]: autoDismiss,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getAutoDismissInitScriptIndicator: (projectPath) => {
|
||||||
|
// Default to true (auto-dismiss enabled) if not set
|
||||||
|
return get().autoDismissInitScriptIndicatorByProject[projectPath] ?? true;
|
||||||
|
},
|
||||||
|
|
||||||
// UI State actions (previously in localStorage, now synced via API)
|
// UI State actions (previously in localStorage, now synced via API)
|
||||||
setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }),
|
setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }),
|
||||||
setLastProjectDir: (dir) => set({ lastProjectDir: dir }),
|
setLastProjectDir: (dir) => set({ lastProjectDir: dir }),
|
||||||
@@ -3149,6 +3279,62 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
set({ recentFolders: updated });
|
set({ recentFolders: updated });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Init Script State actions (keyed by "projectPath::branch")
|
||||||
|
setInitScriptState: (projectPath, branch, state) => {
|
||||||
|
const key = `${projectPath}::${branch}`;
|
||||||
|
const current = get().initScriptState[key] || {
|
||||||
|
status: 'idle',
|
||||||
|
branch,
|
||||||
|
output: [],
|
||||||
|
};
|
||||||
|
set({
|
||||||
|
initScriptState: {
|
||||||
|
...get().initScriptState,
|
||||||
|
[key]: { ...current, ...state },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
appendInitScriptOutput: (projectPath, branch, content) => {
|
||||||
|
const key = `${projectPath}::${branch}`;
|
||||||
|
// Initialize state if absent to avoid dropping output due to event-order races
|
||||||
|
const current = get().initScriptState[key] || {
|
||||||
|
status: 'idle' as const,
|
||||||
|
branch,
|
||||||
|
output: [],
|
||||||
|
};
|
||||||
|
// Append new content and enforce fixed-size buffer to prevent memory bloat
|
||||||
|
const newOutput = [...current.output, content].slice(-MAX_INIT_OUTPUT_LINES);
|
||||||
|
set({
|
||||||
|
initScriptState: {
|
||||||
|
...get().initScriptState,
|
||||||
|
[key]: {
|
||||||
|
...current,
|
||||||
|
output: newOutput,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearInitScriptState: (projectPath, branch) => {
|
||||||
|
const key = `${projectPath}::${branch}`;
|
||||||
|
const { [key]: _, ...rest } = get().initScriptState;
|
||||||
|
set({ initScriptState: rest });
|
||||||
|
},
|
||||||
|
|
||||||
|
getInitScriptState: (projectPath, branch) => {
|
||||||
|
const key = `${projectPath}::${branch}`;
|
||||||
|
return get().initScriptState[key] || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
getInitScriptStatesForProject: (projectPath) => {
|
||||||
|
const prefix = `${projectPath}::`;
|
||||||
|
const states = get().initScriptState;
|
||||||
|
return Object.entries(states)
|
||||||
|
.filter(([key]) => key.startsWith(prefix))
|
||||||
|
.map(([key, state]) => ({ key, state }));
|
||||||
|
},
|
||||||
|
|
||||||
// Reset
|
// Reset
|
||||||
reset: () => set(initialState),
|
reset: () => set(initialState),
|
||||||
}));
|
}));
|
||||||
|
|||||||
44
apps/ui/src/types/electron.d.ts
vendored
44
apps/ui/src/types/electron.d.ts
vendored
@@ -1015,6 +1015,50 @@ export interface WorktreeAPI {
|
|||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
// Get init script content for a project
|
||||||
|
getInitScript: (projectPath: string) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
exists: boolean;
|
||||||
|
content: string;
|
||||||
|
path: string;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Set init script content for a project
|
||||||
|
setInitScript: (
|
||||||
|
projectPath: string,
|
||||||
|
content: string
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
path?: string;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Delete init script for a project
|
||||||
|
deleteInitScript: (projectPath: string) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Run (or re-run) init script for a worktree
|
||||||
|
runInitScript: (
|
||||||
|
projectPath: string,
|
||||||
|
worktreePath: string,
|
||||||
|
branch: string
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Subscribe to init script events
|
||||||
|
onInitScriptEvent: (
|
||||||
|
callback: (event: {
|
||||||
|
type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed';
|
||||||
|
payload: unknown;
|
||||||
|
}) => void
|
||||||
|
) => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitAPI {
|
export interface GitAPI {
|
||||||
|
|||||||
30
docs/worktree-init-script-example.sh
Normal file
30
docs/worktree-init-script-example.sh
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/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
|
||||||
|
if npm install; then
|
||||||
|
echo "Dependencies installed successfully!"
|
||||||
|
else
|
||||||
|
echo "ERROR: npm install failed with exit code $?"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "No package.json found, skipping npm install"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Worktree initialization complete!"
|
||||||
|
echo "=========================================="
|
||||||
@@ -97,6 +97,7 @@ export {
|
|||||||
getCodexCliPaths,
|
getCodexCliPaths,
|
||||||
getCodexConfigDir,
|
getCodexConfigDir,
|
||||||
getCodexAuthPath,
|
getCodexAuthPath,
|
||||||
|
getGitBashPaths,
|
||||||
getOpenCodeCliPaths,
|
getOpenCodeCliPaths,
|
||||||
getOpenCodeConfigDir,
|
getOpenCodeConfigDir,
|
||||||
getOpenCodeAuthPath,
|
getOpenCodeAuthPath,
|
||||||
@@ -130,6 +131,7 @@ export {
|
|||||||
findCodexCliPath,
|
findCodexCliPath,
|
||||||
getCodexAuthIndicators,
|
getCodexAuthIndicators,
|
||||||
type CodexAuthIndicators,
|
type CodexAuthIndicators,
|
||||||
|
findGitBashPath,
|
||||||
findOpenCodeCliPath,
|
findOpenCodeCliPath,
|
||||||
getOpenCodeAuthIndicators,
|
getOpenCodeAuthIndicators,
|
||||||
type OpenCodeAuthIndicators,
|
type OpenCodeAuthIndicators,
|
||||||
|
|||||||
@@ -232,6 +232,87 @@ export function getClaudeProjectsDir(): string {
|
|||||||
return path.join(getClaudeConfigDir(), 'projects');
|
return path.join(getClaudeConfigDir(), 'projects');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumerate directories matching a prefix pattern and return full paths
|
||||||
|
* Used to resolve dynamic directory names like version numbers
|
||||||
|
*/
|
||||||
|
function enumerateMatchingPaths(
|
||||||
|
parentDir: string,
|
||||||
|
prefix: string,
|
||||||
|
...subPathParts: string[]
|
||||||
|
): string[] {
|
||||||
|
try {
|
||||||
|
if (!fsSync.existsSync(parentDir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const entries = fsSync.readdirSync(parentDir);
|
||||||
|
const matching = entries.filter((entry) => entry.startsWith(prefix));
|
||||||
|
return matching.map((entry) => path.join(parentDir, entry, ...subPathParts));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get common Git Bash installation paths on Windows
|
||||||
|
* Git Bash is needed for running shell scripts cross-platform
|
||||||
|
*/
|
||||||
|
export function getGitBashPaths(): string[] {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const homeDir = os.homedir();
|
||||||
|
const localAppData = process.env.LOCALAPPDATA || '';
|
||||||
|
|
||||||
|
// Dynamic paths that require directory enumeration
|
||||||
|
// winget installs to: LocalAppData\Microsoft\WinGet\Packages\Git.Git_<hash>\bin\bash.exe
|
||||||
|
const wingetGitPaths = localAppData
|
||||||
|
? enumerateMatchingPaths(
|
||||||
|
path.join(localAppData, 'Microsoft', 'WinGet', 'Packages'),
|
||||||
|
'Git.Git_',
|
||||||
|
'bin',
|
||||||
|
'bash.exe'
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// GitHub Desktop bundles Git at: LocalAppData\GitHubDesktop\app-<version>\resources\app\git\cmd\bash.exe
|
||||||
|
const githubDesktopPaths = localAppData
|
||||||
|
? enumerateMatchingPaths(
|
||||||
|
path.join(localAppData, 'GitHubDesktop'),
|
||||||
|
'app-',
|
||||||
|
'resources',
|
||||||
|
'app',
|
||||||
|
'git',
|
||||||
|
'cmd',
|
||||||
|
'bash.exe'
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
// Standard Git for Windows installations
|
||||||
|
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||||
|
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
||||||
|
// User-local installations
|
||||||
|
path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'),
|
||||||
|
// Scoop package manager
|
||||||
|
path.join(homeDir, 'scoop', 'apps', 'git', 'current', 'bin', 'bash.exe'),
|
||||||
|
// Chocolatey
|
||||||
|
path.join(
|
||||||
|
process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey',
|
||||||
|
'lib',
|
||||||
|
'git',
|
||||||
|
'tools',
|
||||||
|
'bin',
|
||||||
|
'bash.exe'
|
||||||
|
),
|
||||||
|
// winget installations (dynamically resolved)
|
||||||
|
...wingetGitPaths,
|
||||||
|
// GitHub Desktop bundled Git (dynamically resolved)
|
||||||
|
...githubDesktopPaths,
|
||||||
|
].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get common shell paths for shell detection
|
* Get common shell paths for shell detection
|
||||||
* Includes both full paths and short names to match $SHELL or PATH entries
|
* Includes both full paths and short names to match $SHELL or PATH entries
|
||||||
@@ -550,6 +631,8 @@ function getAllAllowedSystemPaths(): string[] {
|
|||||||
getOpenCodeAuthPath(),
|
getOpenCodeAuthPath(),
|
||||||
// Shell paths
|
// Shell paths
|
||||||
...getShellPaths(),
|
...getShellPaths(),
|
||||||
|
// Git Bash paths (for Windows cross-platform shell script execution)
|
||||||
|
...getGitBashPaths(),
|
||||||
// Node.js system paths
|
// Node.js system paths
|
||||||
...getNodeSystemPaths(),
|
...getNodeSystemPaths(),
|
||||||
getScoopNodePath(),
|
getScoopNodePath(),
|
||||||
@@ -883,6 +966,13 @@ export async function findCodexCliPath(): Promise<string | null> {
|
|||||||
return findFirstExistingPath(getCodexCliPaths());
|
return findFirstExistingPath(getCodexCliPaths());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find Git Bash on Windows and return its path
|
||||||
|
*/
|
||||||
|
export async function findGitBashPath(): Promise<string | null> {
|
||||||
|
return findFirstExistingPath(getGitBashPaths());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Claude authentication status by checking various indicators
|
* Get Claude authentication status by checking various indicators
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ export type EventType =
|
|||||||
| 'ideation:idea-created'
|
| 'ideation:idea-created'
|
||||||
| 'ideation:idea-updated'
|
| 'ideation:idea-updated'
|
||||||
| 'ideation:idea-deleted'
|
| '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;
|
export type EventCallback = (type: EventType, payload: unknown) => void;
|
||||||
|
|||||||
@@ -599,6 +599,14 @@ export interface ProjectSettings {
|
|||||||
// UI Visibility
|
// UI Visibility
|
||||||
/** Whether the worktree panel row is visible (default: true) */
|
/** Whether the worktree panel row is visible (default: true) */
|
||||||
worktreePanelVisible?: boolean;
|
worktreePanelVisible?: boolean;
|
||||||
|
/** Whether to show the init script indicator panel (default: true) */
|
||||||
|
showInitScriptIndicator?: boolean;
|
||||||
|
|
||||||
|
// Worktree Behavior
|
||||||
|
/** Default value for "delete branch" checkbox when deleting a worktree (default: false) */
|
||||||
|
defaultDeleteBranchWithWorktree?: boolean;
|
||||||
|
/** Auto-dismiss init script indicator after completion (default: true) */
|
||||||
|
autoDismissInitScriptIndicator?: boolean;
|
||||||
|
|
||||||
// Session Tracking
|
// Session Tracking
|
||||||
/** Last chat session selected in this project */
|
/** Last chat session selected in this project */
|
||||||
|
|||||||
25
package-lock.json
generated
25
package-lock.json
generated
@@ -87,6 +87,8 @@
|
|||||||
"@automaker/dependency-resolver": "1.0.0",
|
"@automaker/dependency-resolver": "1.0.0",
|
||||||
"@automaker/types": "1.0.0",
|
"@automaker/types": "1.0.0",
|
||||||
"@codemirror/lang-xml": "6.1.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",
|
"@codemirror/theme-one-dark": "6.1.3",
|
||||||
"@dnd-kit/core": "6.3.1",
|
"@dnd-kit/core": "6.3.1",
|
||||||
"@dnd-kit/sortable": "10.0.0",
|
"@dnd-kit/sortable": "10.0.0",
|
||||||
@@ -1199,19 +1201,28 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@codemirror/language": {
|
"node_modules/@codemirror/language": {
|
||||||
"version": "6.11.3",
|
"version": "6.12.1",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz",
|
||||||
"integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
|
"integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/state": "^6.0.0",
|
"@codemirror/state": "^6.0.0",
|
||||||
"@codemirror/view": "^6.23.0",
|
"@codemirror/view": "^6.23.0",
|
||||||
"@lezer/common": "^1.1.0",
|
"@lezer/common": "^1.5.0",
|
||||||
"@lezer/highlight": "^1.0.0",
|
"@lezer/highlight": "^1.0.0",
|
||||||
"@lezer/lr": "^1.0.0",
|
"@lezer/lr": "^1.0.0",
|
||||||
"style-mod": "^4.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": {
|
"node_modules/@codemirror/lint": {
|
||||||
"version": "6.9.2",
|
"version": "6.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.2.tgz",
|
||||||
@@ -3604,9 +3615,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@lezer/common": {
|
"node_modules/@lezer/common": {
|
||||||
"version": "1.4.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.0.tgz",
|
||||||
"integrity": "sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==",
|
"integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@lezer/highlight": {
|
"node_modules/@lezer/highlight": {
|
||||||
|
|||||||
Reference in New Issue
Block a user