mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
feat: Implement worktree initialization script functionality
This commit introduces a new feature for managing worktree initialization scripts, allowing users to configure and execute scripts upon worktree creation. Key changes include: 1. **New API Endpoints**: Added endpoints for getting, setting, and deleting init scripts. 2. **Worktree Routes**: Updated worktree routes to include init script handling. 3. **Init Script Service**: Created a service to execute the init scripts asynchronously, with support for cross-platform compatibility. 4. **UI Components**: Added UI components for displaying and editing init scripts, including a dedicated section in the settings view. 5. **Event Handling**: Implemented event handling for init script execution status, providing real-time feedback in the UI. This enhancement improves the user experience by allowing automated setup processes for new worktrees, streamlining project workflows.
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -30,8 +31,13 @@ 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,
|
||||||
|
} 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());
|
||||||
@@ -45,7 +51,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());
|
||||||
@@ -87,5 +93,10 @@ 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.post('/init-script', validatePathParams('projectPath'), createGetInitScriptHandler());
|
||||||
|
router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler());
|
||||||
|
router.delete('/init-script', validatePathParams('projectPath'), createDeleteInitScriptHandler());
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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 {
|
import {
|
||||||
isGitRepo,
|
isGitRepo,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
@@ -21,6 +22,8 @@ import {
|
|||||||
} 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, getInitScriptPath, hasInitScriptRun } from '../../../services/init-script-service.js';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
const logger = createLogger('Worktree');
|
const logger = createLogger('Worktree');
|
||||||
|
|
||||||
@@ -77,7 +80,7 @@ async function findExistingWorktreeForBranch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCreateHandler() {
|
export function createCreateHandler(events: EventEmitter) {
|
||||||
return async (req: Request, res: Response): Promise<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 {
|
||||||
@@ -177,6 +180,13 @@ 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);
|
||||||
|
|
||||||
|
// Check if init script exists and should be run (only for new worktrees)
|
||||||
|
const initScriptPath = getInitScriptPath(projectPath);
|
||||||
|
const hasInitScript = fs.existsSync(initScriptPath);
|
||||||
|
const alreadyRan = await hasInitScriptRun(projectPath, branchName);
|
||||||
|
|
||||||
|
// Respond immediately (non-blocking)
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
worktree: {
|
worktree: {
|
||||||
@@ -185,6 +195,19 @@ export function createCreateHandler() {
|
|||||||
isNew: !branchExists,
|
isNew: !branchExists,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Trigger init script asynchronously after response
|
||||||
|
if (hasInitScript && !alreadyRan) {
|
||||||
|
logger.info(`Triggering init script for worktree: ${branchName}`);
|
||||||
|
runInitScript({
|
||||||
|
projectPath,
|
||||||
|
worktreePath: absoluteWorktreePath,
|
||||||
|
branch: branchName,
|
||||||
|
emitter: events,
|
||||||
|
}).catch((err) => {
|
||||||
|
logger.error(`Init script failed for ${branchName}:`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} 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) });
|
||||||
|
|||||||
162
apps/server/src/routes/worktree/routes/init-script.ts
Normal file
162
apps/server/src/routes/worktree/routes/init-script.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* Init Script routes - Read/write the worktree-init.sh file
|
||||||
|
*
|
||||||
|
* GET /init-script - Read the init script content
|
||||||
|
* PUT /init-script - Write content to the init script file
|
||||||
|
* DELETE /init-script - Delete the init script file
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import path from 'path';
|
||||||
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
|
const logger = createLogger('InitScript');
|
||||||
|
|
||||||
|
/** Fixed path for init script within .automaker directory */
|
||||||
|
const INIT_SCRIPT_FILENAME = 'worktree-init.sh';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the full path to the init script for a project
|
||||||
|
*/
|
||||||
|
function getInitScriptPath(projectPath: string): string {
|
||||||
|
return path.join(projectPath, '.automaker', INIT_SCRIPT_FILENAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /init-script - Read the init script content
|
||||||
|
*/
|
||||||
|
export function createGetInitScriptHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<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);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await secureFs.rm(scriptPath, { force: true });
|
||||||
|
logger.info(`Deleted init script at ${scriptPath}`);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist - still success
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Delete init script failed');
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
258
apps/server/src/services/init-script-service.ts
Normal file
258
apps/server/src/services/init-script-service.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
/**
|
||||||
|
* Init Script Service - Executes worktree initialization scripts
|
||||||
|
*
|
||||||
|
* Runs the .automaker/worktree-init.sh script after worktree creation.
|
||||||
|
* Uses Git Bash on Windows for cross-platform shell script compatibility.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import type { EventEmitter } from '../lib/events.js';
|
||||||
|
import {
|
||||||
|
readWorktreeMetadata,
|
||||||
|
writeWorktreeMetadata,
|
||||||
|
} from '../lib/worktree-metadata.js';
|
||||||
|
|
||||||
|
const logger = createLogger('InitScript');
|
||||||
|
|
||||||
|
/** Common Git Bash installation paths on Windows */
|
||||||
|
const GIT_BASH_PATHS = [
|
||||||
|
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||||
|
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
||||||
|
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Git', 'bin', 'bash.exe'),
|
||||||
|
path.join(process.env.USERPROFILE || '', 'scoop', 'apps', 'git', 'current', 'bin', 'bash.exe'),
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find Git Bash executable on Windows
|
||||||
|
*/
|
||||||
|
function findGitBash(): string | null {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const bashPath of GIT_BASH_PATHS) {
|
||||||
|
if (fs.existsSync(bashPath)) {
|
||||||
|
return bashPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the shell command for running scripts
|
||||||
|
* Returns [shellPath, shellArgs] for cross-platform compatibility
|
||||||
|
*/
|
||||||
|
function getShellCommand(): { shell: string; args: string[] } | null {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const gitBash = findGitBash();
|
||||||
|
if (!gitBash) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { shell: gitBash, args: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unix-like systems: prefer bash, fall back to sh
|
||||||
|
if (fs.existsSync('/bin/bash')) {
|
||||||
|
return { shell: '/bin/bash', args: [] };
|
||||||
|
}
|
||||||
|
if (fs.existsSync('/bin/sh')) {
|
||||||
|
return { shell: '/bin/sh', args: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InitScriptOptions {
|
||||||
|
/** Absolute path to the project root */
|
||||||
|
projectPath: string;
|
||||||
|
/** Absolute path to the worktree directory */
|
||||||
|
worktreePath: string;
|
||||||
|
/** Branch name for this worktree */
|
||||||
|
branch: string;
|
||||||
|
/** Event emitter for streaming output */
|
||||||
|
emitter: EventEmitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if init script exists for a project
|
||||||
|
*/
|
||||||
|
export function getInitScriptPath(projectPath: string): string {
|
||||||
|
return path.join(projectPath, '.automaker', 'worktree-init.sh');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the init script has already been run for a worktree
|
||||||
|
*/
|
||||||
|
export async function hasInitScriptRun(
|
||||||
|
projectPath: string,
|
||||||
|
branch: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const metadata = await readWorktreeMetadata(projectPath, branch);
|
||||||
|
return metadata?.initScriptRan === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the worktree initialization script
|
||||||
|
* Non-blocking - returns immediately after spawning
|
||||||
|
*/
|
||||||
|
export async function runInitScript(options: InitScriptOptions): Promise<void> {
|
||||||
|
const { projectPath, worktreePath, branch, emitter } = options;
|
||||||
|
|
||||||
|
const scriptPath = getInitScriptPath(projectPath);
|
||||||
|
|
||||||
|
// Check if script exists
|
||||||
|
if (!fs.existsSync(scriptPath)) {
|
||||||
|
logger.debug(`No init script found at ${scriptPath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already run
|
||||||
|
if (await hasInitScriptRun(projectPath, branch)) {
|
||||||
|
logger.info(`Init script already ran for branch "${branch}", skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get shell command
|
||||||
|
const shellCmd = getShellCommand();
|
||||||
|
if (!shellCmd) {
|
||||||
|
const error =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? 'Git Bash not found. Please install Git for Windows to run init scripts.'
|
||||||
|
: 'No shell found (/bin/bash or /bin/sh)';
|
||||||
|
logger.error(error);
|
||||||
|
|
||||||
|
// Update metadata with error
|
||||||
|
await writeWorktreeMetadata(projectPath, branch, {
|
||||||
|
branch,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
initScriptRan: true,
|
||||||
|
initScriptStatus: 'failed',
|
||||||
|
initScriptError: error,
|
||||||
|
});
|
||||||
|
|
||||||
|
emitter.emit('worktree:init-completed', {
|
||||||
|
projectPath,
|
||||||
|
worktreePath,
|
||||||
|
branch,
|
||||||
|
success: false,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Running init script for branch "${branch}" in ${worktreePath}`);
|
||||||
|
|
||||||
|
// Update metadata to mark as running
|
||||||
|
const existingMetadata = await readWorktreeMetadata(projectPath, branch);
|
||||||
|
await writeWorktreeMetadata(projectPath, branch, {
|
||||||
|
branch,
|
||||||
|
createdAt: existingMetadata?.createdAt || new Date().toISOString(),
|
||||||
|
pr: existingMetadata?.pr,
|
||||||
|
initScriptRan: false,
|
||||||
|
initScriptStatus: 'running',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit started event
|
||||||
|
emitter.emit('worktree:init-started', {
|
||||||
|
projectPath,
|
||||||
|
worktreePath,
|
||||||
|
branch,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spawn the script
|
||||||
|
const child = spawn(shellCmd.shell, [...shellCmd.args, scriptPath], {
|
||||||
|
cwd: worktreePath,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
// Provide useful env vars to the script
|
||||||
|
AUTOMAKER_PROJECT_PATH: projectPath,
|
||||||
|
AUTOMAKER_WORKTREE_PATH: worktreePath,
|
||||||
|
AUTOMAKER_BRANCH: branch,
|
||||||
|
// Force color output even though we're not a TTY
|
||||||
|
FORCE_COLOR: '1',
|
||||||
|
npm_config_color: 'always',
|
||||||
|
CLICOLOR_FORCE: '1',
|
||||||
|
// Git colors
|
||||||
|
GIT_TERMINAL_PROMPT: '0',
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stream stdout
|
||||||
|
child.stdout?.on('data', (data: Buffer) => {
|
||||||
|
const content = data.toString();
|
||||||
|
emitter.emit('worktree:init-output', {
|
||||||
|
projectPath,
|
||||||
|
branch,
|
||||||
|
type: 'stdout',
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stream stderr
|
||||||
|
child.stderr?.on('data', (data: Buffer) => {
|
||||||
|
const content = data.toString();
|
||||||
|
emitter.emit('worktree:init-output', {
|
||||||
|
projectPath,
|
||||||
|
branch,
|
||||||
|
type: 'stderr',
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle completion
|
||||||
|
child.on('exit', async (code) => {
|
||||||
|
const success = code === 0;
|
||||||
|
const status = success ? 'success' : 'failed';
|
||||||
|
|
||||||
|
logger.info(`Init script for branch "${branch}" ${status} with exit code ${code}`);
|
||||||
|
|
||||||
|
// Update metadata
|
||||||
|
const metadata = await readWorktreeMetadata(projectPath, branch);
|
||||||
|
await writeWorktreeMetadata(projectPath, branch, {
|
||||||
|
branch,
|
||||||
|
createdAt: metadata?.createdAt || new Date().toISOString(),
|
||||||
|
pr: metadata?.pr,
|
||||||
|
initScriptRan: true,
|
||||||
|
initScriptStatus: status,
|
||||||
|
initScriptError: success ? undefined : `Exit code: ${code}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit completion event
|
||||||
|
emitter.emit('worktree:init-completed', {
|
||||||
|
projectPath,
|
||||||
|
worktreePath,
|
||||||
|
branch,
|
||||||
|
success,
|
||||||
|
exitCode: code,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', async (error) => {
|
||||||
|
logger.error(`Init script error for branch "${branch}":`, error);
|
||||||
|
|
||||||
|
// Update metadata
|
||||||
|
const metadata = await readWorktreeMetadata(projectPath, branch);
|
||||||
|
await writeWorktreeMetadata(projectPath, branch, {
|
||||||
|
branch,
|
||||||
|
createdAt: metadata?.createdAt || new Date().toISOString(),
|
||||||
|
pr: metadata?.pr,
|
||||||
|
initScriptRan: true,
|
||||||
|
initScriptStatus: 'failed',
|
||||||
|
initScriptError: error.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit completion with error
|
||||||
|
emitter.emit('worktree:init-completed', {
|
||||||
|
projectPath,
|
||||||
|
worktreePath,
|
||||||
|
branch,
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
262
apps/ui/src/components/ui/ansi-output.tsx
Normal file
262
apps/ui/src/components/ui/ansi-output.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface AnsiOutputProps {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ANSI color codes to CSS color mappings
|
||||||
|
const ANSI_COLORS: Record<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
apps/ui/src/components/ui/shell-syntax-editor.tsx
Normal file
141
apps/ui/src/components/ui/shell-syntax-editor.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import CodeMirror from '@uiw/react-codemirror';
|
||||||
|
import { StreamLanguage } from '@codemirror/language';
|
||||||
|
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
|
import { Extension } from '@codemirror/state';
|
||||||
|
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||||
|
import { tags as t } from '@lezer/highlight';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ShellSyntaxEditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
minHeight?: string;
|
||||||
|
'data-testid'?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Syntax highlighting using CSS variables for theme compatibility
|
||||||
|
const syntaxColors = HighlightStyle.define([
|
||||||
|
// Keywords (if, then, else, fi, for, while, do, done, case, esac, etc.)
|
||||||
|
{ tag: t.keyword, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
|
||||||
|
|
||||||
|
// Strings (single and double quoted)
|
||||||
|
{ tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
|
||||||
|
|
||||||
|
// Comments
|
||||||
|
{ tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' },
|
||||||
|
|
||||||
|
// Variables ($VAR, ${VAR})
|
||||||
|
{ tag: t.variableName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
|
||||||
|
|
||||||
|
// Operators
|
||||||
|
{ tag: t.operator, color: 'var(--muted-foreground)' },
|
||||||
|
|
||||||
|
// Numbers
|
||||||
|
{ tag: t.number, color: 'var(--chart-3, oklch(0.7 0.15 150))' },
|
||||||
|
|
||||||
|
// Function names / commands
|
||||||
|
{ tag: t.function(t.variableName), color: 'var(--primary)' },
|
||||||
|
{ tag: t.attributeName, color: 'var(--chart-5, oklch(0.65 0.2 30))' },
|
||||||
|
|
||||||
|
// Default text
|
||||||
|
{ tag: t.content, color: 'var(--foreground)' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Editor theme using CSS variables
|
||||||
|
const editorTheme = EditorView.theme({
|
||||||
|
'&': {
|
||||||
|
height: '100%',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'var(--foreground)',
|
||||||
|
},
|
||||||
|
'.cm-scroller': {
|
||||||
|
overflow: 'auto',
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||||
|
},
|
||||||
|
'.cm-content': {
|
||||||
|
padding: '0.75rem',
|
||||||
|
minHeight: '100%',
|
||||||
|
caretColor: 'var(--primary)',
|
||||||
|
},
|
||||||
|
'.cm-cursor, .cm-dropCursor': {
|
||||||
|
borderLeftColor: 'var(--primary)',
|
||||||
|
},
|
||||||
|
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
|
||||||
|
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
|
||||||
|
},
|
||||||
|
'.cm-activeLine': {
|
||||||
|
backgroundColor: 'var(--accent)',
|
||||||
|
opacity: '0.3',
|
||||||
|
},
|
||||||
|
'.cm-line': {
|
||||||
|
padding: '0 0.25rem',
|
||||||
|
},
|
||||||
|
'&.cm-focused': {
|
||||||
|
outline: 'none',
|
||||||
|
},
|
||||||
|
'.cm-gutters': {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'var(--muted-foreground)',
|
||||||
|
border: 'none',
|
||||||
|
paddingRight: '0.5rem',
|
||||||
|
},
|
||||||
|
'.cm-lineNumbers .cm-gutterElement': {
|
||||||
|
minWidth: '2rem',
|
||||||
|
textAlign: 'right',
|
||||||
|
paddingRight: '0.5rem',
|
||||||
|
},
|
||||||
|
'.cm-placeholder': {
|
||||||
|
color: 'var(--muted-foreground)',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine all extensions
|
||||||
|
const extensions: Extension[] = [
|
||||||
|
StreamLanguage.define(shell),
|
||||||
|
syntaxHighlighting(syntaxColors),
|
||||||
|
editorTheme,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ShellSyntaxEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
minHeight = '200px',
|
||||||
|
'data-testid': testId,
|
||||||
|
}: ShellSyntaxEditorProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-lg border border-border bg-muted/30 overflow-hidden',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ minHeight }}
|
||||||
|
data-testid={testId}
|
||||||
|
>
|
||||||
|
<CodeMirror
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
extensions={extensions}
|
||||||
|
theme="none"
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="h-full [&_.cm-editor]:h-full [&_.cm-editor]:min-h-[inherit]"
|
||||||
|
basicSetup={{
|
||||||
|
lineNumbers: true,
|
||||||
|
foldGutter: false,
|
||||||
|
highlightActiveLine: true,
|
||||||
|
highlightSelectionMatches: true,
|
||||||
|
autocompletion: false,
|
||||||
|
bracketMatching: true,
|
||||||
|
indentOnInput: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -78,6 +78,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']> = [];
|
||||||
@@ -255,6 +257,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
|
||||||
@@ -1570,6 +1575,9 @@ export function BoardView() {
|
|||||||
setSelectedWorktreeForAction(null);
|
setSelectedWorktreeForAction(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Init Script Indicator - floating overlay for worktree init script status */}
|
||||||
|
<InitScriptIndicator projectPath={currentProject.path} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Terminal, Check, X, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAppStore, type InitScriptState } from '@/store/app-store';
|
||||||
|
import { AnsiOutput } from '@/components/ui/ansi-output';
|
||||||
|
|
||||||
|
interface InitScriptIndicatorProps {
|
||||||
|
projectPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) {
|
||||||
|
const initScriptState = useAppStore((s) => s.initScriptState[projectPath]);
|
||||||
|
const clearInitScriptState = useAppStore((s) => s.clearInitScriptState);
|
||||||
|
const [showLogs, setShowLogs] = useState(false);
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when new output arrives
|
||||||
|
useEffect(() => {
|
||||||
|
if (showLogs && logsEndRef.current) {
|
||||||
|
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [initScriptState?.output, showLogs]);
|
||||||
|
|
||||||
|
// Reset dismissed state when a new script starts
|
||||||
|
useEffect(() => {
|
||||||
|
if (initScriptState?.status === 'running') {
|
||||||
|
setDismissed(false);
|
||||||
|
setShowLogs(true);
|
||||||
|
}
|
||||||
|
}, [initScriptState?.status]);
|
||||||
|
|
||||||
|
if (!initScriptState || dismissed) return null;
|
||||||
|
if (initScriptState.status === 'idle') return null;
|
||||||
|
|
||||||
|
const { status, output, branch, error } = initScriptState;
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
setDismissed(true);
|
||||||
|
// Clear state after a delay to allow for future scripts
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInitScriptState(projectPath);
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'fixed bottom-4 right-4 z-50',
|
||||||
|
'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={handleDismiss}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,22 @@ 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) {
|
||||||
@@ -257,32 +252,6 @@ export function FeatureDefaultsSection({
|
|||||||
</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,238 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
|
||||||
|
import { GitBranch, Terminal, FileCode, Check, Loader2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { apiPost, apiPut } from '@/lib/api-fetch';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
|
||||||
|
interface WorktreesSectionProps {
|
||||||
|
useWorktrees: boolean;
|
||||||
|
onUseWorktreesChange: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InitScriptResponse {
|
||||||
|
success: boolean;
|
||||||
|
exists: boolean;
|
||||||
|
content: string;
|
||||||
|
path: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) {
|
||||||
|
const currentProject = useAppStore((s) => s.currentProject);
|
||||||
|
const [scriptContent, setScriptContent] = useState('');
|
||||||
|
const [scriptPath, setScriptPath] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [showSaved, setShowSaved] = useState(false);
|
||||||
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const savedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Load init script content when project changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentProject?.path) {
|
||||||
|
setScriptContent('');
|
||||||
|
setScriptPath('');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadInitScript = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiPost<InitScriptResponse>('/api/worktree/init-script', {
|
||||||
|
projectPath: currentProject.path,
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
setScriptContent(response.content || '');
|
||||||
|
setScriptPath(response.path || '');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load init script:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadInitScript();
|
||||||
|
}, [currentProject?.path]);
|
||||||
|
|
||||||
|
// Debounced save function
|
||||||
|
const saveScript = useCallback(
|
||||||
|
async (content: string) => {
|
||||||
|
if (!currentProject?.path) return;
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const response = await apiPut<{ success: boolean; error?: string }>(
|
||||||
|
'/api/worktree/init-script',
|
||||||
|
{
|
||||||
|
projectPath: currentProject.path,
|
||||||
|
content,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (response.success) {
|
||||||
|
setShowSaved(true);
|
||||||
|
savedTimeoutRef.current = setTimeout(() => setShowSaved(false), 2000);
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to save init script', {
|
||||||
|
description: response.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save init script:', error);
|
||||||
|
toast.error('Failed to save init script');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentProject?.path]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle content change with debounce
|
||||||
|
const handleContentChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setScriptContent(value);
|
||||||
|
setShowSaved(false);
|
||||||
|
|
||||||
|
// Clear existing timeouts
|
||||||
|
if (saveTimeoutRef.current) {
|
||||||
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
if (savedTimeoutRef.current) {
|
||||||
|
clearTimeout(savedTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce save
|
||||||
|
saveTimeoutRef.current = setTimeout(() => {
|
||||||
|
saveScript(value);
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
[saveScript]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup timeouts
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||||
|
if (savedTimeoutRef.current) clearTimeout(savedTimeoutRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 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 className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{isSaving && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{showSaved && !isSaving && (
|
||||||
|
<span className="flex items-center gap-1 text-green-500">
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
Saved
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
</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"
|
||||||
|
data-testid="init-script-editor"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<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, {
|
||||||
|
status: 'running',
|
||||||
|
branch: startPayload.branch,
|
||||||
|
output: [],
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'worktree:init-output': {
|
||||||
|
const outputPayload = payload as InitScriptOutputPayload;
|
||||||
|
appendInitScriptOutput(projectPath, outputPayload.content);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'worktree:init-completed': {
|
||||||
|
const completePayload = payload as InitScriptCompletedPayload;
|
||||||
|
setInitScriptState(projectPath, {
|
||||||
|
status: completePayload.success ? 'success' : 'failed',
|
||||||
|
error: completePayload.error,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, [projectPath, setInitScriptState, appendInitScriptOutput]);
|
||||||
|
}
|
||||||
@@ -1608,6 +1608,35 @@ 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.post('/api/worktree/init-script', { projectPath }),
|
||||||
|
setInitScript: (projectPath: string, content: string) =>
|
||||||
|
this.put('/api/worktree/init-script', { projectPath, content }),
|
||||||
|
deleteInitScript: (projectPath: string) =>
|
||||||
|
this.delete('/api/worktree/init-script', { projectPath }),
|
||||||
|
onInitScriptEvent: (
|
||||||
|
callback: (event: {
|
||||||
|
type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed';
|
||||||
|
payload: unknown;
|
||||||
|
}) => void
|
||||||
|
) => {
|
||||||
|
// Note: subscribeToEvent callback receives (payload) not (_, payload)
|
||||||
|
const unsub1 = this.subscribeToEvent('worktree:init-started', (payload) =>
|
||||||
|
callback({ type: 'worktree:init-started', payload })
|
||||||
|
);
|
||||||
|
const unsub2 = this.subscribeToEvent('worktree:init-output', (payload) =>
|
||||||
|
callback({ type: 'worktree:init-output', payload })
|
||||||
|
);
|
||||||
|
const unsub3 = this.subscribeToEvent('worktree:init-completed', (payload) =>
|
||||||
|
callback({ type: 'worktree:init-completed', payload })
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
unsub1();
|
||||||
|
unsub2();
|
||||||
|
unsub3();
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Git API
|
// Git API
|
||||||
|
|||||||
@@ -459,6 +459,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[];
|
||||||
@@ -664,6 +672,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 (per-project, keyed by project path)
|
||||||
|
initScriptState: Record<string, InitScriptState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Claude Usage interface matching the server response
|
// Claude Usage interface matching the server response
|
||||||
@@ -1095,6 +1106,12 @@ export interface AppActions {
|
|||||||
}>
|
}>
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
|
// Init Script State actions
|
||||||
|
setInitScriptState: (projectPath: string, state: Partial<InitScriptState>) => void;
|
||||||
|
appendInitScriptOutput: (projectPath: string, content: string) => void;
|
||||||
|
clearInitScriptState: (projectPath: string) => void;
|
||||||
|
getInitScriptState: (projectPath: string) => InitScriptState | null;
|
||||||
|
|
||||||
// Reset
|
// Reset
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
@@ -1195,6 +1212,7 @@ const initialState: AppState = {
|
|||||||
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) => ({
|
||||||
@@ -3119,6 +3137,44 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
set({ recentFolders: updated });
|
set({ recentFolders: updated });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Init Script State actions
|
||||||
|
setInitScriptState: (projectPath, state) => {
|
||||||
|
const current = get().initScriptState[projectPath] || {
|
||||||
|
status: 'idle',
|
||||||
|
branch: '',
|
||||||
|
output: [],
|
||||||
|
};
|
||||||
|
set({
|
||||||
|
initScriptState: {
|
||||||
|
...get().initScriptState,
|
||||||
|
[projectPath]: { ...current, ...state },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
appendInitScriptOutput: (projectPath, content) => {
|
||||||
|
const current = get().initScriptState[projectPath];
|
||||||
|
if (!current) return;
|
||||||
|
set({
|
||||||
|
initScriptState: {
|
||||||
|
...get().initScriptState,
|
||||||
|
[projectPath]: {
|
||||||
|
...current,
|
||||||
|
output: [...current.output, content],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearInitScriptState: (projectPath) => {
|
||||||
|
const { [projectPath]: _, ...rest } = get().initScriptState;
|
||||||
|
set({ initScriptState: rest });
|
||||||
|
},
|
||||||
|
|
||||||
|
getInitScriptState: (projectPath) => {
|
||||||
|
return get().initScriptState[projectPath] || null;
|
||||||
|
},
|
||||||
|
|
||||||
// Reset
|
// Reset
|
||||||
reset: () => set(initialState),
|
reset: () => set(initialState),
|
||||||
}));
|
}));
|
||||||
|
|||||||
26
docs/worktree-init-script-example.sh
Normal file
26
docs/worktree-init-script-example.sh
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Example worktree init script for Automaker
|
||||||
|
# Copy this content to Settings > Worktrees > Init Script
|
||||||
|
# Or save directly as .automaker/worktree-init.sh in your project
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Worktree Init Script Starting..."
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo "Current directory: $(pwd)"
|
||||||
|
echo "Branch: $(git branch --show-current 2>/dev/null || echo 'unknown')"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
echo "[1/1] Installing npm dependencies..."
|
||||||
|
if [ -f "package.json" ]; then
|
||||||
|
npm install
|
||||||
|
echo "Dependencies installed successfully!"
|
||||||
|
else
|
||||||
|
echo "No package.json found, skipping npm install"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Worktree initialization complete!"
|
||||||
|
echo "=========================================="
|
||||||
@@ -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;
|
||||||
|
|||||||
27
package-lock.json
generated
27
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",
|
||||||
@@ -1468,7 +1479,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@electron/node-gyp": {
|
"node_modules/@electron/node-gyp": {
|
||||||
"version": "10.2.0-electron.1",
|
"version": "10.2.0-electron.1",
|
||||||
"resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
|
"resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
|
||||||
"integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==",
|
"integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -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