mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-21 11:23:07 +00:00
Compare commits
5 Commits
47bd7a76cf
...
feature/pu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa345a50ac | ||
|
|
0e020f7e4a | ||
|
|
0a5540c9a2 | ||
|
|
7df2182818 | ||
|
|
ee52333636 |
1
.github/workflows/e2e-tests.yml
vendored
1
.github/workflows/e2e-tests.yml
vendored
@@ -133,6 +133,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
VITE_SERVER_URL: http://localhost:3008
|
VITE_SERVER_URL: http://localhost:3008
|
||||||
|
SERVER_URL: http://localhost:3008
|
||||||
VITE_SKIP_SETUP: 'true'
|
VITE_SKIP_SETUP: 'true'
|
||||||
# Keep UI-side login/defaults consistent
|
# Keep UI-side login/defaults consistent
|
||||||
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
|
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
|
||||||
|
|||||||
2
OPENCODE_CONFIG_CONTENT
Normal file
2
OPENCODE_CONFIG_CONTENT
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",}
|
||||||
@@ -26,23 +26,9 @@ export function createRunFeatureHandler(autoModeService: AutoModeServiceCompat)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check per-worktree capacity before starting
|
// Note: No concurrency limit check here. Manual feature starts always run
|
||||||
const capacity = await autoModeService.checkWorktreeCapacity(projectPath, featureId);
|
// immediately and bypass the concurrency limit. Their presence IS counted
|
||||||
if (!capacity.hasCapacity) {
|
// by the auto-loop coordinator when deciding whether to dispatch new auto-mode tasks.
|
||||||
const worktreeDesc = capacity.branchName
|
|
||||||
? `worktree "${capacity.branchName}"`
|
|
||||||
: 'main worktree';
|
|
||||||
res.status(429).json({
|
|
||||||
success: false,
|
|
||||||
error: `Agent limit reached for ${worktreeDesc} (${capacity.currentAgents}/${capacity.maxAgents}). Wait for running tasks to complete or increase the limit.`,
|
|
||||||
details: {
|
|
||||||
currentAgents: capacity.currentAgents,
|
|
||||||
maxAgents: capacity.maxAgents,
|
|
||||||
branchName: capacity.branchName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start execution in background
|
// Start execution in background
|
||||||
// executeFeature derives workDir from feature.branchName
|
// executeFeature derives workDir from feature.branchName
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ export function createFeaturesRoutes(
|
|||||||
validatePathParams('projectPath'),
|
validatePathParams('projectPath'),
|
||||||
createListHandler(featureLoader, autoModeService)
|
createListHandler(featureLoader, autoModeService)
|
||||||
);
|
);
|
||||||
|
router.get(
|
||||||
|
'/list',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
createListHandler(featureLoader, autoModeService)
|
||||||
|
);
|
||||||
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
|
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
|
||||||
router.post(
|
router.post(
|
||||||
'/create',
|
'/create',
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* POST /list endpoint - List all features for a project
|
* POST/GET /list endpoint - List all features for a project
|
||||||
|
*
|
||||||
|
* projectPath may come from req.body (POST) or req.query (GET fallback).
|
||||||
*
|
*
|
||||||
* Also performs orphan detection when a project is loaded to identify
|
* Also performs orphan detection when a project is loaded to identify
|
||||||
* features whose branches no longer exist. This runs on every project load/switch.
|
* features whose branches no longer exist. This runs on every project load/switch.
|
||||||
@@ -19,7 +21,17 @@ export function createListHandler(
|
|||||||
) {
|
) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath } = req.body as { projectPath: string };
|
const bodyProjectPath =
|
||||||
|
typeof req.body === 'object' && req.body !== null
|
||||||
|
? (req.body as { projectPath?: unknown }).projectPath
|
||||||
|
: undefined;
|
||||||
|
const queryProjectPath = req.query.projectPath;
|
||||||
|
const projectPath =
|
||||||
|
typeof bodyProjectPath === 'string'
|
||||||
|
? bodyProjectPath
|
||||||
|
: typeof queryProjectPath === 'string'
|
||||||
|
? queryProjectPath
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (!projectPath) {
|
if (!projectPath) {
|
||||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import { createImageHandler } from './routes/image.js';
|
|||||||
import { createSaveBoardBackgroundHandler } from './routes/save-board-background.js';
|
import { createSaveBoardBackgroundHandler } from './routes/save-board-background.js';
|
||||||
import { createDeleteBoardBackgroundHandler } from './routes/delete-board-background.js';
|
import { createDeleteBoardBackgroundHandler } from './routes/delete-board-background.js';
|
||||||
import { createBrowseProjectFilesHandler } from './routes/browse-project-files.js';
|
import { createBrowseProjectFilesHandler } from './routes/browse-project-files.js';
|
||||||
|
import { createCopyHandler } from './routes/copy.js';
|
||||||
|
import { createMoveHandler } from './routes/move.js';
|
||||||
|
import { createDownloadHandler } from './routes/download.js';
|
||||||
|
|
||||||
export function createFsRoutes(_events: EventEmitter): Router {
|
export function createFsRoutes(_events: EventEmitter): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -39,6 +42,9 @@ export function createFsRoutes(_events: EventEmitter): Router {
|
|||||||
router.post('/save-board-background', createSaveBoardBackgroundHandler());
|
router.post('/save-board-background', createSaveBoardBackgroundHandler());
|
||||||
router.post('/delete-board-background', createDeleteBoardBackgroundHandler());
|
router.post('/delete-board-background', createDeleteBoardBackgroundHandler());
|
||||||
router.post('/browse-project-files', createBrowseProjectFilesHandler());
|
router.post('/browse-project-files', createBrowseProjectFilesHandler());
|
||||||
|
router.post('/copy', createCopyHandler());
|
||||||
|
router.post('/move', createMoveHandler());
|
||||||
|
router.post('/download', createDownloadHandler());
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
99
apps/server/src/routes/fs/routes/copy.ts
Normal file
99
apps/server/src/routes/fs/routes/copy.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* POST /copy endpoint - Copy file or directory to a new location
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
|
import path from 'path';
|
||||||
|
import { PathNotAllowedError } from '@automaker/platform';
|
||||||
|
import { mkdirSafe } from '@automaker/utils';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively copy a directory and its contents
|
||||||
|
*/
|
||||||
|
async function copyDirectoryRecursive(src: string, dest: string): Promise<void> {
|
||||||
|
await mkdirSafe(dest);
|
||||||
|
const entries = await secureFs.readdir(src, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const srcPath = path.join(src, entry.name);
|
||||||
|
const destPath = path.join(dest, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await copyDirectoryRecursive(srcPath, destPath);
|
||||||
|
} else {
|
||||||
|
await secureFs.copyFile(srcPath, destPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCopyHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { sourcePath, destinationPath, overwrite } = req.body as {
|
||||||
|
sourcePath: string;
|
||||||
|
destinationPath: string;
|
||||||
|
overwrite?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!sourcePath || !destinationPath) {
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.json({ success: false, error: 'sourcePath and destinationPath are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent copying a folder into itself or its own descendant (infinite recursion)
|
||||||
|
const resolvedSrc = path.resolve(sourcePath);
|
||||||
|
const resolvedDest = path.resolve(destinationPath);
|
||||||
|
if (resolvedDest === resolvedSrc || resolvedDest.startsWith(resolvedSrc + path.sep)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Cannot copy a folder into itself or one of its own descendants',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if destination already exists
|
||||||
|
try {
|
||||||
|
await secureFs.stat(destinationPath);
|
||||||
|
// Destination exists
|
||||||
|
if (!overwrite) {
|
||||||
|
res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Destination already exists',
|
||||||
|
exists: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If overwrite is true, remove the existing destination first to avoid merging
|
||||||
|
await secureFs.rm(destinationPath, { recursive: true });
|
||||||
|
} catch {
|
||||||
|
// Destination doesn't exist - good to proceed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
await mkdirSafe(path.dirname(path.resolve(destinationPath)));
|
||||||
|
|
||||||
|
// Check if source is a directory
|
||||||
|
const stats = await secureFs.stat(sourcePath);
|
||||||
|
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
await copyDirectoryRecursive(sourcePath, destinationPath);
|
||||||
|
} else {
|
||||||
|
await secureFs.copyFile(sourcePath, destinationPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PathNotAllowedError) {
|
||||||
|
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logError(error, 'Copy file failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
142
apps/server/src/routes/fs/routes/download.ts
Normal file
142
apps/server/src/routes/fs/routes/download.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* POST /download endpoint - Download a file, or GET /download for streaming
|
||||||
|
* For folders, creates a zip archive on the fly
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
|
import path from 'path';
|
||||||
|
import { PathNotAllowedError } from '@automaker/platform';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { createReadStream } from 'fs';
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total size of a directory recursively
|
||||||
|
*/
|
||||||
|
async function getDirectorySize(dirPath: string): Promise<number> {
|
||||||
|
let totalSize = 0;
|
||||||
|
const entries = await secureFs.readdir(dirPath, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = path.join(dirPath, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
totalSize += await getDirectorySize(entryPath);
|
||||||
|
} else {
|
||||||
|
const stats = await secureFs.stat(entryPath);
|
||||||
|
totalSize += Number(stats.size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDownloadHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { filePath } = req.body as { filePath: string };
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
res.status(400).json({ success: false, error: 'filePath is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await secureFs.stat(filePath);
|
||||||
|
const fileName = path.basename(filePath);
|
||||||
|
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
// For directories, create a zip archive
|
||||||
|
const dirSize = await getDirectorySize(filePath);
|
||||||
|
const MAX_DIR_SIZE = 100 * 1024 * 1024; // 100MB limit
|
||||||
|
|
||||||
|
if (dirSize > MAX_DIR_SIZE) {
|
||||||
|
res.status(413).json({
|
||||||
|
success: false,
|
||||||
|
error: `Directory is too large to download (${(dirSize / (1024 * 1024)).toFixed(1)}MB). Maximum size is ${MAX_DIR_SIZE / (1024 * 1024)}MB.`,
|
||||||
|
size: dirSize,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary zip file
|
||||||
|
const zipFileName = `${fileName}.zip`;
|
||||||
|
const tmpZipPath = path.join(tmpdir(), `automaker-download-${Date.now()}-${zipFileName}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use system zip command (available on macOS and Linux)
|
||||||
|
// Use execFile to avoid shell injection via user-provided paths
|
||||||
|
await execFileAsync('zip', ['-r', tmpZipPath, fileName], {
|
||||||
|
cwd: path.dirname(filePath),
|
||||||
|
maxBuffer: 50 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
const zipStats = await secureFs.stat(tmpZipPath);
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/zip');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${zipFileName}"`);
|
||||||
|
res.setHeader('Content-Length', zipStats.size.toString());
|
||||||
|
res.setHeader('X-Directory-Size', dirSize.toString());
|
||||||
|
|
||||||
|
const stream = createReadStream(tmpZipPath);
|
||||||
|
stream.pipe(res);
|
||||||
|
|
||||||
|
stream.on('end', async () => {
|
||||||
|
// Cleanup temp file
|
||||||
|
try {
|
||||||
|
await secureFs.rm(tmpZipPath);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('error', async (err) => {
|
||||||
|
logError(err, 'Download stream error');
|
||||||
|
try {
|
||||||
|
await secureFs.rm(tmpZipPath);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ success: false, error: 'Stream error during download' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (zipError) {
|
||||||
|
// Cleanup on zip failure
|
||||||
|
try {
|
||||||
|
await secureFs.rm(tmpZipPath);
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
throw zipError;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For individual files, stream directly
|
||||||
|
res.setHeader('Content-Type', 'application/octet-stream');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
||||||
|
res.setHeader('Content-Length', stats.size.toString());
|
||||||
|
|
||||||
|
const stream = createReadStream(filePath);
|
||||||
|
stream.pipe(res);
|
||||||
|
|
||||||
|
stream.on('error', (err) => {
|
||||||
|
logError(err, 'Download stream error');
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ success: false, error: 'Stream error during download' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PathNotAllowedError) {
|
||||||
|
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logError(error, 'Download failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
79
apps/server/src/routes/fs/routes/move.ts
Normal file
79
apps/server/src/routes/fs/routes/move.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* POST /move endpoint - Move (rename) file or directory to a new location
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
|
import path from 'path';
|
||||||
|
import { PathNotAllowedError } from '@automaker/platform';
|
||||||
|
import { mkdirSafe } from '@automaker/utils';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
|
export function createMoveHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { sourcePath, destinationPath, overwrite } = req.body as {
|
||||||
|
sourcePath: string;
|
||||||
|
destinationPath: string;
|
||||||
|
overwrite?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!sourcePath || !destinationPath) {
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.json({ success: false, error: 'sourcePath and destinationPath are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent moving to same location or into its own descendant
|
||||||
|
const resolvedSrc = path.resolve(sourcePath);
|
||||||
|
const resolvedDest = path.resolve(destinationPath);
|
||||||
|
if (resolvedDest === resolvedSrc) {
|
||||||
|
// No-op: source and destination are the same
|
||||||
|
res.json({ success: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (resolvedDest.startsWith(resolvedSrc + path.sep)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Cannot move a folder into one of its own descendants',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if destination already exists
|
||||||
|
try {
|
||||||
|
await secureFs.stat(destinationPath);
|
||||||
|
// Destination exists
|
||||||
|
if (!overwrite) {
|
||||||
|
res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Destination already exists',
|
||||||
|
exists: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If overwrite is true, remove the existing destination first
|
||||||
|
await secureFs.rm(destinationPath, { recursive: true });
|
||||||
|
} catch {
|
||||||
|
// Destination doesn't exist - good to proceed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
await mkdirSafe(path.dirname(path.resolve(destinationPath)));
|
||||||
|
|
||||||
|
// Use rename for the move operation
|
||||||
|
await secureFs.rename(sourcePath, destinationPath);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PathNotAllowedError) {
|
||||||
|
res.status(403).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logError(error, 'Move file failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import { validatePathParams } from '../../middleware/validate-paths.js';
|
|||||||
import { createDiffsHandler } from './routes/diffs.js';
|
import { createDiffsHandler } from './routes/diffs.js';
|
||||||
import { createFileDiffHandler } from './routes/file-diff.js';
|
import { createFileDiffHandler } from './routes/file-diff.js';
|
||||||
import { createStageFilesHandler } from './routes/stage-files.js';
|
import { createStageFilesHandler } from './routes/stage-files.js';
|
||||||
|
import { createDetailsHandler } from './routes/details.js';
|
||||||
|
import { createEnhancedStatusHandler } from './routes/enhanced-status.js';
|
||||||
|
|
||||||
export function createGitRoutes(): Router {
|
export function createGitRoutes(): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -18,6 +20,8 @@ export function createGitRoutes(): Router {
|
|||||||
validatePathParams('projectPath', 'files[]'),
|
validatePathParams('projectPath', 'files[]'),
|
||||||
createStageFilesHandler()
|
createStageFilesHandler()
|
||||||
);
|
);
|
||||||
|
router.post('/details', validatePathParams('projectPath', 'filePath?'), createDetailsHandler());
|
||||||
|
router.post('/enhanced-status', validatePathParams('projectPath'), createEnhancedStatusHandler());
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
248
apps/server/src/routes/git/routes/details.ts
Normal file
248
apps/server/src/routes/git/routes/details.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* POST /details endpoint - Get detailed git info for a file or project
|
||||||
|
* Returns branch, last commit info, diff stats, and conflict status
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { exec, execFile } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
interface GitFileDetails {
|
||||||
|
branch: string;
|
||||||
|
lastCommitHash: string;
|
||||||
|
lastCommitMessage: string;
|
||||||
|
lastCommitAuthor: string;
|
||||||
|
lastCommitTimestamp: string;
|
||||||
|
linesAdded: number;
|
||||||
|
linesRemoved: number;
|
||||||
|
isConflicted: boolean;
|
||||||
|
isStaged: boolean;
|
||||||
|
isUnstaged: boolean;
|
||||||
|
statusLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDetailsHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { projectPath, filePath } = req.body as {
|
||||||
|
projectPath: string;
|
||||||
|
filePath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({ success: false, error: 'projectPath required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get current branch
|
||||||
|
const { stdout: branchRaw } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||||
|
cwd: projectPath,
|
||||||
|
});
|
||||||
|
const branch = branchRaw.trim();
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
// Project-level details - just return branch info
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
details: { branch },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get last commit info for this file
|
||||||
|
let lastCommitHash = '';
|
||||||
|
let lastCommitMessage = '';
|
||||||
|
let lastCommitAuthor = '';
|
||||||
|
let lastCommitTimestamp = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout: logOutput } = await execFileAsync(
|
||||||
|
'git',
|
||||||
|
['log', '-1', '--format=%H|%s|%an|%aI', '--', filePath],
|
||||||
|
{ cwd: projectPath }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (logOutput.trim()) {
|
||||||
|
const parts = logOutput.trim().split('|');
|
||||||
|
lastCommitHash = parts[0] || '';
|
||||||
|
lastCommitMessage = parts[1] || '';
|
||||||
|
lastCommitAuthor = parts[2] || '';
|
||||||
|
lastCommitTimestamp = parts[3] || '';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File may not have any commits yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get diff stats (lines added/removed)
|
||||||
|
let linesAdded = 0;
|
||||||
|
let linesRemoved = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if file is untracked first
|
||||||
|
const { stdout: statusLine } = await execFileAsync(
|
||||||
|
'git',
|
||||||
|
['status', '--porcelain', '--', filePath],
|
||||||
|
{ cwd: projectPath }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (statusLine.trim().startsWith('??')) {
|
||||||
|
// Untracked file - count all lines as added using Node.js instead of shell
|
||||||
|
try {
|
||||||
|
const fileContent = (await secureFs.readFile(filePath, 'utf-8')).toString();
|
||||||
|
const lines = fileContent.split('\n');
|
||||||
|
// Don't count trailing empty line from final newline
|
||||||
|
linesAdded =
|
||||||
|
lines.length > 0 && lines[lines.length - 1] === ''
|
||||||
|
? lines.length - 1
|
||||||
|
: lines.length;
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { stdout: diffStatRaw } = await execFileAsync(
|
||||||
|
'git',
|
||||||
|
['diff', '--numstat', 'HEAD', '--', filePath],
|
||||||
|
{ cwd: projectPath }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (diffStatRaw.trim()) {
|
||||||
|
const parts = diffStatRaw.trim().split('\t');
|
||||||
|
linesAdded = parseInt(parts[0], 10) || 0;
|
||||||
|
linesRemoved = parseInt(parts[1], 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check staged diff stats
|
||||||
|
const { stdout: stagedDiffStatRaw } = await execFileAsync(
|
||||||
|
'git',
|
||||||
|
['diff', '--numstat', '--cached', '--', filePath],
|
||||||
|
{ cwd: projectPath }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (stagedDiffStatRaw.trim()) {
|
||||||
|
const parts = stagedDiffStatRaw.trim().split('\t');
|
||||||
|
linesAdded += parseInt(parts[0], 10) || 0;
|
||||||
|
linesRemoved += parseInt(parts[1], 10) || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Diff might not be available
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get conflict and staging status
|
||||||
|
let isConflicted = false;
|
||||||
|
let isStaged = false;
|
||||||
|
let isUnstaged = false;
|
||||||
|
let statusLabel = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout: statusOutput } = await execFileAsync(
|
||||||
|
'git',
|
||||||
|
['status', '--porcelain', '--', filePath],
|
||||||
|
{ cwd: projectPath }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (statusOutput.trim()) {
|
||||||
|
const indexStatus = statusOutput[0];
|
||||||
|
const workTreeStatus = statusOutput[1];
|
||||||
|
|
||||||
|
// Check for conflicts (both modified, unmerged states)
|
||||||
|
if (
|
||||||
|
indexStatus === 'U' ||
|
||||||
|
workTreeStatus === 'U' ||
|
||||||
|
(indexStatus === 'A' && workTreeStatus === 'A') ||
|
||||||
|
(indexStatus === 'D' && workTreeStatus === 'D')
|
||||||
|
) {
|
||||||
|
isConflicted = true;
|
||||||
|
statusLabel = 'Conflicted';
|
||||||
|
} else {
|
||||||
|
// Staged changes (index has a status)
|
||||||
|
if (indexStatus !== ' ' && indexStatus !== '?') {
|
||||||
|
isStaged = true;
|
||||||
|
}
|
||||||
|
// Unstaged changes (work tree has a status)
|
||||||
|
if (workTreeStatus !== ' ' && workTreeStatus !== '?') {
|
||||||
|
isUnstaged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build status label
|
||||||
|
if (isStaged && isUnstaged) {
|
||||||
|
statusLabel = 'Staged + Modified';
|
||||||
|
} else if (isStaged) {
|
||||||
|
statusLabel = 'Staged';
|
||||||
|
} else {
|
||||||
|
const statusChar = workTreeStatus !== ' ' ? workTreeStatus : indexStatus;
|
||||||
|
switch (statusChar) {
|
||||||
|
case 'M':
|
||||||
|
statusLabel = 'Modified';
|
||||||
|
break;
|
||||||
|
case 'A':
|
||||||
|
statusLabel = 'Added';
|
||||||
|
break;
|
||||||
|
case 'D':
|
||||||
|
statusLabel = 'Deleted';
|
||||||
|
break;
|
||||||
|
case 'R':
|
||||||
|
statusLabel = 'Renamed';
|
||||||
|
break;
|
||||||
|
case 'C':
|
||||||
|
statusLabel = 'Copied';
|
||||||
|
break;
|
||||||
|
case '?':
|
||||||
|
statusLabel = 'Untracked';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
statusLabel = statusChar || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Status might not be available
|
||||||
|
}
|
||||||
|
|
||||||
|
const details: GitFileDetails = {
|
||||||
|
branch,
|
||||||
|
lastCommitHash,
|
||||||
|
lastCommitMessage,
|
||||||
|
lastCommitAuthor,
|
||||||
|
lastCommitTimestamp,
|
||||||
|
linesAdded,
|
||||||
|
linesRemoved,
|
||||||
|
isConflicted,
|
||||||
|
isStaged,
|
||||||
|
isUnstaged,
|
||||||
|
statusLabel,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({ success: true, details });
|
||||||
|
} catch (innerError) {
|
||||||
|
logError(innerError, 'Git details failed');
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
details: {
|
||||||
|
branch: '',
|
||||||
|
lastCommitHash: '',
|
||||||
|
lastCommitMessage: '',
|
||||||
|
lastCommitAuthor: '',
|
||||||
|
lastCommitTimestamp: '',
|
||||||
|
linesAdded: 0,
|
||||||
|
linesRemoved: 0,
|
||||||
|
isConflicted: false,
|
||||||
|
isStaged: false,
|
||||||
|
isUnstaged: false,
|
||||||
|
statusLabel: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Get git details failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ export function createDiffsHandler() {
|
|||||||
diff: result.diff,
|
diff: result.diff,
|
||||||
files: result.files,
|
files: result.files,
|
||||||
hasChanges: result.hasChanges,
|
hasChanges: result.hasChanges,
|
||||||
|
...(result.mergeState ? { mergeState: result.mergeState } : {}),
|
||||||
});
|
});
|
||||||
} catch (innerError) {
|
} catch (innerError) {
|
||||||
logError(innerError, 'Git diff failed');
|
logError(innerError, 'Git diff failed');
|
||||||
|
|||||||
176
apps/server/src/routes/git/routes/enhanced-status.ts
Normal file
176
apps/server/src/routes/git/routes/enhanced-status.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* POST /enhanced-status endpoint - Get enhanced git status with diff stats per file
|
||||||
|
* Returns per-file status with lines added/removed and staged/unstaged differentiation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
interface EnhancedFileStatus {
|
||||||
|
path: string;
|
||||||
|
indexStatus: string;
|
||||||
|
workTreeStatus: string;
|
||||||
|
isConflicted: boolean;
|
||||||
|
isStaged: boolean;
|
||||||
|
isUnstaged: boolean;
|
||||||
|
linesAdded: number;
|
||||||
|
linesRemoved: number;
|
||||||
|
statusLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(indexStatus: string, workTreeStatus: string): string {
|
||||||
|
// Check for conflicts
|
||||||
|
if (
|
||||||
|
indexStatus === 'U' ||
|
||||||
|
workTreeStatus === 'U' ||
|
||||||
|
(indexStatus === 'A' && workTreeStatus === 'A') ||
|
||||||
|
(indexStatus === 'D' && workTreeStatus === 'D')
|
||||||
|
) {
|
||||||
|
return 'Conflicted';
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasStaged = indexStatus !== ' ' && indexStatus !== '?';
|
||||||
|
const hasUnstaged = workTreeStatus !== ' ' && workTreeStatus !== '?';
|
||||||
|
|
||||||
|
if (hasStaged && hasUnstaged) return 'Staged + Modified';
|
||||||
|
if (hasStaged) return 'Staged';
|
||||||
|
|
||||||
|
const statusChar = workTreeStatus !== ' ' ? workTreeStatus : indexStatus;
|
||||||
|
switch (statusChar) {
|
||||||
|
case 'M':
|
||||||
|
return 'Modified';
|
||||||
|
case 'A':
|
||||||
|
return 'Added';
|
||||||
|
case 'D':
|
||||||
|
return 'Deleted';
|
||||||
|
case 'R':
|
||||||
|
return 'Renamed';
|
||||||
|
case 'C':
|
||||||
|
return 'Copied';
|
||||||
|
case '?':
|
||||||
|
return 'Untracked';
|
||||||
|
default:
|
||||||
|
return statusChar || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEnhancedStatusHandler() {
|
||||||
|
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 required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get current branch
|
||||||
|
const { stdout: branchRaw } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||||
|
cwd: projectPath,
|
||||||
|
});
|
||||||
|
const branch = branchRaw.trim();
|
||||||
|
|
||||||
|
// Get porcelain status for all files
|
||||||
|
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
|
||||||
|
cwd: projectPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get diff numstat for working tree changes
|
||||||
|
let workTreeStats: Record<string, { added: number; removed: number }> = {};
|
||||||
|
try {
|
||||||
|
const { stdout: numstatRaw } = await execAsync('git diff --numstat', {
|
||||||
|
cwd: projectPath,
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
for (const line of numstatRaw.trim().split('\n').filter(Boolean)) {
|
||||||
|
const parts = line.split('\t');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const added = parseInt(parts[0], 10) || 0;
|
||||||
|
const removed = parseInt(parts[1], 10) || 0;
|
||||||
|
workTreeStats[parts[2]] = { added, removed };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get diff numstat for staged changes
|
||||||
|
let stagedStats: Record<string, { added: number; removed: number }> = {};
|
||||||
|
try {
|
||||||
|
const { stdout: stagedNumstatRaw } = await execAsync('git diff --numstat --cached', {
|
||||||
|
cwd: projectPath,
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
for (const line of stagedNumstatRaw.trim().split('\n').filter(Boolean)) {
|
||||||
|
const parts = line.split('\t');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const added = parseInt(parts[0], 10) || 0;
|
||||||
|
const removed = parseInt(parts[1], 10) || 0;
|
||||||
|
stagedStats[parts[2]] = { added, removed };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse status and build enhanced file list
|
||||||
|
const files: EnhancedFileStatus[] = [];
|
||||||
|
|
||||||
|
for (const line of statusOutput.split('\n').filter(Boolean)) {
|
||||||
|
if (line.length < 4) continue;
|
||||||
|
|
||||||
|
const indexStatus = line[0];
|
||||||
|
const workTreeStatus = line[1];
|
||||||
|
const filePath = line.substring(3).trim();
|
||||||
|
|
||||||
|
// Handle renamed files (format: "R old -> new")
|
||||||
|
const actualPath = filePath.includes(' -> ')
|
||||||
|
? filePath.split(' -> ')[1].trim()
|
||||||
|
: filePath;
|
||||||
|
|
||||||
|
const isConflicted =
|
||||||
|
indexStatus === 'U' ||
|
||||||
|
workTreeStatus === 'U' ||
|
||||||
|
(indexStatus === 'A' && workTreeStatus === 'A') ||
|
||||||
|
(indexStatus === 'D' && workTreeStatus === 'D');
|
||||||
|
|
||||||
|
const isStaged = indexStatus !== ' ' && indexStatus !== '?';
|
||||||
|
const isUnstaged = workTreeStatus !== ' ' && workTreeStatus !== '?';
|
||||||
|
|
||||||
|
// Combine diff stats from both working tree and staged
|
||||||
|
const wtStats = workTreeStats[actualPath] || { added: 0, removed: 0 };
|
||||||
|
const stStats = stagedStats[actualPath] || { added: 0, removed: 0 };
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
path: actualPath,
|
||||||
|
indexStatus,
|
||||||
|
workTreeStatus,
|
||||||
|
isConflicted,
|
||||||
|
isStaged,
|
||||||
|
isUnstaged,
|
||||||
|
linesAdded: wtStats.added + stStats.added,
|
||||||
|
linesRemoved: wtStats.removed + stStats.removed,
|
||||||
|
statusLabel: getStatusLabel(indexStatus, workTreeStatus),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
branch,
|
||||||
|
files,
|
||||||
|
});
|
||||||
|
} catch (innerError) {
|
||||||
|
logError(innerError, 'Git enhanced status failed');
|
||||||
|
res.json({ success: true, branch: '', files: [] });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Get enhanced status failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js'
|
|||||||
import { createListIssuesHandler } from './routes/list-issues.js';
|
import { createListIssuesHandler } from './routes/list-issues.js';
|
||||||
import { createListPRsHandler } from './routes/list-prs.js';
|
import { createListPRsHandler } from './routes/list-prs.js';
|
||||||
import { createListCommentsHandler } from './routes/list-comments.js';
|
import { createListCommentsHandler } from './routes/list-comments.js';
|
||||||
|
import { createListPRReviewCommentsHandler } from './routes/list-pr-review-comments.js';
|
||||||
|
import { createResolvePRCommentHandler } from './routes/resolve-pr-comment.js';
|
||||||
import { createValidateIssueHandler } from './routes/validate-issue.js';
|
import { createValidateIssueHandler } from './routes/validate-issue.js';
|
||||||
import {
|
import {
|
||||||
createValidationStatusHandler,
|
createValidationStatusHandler,
|
||||||
@@ -29,6 +31,16 @@ export function createGitHubRoutes(
|
|||||||
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler());
|
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler());
|
||||||
router.post('/prs', validatePathParams('projectPath'), createListPRsHandler());
|
router.post('/prs', validatePathParams('projectPath'), createListPRsHandler());
|
||||||
router.post('/issue-comments', validatePathParams('projectPath'), createListCommentsHandler());
|
router.post('/issue-comments', validatePathParams('projectPath'), createListCommentsHandler());
|
||||||
|
router.post(
|
||||||
|
'/pr-review-comments',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
createListPRReviewCommentsHandler()
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/resolve-pr-comment',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
createResolvePRCommentHandler()
|
||||||
|
);
|
||||||
router.post(
|
router.post(
|
||||||
'/validate-issue',
|
'/validate-issue',
|
||||||
validatePathParams('projectPath'),
|
validatePathParams('projectPath'),
|
||||||
|
|||||||
333
apps/server/src/routes/github/routes/list-pr-review-comments.ts
Normal file
333
apps/server/src/routes/github/routes/list-pr-review-comments.ts
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
/**
|
||||||
|
* POST /pr-review-comments endpoint - Fetch review comments for a GitHub PR
|
||||||
|
*
|
||||||
|
* Fetches both regular PR comments and inline code review comments
|
||||||
|
* for a specific pull request, providing file path and line context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
||||||
|
import { checkGitHubRemote } from './check-github-remote.js';
|
||||||
|
|
||||||
|
export interface PRReviewComment {
|
||||||
|
id: string;
|
||||||
|
author: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
body: string;
|
||||||
|
path?: string;
|
||||||
|
line?: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
isReviewComment: boolean;
|
||||||
|
/** Whether this is an outdated review comment (code has changed since) */
|
||||||
|
isOutdated?: boolean;
|
||||||
|
/** Whether the review thread containing this comment has been resolved */
|
||||||
|
isResolved?: boolean;
|
||||||
|
/** The GraphQL node ID of the review thread (used for resolve/unresolve mutations) */
|
||||||
|
threadId?: string;
|
||||||
|
/** The diff hunk context for the comment */
|
||||||
|
diffHunk?: string;
|
||||||
|
/** The side of the diff (LEFT or RIGHT) */
|
||||||
|
side?: string;
|
||||||
|
/** The commit ID the comment was made on */
|
||||||
|
commitId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListPRReviewCommentsResult {
|
||||||
|
success: boolean;
|
||||||
|
comments?: PRReviewComment[];
|
||||||
|
totalCount?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListPRReviewCommentsRequest {
|
||||||
|
projectPath: string;
|
||||||
|
prNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Timeout for GitHub GraphQL API requests in milliseconds */
|
||||||
|
const GITHUB_API_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
|
interface GraphQLReviewThreadComment {
|
||||||
|
databaseId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphQLReviewThread {
|
||||||
|
id: string;
|
||||||
|
isResolved: boolean;
|
||||||
|
comments: {
|
||||||
|
nodes: GraphQLReviewThreadComment[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphQLResponse {
|
||||||
|
data?: {
|
||||||
|
repository?: {
|
||||||
|
pullRequest?: {
|
||||||
|
reviewThreads?: {
|
||||||
|
nodes: GraphQLReviewThread[];
|
||||||
|
};
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
errors?: Array<{ message: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewThreadInfo {
|
||||||
|
isResolved: boolean;
|
||||||
|
threadId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch review thread resolved status and thread IDs using GitHub GraphQL API.
|
||||||
|
* Returns a map of comment ID (string) -> { isResolved, threadId }.
|
||||||
|
*/
|
||||||
|
async function fetchReviewThreadResolvedStatus(
|
||||||
|
projectPath: string,
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
prNumber: number
|
||||||
|
): Promise<Map<string, ReviewThreadInfo>> {
|
||||||
|
const resolvedMap = new Map<string, ReviewThreadInfo>();
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query GetPRReviewThreads(
|
||||||
|
$owner: String!
|
||||||
|
$repo: String!
|
||||||
|
$prNumber: Int!
|
||||||
|
) {
|
||||||
|
repository(owner: $owner, name: $repo) {
|
||||||
|
pullRequest(number: $prNumber) {
|
||||||
|
reviewThreads(first: 100) {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
isResolved
|
||||||
|
comments(first: 100) {
|
||||||
|
nodes {
|
||||||
|
databaseId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const variables = { owner, repo, prNumber };
|
||||||
|
const requestBody = JSON.stringify({ query, variables });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await new Promise<GraphQLResponse>((resolve, reject) => {
|
||||||
|
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
|
||||||
|
cwd: projectPath,
|
||||||
|
env: execEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
gh.kill();
|
||||||
|
reject(new Error('GitHub GraphQL API request timed out'));
|
||||||
|
}, GITHUB_API_TIMEOUT_MS);
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
gh.stdout.on('data', (data: Buffer) => (stdout += data.toString()));
|
||||||
|
gh.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
|
||||||
|
|
||||||
|
gh.on('close', (code) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (code !== 0) {
|
||||||
|
return reject(new Error(`gh process exited with code ${code}: ${stderr}`));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(stdout));
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
gh.stdin.write(requestBody);
|
||||||
|
gh.stdin.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.errors && response.errors.length > 0) {
|
||||||
|
throw new Error(response.errors[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const threads = response.data?.repository?.pullRequest?.reviewThreads?.nodes ?? [];
|
||||||
|
for (const thread of threads) {
|
||||||
|
const info: ReviewThreadInfo = { isResolved: thread.isResolved, threadId: thread.id };
|
||||||
|
for (const comment of thread.comments.nodes) {
|
||||||
|
resolvedMap.set(String(comment.databaseId), info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Log but don't fail — resolved status is best-effort
|
||||||
|
logError(error, 'Failed to fetch PR review thread resolved status');
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all comments for a PR (both regular and inline review comments)
|
||||||
|
*/
|
||||||
|
async function fetchPRReviewComments(
|
||||||
|
projectPath: string,
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
prNumber: number
|
||||||
|
): Promise<PRReviewComment[]> {
|
||||||
|
const allComments: PRReviewComment[] = [];
|
||||||
|
|
||||||
|
// Fetch review thread resolved status in parallel with comment fetching
|
||||||
|
const resolvedStatusPromise = fetchReviewThreadResolvedStatus(projectPath, owner, repo, prNumber);
|
||||||
|
|
||||||
|
// 1. Fetch regular PR comments (issue-level comments)
|
||||||
|
try {
|
||||||
|
const { stdout: commentsOutput } = await execAsync(
|
||||||
|
`gh pr view ${prNumber} -R ${owner}/${repo} --json comments`,
|
||||||
|
{
|
||||||
|
cwd: projectPath,
|
||||||
|
env: execEnv,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const commentsData = JSON.parse(commentsOutput);
|
||||||
|
const regularComments = (commentsData.comments || []).map(
|
||||||
|
(c: {
|
||||||
|
id: string;
|
||||||
|
author: { login: string; avatarUrl?: string };
|
||||||
|
body: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}) => ({
|
||||||
|
id: String(c.id),
|
||||||
|
author: c.author?.login || 'unknown',
|
||||||
|
avatarUrl: c.author?.avatarUrl,
|
||||||
|
body: c.body,
|
||||||
|
createdAt: c.createdAt,
|
||||||
|
updatedAt: c.updatedAt,
|
||||||
|
isReviewComment: false,
|
||||||
|
isOutdated: false,
|
||||||
|
// Regular PR comments are not part of review threads, so not resolvable
|
||||||
|
isResolved: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
allComments.push(...regularComments);
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Failed to fetch regular PR comments');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch inline review comments (code-level comments with file/line info)
|
||||||
|
try {
|
||||||
|
const reviewsEndpoint = `repos/${owner}/${repo}/pulls/${prNumber}/comments`;
|
||||||
|
const { stdout: reviewsOutput } = await execAsync(`gh api ${reviewsEndpoint} --paginate`, {
|
||||||
|
cwd: projectPath,
|
||||||
|
env: execEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reviewsData = JSON.parse(reviewsOutput);
|
||||||
|
const reviewComments = (Array.isArray(reviewsData) ? reviewsData : []).map(
|
||||||
|
(c: {
|
||||||
|
id: number;
|
||||||
|
user: { login: string; avatar_url?: string };
|
||||||
|
body: string;
|
||||||
|
path: string;
|
||||||
|
line?: number;
|
||||||
|
original_line?: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
|
diff_hunk?: string;
|
||||||
|
side?: string;
|
||||||
|
commit_id?: string;
|
||||||
|
position?: number | null;
|
||||||
|
}) => ({
|
||||||
|
id: String(c.id),
|
||||||
|
author: c.user?.login || 'unknown',
|
||||||
|
avatarUrl: c.user?.avatar_url,
|
||||||
|
body: c.body,
|
||||||
|
path: c.path,
|
||||||
|
line: c.line || c.original_line,
|
||||||
|
createdAt: c.created_at,
|
||||||
|
updatedAt: c.updated_at,
|
||||||
|
isReviewComment: true,
|
||||||
|
// A review comment is "outdated" if position is null (code has changed)
|
||||||
|
isOutdated: c.position === null && !c.line,
|
||||||
|
// isResolved will be filled in below from GraphQL data
|
||||||
|
isResolved: false,
|
||||||
|
diffHunk: c.diff_hunk,
|
||||||
|
side: c.side,
|
||||||
|
commitId: c.commit_id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
allComments.push(...reviewComments);
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Failed to fetch inline review comments');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for resolved status and apply to inline review comments
|
||||||
|
const resolvedMap = await resolvedStatusPromise;
|
||||||
|
if (resolvedMap.size > 0) {
|
||||||
|
for (const comment of allComments) {
|
||||||
|
if (comment.isReviewComment && resolvedMap.has(comment.id)) {
|
||||||
|
const info = resolvedMap.get(comment.id);
|
||||||
|
comment.isResolved = info?.isResolved ?? false;
|
||||||
|
comment.threadId = info?.threadId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by createdAt descending (newest first)
|
||||||
|
allComments.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
|
||||||
|
return allComments;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createListPRReviewCommentsHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { projectPath, prNumber } = req.body as ListPRReviewCommentsRequest;
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prNumber || typeof prNumber !== 'number') {
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.json({ success: false, error: 'prNumber is required and must be a number' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a GitHub repo and get owner/repo
|
||||||
|
const remoteStatus = await checkGitHubRemote(projectPath);
|
||||||
|
if (!remoteStatus.hasGitHubRemote || !remoteStatus.owner || !remoteStatus.repo) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Project does not have a GitHub remote',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const comments = await fetchPRReviewComments(
|
||||||
|
projectPath,
|
||||||
|
remoteStatus.owner,
|
||||||
|
remoteStatus.repo,
|
||||||
|
prNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
comments,
|
||||||
|
totalCount: comments.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Fetch PR review comments failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
151
apps/server/src/routes/github/routes/resolve-pr-comment.ts
Normal file
151
apps/server/src/routes/github/routes/resolve-pr-comment.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* POST /resolve-pr-comment endpoint - Resolve or unresolve a GitHub PR review thread
|
||||||
|
*
|
||||||
|
* Uses the GitHub GraphQL API to resolve or unresolve a review thread
|
||||||
|
* identified by its GraphQL node ID (threadId).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { execEnv, getErrorMessage, logError } from './common.js';
|
||||||
|
import { checkGitHubRemote } from './check-github-remote.js';
|
||||||
|
|
||||||
|
export interface ResolvePRCommentResult {
|
||||||
|
success: boolean;
|
||||||
|
isResolved?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvePRCommentRequest {
|
||||||
|
projectPath: string;
|
||||||
|
threadId: string;
|
||||||
|
resolve: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Timeout for GitHub GraphQL API requests in milliseconds */
|
||||||
|
const GITHUB_API_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
|
interface GraphQLMutationResponse {
|
||||||
|
data?: {
|
||||||
|
resolveReviewThread?: {
|
||||||
|
thread?: { isResolved: boolean; id: string } | null;
|
||||||
|
} | null;
|
||||||
|
unresolveReviewThread?: {
|
||||||
|
thread?: { isResolved: boolean; id: string } | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
errors?: Array<{ message: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a GraphQL mutation to resolve or unresolve a review thread.
|
||||||
|
*/
|
||||||
|
async function executeReviewThreadMutation(
|
||||||
|
projectPath: string,
|
||||||
|
threadId: string,
|
||||||
|
resolve: boolean
|
||||||
|
): Promise<{ isResolved: boolean }> {
|
||||||
|
const mutationName = resolve ? 'resolveReviewThread' : 'unresolveReviewThread';
|
||||||
|
|
||||||
|
const mutation = `
|
||||||
|
mutation ${resolve ? 'ResolveThread' : 'UnresolveThread'}($threadId: ID!) {
|
||||||
|
${mutationName}(input: { threadId: $threadId }) {
|
||||||
|
thread {
|
||||||
|
id
|
||||||
|
isResolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const variables = { threadId };
|
||||||
|
const requestBody = JSON.stringify({ query: mutation, variables });
|
||||||
|
|
||||||
|
const response = await new Promise<GraphQLMutationResponse>((res, rej) => {
|
||||||
|
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
|
||||||
|
cwd: projectPath,
|
||||||
|
env: execEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
gh.kill();
|
||||||
|
rej(new Error('GitHub GraphQL API request timed out'));
|
||||||
|
}, GITHUB_API_TIMEOUT_MS);
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
gh.stdout.on('data', (data: Buffer) => (stdout += data.toString()));
|
||||||
|
gh.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
|
||||||
|
|
||||||
|
gh.on('close', (code) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (code !== 0) {
|
||||||
|
return rej(new Error(`gh process exited with code ${code}: ${stderr}`));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
res(JSON.parse(stdout));
|
||||||
|
} catch (e) {
|
||||||
|
rej(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
gh.stdin.write(requestBody);
|
||||||
|
gh.stdin.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.errors && response.errors.length > 0) {
|
||||||
|
throw new Error(response.errors[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadData = resolve
|
||||||
|
? response.data?.resolveReviewThread?.thread
|
||||||
|
: response.data?.unresolveReviewThread?.thread;
|
||||||
|
|
||||||
|
if (!threadData) {
|
||||||
|
throw new Error('No thread data returned from GitHub API');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isResolved: threadData.isResolved };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createResolvePRCommentHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { projectPath, threadId, resolve } = req.body as ResolvePRCommentRequest;
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!threadId) {
|
||||||
|
res.status(400).json({ success: false, error: 'threadId is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof resolve !== 'boolean') {
|
||||||
|
res.status(400).json({ success: false, error: 'resolve must be a boolean' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a GitHub repo
|
||||||
|
const remoteStatus = await checkGitHubRemote(projectPath);
|
||||||
|
if (!remoteStatus.hasGitHubRemote) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Project does not have a GitHub remote',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeReviewThreadMutation(projectPath, threadId, resolve);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
isResolved: result.isResolved,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Resolve PR comment failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,7 +2,12 @@
|
|||||||
* Common utilities for worktree routes
|
* Common utilities for worktree routes
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createLogger, isValidBranchName, MAX_BRANCH_NAME_LENGTH } from '@automaker/utils';
|
import {
|
||||||
|
createLogger,
|
||||||
|
isValidBranchName,
|
||||||
|
isValidRemoteName,
|
||||||
|
MAX_BRANCH_NAME_LENGTH,
|
||||||
|
} from '@automaker/utils';
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||||
@@ -16,7 +21,7 @@ export const execAsync = promisify(exec);
|
|||||||
|
|
||||||
// Re-export git validation utilities from the canonical shared module so
|
// Re-export git validation utilities from the canonical shared module so
|
||||||
// existing consumers that import from this file continue to work.
|
// existing consumers that import from this file continue to work.
|
||||||
export { isValidBranchName, MAX_BRANCH_NAME_LENGTH };
|
export { isValidBranchName, isValidRemoteName, MAX_BRANCH_NAME_LENGTH };
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Extended PATH configuration for Electron apps
|
// Extended PATH configuration for Electron apps
|
||||||
@@ -60,25 +65,6 @@ export const execEnv = {
|
|||||||
PATH: extendedPath,
|
PATH: extendedPath,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate git remote name to prevent command injection.
|
|
||||||
* Matches the strict validation used in add-remote.ts:
|
|
||||||
* - Rejects empty strings and names that are too long
|
|
||||||
* - Disallows names that start with '-' or '.'
|
|
||||||
* - Forbids the substring '..'
|
|
||||||
* - Rejects '/' characters
|
|
||||||
* - Rejects NUL bytes
|
|
||||||
* - Must consist only of alphanumerics, hyphens, underscores, and dots
|
|
||||||
*/
|
|
||||||
export function isValidRemoteName(name: string): boolean {
|
|
||||||
if (!name || name.length === 0 || name.length >= MAX_BRANCH_NAME_LENGTH) return false;
|
|
||||||
if (name.startsWith('-') || name.startsWith('.')) return false;
|
|
||||||
if (name.includes('..')) return false;
|
|
||||||
if (name.includes('/')) return false;
|
|
||||||
if (name.includes('\0')) return false;
|
|
||||||
return /^[a-zA-Z0-9._-]+$/.test(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if gh CLI is available on the system
|
* Check if gh CLI is available on the system
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -22,6 +22,36 @@ import { getErrorMessage, logError, isValidBranchName } from '../common.js';
|
|||||||
import { execGitCommand } from '../../../lib/git.js';
|
import { execGitCommand } from '../../../lib/git.js';
|
||||||
import type { EventEmitter } from '../../../lib/events.js';
|
import type { EventEmitter } from '../../../lib/events.js';
|
||||||
import { performCheckoutBranch } from '../../../services/checkout-branch-service.js';
|
import { performCheckoutBranch } from '../../../services/checkout-branch-service.js';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
|
const logger = createLogger('CheckoutBranchRoute');
|
||||||
|
|
||||||
|
/** Timeout for git fetch operations (30 seconds) */
|
||||||
|
const FETCH_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch latest from all remotes (silently, with timeout).
|
||||||
|
* Non-fatal: fetch errors are logged and swallowed so the workflow continues.
|
||||||
|
*/
|
||||||
|
async function fetchRemotes(cwd: string): Promise<void> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timerId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Process aborted') {
|
||||||
|
logger.warn(
|
||||||
|
`fetchRemotes timed out after ${FETCH_TIMEOUT_MS}ms - continuing without latest remote refs`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.warn(`fetchRemotes failed: ${getErrorMessage(error)} - continuing with local refs`);
|
||||||
|
}
|
||||||
|
// Non-fatal: continue with locally available refs
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createCheckoutBranchHandler(events?: EventEmitter) {
|
export function createCheckoutBranchHandler(events?: EventEmitter) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
@@ -127,6 +157,10 @@ export function createCheckoutBranchHandler(events?: EventEmitter) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Original simple flow (no stash handling)
|
// Original simple flow (no stash handling)
|
||||||
|
// Fetch latest remote refs before creating the branch so that
|
||||||
|
// base branch validation works for remote references like "origin/main"
|
||||||
|
await fetchRemotes(resolvedPath);
|
||||||
|
|
||||||
const currentBranchOutput = await execGitCommand(
|
const currentBranchOutput = await execGitCommand(
|
||||||
['rev-parse', '--abbrev-ref', 'HEAD'],
|
['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||||
resolvedPath
|
resolvedPath
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { spawnProcess } from '@automaker/platform';
|
|||||||
import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { validatePRState } from '@automaker/types';
|
import { validatePRState } from '@automaker/types';
|
||||||
|
import { resolvePrTarget } from '../../../services/pr-service.js';
|
||||||
|
|
||||||
const logger = createLogger('CreatePR');
|
const logger = createLogger('CreatePR');
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ export function createCreatePRHandler() {
|
|||||||
baseBranch,
|
baseBranch,
|
||||||
draft,
|
draft,
|
||||||
remote,
|
remote,
|
||||||
|
targetRemote,
|
||||||
} = req.body as {
|
} = req.body as {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
@@ -41,6 +43,8 @@ export function createCreatePRHandler() {
|
|||||||
baseBranch?: string;
|
baseBranch?: string;
|
||||||
draft?: boolean;
|
draft?: boolean;
|
||||||
remote?: string;
|
remote?: string;
|
||||||
|
/** Remote to create the PR against (e.g. upstream). If not specified, inferred from repo setup. */
|
||||||
|
targetRemote?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!worktreePath) {
|
if (!worktreePath) {
|
||||||
@@ -71,6 +75,52 @@ export function createCreatePRHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Input validation: run all validation before any git write operations ---
|
||||||
|
|
||||||
|
// Validate remote names before use to prevent command injection
|
||||||
|
if (remote !== undefined && !isValidRemoteName(remote)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid remote name contains unsafe characters',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (targetRemote !== undefined && !isValidRemoteName(targetRemote)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid target remote name contains unsafe characters',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushRemote = remote || 'origin';
|
||||||
|
|
||||||
|
// Resolve repository URL, fork workflow, and target remote information.
|
||||||
|
// This is needed for both the existing PR check and PR creation.
|
||||||
|
// Resolve early so validation errors are caught before any writes.
|
||||||
|
let repoUrl: string | null = null;
|
||||||
|
let upstreamRepo: string | null = null;
|
||||||
|
let originOwner: string | null = null;
|
||||||
|
try {
|
||||||
|
const prTarget = await resolvePrTarget({
|
||||||
|
worktreePath,
|
||||||
|
pushRemote,
|
||||||
|
targetRemote,
|
||||||
|
});
|
||||||
|
repoUrl = prTarget.repoUrl;
|
||||||
|
upstreamRepo = prTarget.upstreamRepo;
|
||||||
|
originOwner = prTarget.originOwner;
|
||||||
|
} catch (resolveErr) {
|
||||||
|
// resolvePrTarget throws for validation errors (unknown targetRemote, missing pushRemote)
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(resolveErr),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Validation complete — proceed with git operations ---
|
||||||
|
|
||||||
// Check for uncommitted changes
|
// Check for uncommitted changes
|
||||||
logger.debug(`Checking for uncommitted changes in: ${worktreePath}`);
|
logger.debug(`Checking for uncommitted changes in: ${worktreePath}`);
|
||||||
const { stdout: status } = await execAsync('git status --porcelain', {
|
const { stdout: status } = await execAsync('git status --porcelain', {
|
||||||
@@ -119,30 +169,19 @@ export function createCreatePRHandler() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate remote name before use to prevent command injection
|
|
||||||
if (remote !== undefined && !isValidRemoteName(remote)) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: 'Invalid remote name contains unsafe characters',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push the branch to remote (use selected remote or default to 'origin')
|
// Push the branch to remote (use selected remote or default to 'origin')
|
||||||
const pushRemote = remote || 'origin';
|
// Uses array-based execGitCommand to avoid shell injection from pushRemote/branchName.
|
||||||
let pushError: string | null = null;
|
let pushError: string | null = null;
|
||||||
try {
|
try {
|
||||||
await execAsync(`git push ${pushRemote} ${branchName}`, {
|
await execGitCommand(['push', pushRemote, branchName], worktreePath, execEnv);
|
||||||
cwd: worktreePath,
|
|
||||||
env: execEnv,
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
// If push fails, try with --set-upstream
|
// If push fails, try with --set-upstream
|
||||||
try {
|
try {
|
||||||
await execAsync(`git push --set-upstream ${pushRemote} ${branchName}`, {
|
await execGitCommand(
|
||||||
cwd: worktreePath,
|
['push', '--set-upstream', pushRemote, branchName],
|
||||||
env: execEnv,
|
worktreePath,
|
||||||
});
|
execEnv
|
||||||
|
);
|
||||||
} catch (error2: unknown) {
|
} catch (error2: unknown) {
|
||||||
// Capture push error for reporting
|
// Capture push error for reporting
|
||||||
const err = error2 as { stderr?: string; message?: string };
|
const err = error2 as { stderr?: string; message?: string };
|
||||||
@@ -164,82 +203,11 @@ export function createCreatePRHandler() {
|
|||||||
const base = baseBranch || 'main';
|
const base = baseBranch || 'main';
|
||||||
const title = prTitle || branchName;
|
const title = prTitle || branchName;
|
||||||
const body = prBody || `Changes from branch ${branchName}`;
|
const body = prBody || `Changes from branch ${branchName}`;
|
||||||
const draftFlag = draft ? '--draft' : '';
|
|
||||||
|
|
||||||
let prUrl: string | null = null;
|
let prUrl: string | null = null;
|
||||||
let prError: string | null = null;
|
let prError: string | null = null;
|
||||||
let browserUrl: string | null = null;
|
let browserUrl: string | null = null;
|
||||||
let ghCliAvailable = false;
|
let ghCliAvailable = false;
|
||||||
|
|
||||||
// Get repository URL and detect fork workflow FIRST
|
|
||||||
// This is needed for both the existing PR check and PR creation
|
|
||||||
let repoUrl: string | null = null;
|
|
||||||
let upstreamRepo: string | null = null;
|
|
||||||
let originOwner: string | null = null;
|
|
||||||
try {
|
|
||||||
const { stdout: remotes } = await execAsync('git remote -v', {
|
|
||||||
cwd: worktreePath,
|
|
||||||
env: execEnv,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Parse remotes to detect fork workflow and get repo URL
|
|
||||||
const lines = remotes.split(/\r?\n/); // Handle both Unix and Windows line endings
|
|
||||||
for (const line of lines) {
|
|
||||||
// Try multiple patterns to match different remote URL formats
|
|
||||||
// Pattern 1: git@github.com:owner/repo.git (fetch)
|
|
||||||
// Pattern 2: https://github.com/owner/repo.git (fetch)
|
|
||||||
// Pattern 3: https://github.com/owner/repo (fetch)
|
|
||||||
let match = line.match(/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/);
|
|
||||||
if (!match) {
|
|
||||||
// Try SSH format: git@github.com:owner/repo.git
|
|
||||||
match = line.match(/^(\w+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/);
|
|
||||||
}
|
|
||||||
if (!match) {
|
|
||||||
// Try HTTPS format: https://github.com/owner/repo.git
|
|
||||||
match = line.match(
|
|
||||||
/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (match) {
|
|
||||||
const [, remoteName, owner, repo] = match;
|
|
||||||
if (remoteName === 'upstream') {
|
|
||||||
upstreamRepo = `${owner}/${repo}`;
|
|
||||||
repoUrl = `https://github.com/${owner}/${repo}`;
|
|
||||||
} else if (remoteName === 'origin') {
|
|
||||||
originOwner = owner;
|
|
||||||
if (!repoUrl) {
|
|
||||||
repoUrl = `https://github.com/${owner}/${repo}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Couldn't parse remotes - will try fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Try to get repo URL from git config if remote parsing failed
|
|
||||||
if (!repoUrl) {
|
|
||||||
try {
|
|
||||||
const { stdout: originUrl } = await execAsync('git config --get remote.origin.url', {
|
|
||||||
cwd: worktreePath,
|
|
||||||
env: execEnv,
|
|
||||||
});
|
|
||||||
const url = originUrl.trim();
|
|
||||||
|
|
||||||
// Parse URL to extract owner/repo
|
|
||||||
// Handle both SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git)
|
|
||||||
let match = url.match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/);
|
|
||||||
if (match) {
|
|
||||||
const [, owner, repo] = match;
|
|
||||||
originOwner = owner;
|
|
||||||
repoUrl = `https://github.com/${owner}/${repo}`;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Failed to get repo URL from config
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if gh CLI is available (cross-platform)
|
// Check if gh CLI is available (cross-platform)
|
||||||
ghCliAvailable = await isGhCliAvailable();
|
ghCliAvailable = await isGhCliAvailable();
|
||||||
|
|
||||||
@@ -247,13 +215,16 @@ export function createCreatePRHandler() {
|
|||||||
if (repoUrl) {
|
if (repoUrl) {
|
||||||
const encodedTitle = encodeURIComponent(title);
|
const encodedTitle = encodeURIComponent(title);
|
||||||
const encodedBody = encodeURIComponent(body);
|
const encodedBody = encodeURIComponent(body);
|
||||||
|
// Encode base branch and head branch to handle special chars like # or %
|
||||||
|
const encodedBase = encodeURIComponent(base);
|
||||||
|
const encodedBranch = encodeURIComponent(branchName);
|
||||||
|
|
||||||
if (upstreamRepo && originOwner) {
|
if (upstreamRepo && originOwner) {
|
||||||
// Fork workflow: PR to upstream from origin
|
// Fork workflow (or cross-remote PR): PR to target from push remote
|
||||||
browserUrl = `https://github.com/${upstreamRepo}/compare/${base}...${originOwner}:${branchName}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
|
browserUrl = `https://github.com/${upstreamRepo}/compare/${encodedBase}...${originOwner}:${encodedBranch}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
|
||||||
} else {
|
} else {
|
||||||
// Regular repo
|
// Regular repo
|
||||||
browserUrl = `${repoUrl}/compare/${base}...${branchName}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
|
browserUrl = `${repoUrl}/compare/${encodedBase}...${encodedBranch}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,18 +234,40 @@ export function createCreatePRHandler() {
|
|||||||
if (ghCliAvailable) {
|
if (ghCliAvailable) {
|
||||||
// First, check if a PR already exists for this branch using gh pr list
|
// First, check if a PR already exists for this branch using gh pr list
|
||||||
// This is more reliable than gh pr view as it explicitly searches by branch name
|
// This is more reliable than gh pr view as it explicitly searches by branch name
|
||||||
// For forks, we need to use owner:branch format for the head parameter
|
// For forks/cross-remote, we need to use owner:branch format for the head parameter
|
||||||
const headRef = upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName;
|
const headRef = upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName;
|
||||||
const repoArg = upstreamRepo ? ` --repo "${upstreamRepo}"` : '';
|
|
||||||
|
|
||||||
logger.debug(`Checking for existing PR for branch: ${branchName} (headRef: ${headRef})`);
|
logger.debug(`Checking for existing PR for branch: ${branchName} (headRef: ${headRef})`);
|
||||||
try {
|
try {
|
||||||
const listCmd = `gh pr list${repoArg} --head "${headRef}" --json number,title,url,state --limit 1`;
|
const listArgs = ['pr', 'list'];
|
||||||
logger.debug(`Running: ${listCmd}`);
|
if (upstreamRepo) {
|
||||||
const { stdout: existingPrOutput } = await execAsync(listCmd, {
|
listArgs.push('--repo', upstreamRepo);
|
||||||
|
}
|
||||||
|
listArgs.push(
|
||||||
|
'--head',
|
||||||
|
headRef,
|
||||||
|
'--json',
|
||||||
|
'number,title,url,state,createdAt',
|
||||||
|
'--limit',
|
||||||
|
'1'
|
||||||
|
);
|
||||||
|
logger.debug(`Running: gh ${listArgs.join(' ')}`);
|
||||||
|
const listResult = await spawnProcess({
|
||||||
|
command: 'gh',
|
||||||
|
args: listArgs,
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
env: execEnv,
|
env: execEnv,
|
||||||
});
|
});
|
||||||
|
if (listResult.exitCode !== 0) {
|
||||||
|
logger.error(
|
||||||
|
`gh pr list failed with exit code ${listResult.exitCode}: ` +
|
||||||
|
`stderr=${listResult.stderr}, stdout=${listResult.stdout}`
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`gh pr list failed (exit code ${listResult.exitCode}): ${listResult.stderr || listResult.stdout}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const existingPrOutput = listResult.stdout;
|
||||||
logger.debug(`gh pr list output: ${existingPrOutput}`);
|
logger.debug(`gh pr list output: ${existingPrOutput}`);
|
||||||
|
|
||||||
const existingPrs = JSON.parse(existingPrOutput);
|
const existingPrs = JSON.parse(existingPrOutput);
|
||||||
@@ -294,7 +287,7 @@ export function createCreatePRHandler() {
|
|||||||
url: existingPr.url,
|
url: existingPr.url,
|
||||||
title: existingPr.title || title,
|
title: existingPr.title || title,
|
||||||
state: validatePRState(existingPr.state),
|
state: validatePRState(existingPr.state),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: existingPr.createdAt || new Date().toISOString(),
|
||||||
});
|
});
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Stored existing PR info for branch ${branchName}: PR #${existingPr.number}`
|
`Stored existing PR info for branch ${branchName}: PR #${existingPr.number}`
|
||||||
@@ -372,11 +365,26 @@ export function createCreatePRHandler() {
|
|||||||
if (errorMessage.toLowerCase().includes('already exists')) {
|
if (errorMessage.toLowerCase().includes('already exists')) {
|
||||||
logger.debug(`PR already exists error - trying to fetch existing PR`);
|
logger.debug(`PR already exists error - trying to fetch existing PR`);
|
||||||
try {
|
try {
|
||||||
const { stdout: viewOutput } = await execAsync(
|
// Build args as an array to avoid shell injection.
|
||||||
`gh pr view --json number,title,url,state`,
|
// When upstreamRepo is set (fork/cross-remote workflow) we must
|
||||||
{ cwd: worktreePath, env: execEnv }
|
// query the upstream repository so we find the correct PR.
|
||||||
);
|
const viewArgs = ['pr', 'view', '--json', 'number,title,url,state,createdAt'];
|
||||||
const existingPr = JSON.parse(viewOutput);
|
if (upstreamRepo) {
|
||||||
|
viewArgs.push('--repo', upstreamRepo);
|
||||||
|
}
|
||||||
|
logger.debug(`Running: gh ${viewArgs.join(' ')}`);
|
||||||
|
const viewResult = await spawnProcess({
|
||||||
|
command: 'gh',
|
||||||
|
args: viewArgs,
|
||||||
|
cwd: worktreePath,
|
||||||
|
env: execEnv,
|
||||||
|
});
|
||||||
|
if (viewResult.exitCode !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`gh pr view failed (exit code ${viewResult.exitCode}): ${viewResult.stderr || viewResult.stdout}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const existingPr = JSON.parse(viewResult.stdout);
|
||||||
if (existingPr.url) {
|
if (existingPr.url) {
|
||||||
prUrl = existingPr.url;
|
prUrl = existingPr.url;
|
||||||
prNumber = existingPr.number;
|
prNumber = existingPr.number;
|
||||||
@@ -388,7 +396,7 @@ export function createCreatePRHandler() {
|
|||||||
url: existingPr.url,
|
url: existingPr.url,
|
||||||
title: existingPr.title || title,
|
title: existingPr.title || title,
|
||||||
state: validatePRState(existingPr.state),
|
state: validatePRState(existingPr.state),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: existingPr.createdAt || new Date().toISOString(),
|
||||||
});
|
});
|
||||||
logger.debug(`Fetched and stored existing PR: #${existingPr.number}`);
|
logger.debug(`Fetched and stored existing PR: #${existingPr.number}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ import { runInitScript } from '../../../services/init-script-service.js';
|
|||||||
|
|
||||||
const logger = createLogger('Worktree');
|
const logger = createLogger('Worktree');
|
||||||
|
|
||||||
|
/** Timeout for git fetch operations (30 seconds) */
|
||||||
|
const FETCH_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,7 +94,7 @@ export function createCreateHandler(events: EventEmitter, settingsService?: Sett
|
|||||||
const { projectPath, branchName, baseBranch } = req.body as {
|
const { projectPath, branchName, baseBranch } = req.body as {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
branchName: string;
|
branchName: string;
|
||||||
baseBranch?: string; // Optional base branch to create from (defaults to current HEAD)
|
baseBranch?: string; // Optional base branch to create from (defaults to current HEAD). Can be a remote branch like "origin/main".
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!projectPath || !branchName) {
|
if (!projectPath || !branchName) {
|
||||||
@@ -171,6 +174,25 @@ export function createCreateHandler(events: EventEmitter, settingsService?: Sett
|
|||||||
// 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 });
|
||||||
|
|
||||||
|
// Fetch latest from all remotes before creating the worktree.
|
||||||
|
// This ensures remote refs are up-to-date for:
|
||||||
|
// - Remote base branches (e.g. "origin/main")
|
||||||
|
// - Existing remote branches being checked out as worktrees
|
||||||
|
// - Branch existence checks against fresh remote state
|
||||||
|
logger.info('Fetching from all remotes before creating worktree');
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timerId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
await execGitCommand(['fetch', '--all', '--quiet'], projectPath, undefined, controller);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timerId);
|
||||||
|
}
|
||||||
|
} catch (fetchErr) {
|
||||||
|
// Non-fatal: log but continue — refs might already be cached locally
|
||||||
|
logger.warn(`Failed to fetch from remotes: ${getErrorMessage(fetchErr)}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if branch exists (using array arguments to prevent injection)
|
// Check if branch exists (using array arguments to prevent injection)
|
||||||
let branchExists = false;
|
let branchExists = false;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export function createDiffsHandler() {
|
|||||||
diff: result.diff,
|
diff: result.diff,
|
||||||
files: result.files,
|
files: result.files,
|
||||||
hasChanges: result.hasChanges,
|
hasChanges: result.hasChanges,
|
||||||
|
...(result.mergeState ? { mergeState: result.mergeState } : {}),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -55,6 +56,7 @@ export function createDiffsHandler() {
|
|||||||
diff: result.diff,
|
diff: result.diff,
|
||||||
files: result.files,
|
files: result.files,
|
||||||
hasChanges: result.hasChanges,
|
hasChanges: result.hasChanges,
|
||||||
|
...(result.mergeState ? { mergeState: result.mergeState } : {}),
|
||||||
});
|
});
|
||||||
} catch (innerError) {
|
} catch (innerError) {
|
||||||
// Worktree doesn't exist - fallback to main project path
|
// Worktree doesn't exist - fallback to main project path
|
||||||
@@ -71,6 +73,7 @@ export function createDiffsHandler() {
|
|||||||
diff: result.diff,
|
diff: result.diff,
|
||||||
files: result.files,
|
files: result.files,
|
||||||
hasChanges: result.hasChanges,
|
hasChanges: result.hasChanges,
|
||||||
|
...(result.mergeState ? { mergeState: result.mergeState } : {}),
|
||||||
});
|
});
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
logError(fallbackError, 'Fallback to main project also failed');
|
logError(fallbackError, 'Fallback to main project also failed');
|
||||||
|
|||||||
@@ -213,8 +213,10 @@ export function createGenerateCommitMessageHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
||||||
// Use result if available (some providers return final text here)
|
// Use result text if longer than accumulated text (consistent with simpleQuery pattern)
|
||||||
responseText = msg.result;
|
if (msg.result.length > responseText.length) {
|
||||||
|
responseText = msg.result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const MAX_DIFF_SIZE = 15_000;
|
|||||||
|
|
||||||
const PR_DESCRIPTION_SYSTEM_PROMPT = `You are a pull request description generator. Your task is to create a clear, well-structured PR title and description based on the git diff and branch information provided.
|
const PR_DESCRIPTION_SYSTEM_PROMPT = `You are a pull request description generator. Your task is to create a clear, well-structured PR title and description based on the git diff and branch information provided.
|
||||||
|
|
||||||
|
IMPORTANT: Do NOT include any conversational text, explanations, or preamble. Do NOT say things like "I'll analyze..." or "Here is...". Output ONLY the structured format below and nothing else.
|
||||||
|
|
||||||
Output your response in EXACTLY this format (including the markers):
|
Output your response in EXACTLY this format (including the markers):
|
||||||
---TITLE---
|
---TITLE---
|
||||||
<a concise PR title, 50-72 chars, imperative mood>
|
<a concise PR title, 50-72 chars, imperative mood>
|
||||||
@@ -41,6 +43,7 @@ Output your response in EXACTLY this format (including the markers):
|
|||||||
<Detailed list of what was changed and why>
|
<Detailed list of what was changed and why>
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
- Your ENTIRE response must start with ---TITLE--- and contain nothing before it
|
||||||
- The title should be concise and descriptive (50-72 characters)
|
- The title should be concise and descriptive (50-72 characters)
|
||||||
- Use imperative mood for the title (e.g., "Add dark mode toggle" not "Added dark mode toggle")
|
- Use imperative mood for the title (e.g., "Add dark mode toggle" not "Added dark mode toggle")
|
||||||
- The description should explain WHAT changed and WHY
|
- The description should explain WHAT changed and WHY
|
||||||
@@ -397,7 +400,10 @@ export function createGeneratePRDescriptionHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
||||||
responseText = msg.result;
|
// Use result text if longer than accumulated text (consistent with simpleQuery pattern)
|
||||||
|
if (msg.result.length > responseText.length) {
|
||||||
|
responseText = msg.result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +419,9 @@ export function createGeneratePRDescriptionHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the response to extract title and body
|
// Parse the response to extract title and body.
|
||||||
|
// The model may include conversational preamble before the structured markers,
|
||||||
|
// so we search for the markers anywhere in the response, not just at the start.
|
||||||
let title = '';
|
let title = '';
|
||||||
let body = '';
|
let body = '';
|
||||||
|
|
||||||
@@ -424,14 +432,46 @@ export function createGeneratePRDescriptionHandler(
|
|||||||
title = titleMatch[1].trim();
|
title = titleMatch[1].trim();
|
||||||
body = bodyMatch[1].trim();
|
body = bodyMatch[1].trim();
|
||||||
} else {
|
} else {
|
||||||
// Fallback: treat first line as title, rest as body
|
// Fallback: try to extract meaningful content, skipping any conversational preamble.
|
||||||
const lines = fullResponse.split('\n');
|
// Common preamble patterns start with "I'll", "I will", "Here", "Let me", "Based on", etc.
|
||||||
title = lines[0].trim();
|
const lines = fullResponse.split('\n').filter((line) => line.trim().length > 0);
|
||||||
body = lines.slice(1).join('\n').trim();
|
|
||||||
|
// Skip lines that look like conversational preamble
|
||||||
|
let startIndex = 0;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
// Check if this line looks like conversational AI preamble
|
||||||
|
if (
|
||||||
|
/^(I'll|I will|Here('s| is| are)|Let me|Based on|Looking at|Analyzing|Sure|OK|Okay|Of course)/i.test(
|
||||||
|
line
|
||||||
|
) ||
|
||||||
|
/^(The following|Below is|This (is|will)|After (analyzing|reviewing|looking))/i.test(
|
||||||
|
line
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
startIndex = i + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use remaining lines after skipping preamble
|
||||||
|
const contentLines = lines.slice(startIndex);
|
||||||
|
if (contentLines.length > 0) {
|
||||||
|
title = contentLines[0].trim();
|
||||||
|
body = contentLines.slice(1).join('\n').trim();
|
||||||
|
} else {
|
||||||
|
// If all lines were filtered as preamble, use the original first non-empty line
|
||||||
|
title = lines[0]?.trim() || '';
|
||||||
|
body = lines.slice(1).join('\n').trim();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up title - remove any markdown or quotes
|
// Clean up title - remove any markdown headings, quotes, or marker artifacts
|
||||||
title = title.replace(/^#+\s*/, '').replace(/^["']|["']$/g, '');
|
title = title
|
||||||
|
.replace(/^#+\s*/, '')
|
||||||
|
.replace(/^["']|["']$/g, '')
|
||||||
|
.replace(/^---\w+---\s*/, '');
|
||||||
|
|
||||||
logger.info(`Generated PR title: ${title.substring(0, 100)}...`);
|
logger.info(`Generated PR title: ${title.substring(0, 100)}...`);
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export function createListBranchesHandler() {
|
|||||||
let aheadCount = 0;
|
let aheadCount = 0;
|
||||||
let behindCount = 0;
|
let behindCount = 0;
|
||||||
let hasRemoteBranch = false;
|
let hasRemoteBranch = false;
|
||||||
|
let trackingRemote: string | undefined;
|
||||||
try {
|
try {
|
||||||
// First check if there's a remote tracking branch
|
// First check if there's a remote tracking branch
|
||||||
const { stdout: upstreamOutput } = await execFileAsync(
|
const { stdout: upstreamOutput } = await execFileAsync(
|
||||||
@@ -138,8 +139,14 @@ export function createListBranchesHandler() {
|
|||||||
{ cwd: worktreePath }
|
{ cwd: worktreePath }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (upstreamOutput.trim()) {
|
const upstreamRef = upstreamOutput.trim();
|
||||||
|
if (upstreamRef) {
|
||||||
hasRemoteBranch = true;
|
hasRemoteBranch = true;
|
||||||
|
// Extract the remote name from the upstream ref (e.g. "origin/main" -> "origin")
|
||||||
|
const slashIndex = upstreamRef.indexOf('/');
|
||||||
|
if (slashIndex !== -1) {
|
||||||
|
trackingRemote = upstreamRef.slice(0, slashIndex);
|
||||||
|
}
|
||||||
const { stdout: aheadBehindOutput } = await execFileAsync(
|
const { stdout: aheadBehindOutput } = await execFileAsync(
|
||||||
'git',
|
'git',
|
||||||
['rev-list', '--left-right', '--count', `${currentBranch}@{upstream}...HEAD`],
|
['rev-list', '--left-right', '--count', `${currentBranch}@{upstream}...HEAD`],
|
||||||
@@ -174,6 +181,7 @@ export function createListBranchesHandler() {
|
|||||||
behindCount,
|
behindCount,
|
||||||
hasRemoteBranch,
|
hasRemoteBranch,
|
||||||
hasAnyRemotes,
|
hasAnyRemotes,
|
||||||
|
trackingRemote,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ export function createMergeHandler(events: EventEmitter) {
|
|||||||
branchName: string;
|
branchName: string;
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
targetBranch?: string; // Branch to merge into (defaults to 'main')
|
targetBranch?: string; // Branch to merge into (defaults to 'main')
|
||||||
options?: { squash?: boolean; message?: string; deleteWorktreeAndBranch?: boolean };
|
options?: {
|
||||||
|
squash?: boolean;
|
||||||
|
message?: string;
|
||||||
|
deleteWorktreeAndBranch?: boolean;
|
||||||
|
remote?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!projectPath || !branchName || !worktreePath) {
|
if (!projectPath || !branchName || !worktreePath) {
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ function mapResultToResponse(res: Response, result: PullResult): void {
|
|||||||
stashed: result.stashed,
|
stashed: result.stashed,
|
||||||
stashRestored: result.stashRestored,
|
stashRestored: result.stashRestored,
|
||||||
message: result.message,
|
message: result.message,
|
||||||
|
isMerge: result.isMerge,
|
||||||
|
isFastForward: result.isFastForward,
|
||||||
|
mergeAffectedFiles: result.mergeAffectedFiles,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,17 +14,19 @@
|
|||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
|
import { getErrorMessage, logError, isValidBranchName, isValidRemoteName } from '../common.js';
|
||||||
import type { EventEmitter } from '../../../lib/events.js';
|
import type { EventEmitter } from '../../../lib/events.js';
|
||||||
import { runRebase } from '../../../services/rebase-service.js';
|
import { runRebase } from '../../../services/rebase-service.js';
|
||||||
|
|
||||||
export function createRebaseHandler(events: EventEmitter) {
|
export function createRebaseHandler(events: EventEmitter) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { worktreePath, ontoBranch } = req.body as {
|
const { worktreePath, ontoBranch, remote } = req.body as {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
/** The branch/ref to rebase onto (e.g., 'origin/main', 'main') */
|
/** The branch/ref to rebase onto (e.g., 'origin/main', 'main') */
|
||||||
ontoBranch: string;
|
ontoBranch: string;
|
||||||
|
/** Remote name to fetch from before rebasing (defaults to 'origin') */
|
||||||
|
remote?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!worktreePath) {
|
if (!worktreePath) {
|
||||||
@@ -55,6 +57,15 @@ export function createRebaseHandler(events: EventEmitter) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate optional remote name to reject unsafe characters at the route layer
|
||||||
|
if (remote !== undefined && !isValidRemoteName(remote)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Invalid remote name: "${remote}"`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Emit started event
|
// Emit started event
|
||||||
events.emit('rebase:started', {
|
events.emit('rebase:started', {
|
||||||
worktreePath: resolvedWorktreePath,
|
worktreePath: resolvedWorktreePath,
|
||||||
@@ -62,7 +73,7 @@ export function createRebaseHandler(events: EventEmitter) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Execute the rebase via the service
|
// Execute the rebase via the service
|
||||||
const result = await runRebase(resolvedWorktreePath, ontoBranch);
|
const result = await runRebase(resolvedWorktreePath, ontoBranch, { remote });
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Emit success event
|
// Emit success event
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* For remote branches (e.g., "origin/feature"), automatically creates a
|
* For remote branches (e.g., "origin/feature"), automatically creates a
|
||||||
* local tracking branch and checks it out.
|
* local tracking branch and checks it out.
|
||||||
*
|
*
|
||||||
* Also fetches the latest remote refs after switching.
|
* Also fetches the latest remote refs before switching to ensure accurate branch detection.
|
||||||
*
|
*
|
||||||
* Git business logic is delegated to worktree-branch-service.ts.
|
* Git business logic is delegated to worktree-branch-service.ts.
|
||||||
* Events are emitted at key lifecycle points for WebSocket subscribers.
|
* Events are emitted at key lifecycle points for WebSocket subscribers.
|
||||||
|
|||||||
@@ -163,6 +163,10 @@ export class AutoLoopCoordinator {
|
|||||||
const { projectPath, branchName } = projectState.config;
|
const { projectPath, branchName } = projectState.config;
|
||||||
while (projectState.isRunning && !projectState.abortController.signal.aborted) {
|
while (projectState.isRunning && !projectState.abortController.signal.aborted) {
|
||||||
try {
|
try {
|
||||||
|
// Count ALL running features (both auto and manual) against the concurrency limit.
|
||||||
|
// This ensures auto mode is aware of the total system load and does not over-subscribe
|
||||||
|
// resources. Manual tasks always bypass the limit and run immediately, but their
|
||||||
|
// presence is accounted for when deciding whether to dispatch new auto-mode tasks.
|
||||||
const runningCount = await this.getRunningCountForWorktree(projectPath, branchName);
|
const runningCount = await this.getRunningCountForWorktree(projectPath, branchName);
|
||||||
if (runningCount >= projectState.config.maxConcurrency) {
|
if (runningCount >= projectState.config.maxConcurrency) {
|
||||||
await this.sleep(5000, projectState.abortController.signal);
|
await this.sleep(5000, projectState.abortController.signal);
|
||||||
@@ -298,11 +302,17 @@ export class AutoLoopCoordinator {
|
|||||||
return Array.from(activeProjects);
|
return Array.from(activeProjects);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of running features for a worktree.
|
||||||
|
* By default counts ALL running features (both auto-mode and manual).
|
||||||
|
* Pass `autoModeOnly: true` to count only auto-mode features.
|
||||||
|
*/
|
||||||
async getRunningCountForWorktree(
|
async getRunningCountForWorktree(
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
branchName: string | null
|
branchName: string | null,
|
||||||
|
options?: { autoModeOnly?: boolean }
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
return this.concurrencyManager.getRunningCountForWorktree(projectPath, branchName);
|
return this.concurrencyManager.getRunningCountForWorktree(projectPath, branchName, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
trackFailureAndCheckPauseForProject(
|
trackFailureAndCheckPauseForProject(
|
||||||
|
|||||||
@@ -334,6 +334,23 @@ export class AutoModeServiceFacade {
|
|||||||
async (pPath) => featureLoader.getAll(pPath)
|
async (pPath) => featureLoader.getAll(pPath)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterate all active worktrees for this project, falling back to the
|
||||||
|
* main worktree (null) when none are active.
|
||||||
|
*/
|
||||||
|
const forEachProjectWorktree = (fn: (branchName: string | null) => void): void => {
|
||||||
|
const projectWorktrees = autoLoopCoordinator
|
||||||
|
.getActiveWorktrees()
|
||||||
|
.filter((w) => w.projectPath === projectPath);
|
||||||
|
if (projectWorktrees.length === 0) {
|
||||||
|
fn(null);
|
||||||
|
} else {
|
||||||
|
for (const w of projectWorktrees) {
|
||||||
|
fn(w.branchName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ExecutionService - runAgentFn delegates to AgentExecutor via shared helper
|
// ExecutionService - runAgentFn delegates to AgentExecutor via shared helper
|
||||||
const executionService = new ExecutionService(
|
const executionService = new ExecutionService(
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -357,11 +374,36 @@ export class AutoModeServiceFacade {
|
|||||||
(pPath, featureId) => getFacade().contextExists(featureId),
|
(pPath, featureId) => getFacade().contextExists(featureId),
|
||||||
(pPath, featureId, useWorktrees, _calledInternally) =>
|
(pPath, featureId, useWorktrees, _calledInternally) =>
|
||||||
getFacade().resumeFeature(featureId, useWorktrees, _calledInternally),
|
getFacade().resumeFeature(featureId, useWorktrees, _calledInternally),
|
||||||
(errorInfo) =>
|
(errorInfo) => {
|
||||||
autoLoopCoordinator.trackFailureAndCheckPauseForProject(projectPath, null, errorInfo),
|
// Track failure against ALL active worktrees for this project.
|
||||||
(errorInfo) => autoLoopCoordinator.signalShouldPauseForProject(projectPath, null, errorInfo),
|
// The ExecutionService callbacks don't receive branchName, so we
|
||||||
|
// iterate all active worktrees. Uses a for-of loop (not .some()) to
|
||||||
|
// ensure every worktree's failure counter is incremented.
|
||||||
|
let shouldPause = false;
|
||||||
|
forEachProjectWorktree((branchName) => {
|
||||||
|
if (
|
||||||
|
autoLoopCoordinator.trackFailureAndCheckPauseForProject(
|
||||||
|
projectPath,
|
||||||
|
branchName,
|
||||||
|
errorInfo
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
shouldPause = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return shouldPause;
|
||||||
|
},
|
||||||
|
(errorInfo) => {
|
||||||
|
forEachProjectWorktree((branchName) =>
|
||||||
|
autoLoopCoordinator.signalShouldPauseForProject(projectPath, branchName, errorInfo)
|
||||||
|
);
|
||||||
|
},
|
||||||
() => {
|
() => {
|
||||||
/* recordSuccess - no-op */
|
// Record success to clear failure tracking. This prevents failures
|
||||||
|
// from accumulating over time and incorrectly pausing auto mode.
|
||||||
|
forEachProjectWorktree((branchName) =>
|
||||||
|
autoLoopCoordinator.recordSuccessForProject(projectPath, branchName)
|
||||||
|
);
|
||||||
},
|
},
|
||||||
(_pPath) => getFacade().saveExecutionState(),
|
(_pPath) => getFacade().saveExecutionState(),
|
||||||
loadContextFiles
|
loadContextFiles
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
* Follows the same pattern as worktree-branch-service.ts (performSwitchBranch).
|
* Follows the same pattern as worktree-branch-service.ts (performSwitchBranch).
|
||||||
*
|
*
|
||||||
* The workflow:
|
* The workflow:
|
||||||
|
* 0. Fetch latest from all remotes (ensures remote refs are up-to-date)
|
||||||
* 1. Validate inputs (branch name, base branch)
|
* 1. Validate inputs (branch name, base branch)
|
||||||
* 2. Get current branch name
|
* 2. Get current branch name
|
||||||
* 3. Check if target branch already exists
|
* 3. Check if target branch already exists
|
||||||
@@ -19,11 +20,51 @@
|
|||||||
* 7. Handle error recovery (restore stash if checkout fails)
|
* 7. Handle error recovery (restore stash if checkout fails)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getErrorMessage } from '@automaker/utils';
|
import { createLogger, getErrorMessage } from '@automaker/utils';
|
||||||
import { execGitCommand } from '../lib/git.js';
|
import { execGitCommand } from '../lib/git.js';
|
||||||
import type { EventEmitter } from '../lib/events.js';
|
import type { EventEmitter } from '../lib/events.js';
|
||||||
import { hasAnyChanges, stashChanges, popStash, localBranchExists } from './branch-utils.js';
|
import { hasAnyChanges, stashChanges, popStash, localBranchExists } from './branch-utils.js';
|
||||||
|
|
||||||
|
const logger = createLogger('CheckoutBranchService');
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Local Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Timeout for git fetch operations (30 seconds) */
|
||||||
|
const FETCH_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch latest from all remotes (silently, with timeout).
|
||||||
|
*
|
||||||
|
* A process-level timeout is enforced via an AbortController so that a
|
||||||
|
* slow or unresponsive remote does not block the branch creation flow
|
||||||
|
* indefinitely. Timeout errors are logged and treated as non-fatal
|
||||||
|
* (the same as network-unavailable errors) so the rest of the workflow
|
||||||
|
* continues normally. This is called before creating the new branch to
|
||||||
|
* ensure remote refs are up-to-date when a remote base branch is used.
|
||||||
|
*/
|
||||||
|
async function fetchRemotes(cwd: string): Promise<void> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timerId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller);
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
// Fetch timed out - log and continue; callers should not be blocked by a slow remote
|
||||||
|
logger.warn(
|
||||||
|
`fetchRemotes timed out after ${FETCH_TIMEOUT_MS}ms - continuing without latest remote refs`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.warn(`fetchRemotes failed: ${getErrorMessage(error)} - continuing with local refs`);
|
||||||
|
}
|
||||||
|
// Non-fatal: continue with locally available refs regardless of failure type
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -78,6 +119,11 @@ export async function performCheckoutBranch(
|
|||||||
// Emit start event
|
// Emit start event
|
||||||
events?.emit('switch:start', { worktreePath, branchName, operation: 'checkout' });
|
events?.emit('switch:start', { worktreePath, branchName, operation: 'checkout' });
|
||||||
|
|
||||||
|
// 0. Fetch latest from all remotes before creating the branch
|
||||||
|
// This ensures remote refs are up-to-date so that base branch validation
|
||||||
|
// works correctly for remote branch references (e.g. "origin/main").
|
||||||
|
await fetchRemotes(worktreePath);
|
||||||
|
|
||||||
// 1. Get current branch
|
// 1. Get current branch
|
||||||
let previousBranch: string;
|
let previousBranch: string;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -170,17 +170,28 @@ export class ConcurrencyManager {
|
|||||||
* @param projectPath - The project path
|
* @param projectPath - The project path
|
||||||
* @param branchName - The branch name, or null for main worktree
|
* @param branchName - The branch name, or null for main worktree
|
||||||
* (features without branchName or matching primary branch)
|
* (features without branchName or matching primary branch)
|
||||||
|
* @param options.autoModeOnly - If true, only count features started by auto mode.
|
||||||
|
* Note: The auto-loop coordinator now counts ALL
|
||||||
|
* running features (not just auto-mode) to ensure
|
||||||
|
* total system load is respected. This option is
|
||||||
|
* retained for other callers that may need filtered counts.
|
||||||
* @returns Number of running features for the worktree
|
* @returns Number of running features for the worktree
|
||||||
*/
|
*/
|
||||||
async getRunningCountForWorktree(
|
async getRunningCountForWorktree(
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
branchName: string | null
|
branchName: string | null,
|
||||||
|
options?: { autoModeOnly?: boolean }
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
// Get the actual primary branch name for the project
|
// Get the actual primary branch name for the project
|
||||||
const primaryBranch = await this.getCurrentBranch(projectPath);
|
const primaryBranch = await this.getCurrentBranch(projectPath);
|
||||||
|
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const [, feature] of this.runningFeatures) {
|
for (const [, feature] of this.runningFeatures) {
|
||||||
|
// If autoModeOnly is set, skip manually started features
|
||||||
|
if (options?.autoModeOnly && !feature.isAutoMode) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Filter by project path AND branchName to get accurate worktree-specific count
|
// Filter by project path AND branchName to get accurate worktree-specific count
|
||||||
const featureBranch = feature.branchName ?? null;
|
const featureBranch = feature.branchName ?? null;
|
||||||
if (branchName === null) {
|
if (branchName === null) {
|
||||||
|
|||||||
@@ -19,6 +19,69 @@ const logger = createLogger('DevServerService');
|
|||||||
// Maximum scrollback buffer size (characters) - matches TerminalService pattern
|
// Maximum scrollback buffer size (characters) - matches TerminalService pattern
|
||||||
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per dev server
|
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per dev server
|
||||||
|
|
||||||
|
// URL patterns for detecting full URLs from dev server output.
|
||||||
|
// Defined once at module level to avoid reallocation on every call to detectUrlFromOutput.
|
||||||
|
// Ordered from most specific (framework-specific) to least specific.
|
||||||
|
const URL_PATTERNS: Array<{ pattern: RegExp; description: string }> = [
|
||||||
|
// Vite / Nuxt / SvelteKit / Astro / Angular CLI format: "Local: http://..."
|
||||||
|
{
|
||||||
|
pattern: /(?:Local|Network|External):\s+(https?:\/\/[^\s]+)/i,
|
||||||
|
description: 'Vite/Nuxt/SvelteKit/Astro/Angular format',
|
||||||
|
},
|
||||||
|
// Next.js format: "ready - started server on 0.0.0.0:3000, url: http://localhost:3000"
|
||||||
|
// Next.js 14+: "▲ Next.js 14.0.0\n- Local: http://localhost:3000"
|
||||||
|
{
|
||||||
|
pattern: /(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i,
|
||||||
|
description: 'Next.js format',
|
||||||
|
},
|
||||||
|
// Remix format: "started at http://localhost:3000"
|
||||||
|
// Django format: "Starting development server at http://127.0.0.1:8000/"
|
||||||
|
// Rails / Puma: "Listening on http://127.0.0.1:3000"
|
||||||
|
// Generic: "listening at http://...", "available at http://...", "running at http://..."
|
||||||
|
{
|
||||||
|
pattern:
|
||||||
|
/(?:starting|started|listening|running|available|serving|accessible)\s+(?:at|on)\s+(https?:\/\/[^\s,)]+)/i,
|
||||||
|
description: 'Generic "starting/started/listening at" format',
|
||||||
|
},
|
||||||
|
// PHP built-in server: "Development Server (http://localhost:8000) started"
|
||||||
|
{
|
||||||
|
pattern: /(?:server|development server)\s*\(\s*(https?:\/\/[^\s)]+)\s*\)/i,
|
||||||
|
description: 'PHP server format',
|
||||||
|
},
|
||||||
|
// Webpack Dev Server: "Project is running at http://localhost:8080/"
|
||||||
|
{
|
||||||
|
pattern: /(?:project|app|application)\s+(?:is\s+)?running\s+(?:at|on)\s+(https?:\/\/[^\s,]+)/i,
|
||||||
|
description: 'Webpack/generic "running at" format',
|
||||||
|
},
|
||||||
|
// Go / Rust / generic: "Serving on http://...", "Server on http://..."
|
||||||
|
{
|
||||||
|
pattern: /(?:serving|server)\s+(?:on|at)\s+(https?:\/\/[^\s,]+)/i,
|
||||||
|
description: 'Generic "serving on" format',
|
||||||
|
},
|
||||||
|
// Localhost URL with port (conservative - must be localhost/127.0.0.1/[::]/0.0.0.0)
|
||||||
|
// This catches anything that looks like a dev server URL
|
||||||
|
{
|
||||||
|
pattern: /(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]|0\.0\.0\.0):\d+\S*)/i,
|
||||||
|
description: 'Generic localhost URL with port',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Port-only patterns for detecting port numbers from dev server output
|
||||||
|
// when a full URL is not present in the output.
|
||||||
|
// Defined once at module level to avoid reallocation on every call to detectUrlFromOutput.
|
||||||
|
const PORT_PATTERNS: Array<{ pattern: RegExp; description: string }> = [
|
||||||
|
// "listening on port 3000", "server on port 3000", "started on port 3000"
|
||||||
|
{
|
||||||
|
pattern: /(?:listening|running|started|serving|available)\s+on\s+port\s+(\d+)/i,
|
||||||
|
description: '"listening on port" format',
|
||||||
|
},
|
||||||
|
// "Port: 3000", "port 3000" (at start of line or after whitespace)
|
||||||
|
{
|
||||||
|
pattern: /(?:^|\s)port[:\s]+(\d{4,5})(?:\s|$|[.,;])/im,
|
||||||
|
description: '"port:" format',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// Throttle output to prevent overwhelming WebSocket under heavy load
|
// Throttle output to prevent overwhelming WebSocket under heavy load
|
||||||
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive feedback
|
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive feedback
|
||||||
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
|
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
|
||||||
@@ -105,9 +168,52 @@ class DevServerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip ANSI escape codes from a string
|
||||||
|
* Dev server output often contains color codes that can interfere with URL detection
|
||||||
|
*/
|
||||||
|
private stripAnsi(str: string): string {
|
||||||
|
// Matches ANSI escape sequences: CSI sequences, OSC sequences, and simple escapes
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
return str.replace(/\x1B(?:\[[0-9;]*[a-zA-Z]|\].*?(?:\x07|\x1B\\)|\[[?]?[0-9;]*[hl])/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract port number from a URL string.
|
||||||
|
* Returns the explicit port if present, or null if no port is specified.
|
||||||
|
* Default protocol ports (80/443) are intentionally NOT returned to avoid
|
||||||
|
* overwriting allocated dev server ports with protocol defaults.
|
||||||
|
*/
|
||||||
|
private extractPortFromUrl(url: string): number | null {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (parsed.port) {
|
||||||
|
return parseInt(parsed.port, 10);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect actual server URL from output
|
* Detect actual server URL from output
|
||||||
* Parses stdout/stderr for common URL patterns from dev servers
|
* Parses stdout/stderr for common URL patterns from dev servers.
|
||||||
|
*
|
||||||
|
* Supports detection of URLs from:
|
||||||
|
* - Vite: "Local: http://localhost:5173/"
|
||||||
|
* - Next.js: "ready - started server on 0.0.0.0:3000, url: http://localhost:3000"
|
||||||
|
* - Nuxt: "Local: http://localhost:3000/"
|
||||||
|
* - Remix: "started at http://localhost:3000"
|
||||||
|
* - Astro: "Local http://localhost:4321/"
|
||||||
|
* - SvelteKit: "Local: http://localhost:5173/"
|
||||||
|
* - CRA/Webpack: "On Your Network: http://192.168.1.1:3000"
|
||||||
|
* - Angular: "Local: http://localhost:4200/"
|
||||||
|
* - Express/Fastify/Koa: "Server listening on port 3000"
|
||||||
|
* - Django: "Starting development server at http://127.0.0.1:8000/"
|
||||||
|
* - Rails: "Listening on http://127.0.0.1:3000"
|
||||||
|
* - PHP: "Development Server (http://localhost:8000) started"
|
||||||
|
* - Generic: Any localhost URL with a port
|
||||||
*/
|
*/
|
||||||
private detectUrlFromOutput(server: DevServerInfo, content: string): void {
|
private detectUrlFromOutput(server: DevServerInfo, content: string): void {
|
||||||
// Skip if URL already detected
|
// Skip if URL already detected
|
||||||
@@ -115,39 +221,95 @@ class DevServerService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Common URL patterns from various dev servers:
|
// Strip ANSI escape codes to prevent color codes from breaking regex matching
|
||||||
// - Vite: "Local: http://localhost:5173/"
|
const cleanContent = this.stripAnsi(content);
|
||||||
// - Next.js: "ready - started server on 0.0.0.0:3000, url: http://localhost:3000"
|
|
||||||
// - CRA/Webpack: "On Your Network: http://192.168.1.1:3000"
|
|
||||||
// - Generic: Any http:// or https:// URL
|
|
||||||
const urlPatterns = [
|
|
||||||
/(?:Local|Network):\s+(https?:\/\/[^\s]+)/i, // Vite format
|
|
||||||
/(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i, // Next.js format
|
|
||||||
/(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]):\d+)/i, // Generic localhost URL
|
|
||||||
/(https?:\/\/[^\s<>"{}|\\^`[\]]+)/i, // Any HTTP(S) URL
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const pattern of urlPatterns) {
|
// Phase 1: Try to detect a full URL from output
|
||||||
const match = content.match(pattern);
|
// Patterns are defined at module level (URL_PATTERNS) and reused across calls
|
||||||
|
for (const { pattern, description } of URL_PATTERNS) {
|
||||||
|
const match = cleanContent.match(pattern);
|
||||||
if (match && match[1]) {
|
if (match && match[1]) {
|
||||||
const detectedUrl = match[1].trim();
|
let detectedUrl = match[1].trim();
|
||||||
// Validate it looks like a reasonable URL
|
// Remove trailing punctuation that might have been captured
|
||||||
|
detectedUrl = detectedUrl.replace(/[.,;:!?)\]}>]+$/, '');
|
||||||
|
|
||||||
if (detectedUrl.startsWith('http://') || detectedUrl.startsWith('https://')) {
|
if (detectedUrl.startsWith('http://') || detectedUrl.startsWith('https://')) {
|
||||||
|
// Normalize 0.0.0.0 to localhost for browser accessibility
|
||||||
|
detectedUrl = detectedUrl.replace(
|
||||||
|
/\/\/0\.0\.0\.0(:\d+)?/,
|
||||||
|
(_, port) => `//localhost${port || ''}`
|
||||||
|
);
|
||||||
|
// Normalize [::] to localhost for browser accessibility
|
||||||
|
detectedUrl = detectedUrl.replace(
|
||||||
|
/\/\/\[::\](:\d+)?/,
|
||||||
|
(_, port) => `//localhost${port || ''}`
|
||||||
|
);
|
||||||
|
// Normalize [::1] (IPv6 loopback) to localhost for browser accessibility
|
||||||
|
detectedUrl = detectedUrl.replace(
|
||||||
|
/\/\/\[::1\](:\d+)?/,
|
||||||
|
(_, port) => `//localhost${port || ''}`
|
||||||
|
);
|
||||||
|
|
||||||
server.url = detectedUrl;
|
server.url = detectedUrl;
|
||||||
server.urlDetected = true;
|
server.urlDetected = true;
|
||||||
logger.info(
|
|
||||||
`Detected actual server URL: ${detectedUrl} (allocated port was ${server.port})`
|
// Update the port to match the detected URL's actual port
|
||||||
);
|
const detectedPort = this.extractPortFromUrl(detectedUrl);
|
||||||
|
if (detectedPort && detectedPort !== server.port) {
|
||||||
|
logger.info(
|
||||||
|
`Port mismatch: allocated ${server.port}, detected ${detectedPort} from ${description}`
|
||||||
|
);
|
||||||
|
server.port = detectedPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Detected server URL via ${description}: ${detectedUrl}`);
|
||||||
|
|
||||||
// Emit URL update event
|
// Emit URL update event
|
||||||
if (this.emitter) {
|
if (this.emitter) {
|
||||||
this.emitter.emit('dev-server:url-detected', {
|
this.emitter.emit('dev-server:url-detected', {
|
||||||
worktreePath: server.worktreePath,
|
worktreePath: server.worktreePath,
|
||||||
url: detectedUrl,
|
url: detectedUrl,
|
||||||
|
port: server.port,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Try to detect just a port number from output (no full URL)
|
||||||
|
// Some servers only print "listening on port 3000" without a full URL
|
||||||
|
// Patterns are defined at module level (PORT_PATTERNS) and reused across calls
|
||||||
|
for (const { pattern, description } of PORT_PATTERNS) {
|
||||||
|
const match = cleanContent.match(pattern);
|
||||||
|
if (match && match[1]) {
|
||||||
|
const detectedPort = parseInt(match[1], 10);
|
||||||
|
// Sanity check: port should be in a reasonable range
|
||||||
|
if (detectedPort > 0 && detectedPort <= 65535) {
|
||||||
|
const detectedUrl = `http://localhost:${detectedPort}`;
|
||||||
|
server.url = detectedUrl;
|
||||||
|
server.urlDetected = true;
|
||||||
|
|
||||||
|
if (detectedPort !== server.port) {
|
||||||
|
logger.info(
|
||||||
|
`Port mismatch: allocated ${server.port}, detected ${detectedPort} from ${description}`
|
||||||
|
);
|
||||||
|
server.port = detectedPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Detected server port via ${description}: ${detectedPort} → ${detectedUrl}`);
|
||||||
|
|
||||||
|
// Emit URL update event
|
||||||
|
if (this.emitter) {
|
||||||
|
this.emitter.emit('dev-server:url-detected', {
|
||||||
|
worktreePath: server.worktreePath,
|
||||||
|
url: detectedUrl,
|
||||||
|
port: server.port,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -673,6 +835,7 @@ class DevServerService {
|
|||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
port: number;
|
port: number;
|
||||||
url: string;
|
url: string;
|
||||||
|
urlDetected: boolean;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
} {
|
} {
|
||||||
@@ -680,6 +843,7 @@ class DevServerService {
|
|||||||
worktreePath: s.worktreePath,
|
worktreePath: s.worktreePath,
|
||||||
port: s.port,
|
port: s.port,
|
||||||
url: s.url,
|
url: s.url,
|
||||||
|
urlDetected: s.urlDetected,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -168,6 +168,20 @@ ${feature.spec}
|
|||||||
feature = await this.loadFeatureFn(projectPath, featureId);
|
feature = await this.loadFeatureFn(projectPath, featureId);
|
||||||
if (!feature) throw new Error(`Feature ${featureId} not found`);
|
if (!feature) throw new Error(`Feature ${featureId} not found`);
|
||||||
|
|
||||||
|
// Update status to in_progress immediately after acquiring the feature.
|
||||||
|
// This prevents a race condition where the UI reloads features and sees the
|
||||||
|
// feature still in 'backlog' status while it's actually being executed.
|
||||||
|
// Only do this for the initial call (not internal/recursive calls which would
|
||||||
|
// redundantly update the status).
|
||||||
|
if (
|
||||||
|
!options?._calledInternally &&
|
||||||
|
(feature.status === 'backlog' ||
|
||||||
|
feature.status === 'ready' ||
|
||||||
|
feature.status === 'interrupted')
|
||||||
|
) {
|
||||||
|
await this.updateFeatureStatusFn(projectPath, featureId, 'in_progress');
|
||||||
|
}
|
||||||
|
|
||||||
if (!options?.continuationPrompt) {
|
if (!options?.continuationPrompt) {
|
||||||
if (feature.planSpec?.status === 'approved') {
|
if (feature.planSpec?.status === 'approved') {
|
||||||
const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]');
|
const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]');
|
||||||
@@ -199,7 +213,18 @@ ${feature.spec}
|
|||||||
validateWorkingDirectory(workDir);
|
validateWorkingDirectory(workDir);
|
||||||
tempRunningFeature.worktreePath = worktreePath;
|
tempRunningFeature.worktreePath = worktreePath;
|
||||||
tempRunningFeature.branchName = branchName ?? null;
|
tempRunningFeature.branchName = branchName ?? null;
|
||||||
await this.updateFeatureStatusFn(projectPath, featureId, 'in_progress');
|
// Ensure status is in_progress (may already be set from the early update above,
|
||||||
|
// but internal/recursive calls skip the early update and need it here).
|
||||||
|
// Mirror the external guard: only transition when the feature is still in
|
||||||
|
// backlog, ready, or interrupted to avoid overwriting a concurrent terminal status.
|
||||||
|
if (
|
||||||
|
options?._calledInternally &&
|
||||||
|
(feature.status === 'backlog' ||
|
||||||
|
feature.status === 'ready' ||
|
||||||
|
feature.status === 'interrupted')
|
||||||
|
) {
|
||||||
|
await this.updateFeatureStatusFn(projectPath, featureId, 'in_progress');
|
||||||
|
}
|
||||||
this.eventBus.emitAutoModeEvent('auto_mode_feature_start', {
|
this.eventBus.emitAutoModeEvent('auto_mode_feature_start', {
|
||||||
featureId,
|
featureId,
|
||||||
projectPath,
|
projectPath,
|
||||||
|
|||||||
@@ -225,6 +225,14 @@ export class FeatureLoader {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear transient runtime flag - titleGenerating is only meaningful during
|
||||||
|
// the current session's async title generation. If it was persisted (e.g.,
|
||||||
|
// app closed before generation completed), it would cause the UI to show
|
||||||
|
// "Generating title..." indefinitely.
|
||||||
|
if (feature.titleGenerating) {
|
||||||
|
delete feature.titleGenerating;
|
||||||
|
}
|
||||||
|
|
||||||
return feature;
|
return feature;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -323,7 +331,14 @@ export class FeatureLoader {
|
|||||||
|
|
||||||
logRecoveryWarning(result, `Feature ${featureId}`, logger);
|
logRecoveryWarning(result, `Feature ${featureId}`, logger);
|
||||||
|
|
||||||
return result.data;
|
const feature = result.data;
|
||||||
|
|
||||||
|
// Clear transient runtime flag (same as in getAll)
|
||||||
|
if (feature?.titleGenerating) {
|
||||||
|
delete feature.titleGenerating;
|
||||||
|
}
|
||||||
|
|
||||||
|
return feature;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -367,8 +382,15 @@ export class FeatureLoader {
|
|||||||
descriptionHistory: initialHistory,
|
descriptionHistory: initialHistory,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Remove transient runtime fields before persisting to disk.
|
||||||
|
// titleGenerating is UI-only state that tracks in-flight async title generation.
|
||||||
|
// Persisting it can cause cards to show "Generating title..." indefinitely
|
||||||
|
// if the app restarts before generation completes.
|
||||||
|
const featureToWrite = { ...feature };
|
||||||
|
delete featureToWrite.titleGenerating;
|
||||||
|
|
||||||
// Write feature.json atomically with backup support
|
// Write feature.json atomically with backup support
|
||||||
await atomicWriteJson(featureJsonPath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
|
await atomicWriteJson(featureJsonPath, featureToWrite, { backupCount: DEFAULT_BACKUP_COUNT });
|
||||||
|
|
||||||
logger.info(`Created feature ${featureId}`);
|
logger.info(`Created feature ${featureId}`);
|
||||||
return feature;
|
return feature;
|
||||||
@@ -452,9 +474,13 @@ export class FeatureLoader {
|
|||||||
descriptionHistory: updatedHistory,
|
descriptionHistory: updatedHistory,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Remove transient runtime fields before persisting (same as create)
|
||||||
|
const featureToWrite = { ...updatedFeature };
|
||||||
|
delete featureToWrite.titleGenerating;
|
||||||
|
|
||||||
// Write back to file atomically with backup support
|
// Write back to file atomically with backup support
|
||||||
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
||||||
await atomicWriteJson(featureJsonPath, updatedFeature, { backupCount: DEFAULT_BACKUP_COUNT });
|
await atomicWriteJson(featureJsonPath, featureToWrite, { backupCount: DEFAULT_BACKUP_COUNT });
|
||||||
|
|
||||||
logger.info(`Updated feature ${featureId}`);
|
logger.info(`Updated feature ${featureId}`);
|
||||||
return updatedFeature;
|
return updatedFeature;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Extracted from worktree merge route to allow internal service calls.
|
* Extracted from worktree merge route to allow internal service calls.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createLogger, isValidBranchName } from '@automaker/utils';
|
import { createLogger, isValidBranchName, isValidRemoteName } from '@automaker/utils';
|
||||||
import { type EventEmitter } from '../lib/events.js';
|
import { type EventEmitter } from '../lib/events.js';
|
||||||
import { execGitCommand } from '@automaker/git-utils';
|
import { execGitCommand } from '@automaker/git-utils';
|
||||||
const logger = createLogger('MergeService');
|
const logger = createLogger('MergeService');
|
||||||
@@ -13,6 +13,8 @@ export interface MergeOptions {
|
|||||||
squash?: boolean;
|
squash?: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
deleteWorktreeAndBranch?: boolean;
|
deleteWorktreeAndBranch?: boolean;
|
||||||
|
/** Remote name to fetch from before merging (defaults to 'origin') */
|
||||||
|
remote?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MergeServiceResult {
|
export interface MergeServiceResult {
|
||||||
@@ -35,7 +37,11 @@ export interface MergeServiceResult {
|
|||||||
* @param branchName - Source branch to merge
|
* @param branchName - Source branch to merge
|
||||||
* @param worktreePath - Path to the worktree (used for deletion if requested)
|
* @param worktreePath - Path to the worktree (used for deletion if requested)
|
||||||
* @param targetBranch - Branch to merge into (defaults to 'main')
|
* @param targetBranch - Branch to merge into (defaults to 'main')
|
||||||
* @param options - Merge options (squash, message, deleteWorktreeAndBranch)
|
* @param options - Merge options
|
||||||
|
* @param options.squash - If true, perform a squash merge
|
||||||
|
* @param options.message - Custom merge commit message
|
||||||
|
* @param options.deleteWorktreeAndBranch - If true, delete worktree and branch after merge
|
||||||
|
* @param options.remote - Remote name to fetch from before merging (defaults to 'origin')
|
||||||
*/
|
*/
|
||||||
export async function performMerge(
|
export async function performMerge(
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
@@ -88,6 +94,33 @@ export async function performMerge(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate the remote name to prevent git option injection.
|
||||||
|
// Reject invalid remote names so the caller knows their input was wrong,
|
||||||
|
// consistent with how invalid branch names are handled above.
|
||||||
|
const remote = options?.remote || 'origin';
|
||||||
|
if (!isValidRemoteName(remote)) {
|
||||||
|
logger.warn('Invalid remote name supplied to merge-service', {
|
||||||
|
remote,
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Invalid remote name: "${remote}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch latest from remote before merging to ensure we have up-to-date refs
|
||||||
|
try {
|
||||||
|
await execGitCommand(['fetch', remote], projectPath);
|
||||||
|
} catch (fetchError) {
|
||||||
|
logger.warn('Failed to fetch from remote before merge; proceeding with local refs', {
|
||||||
|
remote,
|
||||||
|
projectPath,
|
||||||
|
error: (fetchError as Error).message,
|
||||||
|
});
|
||||||
|
// Non-fatal: proceed with local refs if fetch fails (e.g. offline)
|
||||||
|
}
|
||||||
|
|
||||||
// Emit merge:start after validating inputs
|
// Emit merge:start after validating inputs
|
||||||
emitter?.emit('merge:start', { branchName, targetBranch: mergeTo, worktreePath });
|
emitter?.emit('merge:start', { branchName, targetBranch: mergeTo, worktreePath });
|
||||||
|
|
||||||
|
|||||||
225
apps/server/src/services/pr-service.ts
Normal file
225
apps/server/src/services/pr-service.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
/**
|
||||||
|
* Service for resolving PR target information from git remotes.
|
||||||
|
*
|
||||||
|
* Extracts remote-parsing and target-resolution logic that was previously
|
||||||
|
* inline in the create-pr route handler.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// TODO: Move execAsync/execEnv to a shared lib (lib/exec.ts or @automaker/utils) so that
|
||||||
|
// services no longer depend on route internals. Tracking issue: route-to-service dependency
|
||||||
|
// inversion. For now, a local thin wrapper is used within the service boundary.
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { createLogger, isValidRemoteName } from '@automaker/utils';
|
||||||
|
|
||||||
|
// Thin local wrapper — duplicates the route-level execAsync/execEnv until a
|
||||||
|
// shared lib/exec.ts (or @automaker/utils export) is created.
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
const pathSeparator = process.platform === 'win32' ? ';' : ':';
|
||||||
|
const _additionalPaths: string[] = [];
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
if (process.env.LOCALAPPDATA)
|
||||||
|
_additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
|
||||||
|
if (process.env.PROGRAMFILES) _additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`);
|
||||||
|
if (process.env['ProgramFiles(x86)'])
|
||||||
|
_additionalPaths.push(`${process.env['ProgramFiles(x86)']}\\Git\\cmd`);
|
||||||
|
} else {
|
||||||
|
_additionalPaths.push(
|
||||||
|
'/opt/homebrew/bin',
|
||||||
|
'/usr/local/bin',
|
||||||
|
'/home/linuxbrew/.linuxbrew/bin',
|
||||||
|
`${process.env.HOME}/.local/bin`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const execEnv = {
|
||||||
|
...process.env,
|
||||||
|
PATH: [process.env.PATH, ..._additionalPaths.filter(Boolean)].filter(Boolean).join(pathSeparator),
|
||||||
|
};
|
||||||
|
|
||||||
|
const logger = createLogger('PRService');
|
||||||
|
|
||||||
|
export interface ParsedRemote {
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrTargetResult {
|
||||||
|
repoUrl: string | null;
|
||||||
|
targetRepo: string | null;
|
||||||
|
pushOwner: string | null;
|
||||||
|
upstreamRepo: string | null;
|
||||||
|
originOwner: string | null;
|
||||||
|
parsedRemotes: Map<string, ParsedRemote>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse all git remotes for the given repo path and resolve the PR target.
|
||||||
|
*
|
||||||
|
* @param worktreePath - Working directory of the repository / worktree
|
||||||
|
* @param pushRemote - Remote used for pushing (e.g. "origin")
|
||||||
|
* @param targetRemote - Explicit remote to target the PR against (optional)
|
||||||
|
*
|
||||||
|
* @throws {Error} When targetRemote is specified but not found among repository remotes
|
||||||
|
* @throws {Error} When pushRemote is not found among parsed remotes (when targetRemote is specified)
|
||||||
|
*/
|
||||||
|
export async function resolvePrTarget({
|
||||||
|
worktreePath,
|
||||||
|
pushRemote,
|
||||||
|
targetRemote,
|
||||||
|
}: {
|
||||||
|
worktreePath: string;
|
||||||
|
pushRemote: string;
|
||||||
|
targetRemote?: string;
|
||||||
|
}): Promise<PrTargetResult> {
|
||||||
|
// Validate remote names — pushRemote is a required string so the undefined
|
||||||
|
// guard is unnecessary, but targetRemote is optional.
|
||||||
|
if (!isValidRemoteName(pushRemote)) {
|
||||||
|
throw new Error(`Invalid push remote name: "${pushRemote}"`);
|
||||||
|
}
|
||||||
|
if (targetRemote !== undefined && !isValidRemoteName(targetRemote)) {
|
||||||
|
throw new Error(`Invalid target remote name: "${targetRemote}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let repoUrl: string | null = null;
|
||||||
|
let upstreamRepo: string | null = null;
|
||||||
|
let originOwner: string | null = null;
|
||||||
|
const parsedRemotes: Map<string, ParsedRemote> = new Map();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout: remotes } = await execAsync('git remote -v', {
|
||||||
|
cwd: worktreePath,
|
||||||
|
env: execEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse remotes to detect fork workflow and get repo URL
|
||||||
|
const lines = remotes.split(/\r?\n/); // Handle both Unix and Windows line endings
|
||||||
|
for (const line of lines) {
|
||||||
|
// Try multiple patterns to match different remote URL formats
|
||||||
|
// Pattern 1: git@github.com:owner/repo.git (fetch)
|
||||||
|
// Pattern 2: https://github.com/owner/repo.git (fetch)
|
||||||
|
// Pattern 3: https://github.com/owner/repo (fetch)
|
||||||
|
let match = line.match(
|
||||||
|
/^([a-zA-Z0-9._-]+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/
|
||||||
|
);
|
||||||
|
if (!match) {
|
||||||
|
// Try SSH format: git@github.com:owner/repo.git
|
||||||
|
match = line.match(
|
||||||
|
/^([a-zA-Z0-9._-]+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!match) {
|
||||||
|
// Try HTTPS format: https://github.com/owner/repo.git
|
||||||
|
match = line.match(
|
||||||
|
/^([a-zA-Z0-9._-]+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const [, remoteName, owner, repo] = match;
|
||||||
|
parsedRemotes.set(remoteName, { owner, repo });
|
||||||
|
if (remoteName === 'upstream') {
|
||||||
|
upstreamRepo = `${owner}/${repo}`;
|
||||||
|
repoUrl = `https://github.com/${owner}/${repo}`;
|
||||||
|
} else if (remoteName === 'origin') {
|
||||||
|
originOwner = owner;
|
||||||
|
if (!repoUrl) {
|
||||||
|
repoUrl = `https://github.com/${owner}/${repo}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Log the failure for debugging — control flow falls through to auto-detection
|
||||||
|
logger.debug('Failed to parse git remotes', { worktreePath, error: err });
|
||||||
|
}
|
||||||
|
|
||||||
|
// When targetRemote is explicitly provided but remote parsing failed entirely
|
||||||
|
// (parsedRemotes is empty), we cannot validate or resolve the requested remote.
|
||||||
|
// Silently proceeding to auto-detection would ignore the caller's explicit intent,
|
||||||
|
// so we fail fast with a clear error instead.
|
||||||
|
if (targetRemote && parsedRemotes.size === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`targetRemote "${targetRemote}" was specified but no remotes could be parsed from the repository. ` +
|
||||||
|
`Ensure the repository has at least one configured remote (parsedRemotes is empty).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When a targetRemote is explicitly specified, validate that it is known
|
||||||
|
// before using it. Silently falling back to auto-detection when the caller
|
||||||
|
// explicitly requested a remote that doesn't exist is misleading, so we
|
||||||
|
// fail fast here instead.
|
||||||
|
if (targetRemote && parsedRemotes.size > 0 && !parsedRemotes.has(targetRemote)) {
|
||||||
|
throw new Error(`targetRemote "${targetRemote}" not found in repository remotes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When a targetRemote is explicitly specified, override fork detection
|
||||||
|
// to use the specified remote as the PR target
|
||||||
|
let targetRepo: string | null = null;
|
||||||
|
let pushOwner: string | null = null;
|
||||||
|
if (targetRemote && parsedRemotes.size > 0) {
|
||||||
|
const targetInfo = parsedRemotes.get(targetRemote);
|
||||||
|
const pushInfo = parsedRemotes.get(pushRemote);
|
||||||
|
|
||||||
|
// If the push remote is not found in the parsed remotes, we cannot
|
||||||
|
// determine the push owner and would build incorrect URLs. Fail fast
|
||||||
|
// instead of silently proceeding with null values.
|
||||||
|
if (!pushInfo) {
|
||||||
|
logger.warn('Push remote not found in parsed remotes', {
|
||||||
|
pushRemote,
|
||||||
|
targetRemote,
|
||||||
|
availableRemotes: [...parsedRemotes.keys()],
|
||||||
|
});
|
||||||
|
throw new Error(`Push remote "${pushRemote}" not found in repository remotes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetInfo) {
|
||||||
|
targetRepo = `${targetInfo.owner}/${targetInfo.repo}`;
|
||||||
|
repoUrl = `https://github.com/${targetInfo.owner}/${targetInfo.repo}`;
|
||||||
|
}
|
||||||
|
pushOwner = pushInfo.owner;
|
||||||
|
|
||||||
|
// Override the auto-detected upstream/origin with explicit targetRemote
|
||||||
|
// Only treat as cross-remote if target differs from push remote
|
||||||
|
if (targetRemote !== pushRemote && targetInfo) {
|
||||||
|
upstreamRepo = targetRepo;
|
||||||
|
originOwner = pushOwner;
|
||||||
|
} else if (targetInfo) {
|
||||||
|
// Same remote for push and target - regular (non-fork) workflow
|
||||||
|
upstreamRepo = null;
|
||||||
|
originOwner = targetInfo.owner;
|
||||||
|
repoUrl = `https://github.com/${targetInfo.owner}/${targetInfo.repo}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Try to get repo URL from git config if remote parsing failed
|
||||||
|
if (!repoUrl) {
|
||||||
|
try {
|
||||||
|
const { stdout: originUrl } = await execAsync('git config --get remote.origin.url', {
|
||||||
|
cwd: worktreePath,
|
||||||
|
env: execEnv,
|
||||||
|
});
|
||||||
|
const url = originUrl.trim();
|
||||||
|
|
||||||
|
// Parse URL to extract owner/repo
|
||||||
|
// Handle both SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git)
|
||||||
|
const match = url.match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/);
|
||||||
|
if (match) {
|
||||||
|
const [, owner, repo] = match;
|
||||||
|
originOwner = owner;
|
||||||
|
repoUrl = `https://github.com/${owner}/${repo}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Failed to get repo URL from config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
repoUrl,
|
||||||
|
targetRepo,
|
||||||
|
pushOwner,
|
||||||
|
upstreamRepo,
|
||||||
|
originOwner,
|
||||||
|
parsedRemotes,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -46,6 +46,12 @@ export interface PullResult {
|
|||||||
conflictSource?: 'pull' | 'stash';
|
conflictSource?: 'pull' | 'stash';
|
||||||
conflictFiles?: string[];
|
conflictFiles?: string[];
|
||||||
message?: string;
|
message?: string;
|
||||||
|
/** Whether the pull resulted in a merge commit (not fast-forward) */
|
||||||
|
isMerge?: boolean;
|
||||||
|
/** Whether the pull was a fast-forward (no merge commit needed) */
|
||||||
|
isFastForward?: boolean;
|
||||||
|
/** Files affected by the merge (only present when isMerge is true) */
|
||||||
|
mergeAffectedFiles?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -178,6 +184,31 @@ function isConflictError(errorOutput: string): boolean {
|
|||||||
return errorOutput.includes('CONFLICT') || errorOutput.includes('Automatic merge failed');
|
return errorOutput.includes('CONFLICT') || errorOutput.includes('Automatic merge failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the current HEAD commit is a merge commit by checking
|
||||||
|
* whether it has two or more parent hashes.
|
||||||
|
*
|
||||||
|
* Runs `git show -s --pretty=%P HEAD` which prints the parent SHAs separated
|
||||||
|
* by spaces. A merge commit has at least two parents; a regular commit has one.
|
||||||
|
*
|
||||||
|
* @param worktreePath - Path to the git worktree
|
||||||
|
* @returns true if HEAD is a merge commit, false otherwise
|
||||||
|
*/
|
||||||
|
async function isMergeCommit(worktreePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const output = await execGitCommand(['show', '-s', '--pretty=%P', 'HEAD'], worktreePath);
|
||||||
|
// Each parent SHA is separated by a space; two or more means it's a merge
|
||||||
|
const parents = output
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((p) => p.length > 0);
|
||||||
|
return parents.length >= 2;
|
||||||
|
} catch {
|
||||||
|
// If the check fails for any reason, assume it is not a merge commit
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether an output string indicates a stash conflict.
|
* Check whether an output string indicates a stash conflict.
|
||||||
*/
|
*/
|
||||||
@@ -302,10 +333,39 @@ export async function performPull(
|
|||||||
const pullArgs = upstreamStatus === 'tracking' ? ['pull'] : ['pull', targetRemote, branchName];
|
const pullArgs = upstreamStatus === 'tracking' ? ['pull'] : ['pull', targetRemote, branchName];
|
||||||
let pullConflict = false;
|
let pullConflict = false;
|
||||||
let pullConflictFiles: string[] = [];
|
let pullConflictFiles: string[] = [];
|
||||||
|
|
||||||
|
// Declare merge detection variables before the try block so they are accessible
|
||||||
|
// in the stash reapplication path even when didStash is true.
|
||||||
|
let isMerge = false;
|
||||||
|
let isFastForward = false;
|
||||||
|
let mergeAffectedFiles: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pullOutput = await execGitCommand(pullArgs, worktreePath);
|
const pullOutput = await execGitCommand(pullArgs, worktreePath);
|
||||||
|
|
||||||
const alreadyUpToDate = pullOutput.includes('Already up to date');
|
const alreadyUpToDate = pullOutput.includes('Already up to date');
|
||||||
|
// Detect fast-forward from git pull output
|
||||||
|
isFastForward = pullOutput.includes('Fast-forward') || pullOutput.includes('fast-forward');
|
||||||
|
// Detect merge by checking whether the new HEAD has two parents (more reliable
|
||||||
|
// than string-matching localised pull output which may not contain 'Merge').
|
||||||
|
isMerge = !alreadyUpToDate && !isFastForward ? await isMergeCommit(worktreePath) : false;
|
||||||
|
|
||||||
|
// If it was a real merge (not fast-forward), get the affected files
|
||||||
|
if (isMerge) {
|
||||||
|
try {
|
||||||
|
// Get files changed in the merge commit
|
||||||
|
const diffOutput = await execGitCommand(
|
||||||
|
['diff', '--name-only', 'HEAD~1', 'HEAD'],
|
||||||
|
worktreePath
|
||||||
|
);
|
||||||
|
mergeAffectedFiles = diffOutput
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter((f: string) => f.trim().length > 0);
|
||||||
|
} catch {
|
||||||
|
// Ignore errors - this is best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If no stash to reapply, return success
|
// If no stash to reapply, return success
|
||||||
if (!didStash) {
|
if (!didStash) {
|
||||||
@@ -317,6 +377,8 @@ export async function performPull(
|
|||||||
stashed: false,
|
stashed: false,
|
||||||
stashRestored: false,
|
stashRestored: false,
|
||||||
message: alreadyUpToDate ? 'Already up to date' : 'Pulled latest changes',
|
message: alreadyUpToDate ? 'Already up to date' : 'Pulled latest changes',
|
||||||
|
...(isMerge ? { isMerge: true, mergeAffectedFiles } : {}),
|
||||||
|
...(isFastForward ? { isFastForward: true } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (pullError: unknown) {
|
} catch (pullError: unknown) {
|
||||||
@@ -374,7 +436,11 @@ export async function performPull(
|
|||||||
|
|
||||||
// 10. Pull succeeded, now try to reapply stash
|
// 10. Pull succeeded, now try to reapply stash
|
||||||
if (didStash) {
|
if (didStash) {
|
||||||
return await reapplyStash(worktreePath, branchName);
|
return await reapplyStash(worktreePath, branchName, {
|
||||||
|
isMerge,
|
||||||
|
isFastForward,
|
||||||
|
mergeAffectedFiles,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shouldn't reach here, but return a safe default
|
// Shouldn't reach here, but return a safe default
|
||||||
@@ -392,9 +458,21 @@ export async function performPull(
|
|||||||
*
|
*
|
||||||
* @param worktreePath - Path to the git worktree
|
* @param worktreePath - Path to the git worktree
|
||||||
* @param branchName - Current branch name
|
* @param branchName - Current branch name
|
||||||
|
* @param mergeInfo - Merge/fast-forward detection info from the pull step
|
||||||
* @returns PullResult reflecting stash reapplication status
|
* @returns PullResult reflecting stash reapplication status
|
||||||
*/
|
*/
|
||||||
async function reapplyStash(worktreePath: string, branchName: string): Promise<PullResult> {
|
async function reapplyStash(
|
||||||
|
worktreePath: string,
|
||||||
|
branchName: string,
|
||||||
|
mergeInfo: { isMerge: boolean; isFastForward: boolean; mergeAffectedFiles: string[] }
|
||||||
|
): Promise<PullResult> {
|
||||||
|
const mergeFields: Partial<PullResult> = {
|
||||||
|
...(mergeInfo.isMerge
|
||||||
|
? { isMerge: true, mergeAffectedFiles: mergeInfo.mergeAffectedFiles }
|
||||||
|
: {}),
|
||||||
|
...(mergeInfo.isFastForward ? { isFastForward: true } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await popStash(worktreePath);
|
await popStash(worktreePath);
|
||||||
|
|
||||||
@@ -406,6 +484,7 @@ async function reapplyStash(worktreePath: string, branchName: string): Promise<P
|
|||||||
hasConflicts: false,
|
hasConflicts: false,
|
||||||
stashed: true,
|
stashed: true,
|
||||||
stashRestored: true,
|
stashRestored: true,
|
||||||
|
...mergeFields,
|
||||||
message: 'Pulled latest changes and restored your stashed changes.',
|
message: 'Pulled latest changes and restored your stashed changes.',
|
||||||
};
|
};
|
||||||
} catch (stashPopError: unknown) {
|
} catch (stashPopError: unknown) {
|
||||||
@@ -431,6 +510,7 @@ async function reapplyStash(worktreePath: string, branchName: string): Promise<P
|
|||||||
conflictFiles: stashConflictFiles,
|
conflictFiles: stashConflictFiles,
|
||||||
stashed: true,
|
stashed: true,
|
||||||
stashRestored: false,
|
stashRestored: false,
|
||||||
|
...mergeFields,
|
||||||
message: 'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.',
|
message: 'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -445,6 +525,7 @@ async function reapplyStash(worktreePath: string, branchName: string): Promise<P
|
|||||||
hasConflicts: false,
|
hasConflicts: false,
|
||||||
stashed: true,
|
stashed: true,
|
||||||
stashRestored: false,
|
stashRestored: false,
|
||||||
|
...mergeFields,
|
||||||
message:
|
message:
|
||||||
'Pull succeeded but failed to reapply stashed changes. Your changes are still in the stash list.',
|
'Pull succeeded but failed to reapply stashed changes. Your changes are still in the stash list.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { createLogger, getErrorMessage } from '@automaker/utils';
|
import { createLogger, getErrorMessage, isValidRemoteName } from '@automaker/utils';
|
||||||
import { execGitCommand, getCurrentBranch, getConflictFiles } from '@automaker/git-utils';
|
import { execGitCommand, getCurrentBranch, getConflictFiles } from '@automaker/git-utils';
|
||||||
|
|
||||||
const logger = createLogger('RebaseService');
|
const logger = createLogger('RebaseService');
|
||||||
@@ -16,6 +16,11 @@ const logger = createLogger('RebaseService');
|
|||||||
// Types
|
// Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface RebaseOptions {
|
||||||
|
/** Remote name to fetch from before rebasing (defaults to 'origin') */
|
||||||
|
remote?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RebaseResult {
|
export interface RebaseResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -36,9 +41,14 @@ export interface RebaseResult {
|
|||||||
*
|
*
|
||||||
* @param worktreePath - Path to the git worktree
|
* @param worktreePath - Path to the git worktree
|
||||||
* @param ontoBranch - The branch to rebase onto (e.g., 'origin/main')
|
* @param ontoBranch - The branch to rebase onto (e.g., 'origin/main')
|
||||||
|
* @param options - Optional rebase options (remote name for fetch)
|
||||||
* @returns RebaseResult with success/failure information
|
* @returns RebaseResult with success/failure information
|
||||||
*/
|
*/
|
||||||
export async function runRebase(worktreePath: string, ontoBranch: string): Promise<RebaseResult> {
|
export async function runRebase(
|
||||||
|
worktreePath: string,
|
||||||
|
ontoBranch: string,
|
||||||
|
options?: RebaseOptions
|
||||||
|
): Promise<RebaseResult> {
|
||||||
// Reject empty, whitespace-only, or dash-prefixed branch names.
|
// Reject empty, whitespace-only, or dash-prefixed branch names.
|
||||||
const normalizedOntoBranch = ontoBranch?.trim() ?? '';
|
const normalizedOntoBranch = ontoBranch?.trim() ?? '';
|
||||||
if (normalizedOntoBranch === '' || normalizedOntoBranch.startsWith('-')) {
|
if (normalizedOntoBranch === '' || normalizedOntoBranch.startsWith('-')) {
|
||||||
@@ -59,6 +69,33 @@ export async function runRebase(worktreePath: string, ontoBranch: string): Promi
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate the remote name to prevent git option injection.
|
||||||
|
// Reject invalid remote names so the caller knows their input was wrong,
|
||||||
|
// consistent with how invalid branch names are handled above.
|
||||||
|
const remote = options?.remote || 'origin';
|
||||||
|
if (!isValidRemoteName(remote)) {
|
||||||
|
logger.warn('Invalid remote name supplied to rebase-service', {
|
||||||
|
remote,
|
||||||
|
worktreePath,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Invalid remote name: "${remote}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch latest from remote before rebasing to ensure we have up-to-date refs
|
||||||
|
try {
|
||||||
|
await execGitCommand(['fetch', remote], worktreePath);
|
||||||
|
} catch (fetchError) {
|
||||||
|
logger.warn('Failed to fetch from remote before rebase; proceeding with local refs', {
|
||||||
|
remote,
|
||||||
|
worktreePath,
|
||||||
|
error: getErrorMessage(fetchError),
|
||||||
|
});
|
||||||
|
// Non-fatal: proceed with local refs if fetch fails (e.g. offline)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Pass ontoBranch after '--' so git treats it as a ref, not an option.
|
// Pass ontoBranch after '--' so git treats it as a ref, not an option.
|
||||||
// Set LC_ALL=C so git always emits English output regardless of the system
|
// Set LC_ALL=C so git always emits English output regardless of the system
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
* For remote branches (e.g., "origin/feature"), automatically creates a
|
* For remote branches (e.g., "origin/feature"), automatically creates a
|
||||||
* local tracking branch and checks it out.
|
* local tracking branch and checks it out.
|
||||||
*
|
*
|
||||||
* Also fetches the latest remote refs after switching.
|
* Fetches the latest remote refs before switching to ensure remote branch
|
||||||
|
* references are up-to-date for accurate detection and checkout.
|
||||||
*
|
*
|
||||||
* Extracted from the worktree switch-branch route to improve organization
|
* Extracted from the worktree switch-branch route to improve organization
|
||||||
* and testability. Follows the same pattern as pull-service.ts and
|
* and testability. Follows the same pattern as pull-service.ts and
|
||||||
@@ -57,7 +58,8 @@ const FETCH_TIMEOUT_MS = 30_000;
|
|||||||
* slow or unresponsive remote does not block the branch-switch flow
|
* slow or unresponsive remote does not block the branch-switch flow
|
||||||
* indefinitely. Timeout errors are logged and treated as non-fatal
|
* indefinitely. Timeout errors are logged and treated as non-fatal
|
||||||
* (the same as network-unavailable errors) so the rest of the workflow
|
* (the same as network-unavailable errors) so the rest of the workflow
|
||||||
* continues normally.
|
* continues normally. This is called before the branch switch to
|
||||||
|
* ensure remote refs are up-to-date for branch detection and checkout.
|
||||||
*/
|
*/
|
||||||
async function fetchRemotes(cwd: string): Promise<void> {
|
async function fetchRemotes(cwd: string): Promise<void> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -66,15 +68,15 @@ async function fetchRemotes(cwd: string): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller);
|
await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === 'Process aborted') {
|
if (controller.signal.aborted) {
|
||||||
// Fetch timed out - log and continue; callers should not be blocked by a slow remote
|
// Fetch timed out - log and continue; callers should not be blocked by a slow remote
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`fetchRemotes timed out after ${FETCH_TIMEOUT_MS}ms - continuing without latest remote refs`
|
`fetchRemotes timed out after ${FETCH_TIMEOUT_MS}ms - continuing without latest remote refs`
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
logger.warn(`fetchRemotes failed: ${getErrorMessage(error)} - continuing with local refs`);
|
||||||
}
|
}
|
||||||
// Ignore all fetch errors (timeout or otherwise) - we may be offline or the
|
// Non-fatal: continue with locally available refs regardless of failure type
|
||||||
// remote may be temporarily unavailable. The branch switch itself has
|
|
||||||
// already succeeded at this point.
|
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timerId);
|
clearTimeout(timerId);
|
||||||
}
|
}
|
||||||
@@ -126,13 +128,13 @@ async function isRemoteBranch(cwd: string, branchName: string): Promise<boolean>
|
|||||||
* Perform a full branch switch workflow on the given worktree.
|
* Perform a full branch switch workflow on the given worktree.
|
||||||
*
|
*
|
||||||
* The workflow:
|
* The workflow:
|
||||||
* 1. Get current branch name
|
* 1. Fetch latest from all remotes (ensures remote refs are up-to-date)
|
||||||
* 2. Detect remote vs local branch and determine target
|
* 2. Get current branch name
|
||||||
* 3. Return early if already on target branch
|
* 3. Detect remote vs local branch and determine target
|
||||||
* 4. Validate branch existence
|
* 4. Return early if already on target branch
|
||||||
* 5. Stash local changes if any
|
* 5. Validate branch existence
|
||||||
* 6. Checkout the target branch
|
* 6. Stash local changes if any
|
||||||
* 7. Fetch latest from remotes
|
* 7. Checkout the target branch
|
||||||
* 8. Reapply stashed changes (detect conflicts)
|
* 8. Reapply stashed changes (detect conflicts)
|
||||||
* 9. Handle error recovery (restore stash if checkout fails)
|
* 9. Handle error recovery (restore stash if checkout fails)
|
||||||
*
|
*
|
||||||
@@ -149,14 +151,20 @@ export async function performSwitchBranch(
|
|||||||
// Emit start event
|
// Emit start event
|
||||||
events?.emit('switch:start', { worktreePath, branchName });
|
events?.emit('switch:start', { worktreePath, branchName });
|
||||||
|
|
||||||
// 1. Get current branch
|
// 1. Fetch latest from all remotes before switching
|
||||||
|
// This ensures remote branch refs are up-to-date so that isRemoteBranch()
|
||||||
|
// can detect newly created remote branches and local tracking branches
|
||||||
|
// are aware of upstream changes.
|
||||||
|
await fetchRemotes(worktreePath);
|
||||||
|
|
||||||
|
// 2. Get current branch
|
||||||
const currentBranchOutput = await execGitCommand(
|
const currentBranchOutput = await execGitCommand(
|
||||||
['rev-parse', '--abbrev-ref', 'HEAD'],
|
['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||||
worktreePath
|
worktreePath
|
||||||
);
|
);
|
||||||
const previousBranch = currentBranchOutput.trim();
|
const previousBranch = currentBranchOutput.trim();
|
||||||
|
|
||||||
// 2. Determine the actual target branch name for checkout
|
// 3. Determine the actual target branch name for checkout
|
||||||
let targetBranch = branchName;
|
let targetBranch = branchName;
|
||||||
let isRemote = false;
|
let isRemote = false;
|
||||||
|
|
||||||
@@ -180,7 +188,7 @@ export async function performSwitchBranch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Return early if already on the target branch
|
// 4. Return early if already on the target branch
|
||||||
if (previousBranch === targetBranch) {
|
if (previousBranch === targetBranch) {
|
||||||
events?.emit('switch:done', {
|
events?.emit('switch:done', {
|
||||||
worktreePath,
|
worktreePath,
|
||||||
@@ -198,7 +206,7 @@ export async function performSwitchBranch(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Check if target branch exists as a local branch
|
// 5. Check if target branch exists as a local branch
|
||||||
if (!isRemote) {
|
if (!isRemote) {
|
||||||
if (!(await localBranchExists(worktreePath, branchName))) {
|
if (!(await localBranchExists(worktreePath, branchName))) {
|
||||||
events?.emit('switch:error', {
|
events?.emit('switch:error', {
|
||||||
@@ -213,7 +221,7 @@ export async function performSwitchBranch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Stash local changes if any exist
|
// 6. Stash local changes if any exist
|
||||||
const hadChanges = await hasAnyChanges(worktreePath, { excludeWorktreePaths: true });
|
const hadChanges = await hasAnyChanges(worktreePath, { excludeWorktreePaths: true });
|
||||||
let didStash = false;
|
let didStash = false;
|
||||||
|
|
||||||
@@ -242,7 +250,7 @@ export async function performSwitchBranch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 6. Switch to the target branch
|
// 7. Switch to the target branch
|
||||||
events?.emit('switch:checkout', {
|
events?.emit('switch:checkout', {
|
||||||
worktreePath,
|
worktreePath,
|
||||||
targetBranch,
|
targetBranch,
|
||||||
@@ -265,9 +273,6 @@ export async function performSwitchBranch(
|
|||||||
await execGitCommand(['checkout', targetBranch], worktreePath);
|
await execGitCommand(['checkout', targetBranch], worktreePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Fetch latest from remotes after switching
|
|
||||||
await fetchRemotes(worktreePath);
|
|
||||||
|
|
||||||
// 8. Reapply stashed changes if we stashed earlier
|
// 8. Reapply stashed changes if we stashed earlier
|
||||||
let hasConflicts = false;
|
let hasConflicts = false;
|
||||||
let conflictMessage = '';
|
let conflictMessage = '';
|
||||||
@@ -347,7 +352,7 @@ export async function performSwitchBranch(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (checkoutError) {
|
} catch (checkoutError) {
|
||||||
// 9. If checkout failed and we stashed, try to restore the stash
|
// 9. Error recovery: if checkout failed and we stashed, try to restore the stash
|
||||||
if (didStash) {
|
if (didStash) {
|
||||||
const popResult = await popStash(worktreePath);
|
const popResult = await popStash(worktreePath);
|
||||||
if (popResult.hasConflicts) {
|
if (popResult.hasConflicts) {
|
||||||
|
|||||||
@@ -328,6 +328,86 @@ describe('auto-loop-coordinator.ts', () => {
|
|||||||
// Should not have executed features because at capacity
|
// Should not have executed features because at capacity
|
||||||
expect(mockExecuteFeature).not.toHaveBeenCalled();
|
expect(mockExecuteFeature).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('counts all running features (auto + manual) against concurrency limit', async () => {
|
||||||
|
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]);
|
||||||
|
// 2 manual features running — total count is 2
|
||||||
|
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(2);
|
||||||
|
|
||||||
|
await coordinator.startAutoLoopForProject('/test/project', null, 2);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(6000);
|
||||||
|
|
||||||
|
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||||
|
|
||||||
|
// Should NOT execute because total running count (2) meets the concurrency limit (2)
|
||||||
|
expect(mockExecuteFeature).not.toHaveBeenCalled();
|
||||||
|
// Verify it was called WITHOUT autoModeOnly (counts all tasks)
|
||||||
|
// The coordinator's wrapper passes options through as undefined when not specified
|
||||||
|
expect(mockConcurrencyManager.getRunningCountForWorktree).toHaveBeenCalledWith(
|
||||||
|
'/test/project',
|
||||||
|
null,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows auto dispatch when manual tasks finish and capacity becomes available', async () => {
|
||||||
|
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]);
|
||||||
|
// First call: at capacity (2 manual features running)
|
||||||
|
// Second call: capacity freed (1 feature running)
|
||||||
|
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree)
|
||||||
|
.mockResolvedValueOnce(2) // at capacity
|
||||||
|
.mockResolvedValueOnce(1); // capacity available after manual task completes
|
||||||
|
|
||||||
|
await coordinator.startAutoLoopForProject('/test/project', null, 2);
|
||||||
|
|
||||||
|
// First iteration: at capacity, should wait
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
|
||||||
|
// Second iteration: capacity available, should execute
|
||||||
|
await vi.advanceTimersByTimeAsync(6000);
|
||||||
|
|
||||||
|
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||||
|
|
||||||
|
// Should execute after capacity freed
|
||||||
|
expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-1', true, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits when manually started tasks already fill concurrency limit at auto mode activation', async () => {
|
||||||
|
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]);
|
||||||
|
// Manual tasks already fill the limit
|
||||||
|
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(3);
|
||||||
|
|
||||||
|
await coordinator.startAutoLoopForProject('/test/project', null, 3);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(6000);
|
||||||
|
|
||||||
|
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||||
|
|
||||||
|
// Auto mode should remain waiting, not dispatch
|
||||||
|
expect(mockExecuteFeature).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resumes dispatching when all running tasks complete simultaneously', async () => {
|
||||||
|
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]);
|
||||||
|
// First check: all 3 slots occupied
|
||||||
|
// Second check: all tasks completed simultaneously
|
||||||
|
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree)
|
||||||
|
.mockResolvedValueOnce(3) // all slots full
|
||||||
|
.mockResolvedValueOnce(0); // all tasks completed at once
|
||||||
|
|
||||||
|
await coordinator.startAutoLoopForProject('/test/project', null, 3);
|
||||||
|
|
||||||
|
// First iteration: at capacity
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
// Second iteration: all freed
|
||||||
|
await vi.advanceTimersByTimeAsync(6000);
|
||||||
|
|
||||||
|
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||||
|
|
||||||
|
// Should execute after all tasks freed capacity
|
||||||
|
expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-1', true, true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('priority-based feature selection', () => {
|
describe('priority-based feature selection', () => {
|
||||||
@@ -788,7 +868,23 @@ describe('auto-loop-coordinator.ts', () => {
|
|||||||
expect(count).toBe(3);
|
expect(count).toBe(3);
|
||||||
expect(mockConcurrencyManager.getRunningCountForWorktree).toHaveBeenCalledWith(
|
expect(mockConcurrencyManager.getRunningCountForWorktree).toHaveBeenCalledWith(
|
||||||
'/test/project',
|
'/test/project',
|
||||||
null
|
null,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes autoModeOnly option to ConcurrencyManager', async () => {
|
||||||
|
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(1);
|
||||||
|
|
||||||
|
const count = await coordinator.getRunningCountForWorktree('/test/project', null, {
|
||||||
|
autoModeOnly: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(count).toBe(1);
|
||||||
|
expect(mockConcurrencyManager.getRunningCountForWorktree).toHaveBeenCalledWith(
|
||||||
|
'/test/project',
|
||||||
|
null,
|
||||||
|
{ autoModeOnly: true }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -416,6 +416,90 @@ describe('ConcurrencyManager', () => {
|
|||||||
expect(mainCount).toBe(2);
|
expect(mainCount).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should count only auto-mode features when autoModeOnly is true', async () => {
|
||||||
|
// Auto-mode feature on main worktree
|
||||||
|
manager.acquire({
|
||||||
|
featureId: 'feature-auto',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
isAutoMode: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manual feature on main worktree
|
||||||
|
manager.acquire({
|
||||||
|
featureId: 'feature-manual',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
isAutoMode: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Without autoModeOnly: counts both
|
||||||
|
const totalCount = await manager.getRunningCountForWorktree('/test/project', null);
|
||||||
|
expect(totalCount).toBe(2);
|
||||||
|
|
||||||
|
// With autoModeOnly: counts only auto-mode features
|
||||||
|
const autoModeCount = await manager.getRunningCountForWorktree('/test/project', null, {
|
||||||
|
autoModeOnly: true,
|
||||||
|
});
|
||||||
|
expect(autoModeCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should count only auto-mode features on specific worktree when autoModeOnly is true', async () => {
|
||||||
|
// Auto-mode feature on feature branch
|
||||||
|
manager.acquire({
|
||||||
|
featureId: 'feature-auto',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
isAutoMode: true,
|
||||||
|
});
|
||||||
|
manager.updateRunningFeature('feature-auto', { branchName: 'feature-branch' });
|
||||||
|
|
||||||
|
// Manual feature on same feature branch
|
||||||
|
manager.acquire({
|
||||||
|
featureId: 'feature-manual',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
isAutoMode: false,
|
||||||
|
});
|
||||||
|
manager.updateRunningFeature('feature-manual', { branchName: 'feature-branch' });
|
||||||
|
|
||||||
|
// Another auto-mode feature on different branch (should not be counted)
|
||||||
|
manager.acquire({
|
||||||
|
featureId: 'feature-other',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
isAutoMode: true,
|
||||||
|
});
|
||||||
|
manager.updateRunningFeature('feature-other', { branchName: 'other-branch' });
|
||||||
|
|
||||||
|
const autoModeCount = await manager.getRunningCountForWorktree(
|
||||||
|
'/test/project',
|
||||||
|
'feature-branch',
|
||||||
|
{ autoModeOnly: true }
|
||||||
|
);
|
||||||
|
expect(autoModeCount).toBe(1);
|
||||||
|
|
||||||
|
const totalCount = await manager.getRunningCountForWorktree(
|
||||||
|
'/test/project',
|
||||||
|
'feature-branch'
|
||||||
|
);
|
||||||
|
expect(totalCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 when autoModeOnly is true and only manual features are running', async () => {
|
||||||
|
manager.acquire({
|
||||||
|
featureId: 'feature-manual-1',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
isAutoMode: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.acquire({
|
||||||
|
featureId: 'feature-manual-2',
|
||||||
|
projectPath: '/test/project',
|
||||||
|
isAutoMode: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const autoModeCount = await manager.getRunningCountForWorktree('/test/project', null, {
|
||||||
|
autoModeOnly: true,
|
||||||
|
});
|
||||||
|
expect(autoModeCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('should filter by both projectPath and branchName', async () => {
|
it('should filter by both projectPath and branchName', async () => {
|
||||||
manager.acquire({
|
manager.acquire({
|
||||||
featureId: 'feature-1',
|
featureId: 'feature-1',
|
||||||
|
|||||||
@@ -486,7 +486,7 @@ describe('dev-server-service.ts', () => {
|
|||||||
await service.startDevServer(testDir, testDir);
|
await service.startDevServer(testDir, testDir);
|
||||||
|
|
||||||
// Simulate HTTPS dev server
|
// Simulate HTTPS dev server
|
||||||
mockProcess.stdout.emit('data', Buffer.from('Server at https://localhost:3443\n'));
|
mockProcess.stdout.emit('data', Buffer.from('Server listening at https://localhost:3443\n'));
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
@@ -521,6 +521,368 @@ describe('dev-server-service.ts', () => {
|
|||||||
expect(serverInfo?.url).toBe(firstUrl);
|
expect(serverInfo?.url).toBe(firstUrl);
|
||||||
expect(serverInfo?.url).toBe('http://localhost:5173/');
|
expect(serverInfo?.url).toBe('http://localhost:5173/');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should detect Astro format URL', async () => {
|
||||||
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockProcess = createMockProcess();
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||||
|
const service = getDevServerService();
|
||||||
|
|
||||||
|
await service.startDevServer(testDir, testDir);
|
||||||
|
|
||||||
|
// Astro uses the same "Local:" prefix as Vite
|
||||||
|
mockProcess.stdout.emit('data', Buffer.from(' 🚀 astro v4.0.0 started in 200ms\n'));
|
||||||
|
mockProcess.stdout.emit('data', Buffer.from(' ┃ Local http://localhost:4321/\n'));
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const serverInfo = service.getServerInfo(testDir);
|
||||||
|
// Astro doesn't use "Local:" with colon, so it should be caught by the localhost URL pattern
|
||||||
|
expect(serverInfo?.url).toBe('http://localhost:4321/');
|
||||||
|
expect(serverInfo?.urlDetected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect Remix format URL', async () => {
|
||||||
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockProcess = createMockProcess();
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||||
|
const service = getDevServerService();
|
||||||
|
|
||||||
|
await service.startDevServer(testDir, testDir);
|
||||||
|
|
||||||
|
mockProcess.stdout.emit(
|
||||||
|
'data',
|
||||||
|
Buffer.from('Remix App Server started at http://localhost:3000\n')
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const serverInfo = service.getServerInfo(testDir);
|
||||||
|
expect(serverInfo?.url).toBe('http://localhost:3000');
|
||||||
|
expect(serverInfo?.urlDetected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect Django format URL', async () => {
|
||||||
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockProcess = createMockProcess();
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||||
|
const service = getDevServerService();
|
||||||
|
|
||||||
|
await service.startDevServer(testDir, testDir);
|
||||||
|
|
||||||
|
mockProcess.stdout.emit(
|
||||||
|
'data',
|
||||||
|
Buffer.from('Starting development server at http://127.0.0.1:8000/\n')
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const serverInfo = service.getServerInfo(testDir);
|
||||||
|
expect(serverInfo?.url).toBe('http://127.0.0.1:8000/');
|
||||||
|
expect(serverInfo?.urlDetected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect Webpack Dev Server format URL', async () => {
|
||||||
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockProcess = createMockProcess();
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||||
|
const service = getDevServerService();
|
||||||
|
|
||||||
|
await service.startDevServer(testDir, testDir);
|
||||||
|
|
||||||
|
mockProcess.stdout.emit(
|
||||||
|
'data',
|
||||||
|
Buffer.from('<i> [webpack-dev-server] Project is running at http://localhost:8080/\n')
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const serverInfo = service.getServerInfo(testDir);
|
||||||
|
expect(serverInfo?.url).toBe('http://localhost:8080/');
|
||||||
|
expect(serverInfo?.urlDetected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect PHP built-in server format URL', async () => {
|
||||||
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockProcess = createMockProcess();
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||||
|
const service = getDevServerService();
|
||||||
|
|
||||||
|
await service.startDevServer(testDir, testDir);
|
||||||
|
|
||||||
|
mockProcess.stdout.emit(
|
||||||
|
'data',
|
||||||
|
Buffer.from('Development Server (http://localhost:8000) started\n')
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const serverInfo = service.getServerInfo(testDir);
|
||||||
|
expect(serverInfo?.url).toBe('http://localhost:8000');
|
||||||
|
expect(serverInfo?.urlDetected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect "listening on port" format (port-only detection)', async () => {
|
||||||
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockProcess = createMockProcess();
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||||
|
const service = getDevServerService();
|
||||||
|
|
||||||
|
await service.startDevServer(testDir, testDir);
|
||||||
|
|
||||||
|
// Some servers only print the port number, not a full URL
|
||||||
|
mockProcess.stdout.emit('data', Buffer.from('Server listening on port 4000\n'));
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const serverInfo = service.getServerInfo(testDir);
|
||||||
|
expect(serverInfo?.url).toBe('http://localhost:4000');
|
||||||
|
expect(serverInfo?.urlDetected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect "running on port" format (port-only detection)', async () => {
|
||||||
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockProcess = createMockProcess();
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||||
|
const service = getDevServerService();
|
||||||
|
|
||||||
|
await service.startDevServer(testDir, testDir);
|
||||||
|
|
||||||
|
mockProcess.stdout.emit('data', Buffer.from('Application running on port 9000\n'));
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const serverInfo = service.getServerInfo(testDir);
|
||||||
|
expect(serverInfo?.url).toBe('http://localhost:9000');
|
||||||
|
expect(serverInfo?.urlDetected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should strip ANSI escape codes before detecting URL', async () => {
|
||||||
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockProcess = createMockProcess();
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||||
|
const service = getDevServerService();
|
||||||
|
|
||||||
|
await service.startDevServer(testDir, testDir);
|
||||||
|
|
||||||
|
// Simulate Vite output with ANSI color codes
|
||||||
|
mockProcess.stdout.emit(
|
||||||
|
'data',
|
||||||
|
Buffer.from(
|
||||||
|
' \x1B[32m➜\x1B[0m \x1B[1mLocal:\x1B[0m \x1B[36mhttp://localhost:5173/\x1B[0m\n'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const serverInfo = service.getServerInfo(testDir);
|
||||||
|
expect(serverInfo?.url).toBe('http://localhost:5173/');
|
||||||
|
expect(serverInfo?.urlDetected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalize 0.0.0.0 to localhost', async () => {
|
||||||
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockProcess = createMockProcess();
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||||
|
const service = getDevServerService();
|
||||||
|
|
||||||
|
await service.startDevServer(testDir, testDir);
|
||||||
|
|
||||||
|
mockProcess.stdout.emit('data', Buffer.from('Server listening at http://0.0.0.0:3000\n'));
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const serverInfo = service.getServerInfo(testDir);
|
||||||
|
expect(serverInfo?.url).toBe('http://localhost:3000');
|
||||||
|
expect(serverInfo?.urlDetected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalize [::] to localhost', async () => {
|
||||||
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockProcess = createMockProcess();
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||||
|
const service = getDevServerService();
|
||||||
|
|
||||||
|
await service.startDevServer(testDir, testDir);
|
||||||
|
|
||||||
|
mockProcess.stdout.emit('data', Buffer.from('Local: http://[::]:4000/\n'));
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const serverInfo = service.getServerInfo(testDir);
|
||||||
|
expect(serverInfo?.url).toBe('http://localhost:4000/');
|
||||||
|
expect(serverInfo?.urlDetected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update port field when detected URL has different port', async () => {
|
||||||
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockProcess = createMockProcess();
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||||
|
const service = getDevServerService();
|
||||||
|
|
||||||
|
const result = await service.startDevServer(testDir, testDir);
|
||||||
|
const allocatedPort = result.result?.port;
|
||||||
|
|
||||||
|
// Server starts on a completely different port (ignoring PORT env var)
|
||||||
|
mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:9999/\n'));
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const serverInfo = service.getServerInfo(testDir);
|
||||||
|
expect(serverInfo?.url).toBe('http://localhost:9999/');
|
||||||
|
expect(serverInfo?.port).toBe(9999);
|
||||||
|
// The port should be different from what was initially allocated
|
||||||
|
if (allocatedPort !== 9999) {
|
||||||
|
expect(serverInfo?.port).not.toBe(allocatedPort);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect URL from stderr output', async () => {
|
||||||
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockProcess = createMockProcess();
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||||
|
const service = getDevServerService();
|
||||||
|
|
||||||
|
await service.startDevServer(testDir, testDir);
|
||||||
|
|
||||||
|
// Some servers output URL info to stderr
|
||||||
|
mockProcess.stderr.emit('data', Buffer.from('Local: http://localhost:3000/\n'));
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const serverInfo = service.getServerInfo(testDir);
|
||||||
|
expect(serverInfo?.url).toBe('http://localhost:3000/');
|
||||||
|
expect(serverInfo?.urlDetected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not match URLs without a port (non-dev-server URLs)', async () => {
|
||||||
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockProcess = createMockProcess();
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||||
|
const service = getDevServerService();
|
||||||
|
|
||||||
|
const result = await service.startDevServer(testDir, testDir);
|
||||||
|
|
||||||
|
// CDN/external URLs should not be detected
|
||||||
|
mockProcess.stdout.emit(
|
||||||
|
'data',
|
||||||
|
Buffer.from('Downloading from https://cdn.example.com/bundle.js\n')
|
||||||
|
);
|
||||||
|
mockProcess.stdout.emit('data', Buffer.from('Fetching https://registry.npmjs.org/package\n'));
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const serverInfo = service.getServerInfo(testDir);
|
||||||
|
// Should keep the initial allocated URL since external URLs don't match
|
||||||
|
expect(serverInfo?.url).toBe(result.result?.url);
|
||||||
|
expect(serverInfo?.urlDetected).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle URLs with trailing punctuation', async () => {
|
||||||
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockProcess = createMockProcess();
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||||
|
const service = getDevServerService();
|
||||||
|
|
||||||
|
await service.startDevServer(testDir, testDir);
|
||||||
|
|
||||||
|
// URL followed by punctuation
|
||||||
|
mockProcess.stdout.emit('data', Buffer.from('Server started at http://localhost:3000.\n'));
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const serverInfo = service.getServerInfo(testDir);
|
||||||
|
expect(serverInfo?.url).toBe('http://localhost:3000');
|
||||||
|
expect(serverInfo?.urlDetected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect Express/Fastify format URL', async () => {
|
||||||
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockProcess = createMockProcess();
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||||
|
const service = getDevServerService();
|
||||||
|
|
||||||
|
await service.startDevServer(testDir, testDir);
|
||||||
|
|
||||||
|
mockProcess.stdout.emit('data', Buffer.from('Server listening on http://localhost:3000\n'));
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const serverInfo = service.getServerInfo(testDir);
|
||||||
|
expect(serverInfo?.url).toBe('http://localhost:3000');
|
||||||
|
expect(serverInfo?.urlDetected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect Angular CLI format URL', async () => {
|
||||||
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockProcess = createMockProcess();
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||||
|
const service = getDevServerService();
|
||||||
|
|
||||||
|
await service.startDevServer(testDir, testDir);
|
||||||
|
|
||||||
|
// Angular CLI output
|
||||||
|
mockProcess.stderr.emit(
|
||||||
|
'data',
|
||||||
|
Buffer.from(
|
||||||
|
'** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **\n'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const serverInfo = service.getServerInfo(testDir);
|
||||||
|
expect(serverInfo?.url).toBe('http://localhost:4200/');
|
||||||
|
expect(serverInfo?.urlDetected).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -531,6 +893,7 @@ function createMockProcess() {
|
|||||||
mockProcess.stderr = new EventEmitter();
|
mockProcess.stderr = new EventEmitter();
|
||||||
mockProcess.kill = vi.fn();
|
mockProcess.kill = vi.fn();
|
||||||
mockProcess.killed = false;
|
mockProcess.killed = false;
|
||||||
|
mockProcess.pid = 12345;
|
||||||
|
|
||||||
// Don't exit immediately - let the test control the lifecycle
|
// Don't exit immediately - let the test control the lifecycle
|
||||||
return mockProcess;
|
return mockProcess;
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const eslintConfig = defineConfig([
|
|||||||
requestAnimationFrame: 'readonly',
|
requestAnimationFrame: 'readonly',
|
||||||
cancelAnimationFrame: 'readonly',
|
cancelAnimationFrame: 'readonly',
|
||||||
requestIdleCallback: 'readonly',
|
requestIdleCallback: 'readonly',
|
||||||
|
cancelIdleCallback: 'readonly',
|
||||||
alert: 'readonly',
|
alert: 'readonly',
|
||||||
// DOM Element Types
|
// DOM Element Types
|
||||||
HTMLElement: 'readonly',
|
HTMLElement: 'readonly',
|
||||||
|
|||||||
@@ -72,30 +72,169 @@
|
|||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
/* Inline app shell: shows logo + spinner while JS bundle downloads.
|
||||||
|
On slow mobile networks the bundle can take 2-5s; this eliminates
|
||||||
|
the blank screen and gives immediate visual feedback.
|
||||||
|
React's createRoot().render() replaces #app's innerHTML, removing this. */
|
||||||
|
.app-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
gap: 24px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.app-shell-logo {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
/* Default (dark): light spinner + logo strokes */
|
||||||
|
.app-shell-spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-top-color: rgba(255, 255, 255, 0.5);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: shell-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
.app-shell-logo-stroke {
|
||||||
|
stroke: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
.app-shell-logo-bg {
|
||||||
|
fill: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
/* Light themes: dark spinner + logo strokes.
|
||||||
|
The theme script below sets data-theme-type="light" on <html> for any light
|
||||||
|
theme, so future theme additions only need to update the script — not CSS. */
|
||||||
|
html[data-theme-type='light'] .app-shell-spinner {
|
||||||
|
border-color: rgba(0, 0, 0, 0.08);
|
||||||
|
border-top-color: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
html[data-theme-type='light'] .app-shell-logo-stroke {
|
||||||
|
stroke: rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
html[data-theme-type='light'] .app-shell-logo-bg {
|
||||||
|
fill: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
/* System light preference when no theme class is applied yet */
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
html:not([class]) .app-shell-spinner {
|
||||||
|
border-color: rgba(0, 0, 0, 0.08);
|
||||||
|
border-top-color: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
html:not([class]) .app-shell-logo-stroke {
|
||||||
|
stroke: rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
html:not([class]) .app-shell-logo-bg {
|
||||||
|
fill: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes shell-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
// Prevent theme flash - apply stored theme before React hydrates
|
// Prevent theme flash - apply stored theme before React hydrates
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
var stored = localStorage.getItem('automaker-storage');
|
// Primary key used by current builds for pre-React theme persistence.
|
||||||
if (stored) {
|
var theme = localStorage.getItem('automaker:theme');
|
||||||
var data = JSON.parse(stored);
|
|
||||||
var theme = data.state?.theme;
|
// Backward compatibility: older builds stored theme in the Zustand blob.
|
||||||
if (theme && theme !== 'system' && theme !== 'light') {
|
if (!theme) {
|
||||||
document.documentElement.classList.add(theme);
|
var stored = localStorage.getItem('automaker-storage');
|
||||||
} else if (
|
if (stored) {
|
||||||
theme === 'system' &&
|
var data = JSON.parse(stored);
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
theme = data?.state?.theme || data?.theme || null;
|
||||||
) {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Light theme names — kept in sync with the background-color rule above.
|
||||||
|
// Adding a new light theme only requires updating this array.
|
||||||
|
var lightThemes = [
|
||||||
|
'light',
|
||||||
|
'cream',
|
||||||
|
'solarizedlight',
|
||||||
|
'github',
|
||||||
|
'paper',
|
||||||
|
'rose',
|
||||||
|
'mint',
|
||||||
|
'lavender',
|
||||||
|
'sand',
|
||||||
|
'sky',
|
||||||
|
'peach',
|
||||||
|
'snow',
|
||||||
|
'sepia',
|
||||||
|
'gruvboxlight',
|
||||||
|
'nordlight',
|
||||||
|
'blossom',
|
||||||
|
'ayu-light',
|
||||||
|
'onelight',
|
||||||
|
'bluloco',
|
||||||
|
'feather',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (theme && theme !== 'system') {
|
||||||
|
// Apply the stored theme class directly (covers 'light', 'dark', and
|
||||||
|
// all named themes like 'cream', 'nord', etc.)
|
||||||
|
document.documentElement.classList.add(theme);
|
||||||
|
if (lightThemes.indexOf(theme) !== -1) {
|
||||||
|
document.documentElement.setAttribute('data-theme-type', 'light');
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
theme === 'system' &&
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else if (
|
||||||
|
theme === 'system' &&
|
||||||
|
window.matchMedia('(prefers-color-scheme: light)').matches
|
||||||
|
) {
|
||||||
|
document.documentElement.setAttribute('data-theme-type', 'light');
|
||||||
|
}
|
||||||
|
// Detect PWA standalone mode early so CSS can apply reduced bottom safe-area
|
||||||
|
// before first paint, preventing a layout shift on notched devices.
|
||||||
|
if (
|
||||||
|
window.matchMedia('(display-mode: standalone)').matches ||
|
||||||
|
navigator.standalone === true
|
||||||
|
) {
|
||||||
|
document.documentElement.setAttribute('data-pwa', 'standalone');
|
||||||
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="antialiased">
|
<body class="antialiased">
|
||||||
<div id="app"></div>
|
<div id="app">
|
||||||
|
<!-- Inline app shell: renders instantly while JS downloads on slow mobile networks.
|
||||||
|
React's createRoot().render() replaces this content automatically. -->
|
||||||
|
<div class="app-shell">
|
||||||
|
<svg
|
||||||
|
class="app-shell-logo"
|
||||||
|
viewBox="0 0 256 256"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect class="app-shell-logo-bg" x="16" y="16" width="224" height="224" rx="56" />
|
||||||
|
<g
|
||||||
|
class="app-shell-logo-stroke"
|
||||||
|
fill="none"
|
||||||
|
stroke-width="20"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M92 92 L52 128 L92 164" />
|
||||||
|
<path d="M144 72 L116 184" />
|
||||||
|
<path d="M164 92 L204 128 L164 164" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<div class="app-shell-spinner" role="status" aria-label="Loading…"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script type="module" src="/src/renderer.tsx"></script>
|
<script type="module" src="/src/renderer.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -42,10 +42,24 @@
|
|||||||
"@automaker/dependency-resolver": "1.0.0",
|
"@automaker/dependency-resolver": "1.0.0",
|
||||||
"@automaker/spec-parser": "1.0.0",
|
"@automaker/spec-parser": "1.0.0",
|
||||||
"@automaker/types": "1.0.0",
|
"@automaker/types": "1.0.0",
|
||||||
|
"@codemirror/lang-cpp": "^6.0.3",
|
||||||
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
|
"@codemirror/lang-html": "^6.4.11",
|
||||||
|
"@codemirror/lang-java": "^6.0.2",
|
||||||
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
|
"@codemirror/lang-php": "^6.0.2",
|
||||||
|
"@codemirror/lang-python": "^6.2.1",
|
||||||
|
"@codemirror/lang-rust": "^6.0.2",
|
||||||
|
"@codemirror/lang-sql": "^6.10.0",
|
||||||
"@codemirror/lang-xml": "6.1.0",
|
"@codemirror/lang-xml": "6.1.0",
|
||||||
"@codemirror/language": "^6.12.1",
|
"@codemirror/language": "^6.12.1",
|
||||||
"@codemirror/legacy-modes": "^6.5.2",
|
"@codemirror/legacy-modes": "^6.5.2",
|
||||||
|
"@codemirror/search": "^6.6.0",
|
||||||
|
"@codemirror/state": "^6.5.4",
|
||||||
"@codemirror/theme-one-dark": "6.1.3",
|
"@codemirror/theme-one-dark": "6.1.3",
|
||||||
|
"@codemirror/view": "^6.39.15",
|
||||||
"@dnd-kit/core": "6.3.1",
|
"@dnd-kit/core": "6.3.1",
|
||||||
"@dnd-kit/sortable": "10.0.0",
|
"@dnd-kit/sortable": "10.0.0",
|
||||||
"@dnd-kit/utilities": "3.2.2",
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// Automaker Service Worker - Optimized for mobile PWA loading performance
|
// Automaker Service Worker - Optimized for mobile PWA loading performance
|
||||||
// NOTE: CACHE_NAME is injected with a build hash at build time by the swCacheBuster
|
// NOTE: CACHE_NAME is injected with a build hash at build time by the swCacheBuster
|
||||||
// Vite plugin (see vite.config.mts). In development it stays as-is; in production
|
// Vite plugin (see vite.config.mts). In development it stays as-is; in production
|
||||||
// builds it becomes e.g. 'automaker-v3-a1b2c3d4' for automatic cache invalidation.
|
// builds it becomes e.g. 'automaker-v5-a1b2c3d4' for automatic cache invalidation.
|
||||||
const CACHE_NAME = 'automaker-v3'; // replaced at build time → 'automaker-v3-<hash>'
|
const CACHE_NAME = 'automaker-v5'; // replaced at build time → 'automaker-v5-<hash>'
|
||||||
|
|
||||||
// Separate cache for immutable hashed assets (long-lived)
|
// Separate cache for immutable hashed assets (long-lived)
|
||||||
const IMMUTABLE_CACHE = 'automaker-immutable-v2';
|
const IMMUTABLE_CACHE = 'automaker-immutable-v2';
|
||||||
@@ -13,6 +13,7 @@ const API_CACHE = 'automaker-api-v1';
|
|||||||
// Assets to cache on install (app shell for instant loading)
|
// Assets to cache on install (app shell for instant loading)
|
||||||
const SHELL_ASSETS = [
|
const SHELL_ASSETS = [
|
||||||
'/',
|
'/',
|
||||||
|
'/index.html',
|
||||||
'/manifest.json',
|
'/manifest.json',
|
||||||
'/logo.png',
|
'/logo.png',
|
||||||
'/logo_larger.png',
|
'/logo_larger.png',
|
||||||
@@ -20,6 +21,12 @@ const SHELL_ASSETS = [
|
|||||||
'/favicon.ico',
|
'/favicon.ico',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Critical JS/CSS assets extracted from index.html at build time by the swCacheBuster
|
||||||
|
// Vite plugin. Populated during production builds; empty in dev mode.
|
||||||
|
// These are precached on SW install so that PWA cold starts after memory eviction
|
||||||
|
// serve instantly from cache instead of requiring a full network download.
|
||||||
|
const CRITICAL_ASSETS = [];
|
||||||
|
|
||||||
// Whether mobile caching is enabled (set via message from main thread).
|
// Whether mobile caching is enabled (set via message from main thread).
|
||||||
// Persisted to Cache Storage so it survives aggressive SW termination on mobile.
|
// Persisted to Cache Storage so it survives aggressive SW termination on mobile.
|
||||||
let mobileMode = false;
|
let mobileMode = false;
|
||||||
@@ -60,7 +67,10 @@ async function restoreMobileMode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Restore mobileMode immediately on SW startup
|
// Restore mobileMode immediately on SW startup
|
||||||
restoreMobileMode();
|
// Keep a promise so fetch handlers can await restoration on cold SW starts.
|
||||||
|
// This prevents a race where early API requests run before mobileMode is loaded
|
||||||
|
// from Cache Storage, incorrectly falling back to network-first.
|
||||||
|
const mobileModeRestorePromise = restoreMobileMode();
|
||||||
|
|
||||||
// API endpoints that are safe to serve from stale cache on mobile.
|
// API endpoints that are safe to serve from stale cache on mobile.
|
||||||
// These are GET-only, read-heavy endpoints where showing slightly stale data
|
// These are GET-only, read-heavy endpoints where showing slightly stale data
|
||||||
@@ -121,13 +131,68 @@ async function addCacheTimestamp(response) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
|
// Cache the app shell AND critical JS/CSS assets so the PWA loads instantly.
|
||||||
|
// SHELL_ASSETS go into CACHE_NAME (general cache), CRITICAL_ASSETS go into
|
||||||
|
// IMMUTABLE_CACHE (long-lived, content-hashed assets). This ensures that even
|
||||||
|
// the very first visit populates the immutable cache — previously, assets were
|
||||||
|
// only cached on fetch interception, but the SW isn't active during the first
|
||||||
|
// page load so nothing got cached until the second visit.
|
||||||
|
//
|
||||||
|
// self.skipWaiting() is NOT called here — activation is deferred until the main
|
||||||
|
// thread sends a SKIP_WAITING message to avoid disrupting a live page.
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
Promise.all([
|
||||||
return cache.addAll(SHELL_ASSETS);
|
// Cache app shell (HTML, icons, manifest) using individual fetch+put instead of
|
||||||
})
|
// cache.addAll(). This is critical because cache.addAll() respects the server's
|
||||||
|
// Cache-Control response headers — if the server sends 'Cache-Control: no-store'
|
||||||
|
// (which Vite dev server does for index.html), addAll() silently skips caching
|
||||||
|
// and the pre-React loading spinner is never served from cache.
|
||||||
|
//
|
||||||
|
// cache.put() bypasses Cache-Control headers entirely, ensuring the app shell
|
||||||
|
// is always cached on install regardless of what the server sends. This is the
|
||||||
|
// correct approach for SW-managed caches where the SW (not HTTP headers) controls
|
||||||
|
// freshness via the activate event's cache cleanup and the navigation strategy's
|
||||||
|
// background revalidation.
|
||||||
|
caches.open(CACHE_NAME).then((cache) =>
|
||||||
|
Promise.all(
|
||||||
|
SHELL_ASSETS.map((url) =>
|
||||||
|
fetch(url)
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) return cache.put(url, response);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Individual asset fetch failure is non-fatal — the SW still activates
|
||||||
|
// and the next navigation will populate the cache via Strategy 3.
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// Cache critical JS/CSS bundles (injected at build time by swCacheBuster).
|
||||||
|
// Uses individual fetch+put instead of cache.addAll() so a single asset
|
||||||
|
// failure doesn't prevent the rest from being cached.
|
||||||
|
//
|
||||||
|
// IMPORTANT: We fetch with { mode: 'cors' } because Vite's output HTML uses
|
||||||
|
// <script type="module" crossorigin> and <link rel="modulepreload" crossorigin>
|
||||||
|
// for these assets. The Cache API keys entries by URL + request mode, so a
|
||||||
|
// no-cors cached response won't match a cors-mode browser request. Fetching
|
||||||
|
// with cors mode here ensures the cached entries match what the browser requests.
|
||||||
|
CRITICAL_ASSETS.length > 0
|
||||||
|
? caches.open(IMMUTABLE_CACHE).then((cache) =>
|
||||||
|
Promise.all(
|
||||||
|
CRITICAL_ASSETS.map((url) =>
|
||||||
|
fetch(url, { mode: 'cors' })
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) return cache.put(url, response);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Individual asset fetch failure is non-fatal
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: Promise.resolve(),
|
||||||
|
])
|
||||||
);
|
);
|
||||||
// Activate immediately without waiting for existing clients
|
|
||||||
self.skipWaiting();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
@@ -145,10 +210,23 @@ self.addEventListener('activate', (event) => {
|
|||||||
// When enabled, the browser fires the navigation fetch in parallel with
|
// When enabled, the browser fires the navigation fetch in parallel with
|
||||||
// service worker boot, eliminating the SW startup delay (~50-200ms on mobile).
|
// service worker boot, eliminating the SW startup delay (~50-200ms on mobile).
|
||||||
self.registration.navigationPreload && self.registration.navigationPreload.enable(),
|
self.registration.navigationPreload && self.registration.navigationPreload.enable(),
|
||||||
|
// Claim clients so this SW immediately controls all open pages.
|
||||||
|
//
|
||||||
|
// This is safe in all activation scenarios:
|
||||||
|
// 1. First install: No old SW exists — claiming is a no-op with no side effects.
|
||||||
|
// Critically, this lets the fetch handler intercept requests during the same
|
||||||
|
// visit that registered the SW, populating the immutable cache.
|
||||||
|
// 2. SKIP_WAITING from main thread: The page is freshly loaded, so claiming
|
||||||
|
// won't cause a visible flash (the SW was explicitly asked to take over).
|
||||||
|
// 3. Natural activation (all old-SW tabs closed): The new SW activates when
|
||||||
|
// no pages are using the old SW, so claiming controls only new navigations.
|
||||||
|
//
|
||||||
|
// Without clients.claim(), the SW's fetch handler would not intercept any
|
||||||
|
// requests until the next full navigation — meaning the first visit after
|
||||||
|
// install would not benefit from the cache-first asset strategy.
|
||||||
|
self.clients.claim(),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
// Take control of all clients immediately
|
|
||||||
self.clients.claim();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -159,16 +237,33 @@ self.addEventListener('activate', (event) => {
|
|||||||
function isImmutableAsset(url) {
|
function isImmutableAsset(url) {
|
||||||
const path = url.pathname;
|
const path = url.pathname;
|
||||||
// Match Vite's hashed asset pattern: /assets/<name>-<hash>.<ext>
|
// Match Vite's hashed asset pattern: /assets/<name>-<hash>.<ext>
|
||||||
|
// This covers JS bundles, CSS, and font files that Vite outputs to /assets/
|
||||||
|
// with content hashes (e.g., /assets/font-inter-WC6UYoCP.js).
|
||||||
|
// Note: We intentionally do NOT cache all font files globally — only those
|
||||||
|
// under /assets/ (which are Vite-processed, content-hashed, and actively used).
|
||||||
|
// There are 639+ font files (~20MB total) across all font families; caching them
|
||||||
|
// all would push iOS toward its ~50MB PWA quota and trigger eviction of everything.
|
||||||
if (path.startsWith('/assets/') && /-[A-Za-z0-9_-]{6,}\.\w+$/.test(path)) {
|
if (path.startsWith('/assets/') && /-[A-Za-z0-9_-]{6,}\.\w+$/.test(path)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Font files are immutable (woff2, woff, ttf, otf)
|
|
||||||
if (/\.(woff2?|ttf|otf)$/.test(path)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if a request is for app code (JS/CSS) that should be cached aggressively.
|
||||||
|
* This includes both production /assets/* bundles and development /src/* modules.
|
||||||
|
*
|
||||||
|
* The path.startsWith('/src/') check is dev-only — in development the Vite dev server
|
||||||
|
* serves source files directly from /src/*. In production all code is bundled under
|
||||||
|
* /assets/*, so the /src/ check is harmless but only present for developer convenience.
|
||||||
|
*/
|
||||||
|
function isCodeAsset(url) {
|
||||||
|
const path = url.pathname;
|
||||||
|
const isScriptOrStyle = /\.(m?js|css|tsx?)$/.test(path);
|
||||||
|
if (!isScriptOrStyle) return false;
|
||||||
|
return path.startsWith('/assets/') || path.startsWith('/src/');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if a URL points to a static asset that benefits from stale-while-revalidate
|
* Determine if a URL points to a static asset that benefits from stale-while-revalidate
|
||||||
*/
|
*/
|
||||||
@@ -203,69 +298,95 @@ self.addEventListener('fetch', (event) => {
|
|||||||
// The main thread's React Query layer handles the eventual fresh data via its
|
// The main thread's React Query layer handles the eventual fresh data via its
|
||||||
// own refetching mechanism, so the user sees updates within seconds.
|
// own refetching mechanism, so the user sees updates within seconds.
|
||||||
if (url.pathname.startsWith('/api/')) {
|
if (url.pathname.startsWith('/api/')) {
|
||||||
if (mobileMode && isCacheableApiRequest(url)) {
|
event.respondWith(
|
||||||
event.respondWith(
|
(async () => {
|
||||||
(async () => {
|
// On mobile, service workers are frequently terminated and restarted.
|
||||||
const cache = await caches.open(API_CACHE);
|
// Ensure persisted mobileMode is restored before deciding strategy so the
|
||||||
const cachedResponse = await cache.match(event.request);
|
// very first API requests after restart can hit cache immediately.
|
||||||
|
try {
|
||||||
|
await mobileModeRestorePromise;
|
||||||
|
} catch (_e) {
|
||||||
|
// Best-effort restore — keep default mobileMode value on failure.
|
||||||
|
}
|
||||||
|
|
||||||
// Helper: start a network fetch that updates the cache on success.
|
if (!(mobileMode && isCacheableApiRequest(url))) {
|
||||||
// Lazily invoked so we don't fire a network request when the cache
|
// Non-mobile or non-cacheable API: skip SW caching and use network.
|
||||||
// is already fresh — saves bandwidth and battery on mobile.
|
return fetch(event.request);
|
||||||
const startNetworkFetch = () =>
|
}
|
||||||
fetch(event.request)
|
|
||||||
.then(async (networkResponse) => {
|
const cache = await caches.open(API_CACHE);
|
||||||
if (networkResponse.ok) {
|
const cachedResponse = await cache.match(event.request);
|
||||||
// Store with timestamp for freshness checking
|
|
||||||
const timestampedResponse = await addCacheTimestamp(networkResponse);
|
// Helper: start a network fetch that updates the cache on success.
|
||||||
cache.put(event.request, timestampedResponse);
|
// Lazily invoked so we don't fire a network request when the cache
|
||||||
}
|
// is already fresh — saves bandwidth and battery on mobile.
|
||||||
|
const startNetworkFetch = () =>
|
||||||
|
fetch(event.request)
|
||||||
|
.then(async (networkResponse) => {
|
||||||
|
if (networkResponse.ok) {
|
||||||
|
// Store with timestamp for freshness checking
|
||||||
|
const timestampedResponse = await addCacheTimestamp(networkResponse);
|
||||||
|
cache.put(event.request, timestampedResponse);
|
||||||
return networkResponse;
|
return networkResponse;
|
||||||
})
|
}
|
||||||
.catch((err) => {
|
// Non-ok response (e.g. 5xx) — don't resolve with it for the race
|
||||||
// Network failed - if we have cache, that's fine (returned below)
|
// so the caller falls back to cachedResponse instead of showing an error page.
|
||||||
// If no cache, propagate the error
|
if (cachedResponse) return null;
|
||||||
if (cachedResponse) return null;
|
return networkResponse;
|
||||||
throw err;
|
})
|
||||||
});
|
.catch((err) => {
|
||||||
|
// Network failed - if we have cache, that's fine (returned below)
|
||||||
|
// If no cache, propagate the error
|
||||||
|
if (cachedResponse) return null;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
// If we have a fresh-enough cached response, return it immediately
|
// If we have a fresh-enough cached response, return it immediately
|
||||||
// without firing a background fetch — React Query's own refetching
|
// without firing a background fetch — React Query's own refetching
|
||||||
// will request fresh data when its stale time expires.
|
// will request fresh data when its stale time expires.
|
||||||
if (cachedResponse && isApiCacheFresh(cachedResponse)) {
|
if (cachedResponse && isApiCacheFresh(cachedResponse)) {
|
||||||
return cachedResponse;
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// From here the cache is either stale or missing — start the network fetch.
|
||||||
|
const fetchPromise = startNetworkFetch();
|
||||||
|
|
||||||
|
// If we have a stale cached response but network is slow, race them:
|
||||||
|
// Return whichever resolves first (cached immediately vs network)
|
||||||
|
if (cachedResponse) {
|
||||||
|
// Give network a brief window (2s) to respond, otherwise use stale cache
|
||||||
|
const networkResult = await Promise.race([
|
||||||
|
fetchPromise,
|
||||||
|
new Promise((resolve) => setTimeout(() => resolve(null), 2000)),
|
||||||
|
]);
|
||||||
|
if (!networkResult) {
|
||||||
|
// Timeout won — keep the background fetch alive so the cache update
|
||||||
|
// can complete even after we return the stale cached response.
|
||||||
|
event.waitUntil(fetchPromise.catch(() => {}));
|
||||||
}
|
}
|
||||||
|
return networkResult || cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
// From here the cache is either stale or missing — start the network fetch.
|
// No cache at all - must wait for network
|
||||||
const fetchPromise = startNetworkFetch();
|
return fetchPromise;
|
||||||
|
})()
|
||||||
// If we have a stale cached response but network is slow, race them:
|
);
|
||||||
// Return whichever resolves first (cached immediately vs network)
|
|
||||||
if (cachedResponse) {
|
|
||||||
// Give network a brief window (2s) to respond, otherwise use stale cache
|
|
||||||
const networkResult = await Promise.race([
|
|
||||||
fetchPromise,
|
|
||||||
new Promise((resolve) => setTimeout(() => resolve(null), 2000)),
|
|
||||||
]);
|
|
||||||
return networkResult || cachedResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No cache at all - must wait for network
|
|
||||||
return fetchPromise;
|
|
||||||
})()
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Non-mobile or non-cacheable API: skip SW, let browser handle normally
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 1: Cache-first for immutable hashed assets (JS/CSS bundles, fonts)
|
// Strategy 1: Cache-first for immutable hashed assets (JS/CSS bundles, fonts)
|
||||||
// These files contain content hashes in their names - they never change.
|
// These files contain content hashes in their names - they never change.
|
||||||
|
//
|
||||||
|
// Uses { ignoreVary: true } for cache matching because the same asset URL
|
||||||
|
// can be requested with different modes: <link rel="prefetch"> uses no-cors,
|
||||||
|
// <script type="module" crossorigin> and <link rel="modulepreload" crossorigin>
|
||||||
|
// use cors. Without ignoreVary, a cors-mode browser request won't match a
|
||||||
|
// no-cors cached entry (or vice versa), causing unnecessary network fetches
|
||||||
|
// even when the asset is already in the cache.
|
||||||
if (isImmutableAsset(url)) {
|
if (isImmutableAsset(url)) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.open(IMMUTABLE_CACHE).then((cache) => {
|
caches.open(IMMUTABLE_CACHE).then((cache) => {
|
||||||
return cache.match(event.request).then((cachedResponse) => {
|
return cache.match(event.request, { ignoreVary: true }).then((cachedResponse) => {
|
||||||
if (cachedResponse) {
|
if (cachedResponse) {
|
||||||
return cachedResponse;
|
return cachedResponse;
|
||||||
}
|
}
|
||||||
@@ -281,6 +402,59 @@ self.addEventListener('fetch', (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strategy 1b: Cache-first for app code assets that are not immutable-hashed.
|
||||||
|
// This removes network-coupled startup delays for pre-React boot files when
|
||||||
|
// they are served without content hashes (for example, dev-like module paths).
|
||||||
|
if (isCodeAsset(url)) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.open(CACHE_NAME).then((cache) =>
|
||||||
|
cache.match(event.request).then((cachedResponse) => {
|
||||||
|
const fetchPromise = fetch(event.request)
|
||||||
|
.then((networkResponse) => {
|
||||||
|
if (networkResponse.ok) {
|
||||||
|
cache.put(event.request, networkResponse.clone());
|
||||||
|
}
|
||||||
|
return networkResponse;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cachedResponse) return cachedResponse;
|
||||||
|
// Return a safe no-op response matching the asset type so the browser
|
||||||
|
// can parse it without errors, instead of a plain-text 503.
|
||||||
|
const dest = event.request.destination;
|
||||||
|
const urlPath = url.pathname;
|
||||||
|
if (dest === 'script' || urlPath.endsWith('.js') || urlPath.endsWith('.mjs')) {
|
||||||
|
return new Response('// offline', {
|
||||||
|
status: 503,
|
||||||
|
statusText: 'Service Unavailable',
|
||||||
|
headers: { 'Content-Type': 'application/javascript' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (dest === 'style' || urlPath.endsWith('.css')) {
|
||||||
|
return new Response('/* offline */', {
|
||||||
|
status: 503,
|
||||||
|
statusText: 'Service Unavailable',
|
||||||
|
headers: { 'Content-Type': 'text/css' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response('Service Unavailable', {
|
||||||
|
status: 503,
|
||||||
|
statusText: 'Service Unavailable',
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cachedResponse) {
|
||||||
|
event.waitUntil(fetchPromise.catch(() => {}));
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchPromise;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Strategy 2: Stale-while-revalidate for static assets (images, audio)
|
// Strategy 2: Stale-while-revalidate for static assets (images, audio)
|
||||||
// Serve cached version immediately, update cache in background.
|
// Serve cached version immediately, update cache in background.
|
||||||
if (isStaticAsset(url)) {
|
if (isStaticAsset(url)) {
|
||||||
@@ -304,43 +478,55 @@ self.addEventListener('fetch', (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Network-first for navigation requests (HTML)
|
// Strategy 3: Cache-first with background revalidation for navigation requests (HTML)
|
||||||
// Uses Navigation Preload when available - the browser fires the network request
|
//
|
||||||
// in parallel with SW startup, eliminating the ~50-200ms SW boot delay on mobile.
|
// The app shell (index.html) is a thin SPA entry point — its content rarely changes
|
||||||
// Falls back to regular fetch when Navigation Preload is not supported.
|
// meaningfully between deploys because all JS/CSS bundles are content-hashed. Serving
|
||||||
|
// it from cache first eliminates the visible "reload flash" that occurs when the user
|
||||||
|
// switches back to the PWA and the old network-first strategy went to the network.
|
||||||
|
//
|
||||||
|
// The background revalidation ensures the cache stays fresh for the NEXT navigation,
|
||||||
|
// so new deployments are picked up within one page visit. Navigation Preload is used
|
||||||
|
// for the background fetch when available (no extra latency cost).
|
||||||
if (isNavigationRequest(event.request)) {
|
if (isNavigationRequest(event.request)) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
const cache = await caches.open(CACHE_NAME);
|
||||||
// Use the preloaded response if available (fired during SW boot)
|
const cachedResponse = (await cache.match(event.request)) || (await cache.match('/'));
|
||||||
// This is the key mobile performance win - no waiting for SW to start
|
|
||||||
const preloadResponse = event.preloadResponse && (await event.preloadResponse);
|
|
||||||
if (preloadResponse) {
|
|
||||||
// Cache the preloaded response for offline use
|
|
||||||
if (preloadResponse.ok && preloadResponse.type === 'basic') {
|
|
||||||
const clone = preloadResponse.clone();
|
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
|
||||||
}
|
|
||||||
return preloadResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to regular fetch if Navigation Preload is not available
|
// Start a background fetch to update the cache for next time.
|
||||||
const response = await fetch(event.request);
|
// Uses Navigation Preload if available (already in-flight, no extra cost).
|
||||||
|
const updateCache = async () => {
|
||||||
|
try {
|
||||||
|
const preloadResponse = event.preloadResponse && (await event.preloadResponse);
|
||||||
|
const freshResponse = preloadResponse || (await fetch(event.request));
|
||||||
|
if (freshResponse.ok && freshResponse.type === 'basic') {
|
||||||
|
await cache.put(event.request, freshResponse.clone());
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
// Network failed — cache stays as-is, still fine for next visit
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cachedResponse) {
|
||||||
|
// Serve from cache immediately — no network delay, no reload flash.
|
||||||
|
// Update cache in background for the next visit.
|
||||||
|
event.waitUntil(updateCache());
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No cache yet (first visit) — must go to network
|
||||||
|
try {
|
||||||
|
const preloadResponse = event.preloadResponse && (await event.preloadResponse);
|
||||||
|
const response = preloadResponse || (await fetch(event.request));
|
||||||
if (response.ok && response.type === 'basic') {
|
if (response.ok && response.type === 'basic') {
|
||||||
const responseClone = response.clone();
|
// Use event.waitUntil to ensure the cache write completes before
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
// the service worker is terminated (mirrors the cached-path pattern).
|
||||||
cache.put(event.request, responseClone);
|
event.waitUntil(cache.put(event.request, response.clone()));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
// Offline: serve the cached app shell
|
return new Response('Offline', { status: 503 });
|
||||||
const cached = await caches.match('/');
|
|
||||||
return (
|
|
||||||
cached ||
|
|
||||||
(await caches.match(event.request)) ||
|
|
||||||
new Response('Offline', { status: 503 })
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
@@ -391,6 +577,13 @@ self.addEventListener('message', (event) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allow the main thread to explicitly activate a waiting service worker.
|
||||||
|
// This is used when the user acknowledges an "Update available" prompt,
|
||||||
|
// or during fresh page loads where it's safe to swap the SW.
|
||||||
|
if (event.data?.type === 'SKIP_WAITING') {
|
||||||
|
self.skipWaiting();
|
||||||
|
}
|
||||||
|
|
||||||
// Enable/disable mobile caching mode.
|
// Enable/disable mobile caching mode.
|
||||||
// Sent from main thread after detecting the device is mobile.
|
// Sent from main thread after detecting the device is mobile.
|
||||||
// This allows the SW to apply mobile-specific caching strategies.
|
// This allows the SW to apply mobile-specific caching strategies.
|
||||||
@@ -404,22 +597,30 @@ self.addEventListener('message', (event) => {
|
|||||||
// Called from the main thread after the initial render is complete,
|
// Called from the main thread after the initial render is complete,
|
||||||
// so we don't compete with critical resource loading on mobile.
|
// so we don't compete with critical resource loading on mobile.
|
||||||
if (event.data?.type === 'PRECACHE_ASSETS' && Array.isArray(event.data.urls)) {
|
if (event.data?.type === 'PRECACHE_ASSETS' && Array.isArray(event.data.urls)) {
|
||||||
caches.open(IMMUTABLE_CACHE).then((cache) => {
|
event.waitUntil(
|
||||||
event.data.urls.forEach((url) => {
|
caches.open(IMMUTABLE_CACHE).then((cache) => {
|
||||||
cache.match(url).then((existing) => {
|
return Promise.all(
|
||||||
if (!existing) {
|
event.data.urls.map((url) => {
|
||||||
fetch(url, { priority: 'low' })
|
// Use ignoreVary so we find assets regardless of the request mode
|
||||||
.then((response) => {
|
// they were originally cached with (cors vs no-cors).
|
||||||
if (response.ok) {
|
return cache.match(url, { ignoreVary: true }).then((existing) => {
|
||||||
cache.put(url, response);
|
if (!existing) {
|
||||||
}
|
// Fetch with cors mode to match how <script crossorigin> and
|
||||||
})
|
// <link rel="modulepreload" crossorigin> request these assets.
|
||||||
.catch(() => {
|
return fetch(url, { mode: 'cors', priority: 'low' })
|
||||||
// Silently ignore precache failures
|
.then((response) => {
|
||||||
});
|
if (response.ok) {
|
||||||
}
|
return cache.put(url, response);
|
||||||
});
|
}
|
||||||
});
|
})
|
||||||
});
|
.catch(() => {
|
||||||
|
// Silently ignore precache failures
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,4 +5,6 @@ export { FileBrowserDialog } from './file-browser-dialog';
|
|||||||
export { NewProjectModal } from './new-project-modal';
|
export { NewProjectModal } from './new-project-modal';
|
||||||
export { SandboxRejectionScreen } from './sandbox-rejection-screen';
|
export { SandboxRejectionScreen } from './sandbox-rejection-screen';
|
||||||
export { SandboxRiskDialog } from './sandbox-risk-dialog';
|
export { SandboxRiskDialog } from './sandbox-risk-dialog';
|
||||||
|
export { PRCommentResolutionDialog } from './pr-comment-resolution-dialog';
|
||||||
|
export type { PRCommentResolutionPRInfo } from './pr-comment-resolution-dialog';
|
||||||
export { WorkspacePickerModal } from './workspace-picker-modal';
|
export { WorkspacePickerModal } from './workspace-picker-modal';
|
||||||
|
|||||||
1095
apps/ui/src/components/dialogs/pr-comment-resolution-dialog.tsx
Normal file
1095
apps/ui/src/components/dialogs/pr-comment-resolution-dialog.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -103,7 +103,15 @@ export function ProjectSwitcher() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleProjectClick = useCallback(
|
const handleProjectClick = useCallback(
|
||||||
(project: Project) => {
|
async (project: Project) => {
|
||||||
|
try {
|
||||||
|
// Ensure .automaker directory structure exists before switching
|
||||||
|
await initializeProject(project.path);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize project during switch:', error);
|
||||||
|
// Continue with switch even if initialization fails -
|
||||||
|
// the project may already be initialized
|
||||||
|
}
|
||||||
setCurrentProject(project);
|
setCurrentProject(project);
|
||||||
// Navigate to board view when switching projects
|
// Navigate to board view when switching projects
|
||||||
navigate({ to: '/board' });
|
navigate({ to: '/board' });
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Folder,
|
Folder,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -15,6 +16,8 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { formatShortcut, type ThemeMode, useAppStore } from '@/store/app-store';
|
import { formatShortcut, type ThemeMode, useAppStore } from '@/store/app-store';
|
||||||
|
import { initializeProject } from '@/lib/project-init';
|
||||||
|
import type { Project } from '@/lib/electron';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -87,6 +90,22 @@ export function ProjectSelectorWithOptions({
|
|||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
|
// Wrap setCurrentProject to ensure .automaker is initialized before switching
|
||||||
|
const setCurrentProjectWithInit = useCallback(
|
||||||
|
async (p: Project) => {
|
||||||
|
try {
|
||||||
|
// Ensure .automaker directory structure exists before switching
|
||||||
|
await initializeProject(p.path);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize project during switch:', error);
|
||||||
|
// Continue with switch even if initialization fails -
|
||||||
|
// the project may already be initialized
|
||||||
|
}
|
||||||
|
setCurrentProject(p);
|
||||||
|
},
|
||||||
|
[setCurrentProject]
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
projectSearchQuery,
|
projectSearchQuery,
|
||||||
setProjectSearchQuery,
|
setProjectSearchQuery,
|
||||||
@@ -99,7 +118,7 @@ export function ProjectSelectorWithOptions({
|
|||||||
currentProject,
|
currentProject,
|
||||||
isProjectPickerOpen,
|
isProjectPickerOpen,
|
||||||
setIsProjectPickerOpen,
|
setIsProjectPickerOpen,
|
||||||
setCurrentProject,
|
setCurrentProject: setCurrentProjectWithInit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
|
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
|
||||||
@@ -107,6 +126,14 @@ export function ProjectSelectorWithOptions({
|
|||||||
const { globalTheme, setProjectTheme, setPreviewTheme, handlePreviewEnter, handlePreviewLeave } =
|
const { globalTheme, setProjectTheme, setPreviewTheme, handlePreviewEnter, handlePreviewLeave } =
|
||||||
useProjectTheme();
|
useProjectTheme();
|
||||||
|
|
||||||
|
const handleSelectProject = useCallback(
|
||||||
|
async (p: Project) => {
|
||||||
|
await setCurrentProjectWithInit(p);
|
||||||
|
setIsProjectPickerOpen(false);
|
||||||
|
},
|
||||||
|
[setCurrentProjectWithInit, setIsProjectPickerOpen]
|
||||||
|
);
|
||||||
|
|
||||||
if (!sidebarOpen || projects.length === 0) {
|
if (!sidebarOpen || projects.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -204,10 +231,7 @@ export function ProjectSelectorWithOptions({
|
|||||||
project={project}
|
project={project}
|
||||||
currentProjectId={currentProject?.id}
|
currentProjectId={currentProject?.id}
|
||||||
isHighlighted={index === selectedProjectIndex}
|
isHighlighted={index === selectedProjectIndex}
|
||||||
onSelect={(p) => {
|
onSelect={handleSelectProject}
|
||||||
setCurrentProject(p);
|
|
||||||
setIsProjectPickerOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMemo, useState, useEffect } from 'react';
|
|||||||
import type { NavigateOptions } from '@tanstack/react-router';
|
import type { NavigateOptions } from '@tanstack/react-router';
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
|
Folder,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Bot,
|
Bot,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
@@ -142,7 +143,7 @@ export function useNavigation({
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build project items - Terminal is conditionally included
|
// Build project items - Terminal and File Editor are conditionally included
|
||||||
const projectItems: NavItem[] = [
|
const projectItems: NavItem[] = [
|
||||||
{
|
{
|
||||||
id: 'board',
|
id: 'board',
|
||||||
@@ -156,6 +157,11 @@ export function useNavigation({
|
|||||||
icon: Network,
|
icon: Network,
|
||||||
shortcut: shortcuts.graph,
|
shortcut: shortcuts.graph,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'file-editor',
|
||||||
|
label: 'File Editor',
|
||||||
|
icon: Folder,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'agent',
|
id: 'agent',
|
||||||
label: 'Agent Runner',
|
label: 'Agent Runner',
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ interface UseProjectPickerProps {
|
|||||||
currentProject: Project | null;
|
currentProject: Project | null;
|
||||||
isProjectPickerOpen: boolean;
|
isProjectPickerOpen: boolean;
|
||||||
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||||
setCurrentProject: (project: Project) => void;
|
setCurrentProject: (project: Project) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useProjectPicker({
|
export function useProjectPicker({
|
||||||
@@ -92,9 +92,9 @@ export function useProjectPicker({
|
|||||||
}, [selectedProjectIndex, isProjectPickerOpen, filteredProjects, scrollToProject]);
|
}, [selectedProjectIndex, isProjectPickerOpen, filteredProjects, scrollToProject]);
|
||||||
|
|
||||||
// Handle selecting the currently highlighted project
|
// Handle selecting the currently highlighted project
|
||||||
const selectHighlightedProject = useCallback(() => {
|
const selectHighlightedProject = useCallback(async () => {
|
||||||
if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) {
|
if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) {
|
||||||
setCurrentProject(filteredProjects[selectedProjectIndex]);
|
await setCurrentProject(filteredProjects[selectedProjectIndex]);
|
||||||
setIsProjectPickerOpen(false);
|
setIsProjectPickerOpen(false);
|
||||||
}
|
}
|
||||||
}, [filteredProjects, selectedProjectIndex, setCurrentProject, setIsProjectPickerOpen]);
|
}, [filteredProjects, selectedProjectIndex, setCurrentProject, setIsProjectPickerOpen]);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export interface SortableProjectItemProps {
|
|||||||
project: Project;
|
project: Project;
|
||||||
currentProjectId: string | undefined;
|
currentProjectId: string | undefined;
|
||||||
isHighlighted: boolean;
|
isHighlighted: boolean;
|
||||||
onSelect: (project: Project) => void;
|
onSelect: (project: Project) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThemeMenuItemProps {
|
export interface ThemeMenuItemProps {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ interface BranchAutocompleteProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
error?: boolean;
|
error?: boolean;
|
||||||
|
allowCreate?: boolean; // Whether to allow creating new branches (default: true)
|
||||||
|
emptyMessage?: string; // Message shown when no branches match the search
|
||||||
'data-testid'?: string;
|
'data-testid'?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,6 +25,8 @@ export function BranchAutocomplete({
|
|||||||
className,
|
className,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
error = false,
|
error = false,
|
||||||
|
allowCreate = true,
|
||||||
|
emptyMessage = 'No branches found.',
|
||||||
'data-testid': testId,
|
'data-testid': testId,
|
||||||
}: BranchAutocompleteProps) {
|
}: BranchAutocompleteProps) {
|
||||||
// Always include "main" at the top of suggestions
|
// Always include "main" at the top of suggestions
|
||||||
@@ -52,13 +56,13 @@ export function BranchAutocomplete({
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
options={branchOptions}
|
options={branchOptions}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
searchPlaceholder="Search or type new branch..."
|
searchPlaceholder={allowCreate ? 'Search or type new branch...' : 'Search branches...'}
|
||||||
emptyMessage="No branches found."
|
emptyMessage={emptyMessage}
|
||||||
className={className}
|
className={className}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
error={error}
|
error={error}
|
||||||
icon={GitBranch}
|
icon={GitBranch}
|
||||||
allowCreate
|
allowCreate={allowCreate}
|
||||||
createLabel={(v) => `Create "${v}"`}
|
createLabel={(v) => `Create "${v}"`}
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
itemTestIdPrefix="branch-option"
|
itemTestIdPrefix="branch-option"
|
||||||
|
|||||||
@@ -83,12 +83,25 @@ export type DialogContentProps = Omit<
|
|||||||
> & {
|
> & {
|
||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
/** When true, the default sm:max-w-2xl is not applied, allowing className to set max-width. */
|
||||||
|
noDefaultMaxWidth?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
|
const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
|
||||||
({ className, children, showCloseButton = true, compact = false, ...props }, ref) => {
|
(
|
||||||
// Check if className contains a custom max-width
|
{
|
||||||
const hasCustomMaxWidth = typeof className === 'string' && className.includes('max-w-');
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
compact = false,
|
||||||
|
noDefaultMaxWidth = false,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
// Check if className contains a custom max-width (fallback heuristic)
|
||||||
|
const hasCustomMaxWidth =
|
||||||
|
noDefaultMaxWidth || (typeof className === 'string' && className.includes('max-w-'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogPortal data-slot="dialog-portal">
|
||||||
@@ -97,8 +110,10 @@ const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
|
'fixed left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
|
||||||
'flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100dvh-4rem)]',
|
'top-[calc(50%_+_(env(safe-area-inset-top,0px)_-_env(safe-area-inset-bottom,0px))_/_2)]',
|
||||||
|
'flex flex-col w-full max-w-[calc(100%-2rem)]',
|
||||||
|
'max-h-[calc(100dvh_-_4rem_-_env(safe-area-inset-top,0px)_-_env(safe-area-inset-bottom,0px))]',
|
||||||
'bg-card border border-border rounded-xl shadow-2xl',
|
'bg-card border border-border rounded-xl shadow-2xl',
|
||||||
// Premium shadow
|
// Premium shadow
|
||||||
'shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)]',
|
'shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)]',
|
||||||
@@ -108,7 +123,11 @@ const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
|
|||||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
'data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]',
|
'data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]',
|
||||||
'duration-200',
|
'duration-200',
|
||||||
compact ? 'max-w-4xl p-4' : !hasCustomMaxWidth ? 'sm:max-w-2xl p-6' : 'p-6',
|
compact
|
||||||
|
? 'max-w-[min(56rem,calc(100%-2rem))] p-4'
|
||||||
|
: !hasCustomMaxWidth
|
||||||
|
? 'sm:max-w-2xl p-6'
|
||||||
|
: 'p-6',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -118,13 +137,13 @@ const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
|
|||||||
<DialogClosePrimitive
|
<DialogClosePrimitive
|
||||||
data-slot="dialog-close"
|
data-slot="dialog-close"
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute rounded-lg opacity-60 transition-all duration-200 cursor-pointer',
|
'absolute z-10 rounded-lg opacity-60 transition-all duration-200 cursor-pointer',
|
||||||
'hover:opacity-100 hover:bg-muted',
|
'hover:opacity-100 hover:bg-muted',
|
||||||
'focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-none',
|
'focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-none',
|
||||||
'disabled:pointer-events-none disabled:cursor-not-allowed',
|
'disabled:pointer-events-none disabled:cursor-not-allowed',
|
||||||
'[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4',
|
'[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4',
|
||||||
'p-1.5',
|
'p-2 min-w-[2.5rem] min-h-[2.5rem] flex items-center justify-center',
|
||||||
compact ? 'top-2 right-3' : 'top-4 right-4'
|
compact ? 'top-2 right-2' : 'top-3 right-3'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<XIcon />
|
<XIcon />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
GitMerge,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Plus,
|
Plus,
|
||||||
Minus,
|
Minus,
|
||||||
@@ -20,7 +21,7 @@ import { Button } from './button';
|
|||||||
import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries';
|
import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { FileStatus } from '@/types/electron';
|
import type { FileStatus, MergeStateInfo } from '@/types/electron';
|
||||||
|
|
||||||
interface GitDiffPanelProps {
|
interface GitDiffPanelProps {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
@@ -318,6 +319,86 @@ function StagingBadge({ state }: { state: 'staged' | 'unstaged' | 'partial' }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MergeBadge({ mergeType }: { mergeType?: string }) {
|
||||||
|
if (!mergeType) return null;
|
||||||
|
|
||||||
|
const label = (() => {
|
||||||
|
switch (mergeType) {
|
||||||
|
case 'both-modified':
|
||||||
|
return 'Both Modified';
|
||||||
|
case 'added-by-us':
|
||||||
|
return 'Added by Us';
|
||||||
|
case 'added-by-them':
|
||||||
|
return 'Added by Them';
|
||||||
|
case 'deleted-by-us':
|
||||||
|
return 'Deleted by Us';
|
||||||
|
case 'deleted-by-them':
|
||||||
|
return 'Deleted by Them';
|
||||||
|
case 'both-added':
|
||||||
|
return 'Both Added';
|
||||||
|
case 'both-deleted':
|
||||||
|
return 'Both Deleted';
|
||||||
|
case 'merged':
|
||||||
|
return 'Merged';
|
||||||
|
default:
|
||||||
|
return 'Merge';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded border font-medium bg-purple-500/15 text-purple-400 border-purple-500/30 inline-flex items-center gap-1">
|
||||||
|
<GitMerge className="w-2.5 h-2.5" />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MergeStateBanner({ mergeState }: { mergeState: MergeStateInfo }) {
|
||||||
|
// Completed merge commit (HEAD is a merge)
|
||||||
|
if (mergeState.isMergeCommit && !mergeState.isMerging) {
|
||||||
|
return (
|
||||||
|
<div className="mx-4 mt-3 flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20">
|
||||||
|
<GitMerge className="w-4 h-4 text-purple-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-medium text-purple-400">Merge commit</span>
|
||||||
|
<span className="text-purple-400/80 ml-1">
|
||||||
|
— {mergeState.mergeAffectedFiles.length} file
|
||||||
|
{mergeState.mergeAffectedFiles.length !== 1 ? 's' : ''} changed in merge
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-progress merge/rebase/cherry-pick
|
||||||
|
const operationLabel =
|
||||||
|
mergeState.mergeOperationType === 'cherry-pick'
|
||||||
|
? 'Cherry-pick'
|
||||||
|
: mergeState.mergeOperationType === 'rebase'
|
||||||
|
? 'Rebase'
|
||||||
|
: 'Merge';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-4 mt-3 flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20">
|
||||||
|
<GitMerge className="w-4 h-4 text-purple-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-medium text-purple-400">{operationLabel} in progress</span>
|
||||||
|
{mergeState.conflictFiles.length > 0 ? (
|
||||||
|
<span className="text-purple-400/80 ml-1">
|
||||||
|
— {mergeState.conflictFiles.length} file
|
||||||
|
{mergeState.conflictFiles.length !== 1 ? 's' : ''} with conflicts
|
||||||
|
</span>
|
||||||
|
) : mergeState.isCleanMerge ? (
|
||||||
|
<span className="text-purple-400/80 ml-1">
|
||||||
|
— Clean merge, {mergeState.mergeAffectedFiles.length} file
|
||||||
|
{mergeState.mergeAffectedFiles.length !== 1 ? 's' : ''} affected
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FileDiffSection({
|
function FileDiffSection({
|
||||||
fileDiff,
|
fileDiff,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
@@ -348,16 +429,31 @@ function FileDiffSection({
|
|||||||
|
|
||||||
const stagingState = fileStatus ? getStagingState(fileStatus) : undefined;
|
const stagingState = fileStatus ? getStagingState(fileStatus) : undefined;
|
||||||
|
|
||||||
|
const isMergeFile = fileStatus?.isMergeAffected;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-border rounded-lg overflow-hidden">
|
<div
|
||||||
<div className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card hover:bg-accent/50 transition-colors">
|
className={cn(
|
||||||
|
'border rounded-lg overflow-hidden',
|
||||||
|
isMergeFile ? 'border-purple-500/40' : 'border-border'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 flex flex-col gap-1 text-left transition-colors sm:flex-row sm:items-center sm:gap-2',
|
||||||
|
isMergeFile ? 'bg-purple-500/5 hover:bg-purple-500/10' : 'bg-card hover:bg-accent/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* File name row */}
|
||||||
<button onClick={onToggle} className="flex items-center gap-2 flex-1 min-w-0 text-left">
|
<button onClick={onToggle} className="flex items-center gap-2 flex-1 min-w-0 text-left">
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
{fileStatus ? (
|
{isMergeFile ? (
|
||||||
|
<GitMerge className="w-4 h-4 text-purple-500 flex-shrink-0" />
|
||||||
|
) : fileStatus ? (
|
||||||
getFileIcon(fileStatus.status)
|
getFileIcon(fileStatus.status)
|
||||||
) : (
|
) : (
|
||||||
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
@@ -367,7 +463,9 @@ function FileDiffSection({
|
|||||||
className="flex-1 text-sm font-mono text-foreground"
|
className="flex-1 text-sm font-mono text-foreground"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
{/* Indicators & staging row */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0 pl-6 sm:pl-0">
|
||||||
|
{fileStatus?.isMergeAffected && <MergeBadge mergeType={fileStatus.mergeType} />}
|
||||||
{enableStaging && stagingState && <StagingBadge state={stagingState} />}
|
{enableStaging && stagingState && <StagingBadge state={stagingState} />}
|
||||||
{fileDiff.isNew && (
|
{fileDiff.isNew && (
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">
|
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">
|
||||||
@@ -481,9 +579,10 @@ export function GitDiffPanel({
|
|||||||
const isLoading = useWorktrees ? isLoadingWorktree : isLoadingGit;
|
const isLoading = useWorktrees ? isLoadingWorktree : isLoadingGit;
|
||||||
const queryError = useWorktrees ? worktreeError : gitError;
|
const queryError = useWorktrees ? worktreeError : gitError;
|
||||||
|
|
||||||
// Extract files and diff content from the data
|
// Extract files, diff content, and merge state from the data
|
||||||
const files: FileStatus[] = diffsData?.files ?? [];
|
const files: FileStatus[] = diffsData?.files ?? [];
|
||||||
const diffContent = diffsData?.diff ?? '';
|
const diffContent = diffsData?.diff ?? '';
|
||||||
|
const mergeState: MergeStateInfo | undefined = diffsData?.mergeState;
|
||||||
const error = queryError
|
const error = queryError
|
||||||
? queryError instanceof Error
|
? queryError instanceof Error
|
||||||
? queryError.message
|
? queryError.message
|
||||||
@@ -493,8 +592,6 @@ export function GitDiffPanel({
|
|||||||
// Refetch function
|
// Refetch function
|
||||||
const loadDiffs = useWorktrees ? refetchWorktree : refetchGit;
|
const loadDiffs = useWorktrees ? refetchWorktree : refetchGit;
|
||||||
|
|
||||||
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
|
|
||||||
|
|
||||||
// Build a map from file path to FileStatus for quick lookup
|
// Build a map from file path to FileStatus for quick lookup
|
||||||
const fileStatusMap = useMemo(() => {
|
const fileStatusMap = useMemo(() => {
|
||||||
const map = new Map<string, FileStatus>();
|
const map = new Map<string, FileStatus>();
|
||||||
@@ -504,6 +601,24 @@ export function GitDiffPanel({
|
|||||||
return map;
|
return map;
|
||||||
}, [files]);
|
}, [files]);
|
||||||
|
|
||||||
|
const parsedDiffs = useMemo(() => {
|
||||||
|
const diffs = parseDiff(diffContent);
|
||||||
|
// Sort: merge-affected files first, then preserve original order
|
||||||
|
if (mergeState?.isMerging || mergeState?.isMergeCommit) {
|
||||||
|
const mergeSet = new Set(mergeState.mergeAffectedFiles);
|
||||||
|
diffs.sort((a, b) => {
|
||||||
|
const aIsMerge =
|
||||||
|
mergeSet.has(a.filePath) || (fileStatusMap.get(a.filePath)?.isMergeAffected ?? false);
|
||||||
|
const bIsMerge =
|
||||||
|
mergeSet.has(b.filePath) || (fileStatusMap.get(b.filePath)?.isMergeAffected ?? false);
|
||||||
|
if (aIsMerge && !bIsMerge) return -1;
|
||||||
|
if (!aIsMerge && bIsMerge) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return diffs;
|
||||||
|
}, [diffContent, mergeState, fileStatusMap]);
|
||||||
|
|
||||||
const toggleFile = (filePath: string) => {
|
const toggleFile = (filePath: string) => {
|
||||||
setExpandedFiles((prev) => {
|
setExpandedFiles((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -680,6 +795,18 @@ export function GitDiffPanel({
|
|||||||
);
|
);
|
||||||
}, [worktreePath, projectPath, useWorktrees, enableStaging, files, executeStagingAction]);
|
}, [worktreePath, projectPath, useWorktrees, enableStaging, files, executeStagingAction]);
|
||||||
|
|
||||||
|
// Compute merge summary
|
||||||
|
const mergeSummary = useMemo(() => {
|
||||||
|
const mergeFiles = files.filter((f) => f.isMergeAffected);
|
||||||
|
if (mergeFiles.length === 0) return null;
|
||||||
|
return {
|
||||||
|
total: mergeFiles.length,
|
||||||
|
conflicted: mergeFiles.filter(
|
||||||
|
(f) => f.mergeType === 'both-modified' || f.mergeType === 'both-added'
|
||||||
|
).length,
|
||||||
|
};
|
||||||
|
}, [files]);
|
||||||
|
|
||||||
// Compute staging summary
|
// Compute staging summary
|
||||||
const stagingSummary = useMemo(() => {
|
const stagingSummary = useMemo(() => {
|
||||||
if (!enableStaging) return null;
|
if (!enableStaging) return null;
|
||||||
@@ -774,9 +901,14 @@ export function GitDiffPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
|
{/* Merge state banner */}
|
||||||
|
{(mergeState?.isMerging || mergeState?.isMergeCommit) && (
|
||||||
|
<MergeStateBanner mergeState={mergeState} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Summary bar */}
|
{/* Summary bar */}
|
||||||
<div className="p-4 pb-2 border-b border-border-glass">
|
<div className="p-4 pb-2 border-b border-border-glass">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex items-center gap-4 flex-wrap">
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
{(() => {
|
{(() => {
|
||||||
// Group files by status
|
// Group files by status
|
||||||
@@ -797,7 +929,7 @@ export function GitDiffPanel({
|
|||||||
{} as Record<string, { count: number; statusText: string; files: string[] }>
|
{} as Record<string, { count: number; statusText: string; files: string[] }>
|
||||||
);
|
);
|
||||||
|
|
||||||
return Object.entries(statusGroups).map(([status, group]) => (
|
const groups = Object.entries(statusGroups).map(([status, group]) => (
|
||||||
<div
|
<div
|
||||||
key={status}
|
key={status}
|
||||||
className="flex items-center gap-1.5"
|
className="flex items-center gap-1.5"
|
||||||
@@ -815,9 +947,27 @@ export function GitDiffPanel({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Add merge group indicator if merge files exist
|
||||||
|
if (mergeSummary) {
|
||||||
|
groups.unshift(
|
||||||
|
<div
|
||||||
|
key="merge"
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
data-testid="git-status-group-merge"
|
||||||
|
>
|
||||||
|
<GitMerge className="w-4 h-4 text-purple-500" />
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded border font-medium bg-purple-500/20 text-purple-400 border-purple-500/30">
|
||||||
|
{mergeSummary.total} Merge
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
{enableStaging && stagingSummary && (
|
{enableStaging && stagingSummary && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -877,7 +1027,7 @@ export function GitDiffPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="flex items-center gap-4 text-sm mt-2">
|
<div className="flex items-center gap-4 text-sm mt-2 flex-wrap">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{files.length} {files.length === 1 ? 'file' : 'files'} changed
|
{files.length} {files.length === 1 ? 'file' : 'files'} changed
|
||||||
</span>
|
</span>
|
||||||
@@ -905,7 +1055,7 @@ export function GitDiffPanel({
|
|||||||
fileDiff={fileDiff}
|
fileDiff={fileDiff}
|
||||||
isExpanded={expandedFiles.has(fileDiff.filePath)}
|
isExpanded={expandedFiles.has(fileDiff.filePath)}
|
||||||
onToggle={() => toggleFile(fileDiff.filePath)}
|
onToggle={() => toggleFile(fileDiff.filePath)}
|
||||||
fileStatus={enableStaging ? fileStatusMap.get(fileDiff.filePath) : undefined}
|
fileStatus={fileStatusMap.get(fileDiff.filePath)}
|
||||||
enableStaging={enableStaging}
|
enableStaging={enableStaging}
|
||||||
onStage={enableStaging ? handleStageFile : undefined}
|
onStage={enableStaging ? handleStageFile : undefined}
|
||||||
onUnstage={enableStaging ? handleUnstageFile : undefined}
|
onUnstage={enableStaging ? handleUnstageFile : undefined}
|
||||||
@@ -917,55 +1067,75 @@ export function GitDiffPanel({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{files.map((file) => {
|
{files.map((file) => {
|
||||||
const stagingState = getStagingState(file);
|
const stagingState = getStagingState(file);
|
||||||
|
const isFileMerge = file.isMergeAffected;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={file.path}
|
key={file.path}
|
||||||
className="border border-border rounded-lg overflow-hidden"
|
className={cn(
|
||||||
|
'border rounded-lg overflow-hidden',
|
||||||
|
isFileMerge ? 'border-purple-500/40' : 'border-border'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card">
|
<div
|
||||||
{getFileIcon(file.status)}
|
className={cn(
|
||||||
<TruncatedFilePath
|
'w-full px-3 py-2 flex flex-col gap-1 text-left sm:flex-row sm:items-center sm:gap-2',
|
||||||
path={file.path}
|
isFileMerge ? 'bg-purple-500/5 hover:bg-purple-500/10' : 'bg-card'
|
||||||
className="flex-1 text-sm font-mono text-foreground"
|
|
||||||
/>
|
|
||||||
{enableStaging && <StagingBadge state={stagingState} />}
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'text-xs px-1.5 py-0.5 rounded border font-medium',
|
|
||||||
getStatusBadgeColor(file.status)
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{getStatusDisplayName(file.status)}
|
|
||||||
</span>
|
|
||||||
{enableStaging && (
|
|
||||||
<div className="flex items-center gap-1 ml-1">
|
|
||||||
{stagingInProgress.has(file.path) ? (
|
|
||||||
<Spinner size="sm" />
|
|
||||||
) : stagingState === 'staged' || stagingState === 'partial' ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 px-2 text-xs"
|
|
||||||
onClick={() => handleUnstageFile(file.path)}
|
|
||||||
title="Unstage file"
|
|
||||||
>
|
|
||||||
<Minus className="w-3 h-3 mr-1" />
|
|
||||||
Unstage
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 px-2 text-xs"
|
|
||||||
onClick={() => handleStageFile(file.path)}
|
|
||||||
title="Stage file"
|
|
||||||
>
|
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
|
||||||
Stage
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
{/* File name row */}
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
{isFileMerge ? (
|
||||||
|
<GitMerge className="w-4 h-4 text-purple-500 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
getFileIcon(file.status)
|
||||||
|
)}
|
||||||
|
<TruncatedFilePath
|
||||||
|
path={file.path}
|
||||||
|
className="flex-1 text-sm font-mono text-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Indicators & staging row */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0 pl-6 sm:pl-0">
|
||||||
|
{isFileMerge && <MergeBadge mergeType={file.mergeType} />}
|
||||||
|
{enableStaging && <StagingBadge state={stagingState} />}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-1.5 py-0.5 rounded border font-medium',
|
||||||
|
getStatusBadgeColor(file.status)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getStatusDisplayName(file.status)}
|
||||||
|
</span>
|
||||||
|
{enableStaging && (
|
||||||
|
<div className="flex items-center gap-1 ml-1">
|
||||||
|
{stagingInProgress.has(file.path) ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : stagingState === 'staged' || stagingState === 'partial' ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
onClick={() => void handleUnstageFile(file.path)}
|
||||||
|
title="Unstage file"
|
||||||
|
>
|
||||||
|
<Minus className="w-3 h-3 mr-1" />
|
||||||
|
Unstage
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
onClick={() => void handleStageFile(file.path)}
|
||||||
|
title="Stage file"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3 mr-1" />
|
||||||
|
Stage
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-3 text-sm text-muted-foreground bg-background border-t border-border">
|
<div className="px-4 py-3 text-sm text-muted-foreground bg-background border-t border-border">
|
||||||
{file.status === '?' ? (
|
{file.status === '?' ? (
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ const SelectItem = React.forwardRef<
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
{description ? (
|
{description ? (
|
||||||
<div className="flex flex-col items-start">
|
<div className="flex flex-col items-start w-full min-w-0">
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
{description}
|
{description}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ function TestLogsPanelContent({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12">
|
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12 dialog-compact-header-mobile">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<DialogTitle className="flex items-center gap-2 text-base">
|
<DialogTitle className="flex items-center gap-2 text-base">
|
||||||
<FlaskConical className="w-4 h-4 text-primary" />
|
<FlaskConical className="w-4 h-4 text-primary" />
|
||||||
@@ -410,7 +410,7 @@ export function TestLogsPanel({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="w-full h-full max-w-full max-h-full sm:w-[70vw] sm:max-w-[900px] sm:max-h-[85vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col gap-0 p-0 overflow-hidden"
|
className="w-full h-full max-w-full max-h-full sm:w-[70vw] sm:max-w-[900px] sm:max-h-[85vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col gap-0 p-0 overflow-hidden dialog-fullscreen-mobile"
|
||||||
data-testid="test-logs-panel"
|
data-testid="test-logs-panel"
|
||||||
compact
|
compact
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export function AgentView() {
|
|||||||
|
|
||||||
// Ref for quick create session function from SessionManager
|
// Ref for quick create session function from SessionManager
|
||||||
const quickCreateSessionRef = useRef<(() => Promise<void>) | null>(null);
|
const quickCreateSessionRef = useRef<(() => Promise<void>) | null>(null);
|
||||||
|
// Guard to prevent concurrent invocations of handleCreateSessionFromEmptyState
|
||||||
|
const createSessionInFlightRef = useRef(false);
|
||||||
|
|
||||||
// Session management hook
|
// Session management hook
|
||||||
const { currentSessionId, handleSelectSession } = useAgentSession({
|
const { currentSessionId, handleSelectSession } = useAgentSession({
|
||||||
@@ -130,6 +132,51 @@ export function AgentView() {
|
|||||||
await clearHistory();
|
await clearHistory();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle creating a new session from empty state.
|
||||||
|
// On mobile the SessionManager may be unmounted (hidden), clearing the ref.
|
||||||
|
// In that case, show it first and wait for the component to mount and
|
||||||
|
// re-populate quickCreateSessionRef before invoking it.
|
||||||
|
//
|
||||||
|
// A single requestAnimationFrame isn't always sufficient — React concurrent
|
||||||
|
// mode or slow devices may not have committed the SessionManager mount by
|
||||||
|
// the next frame. We use a double-RAF with a short retry loop to wait more
|
||||||
|
// robustly for the ref to be populated.
|
||||||
|
const handleCreateSessionFromEmptyState = useCallback(async () => {
|
||||||
|
if (createSessionInFlightRef.current) return;
|
||||||
|
createSessionInFlightRef.current = true;
|
||||||
|
try {
|
||||||
|
let createFn = quickCreateSessionRef.current;
|
||||||
|
if (!createFn) {
|
||||||
|
// SessionManager is likely unmounted on mobile — show it so it mounts
|
||||||
|
setShowSessionManager(true);
|
||||||
|
// Wait for mount: double RAF + retry loop (handles concurrent mode & slow devices)
|
||||||
|
const MAX_RETRIES = 5;
|
||||||
|
for (let i = 0; i < MAX_RETRIES; i++) {
|
||||||
|
await new Promise<void>((r) =>
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => r()))
|
||||||
|
);
|
||||||
|
createFn = quickCreateSessionRef.current;
|
||||||
|
if (createFn) break;
|
||||||
|
// Small delay between retries to give React time to commit
|
||||||
|
if (i < MAX_RETRIES - 1) {
|
||||||
|
await new Promise<void>((r) => setTimeout(r, 50));
|
||||||
|
createFn = quickCreateSessionRef.current;
|
||||||
|
if (createFn) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (createFn) {
|
||||||
|
await createFn();
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
'[AgentView] quickCreateSessionRef was not populated after retries — SessionManager may not have mounted'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
createSessionInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Auto-focus input when session is selected/changed
|
// Auto-focus input when session is selected/changed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentSessionId && inputRef.current) {
|
if (currentSessionId && inputRef.current) {
|
||||||
@@ -177,7 +224,7 @@ export function AgentView() {
|
|||||||
|
|
||||||
{/* Session Manager Sidebar */}
|
{/* Session Manager Sidebar */}
|
||||||
{showSessionManager && currentProject && (
|
{showSessionManager && currentProject && (
|
||||||
<div className="fixed inset-y-0 left-0 w-72 z-30 lg:relative lg:w-80 lg:z-auto border-r border-border shrink-0 bg-card">
|
<div className="fixed inset-y-0 left-0 w-72 z-30 pt-[env(safe-area-inset-top,0px)] lg:pt-0 lg:relative lg:w-80 lg:z-auto border-r border-border shrink-0 bg-card">
|
||||||
<SessionManager
|
<SessionManager
|
||||||
currentSessionId={currentSessionId}
|
currentSessionId={currentSessionId}
|
||||||
onSelectSession={handleSelectSession}
|
onSelectSession={handleSelectSession}
|
||||||
@@ -212,6 +259,7 @@ export function AgentView() {
|
|||||||
messagesContainerRef={messagesContainerRef}
|
messagesContainerRef={messagesContainerRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
onShowSessionManager={() => setShowSessionManager(true)}
|
onShowSessionManager={() => setShowSessionManager(true)}
|
||||||
|
onCreateSession={handleCreateSessionFromEmptyState}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Input Area */}
|
{/* Input Area */}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface ChatAreaProps {
|
|||||||
messagesContainerRef: React.RefObject<HTMLDivElement | null>;
|
messagesContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||||
onScroll: () => void;
|
onScroll: () => void;
|
||||||
onShowSessionManager: () => void;
|
onShowSessionManager: () => void;
|
||||||
|
onCreateSession?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatArea({
|
export function ChatArea({
|
||||||
@@ -29,12 +30,14 @@ export function ChatArea({
|
|||||||
messagesContainerRef,
|
messagesContainerRef,
|
||||||
onScroll,
|
onScroll,
|
||||||
onShowSessionManager,
|
onShowSessionManager,
|
||||||
|
onCreateSession,
|
||||||
}: ChatAreaProps) {
|
}: ChatAreaProps) {
|
||||||
if (!currentSessionId) {
|
if (!currentSessionId) {
|
||||||
return (
|
return (
|
||||||
<NoSessionState
|
<NoSessionState
|
||||||
showSessionManager={showSessionManager}
|
showSessionManager={showSessionManager}
|
||||||
onShowSessionManager={onShowSessionManager}
|
onShowSessionManager={onShowSessionManager}
|
||||||
|
onCreateSession={onCreateSession}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Sparkles, Bot, PanelLeft } from 'lucide-react';
|
import { Sparkles, Bot, PanelLeft, Plus } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
export function NoProjectState() {
|
export function NoProjectState() {
|
||||||
@@ -23,9 +23,14 @@ export function NoProjectState() {
|
|||||||
interface NoSessionStateProps {
|
interface NoSessionStateProps {
|
||||||
showSessionManager: boolean;
|
showSessionManager: boolean;
|
||||||
onShowSessionManager: () => void;
|
onShowSessionManager: () => void;
|
||||||
|
onCreateSession?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NoSessionState({ showSessionManager, onShowSessionManager }: NoSessionStateProps) {
|
export function NoSessionState({
|
||||||
|
showSessionManager,
|
||||||
|
onShowSessionManager,
|
||||||
|
onCreateSession,
|
||||||
|
}: NoSessionStateProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex-1 flex items-center justify-center bg-background"
|
className="flex-1 flex items-center justify-center bg-background"
|
||||||
@@ -39,10 +44,23 @@ export function NoSessionState({ showSessionManager, onShowSessionManager }: NoS
|
|||||||
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">
|
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">
|
||||||
Create or select a session to start chatting with the AI agent
|
Create or select a session to start chatting with the AI agent
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={onShowSessionManager} variant="outline" className="gap-2">
|
<div className="flex items-center justify-center gap-3">
|
||||||
<PanelLeft className="w-4 h-4" />
|
{onCreateSession && (
|
||||||
{showSessionManager ? 'View' : 'Show'} Sessions
|
<Button
|
||||||
</Button>
|
onClick={onCreateSession}
|
||||||
|
variant="default"
|
||||||
|
className="gap-2"
|
||||||
|
data-testid="empty-state-new-session-button"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
New Session
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={onShowSessionManager} variant="outline" className="gap-2">
|
||||||
|
<PanelLeft className="w-4 h-4" />
|
||||||
|
{showSessionManager ? 'View' : 'Show'} Sessions
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import type { PointerEvent as ReactPointerEvent } from 'react';
|
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -34,7 +34,10 @@ import type { BacklogPlanResult, FeatureStatusWithPipeline } from '@automaker/ty
|
|||||||
import { pathsEqual } from '@/lib/utils';
|
import { pathsEqual } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
|
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import {
|
||||||
|
PRCommentResolutionDialog,
|
||||||
|
type PRCommentResolutionPRInfo,
|
||||||
|
} from '@/components/dialogs/pr-comment-resolution-dialog';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { useAutoMode } from '@/hooks/use-auto-mode';
|
import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||||
import { resolveModelString } from '@automaker/model-resolver';
|
import { resolveModelString } from '@automaker/model-resolver';
|
||||||
@@ -185,6 +188,9 @@ export function BoardView() {
|
|||||||
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
|
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
|
||||||
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
|
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
|
||||||
const [showMergeRebaseDialog, setShowMergeRebaseDialog] = useState(false);
|
const [showMergeRebaseDialog, setShowMergeRebaseDialog] = useState(false);
|
||||||
|
const [showPRCommentDialog, setShowPRCommentDialog] = useState(false);
|
||||||
|
const [prCommentDialogPRInfo, setPRCommentDialogPRInfo] =
|
||||||
|
useState<PRCommentResolutionPRInfo | null>(null);
|
||||||
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<WorktreeInfo | null>(
|
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<WorktreeInfo | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -376,10 +382,20 @@ export function BoardView() {
|
|||||||
return specificTargetCollisions;
|
return specificTargetCollisions;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: Columns
|
// Priority 2: Columns (including column headers and pipeline columns)
|
||||||
const columnCollisions = pointerCollisions.filter((collision: Collision) =>
|
const columnCollisions = pointerCollisions.filter((collision: Collision) => {
|
||||||
COLUMNS.some((col) => col.id === collision.id)
|
const colId = String(collision.id);
|
||||||
);
|
// Direct column ID match (e.g. 'backlog', 'in_progress')
|
||||||
|
if (COLUMNS.some((col) => col.id === colId)) return true;
|
||||||
|
// Column header droppable (e.g. 'column-header-backlog')
|
||||||
|
if (colId.startsWith('column-header-')) {
|
||||||
|
const baseId = colId.replace('column-header-', '');
|
||||||
|
return COLUMNS.some((col) => col.id === baseId) || baseId.startsWith('pipeline_');
|
||||||
|
}
|
||||||
|
// Pipeline column IDs (e.g. 'pipeline_tests')
|
||||||
|
if (colId.startsWith('pipeline_')) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
// If we found a column collision, use that
|
// If we found a column collision, use that
|
||||||
if (columnCollisions.length > 0) {
|
if (columnCollisions.length > 0) {
|
||||||
@@ -420,6 +436,29 @@ export function BoardView() {
|
|||||||
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
|
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
|
||||||
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
|
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
|
||||||
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
|
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
|
||||||
|
|
||||||
|
// Track the previous worktree path to detect worktree switches
|
||||||
|
const prevWorktreePathRef = useRef<string | null | undefined>(undefined);
|
||||||
|
|
||||||
|
// When the active worktree changes, invalidate feature queries to ensure
|
||||||
|
// feature cards (especially their todo lists / planSpec tasks) render fresh data.
|
||||||
|
// Without this, cards that unmount when filtered out and remount when the user
|
||||||
|
// switches back may show stale or missing todo list data until the next polling cycle.
|
||||||
|
useEffect(() => {
|
||||||
|
// Skip the initial mount (prevWorktreePathRef starts as undefined)
|
||||||
|
if (prevWorktreePathRef.current === undefined) {
|
||||||
|
prevWorktreePathRef.current = currentWorktreePath;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only invalidate when the worktree actually changed
|
||||||
|
if (prevWorktreePathRef.current !== currentWorktreePath && currentProject?.path) {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(currentProject.path),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
prevWorktreePathRef.current = currentWorktreePath;
|
||||||
|
}, [currentWorktreePath, currentProject?.path, queryClient]);
|
||||||
|
|
||||||
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
|
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
|
||||||
const worktrees = useMemo(
|
const worktrees = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -880,7 +919,8 @@ export function BoardView() {
|
|||||||
// Capture existing feature IDs before adding
|
// Capture existing feature IDs before adding
|
||||||
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
|
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
|
||||||
try {
|
try {
|
||||||
await handleAddFeature(featureData);
|
// Create feature directly with in_progress status to avoid brief backlog flash
|
||||||
|
await handleAddFeature({ ...featureData, initialStatus: 'in_progress' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to create feature:', error);
|
logger.error('Failed to create feature:', error);
|
||||||
toast.error('Failed to create feature', {
|
toast.error('Failed to create feature', {
|
||||||
@@ -894,7 +934,14 @@ export function BoardView() {
|
|||||||
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
|
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
|
||||||
|
|
||||||
if (newFeature) {
|
if (newFeature) {
|
||||||
await handleStartImplementation(newFeature);
|
try {
|
||||||
|
await handleStartImplementation(newFeature);
|
||||||
|
} catch (startError) {
|
||||||
|
logger.error('Failed to start implementation for feature:', startError);
|
||||||
|
toast.error('Failed to start feature implementation', {
|
||||||
|
description: startError instanceof Error ? startError.message : 'An error occurred',
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.error('Could not find newly created feature to start it automatically.');
|
logger.error('Could not find newly created feature to start it automatically.');
|
||||||
toast.error('Failed to auto-start feature', {
|
toast.error('Failed to auto-start feature', {
|
||||||
@@ -905,26 +952,39 @@ export function BoardView() {
|
|||||||
[handleAddFeature, handleStartImplementation]
|
[handleAddFeature, handleStartImplementation]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handler for addressing PR comments - creates a feature and starts it automatically
|
// Handler for managing PR comments - opens the PR Comment Resolution dialog
|
||||||
const handleAddressPRComments = useCallback(
|
const handleAddressPRComments = useCallback((worktree: WorktreeInfo, prInfo: PRInfo) => {
|
||||||
|
setPRCommentDialogPRInfo({
|
||||||
|
number: prInfo.number,
|
||||||
|
title: prInfo.title,
|
||||||
|
// Pass the worktree's branch so features are created on the correct worktree
|
||||||
|
headRefName: worktree.branch,
|
||||||
|
});
|
||||||
|
setShowPRCommentDialog(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handler for auto-addressing PR comments - immediately creates and starts a feature task
|
||||||
|
const handleAutoAddressPRComments = useCallback(
|
||||||
async (worktree: WorktreeInfo, prInfo: PRInfo) => {
|
async (worktree: WorktreeInfo, prInfo: PRInfo) => {
|
||||||
// Use a simple prompt that instructs the agent to read and address PR feedback
|
if (!prInfo.number) {
|
||||||
// The agent will fetch the PR comments directly, which is more reliable and up-to-date
|
toast.error('Cannot address PR comments', {
|
||||||
const prNumber = prInfo.number;
|
description: 'No PR number available for this worktree.',
|
||||||
const description = `Read the review requests on PR #${prNumber} and address any feedback the best you can.`;
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const featureData = {
|
const featureData = {
|
||||||
title: `Address PR #${prNumber} Review Comments`,
|
title: `Address PR #${prInfo.number} Review Comments`,
|
||||||
category: 'PR Review',
|
category: 'Maintenance',
|
||||||
description,
|
description: `Read the review requests on PR #${prInfo.number} and address any feedback the best you can.`,
|
||||||
images: [],
|
images: [],
|
||||||
imagePaths: [],
|
imagePaths: [],
|
||||||
skipTests: defaultSkipTests,
|
skipTests: defaultSkipTests,
|
||||||
model: 'opus' as const,
|
model: resolveModelString('opus'),
|
||||||
thinkingLevel: 'none' as const,
|
thinkingLevel: 'none' as const,
|
||||||
branchName: worktree.branch,
|
branchName: worktree.branch,
|
||||||
workMode: 'custom' as const, // Use the worktree's branch
|
workMode: 'custom' as const,
|
||||||
priority: 1, // High priority for PR feedback
|
priority: 1,
|
||||||
planningMode: 'skip' as const,
|
planningMode: 'skip' as const,
|
||||||
requirePlanApproval: false,
|
requirePlanApproval: false,
|
||||||
};
|
};
|
||||||
@@ -1225,6 +1285,7 @@ export function BoardView() {
|
|||||||
const { getColumnFeatures, completedFeatures } = useBoardColumnFeatures({
|
const { getColumnFeatures, completedFeatures } = useBoardColumnFeatures({
|
||||||
features: hookFeatures,
|
features: hookFeatures,
|
||||||
runningAutoTasks,
|
runningAutoTasks,
|
||||||
|
runningAutoTasksAllWorktrees,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
currentWorktreePath,
|
currentWorktreePath,
|
||||||
currentWorktreeBranch,
|
currentWorktreeBranch,
|
||||||
@@ -1393,14 +1454,6 @@ export function BoardView() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex items-center justify-center" data-testid="board-view-loading">
|
|
||||||
<Spinner size="lg" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex-1 flex flex-col overflow-hidden content-bg relative"
|
className="flex-1 flex flex-col overflow-hidden content-bg relative"
|
||||||
@@ -1426,13 +1479,12 @@ export function BoardView() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also update backend if auto mode is running
|
// Also update backend if auto mode is running.
|
||||||
|
// Use restartWithConcurrency to avoid toggle flickering - it restarts
|
||||||
|
// the backend without toggling isRunning off/on in the UI.
|
||||||
if (autoMode.isRunning) {
|
if (autoMode.isRunning) {
|
||||||
// Restart auto mode with new concurrency (backend will handle this)
|
autoMode.restartWithConcurrency().catch((error) => {
|
||||||
autoMode.stop().then(() => {
|
logger.error('[AutoMode] Failed to restart with new concurrency:', error);
|
||||||
autoMode.start().catch((error) => {
|
|
||||||
logger.error('[AutoMode] Failed to restart with new concurrency:', error);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1499,6 +1551,7 @@ export function BoardView() {
|
|||||||
setShowCreateBranchDialog(true);
|
setShowCreateBranchDialog(true);
|
||||||
}}
|
}}
|
||||||
onAddressPRComments={handleAddressPRComments}
|
onAddressPRComments={handleAddressPRComments}
|
||||||
|
onAutoAddressPRComments={handleAutoAddressPRComments}
|
||||||
onResolveConflicts={handleResolveConflicts}
|
onResolveConflicts={handleResolveConflicts}
|
||||||
onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
|
onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
|
||||||
onBranchSwitchConflict={handleBranchSwitchConflict}
|
onBranchSwitchConflict={handleBranchSwitchConflict}
|
||||||
@@ -1976,6 +2029,18 @@ export function BoardView() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* PR Comment Resolution Dialog */}
|
||||||
|
{prCommentDialogPRInfo && (
|
||||||
|
<PRCommentResolutionDialog
|
||||||
|
open={showPRCommentDialog}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setShowPRCommentDialog(open);
|
||||||
|
if (!open) setPRCommentDialogPRInfo(null);
|
||||||
|
}}
|
||||||
|
pr={prCommentDialogPRInfo}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Init Script Indicator - floating overlay for worktree init script status */}
|
{/* Init Script Indicator - floating overlay for worktree init script status */}
|
||||||
{getShowInitScriptIndicator(currentProject.path) && (
|
{getShowInitScriptIndicator(currentProject.path) && (
|
||||||
<InitScriptIndicator projectPath={currentProject.path} />
|
<InitScriptIndicator projectPath={currentProject.path} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { memo, useEffect, useState, useMemo, useRef } from 'react';
|
import { memo, useEffect, useState, useMemo, useRef } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { Feature, ThinkingLevel, ReasoningEffort, ParsedTask } from '@/store/app-store';
|
import { Feature, ThinkingLevel, ReasoningEffort, ParsedTask } from '@/store/app-store';
|
||||||
import { getProviderFromModel } from '@/lib/utils';
|
import { getProviderFromModel } from '@/lib/utils';
|
||||||
import { parseAgentContext, formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
|
import { parseAgentContext, formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
|
||||||
@@ -10,6 +11,7 @@ import { getElectronAPI } from '@/lib/electron';
|
|||||||
import { SummaryDialog } from './summary-dialog';
|
import { SummaryDialog } from './summary-dialog';
|
||||||
import { getProviderIconForModel } from '@/components/ui/provider-icon';
|
import { getProviderIconForModel } from '@/components/ui/provider-icon';
|
||||||
import { useFeature, useAgentOutput } from '@/hooks/queries';
|
import { useFeature, useAgentOutput } from '@/hooks/queries';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats thinking level for compact display
|
* Formats thinking level for compact display
|
||||||
@@ -58,6 +60,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
|||||||
summary,
|
summary,
|
||||||
isActivelyRunning,
|
isActivelyRunning,
|
||||||
}: AgentInfoPanelProps) {
|
}: AgentInfoPanelProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
||||||
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
|
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
|
||||||
// Track real-time task status updates from WebSocket events
|
// Track real-time task status updates from WebSocket events
|
||||||
@@ -130,6 +133,25 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
|||||||
pollingInterval,
|
pollingInterval,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// On mount, ensure feature and agent output queries are fresh.
|
||||||
|
// This handles the worktree switch scenario where cards unmount when filtered out
|
||||||
|
// and remount when the user switches back. Without this, the React Query cache
|
||||||
|
// may serve stale data (or no data) for the individual feature query, causing
|
||||||
|
// the todo list to appear empty until the next polling cycle.
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldFetchData && projectPath && feature.id && !contextContent) {
|
||||||
|
// Invalidate both the single feature and agent output queries to trigger immediate refetch
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.single(projectPath, feature.id),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.agentOutput(projectPath, feature.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Only run on mount (feature.id and projectPath identify this specific card instance)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [feature.id, projectPath]);
|
||||||
|
|
||||||
// Parse agent output into agentInfo
|
// Parse agent output into agentInfo
|
||||||
const agentInfo = useMemo(() => {
|
const agentInfo = useMemo(() => {
|
||||||
if (contextContent) {
|
if (contextContent) {
|
||||||
@@ -305,9 +327,11 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
|||||||
|
|
||||||
// Agent Info Panel for non-backlog cards
|
// Agent Info Panel for non-backlog cards
|
||||||
// Show panel if we have agentInfo OR planSpec.tasks (for spec/full mode)
|
// Show panel if we have agentInfo OR planSpec.tasks (for spec/full mode)
|
||||||
|
// OR if the feature has effective todos from any source (handles initial mount after worktree switch)
|
||||||
|
// OR if the feature is actively running (ensures panel stays visible during execution)
|
||||||
// Note: hasPlanSpecTasks is already defined above and includes freshPlanSpec
|
// Note: hasPlanSpecTasks is already defined above and includes freshPlanSpec
|
||||||
// (The backlog case was already handled above and returned early)
|
// (The backlog case was already handled above and returned early)
|
||||||
if (agentInfo || hasPlanSpecTasks) {
|
if (agentInfo || hasPlanSpecTasks || effectiveTodos.length > 0 || isActivelyRunning) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-3 space-y-2 overflow-hidden">
|
<div className="mb-3 space-y-2 overflow-hidden">
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
interface CardActionsProps {
|
interface CardActionsProps {
|
||||||
feature: Feature;
|
feature: Feature;
|
||||||
isCurrentAutoTask: boolean;
|
isCurrentAutoTask: boolean;
|
||||||
|
/** Whether this feature is tracked as a running task (may be true even before status updates to in_progress) */
|
||||||
|
isRunningTask?: boolean;
|
||||||
hasContext?: boolean;
|
hasContext?: boolean;
|
||||||
shortcutKey?: string;
|
shortcutKey?: string;
|
||||||
isSelectionMode?: boolean;
|
isSelectionMode?: boolean;
|
||||||
@@ -36,6 +38,7 @@ interface CardActionsProps {
|
|||||||
export const CardActions = memo(function CardActions({
|
export const CardActions = memo(function CardActions({
|
||||||
feature,
|
feature,
|
||||||
isCurrentAutoTask,
|
isCurrentAutoTask,
|
||||||
|
isRunningTask = false,
|
||||||
hasContext: _hasContext,
|
hasContext: _hasContext,
|
||||||
shortcutKey,
|
shortcutKey,
|
||||||
isSelectionMode = false,
|
isSelectionMode = false,
|
||||||
@@ -340,7 +343,57 @@ export const CardActions = memo(function CardActions({
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{/* Running task with stale status: feature is tracked as running but status hasn't updated yet.
|
||||||
|
Show Logs/Stop controls instead of Make to avoid confusing UI. */}
|
||||||
{!isCurrentAutoTask &&
|
{!isCurrentAutoTask &&
|
||||||
|
isRunningTask &&
|
||||||
|
(feature.status === 'backlog' ||
|
||||||
|
feature.status === 'interrupted' ||
|
||||||
|
feature.status === 'ready') && (
|
||||||
|
<>
|
||||||
|
{onViewOutput && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-7 text-[11px]"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onViewOutput();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`view-output-${feature.id}`}
|
||||||
|
>
|
||||||
|
<FileText className="w-3 h-3 mr-1 shrink-0" />
|
||||||
|
<span className="truncate">Logs</span>
|
||||||
|
{shortcutKey && (
|
||||||
|
<span
|
||||||
|
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-foreground/10"
|
||||||
|
data-testid={`shortcut-key-${feature.id}`}
|
||||||
|
>
|
||||||
|
{shortcutKey}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onForceStop && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-[11px] px-2 shrink-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onForceStop();
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`force-stop-${feature.id}`}
|
||||||
|
>
|
||||||
|
<StopCircle className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isCurrentAutoTask &&
|
||||||
|
!isRunningTask &&
|
||||||
(feature.status === 'backlog' ||
|
(feature.status === 'backlog' ||
|
||||||
feature.status === 'interrupted' ||
|
feature.status === 'interrupted' ||
|
||||||
feature.status === 'ready') && (
|
feature.status === 'ready') && (
|
||||||
|
|||||||
@@ -468,7 +468,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0 overflow-hidden">
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
{feature.titleGenerating ? (
|
{feature.titleGenerating && !feature.title ? (
|
||||||
<div className="flex items-center gap-1.5 mb-1">
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
<Spinner size="xs" />
|
<Spinner size="xs" />
|
||||||
<span className="text-xs text-muted-foreground italic">Generating title...</span>
|
<span className="text-xs text-muted-foreground italic">Generating title...</span>
|
||||||
|
|||||||
@@ -114,15 +114,27 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
currentProject: state.currentProject,
|
currentProject: state.currentProject,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
// A card should only display as "actively running" if it's both in the
|
// A card should display as "actively running" if it's in the runningAutoTasks list
|
||||||
// runningAutoTasks list AND in an execution-compatible status. Cards in resting
|
// AND in an execution-compatible status. However, there's a race window where a feature
|
||||||
// states (backlog, ready, waiting_approval, verified, completed) should never
|
// is tracked as running (in runningAutoTasks) but its disk/UI status hasn't caught up yet
|
||||||
// show running controls, even if they appear in runningAutoTasks due to stale
|
// (still 'backlog', 'ready', or 'interrupted'). In this case, we still want to show
|
||||||
// state (e.g., after a server restart that reconciled features back to backlog).
|
// running controls (Logs/Stop) and animated border, but not the full "actively running"
|
||||||
|
// state that gates all UI behavior.
|
||||||
const isInExecutionState =
|
const isInExecutionState =
|
||||||
feature.status === 'in_progress' ||
|
feature.status === 'in_progress' ||
|
||||||
(typeof feature.status === 'string' && feature.status.startsWith('pipeline_'));
|
(typeof feature.status === 'string' && feature.status.startsWith('pipeline_'));
|
||||||
const isActivelyRunning = !!isCurrentAutoTask && isInExecutionState;
|
const isActivelyRunning = !!isCurrentAutoTask && isInExecutionState;
|
||||||
|
// isRunningWithStaleStatus: feature is tracked as running but status hasn't updated yet.
|
||||||
|
// This happens during the timing gap between when the server starts a feature and when
|
||||||
|
// the UI receives the status update. Show running UI to prevent "Make" button flash.
|
||||||
|
const isRunningWithStaleStatus =
|
||||||
|
!!isCurrentAutoTask &&
|
||||||
|
!isInExecutionState &&
|
||||||
|
(feature.status === 'backlog' ||
|
||||||
|
feature.status === 'ready' ||
|
||||||
|
feature.status === 'interrupted');
|
||||||
|
// Show running visual treatment for both fully confirmed and stale-status running tasks
|
||||||
|
const showRunningVisuals = isActivelyRunning || isRunningWithStaleStatus;
|
||||||
const [isLifted, setIsLifted] = useState(false);
|
const [isLifted, setIsLifted] = useState(false);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -135,6 +147,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
|
|
||||||
const isDraggable =
|
const isDraggable =
|
||||||
!isSelectionMode &&
|
!isSelectionMode &&
|
||||||
|
!isRunningWithStaleStatus &&
|
||||||
(feature.status === 'backlog' ||
|
(feature.status === 'backlog' ||
|
||||||
feature.status === 'interrupted' ||
|
feature.status === 'interrupted' ||
|
||||||
feature.status === 'ready' ||
|
feature.status === 'ready' ||
|
||||||
@@ -198,13 +211,13 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
'kanban-card-content h-full relative',
|
'kanban-card-content h-full relative',
|
||||||
reduceEffects ? 'shadow-none' : 'shadow-sm',
|
reduceEffects ? 'shadow-none' : 'shadow-sm',
|
||||||
'transition-all duration-200 ease-out',
|
'transition-all duration-200 ease-out',
|
||||||
// Disable hover translate for in-progress cards to prevent gap showing gradient
|
// Disable hover translate for running cards to prevent gap showing gradient
|
||||||
isInteractive &&
|
isInteractive &&
|
||||||
!reduceEffects &&
|
!reduceEffects &&
|
||||||
!isActivelyRunning &&
|
!showRunningVisuals &&
|
||||||
'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
|
'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
|
||||||
!glassmorphism && 'backdrop-blur-[0px]!',
|
!glassmorphism && 'backdrop-blur-[0px]!',
|
||||||
!isActivelyRunning &&
|
!showRunningVisuals &&
|
||||||
cardBorderEnabled &&
|
cardBorderEnabled &&
|
||||||
(cardBorderOpacity === 100 ? 'border-border/50' : 'border'),
|
(cardBorderOpacity === 100 ? 'border-border/50' : 'border'),
|
||||||
hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
|
hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
|
||||||
@@ -221,7 +234,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
|
|
||||||
const renderCardContent = () => (
|
const renderCardContent = () => (
|
||||||
<Card
|
<Card
|
||||||
style={isActivelyRunning ? undefined : cardStyle}
|
style={showRunningVisuals ? undefined : cardStyle}
|
||||||
className={innerCardClasses}
|
className={innerCardClasses}
|
||||||
onDoubleClick={isSelectionMode ? undefined : onEdit}
|
onDoubleClick={isSelectionMode ? undefined : onEdit}
|
||||||
onClick={handleCardClick}
|
onClick={handleCardClick}
|
||||||
@@ -290,6 +303,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
<CardActions
|
<CardActions
|
||||||
feature={feature}
|
feature={feature}
|
||||||
isCurrentAutoTask={isActivelyRunning}
|
isCurrentAutoTask={isActivelyRunning}
|
||||||
|
isRunningTask={!!isCurrentAutoTask}
|
||||||
hasContext={hasContext}
|
hasContext={hasContext}
|
||||||
shortcutKey={shortcutKey}
|
shortcutKey={shortcutKey}
|
||||||
isSelectionMode={isSelectionMode}
|
isSelectionMode={isSelectionMode}
|
||||||
@@ -316,7 +330,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
className={wrapperClasses}
|
className={wrapperClasses}
|
||||||
data-testid={`kanban-card-${feature.id}`}
|
data-testid={`kanban-card-${feature.id}`}
|
||||||
>
|
>
|
||||||
{isActivelyRunning ? (
|
{showRunningVisuals ? (
|
||||||
<div className="animated-border-wrapper">{renderCardContent()}</div>
|
<div className="animated-border-wrapper">{renderCardContent()}</div>
|
||||||
) : (
|
) : (
|
||||||
renderCardContent()
|
renderCardContent()
|
||||||
|
|||||||
@@ -42,7 +42,12 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
contentStyle,
|
contentStyle,
|
||||||
disableItemSpacing = false,
|
disableItemSpacing = false,
|
||||||
}: KanbanColumnProps) {
|
}: KanbanColumnProps) {
|
||||||
const { setNodeRef, isOver } = useDroppable({ id });
|
const { setNodeRef, isOver: isColumnOver } = useDroppable({ id });
|
||||||
|
// Also make the header explicitly a drop target so dragging to the top of the column works
|
||||||
|
const { setNodeRef: setHeaderDropRef, isOver: isHeaderOver } = useDroppable({
|
||||||
|
id: `column-header-${id}`,
|
||||||
|
});
|
||||||
|
const isOver = isColumnOver || isHeaderOver;
|
||||||
|
|
||||||
// Use inline style for width if provided, otherwise use default w-72
|
// Use inline style for width if provided, otherwise use default w-72
|
||||||
const widthStyle = width ? { width: `${width}px`, flexShrink: 0 } : undefined;
|
const widthStyle = width ? { width: `${width}px`, flexShrink: 0 } : undefined;
|
||||||
@@ -70,8 +75,9 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
style={{ opacity: opacity / 100 }}
|
style={{ opacity: opacity / 100 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Column Header */}
|
{/* Column Header - also registered as a drop target so dragging to the header area works */}
|
||||||
<div
|
<div
|
||||||
|
ref={setHeaderDropRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative z-10 flex items-center gap-3 px-3 py-2.5',
|
'relative z-10 flex items-center gap-3 px-3 py-2.5',
|
||||||
showBorder && 'border-b border-border/40'
|
showBorder && 'border-b border-border/40'
|
||||||
@@ -96,8 +102,8 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
|
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
|
||||||
// Smooth scrolling
|
// Smooth scrolling
|
||||||
'scroll-smooth',
|
'scroll-smooth',
|
||||||
// Add padding at bottom if there's a footer action
|
// Add padding at bottom if there's a footer action (less on mobile to reduce blank space)
|
||||||
footerAction && 'pb-14',
|
footerAction && 'pb-12 sm:pb-14',
|
||||||
contentClassName
|
contentClassName
|
||||||
)}
|
)}
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
@@ -109,7 +115,7 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
|
|
||||||
{/* Floating Footer Action */}
|
{/* Floating Footer Action */}
|
||||||
{footerAction && (
|
{footerAction && (
|
||||||
<div className="absolute bottom-0 left-0 right-0 z-20 p-2 bg-gradient-to-t from-card/95 via-card/80 to-transparent pt-6">
|
<div className="absolute bottom-0 left-0 right-0 z-20 p-2 bg-gradient-to-t from-card/95 via-card/80 to-transparent pt-4 sm:pt-6">
|
||||||
{footerAction}
|
{footerAction}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -209,15 +209,22 @@ export const ListRow = memo(function ListRow({
|
|||||||
blockingDependencies = [],
|
blockingDependencies = [],
|
||||||
className,
|
className,
|
||||||
}: ListRowProps) {
|
}: ListRowProps) {
|
||||||
// A row should only display as "actively running" if it's both in the
|
// A row should display as "actively running" if it's in the runningAutoTasks list
|
||||||
// runningAutoTasks list AND in an execution-compatible status. Features in resting
|
// AND in an execution-compatible status. However, there's a race window where a feature
|
||||||
// states (backlog, ready, waiting_approval, verified, completed) should never
|
// is tracked as running but its status hasn't caught up yet (still 'backlog', 'ready',
|
||||||
// show running controls, even if they appear in runningAutoTasks due to stale
|
// or 'interrupted'). We handle this with isRunningWithStaleStatus.
|
||||||
// state (e.g., after a server restart that reconciled features back to backlog).
|
|
||||||
const isInExecutionState =
|
const isInExecutionState =
|
||||||
feature.status === 'in_progress' ||
|
feature.status === 'in_progress' ||
|
||||||
(typeof feature.status === 'string' && feature.status.startsWith('pipeline_'));
|
(typeof feature.status === 'string' && feature.status.startsWith('pipeline_'));
|
||||||
const isActivelyRunning = isCurrentAutoTask && isInExecutionState;
|
const isActivelyRunning = isCurrentAutoTask && isInExecutionState;
|
||||||
|
// Feature is tracked as running but status hasn't updated yet - show running UI
|
||||||
|
const isRunningWithStaleStatus =
|
||||||
|
isCurrentAutoTask &&
|
||||||
|
!isInExecutionState &&
|
||||||
|
(feature.status === 'backlog' ||
|
||||||
|
feature.status === 'ready' ||
|
||||||
|
feature.status === 'interrupted');
|
||||||
|
const showRunningVisuals = isActivelyRunning || isRunningWithStaleStatus;
|
||||||
|
|
||||||
const handleRowClick = useCallback(
|
const handleRowClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
@@ -268,7 +275,7 @@ export const ListRow = memo(function ListRow({
|
|||||||
>
|
>
|
||||||
{/* Checkbox column */}
|
{/* Checkbox column */}
|
||||||
{showCheckbox && (
|
{showCheckbox && (
|
||||||
<div role="cell" className="flex items-center justify-center w-10 px-2 py-3 shrink-0">
|
<div role="cell" className="flex items-center justify-center w-10 px-2 py-2 shrink-0">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
@@ -287,7 +294,7 @@ export const ListRow = memo(function ListRow({
|
|||||||
<div
|
<div
|
||||||
role="cell"
|
role="cell"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center pl-3 pr-0 py-3 gap-0',
|
'flex items-center pl-3 pr-0 py-2 gap-0',
|
||||||
getColumnWidth('title'),
|
getColumnWidth('title'),
|
||||||
getColumnAlign('title')
|
getColumnAlign('title')
|
||||||
)}
|
)}
|
||||||
@@ -296,8 +303,8 @@ export const ListRow = memo(function ListRow({
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-medium truncate',
|
'text-sm font-medium truncate',
|
||||||
feature.titleGenerating && 'animate-pulse text-muted-foreground'
|
feature.titleGenerating && !feature.title && 'animate-pulse text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
title={feature.title || feature.description}
|
title={feature.title || feature.description}
|
||||||
>
|
>
|
||||||
@@ -325,7 +332,7 @@ export const ListRow = memo(function ListRow({
|
|||||||
<div
|
<div
|
||||||
role="cell"
|
role="cell"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center pl-0 pr-3 py-3 shrink-0',
|
'flex items-center pl-0 pr-3 py-2 shrink-0',
|
||||||
getColumnWidth('priority'),
|
getColumnWidth('priority'),
|
||||||
getColumnAlign('priority')
|
getColumnAlign('priority')
|
||||||
)}
|
)}
|
||||||
@@ -358,14 +365,19 @@ export const ListRow = memo(function ListRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions column */}
|
{/* Actions column */}
|
||||||
<div role="cell" className="flex items-center justify-end px-3 py-3 w-[80px] shrink-0">
|
<div role="cell" className="flex items-center justify-end px-3 py-2 w-[80px] shrink-0">
|
||||||
<RowActions feature={feature} handlers={handlers} isCurrentAutoTask={isActivelyRunning} />
|
<RowActions
|
||||||
|
feature={feature}
|
||||||
|
handlers={handlers}
|
||||||
|
isCurrentAutoTask={isActivelyRunning}
|
||||||
|
isRunningTask={!!isCurrentAutoTask}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wrap with animated border for currently running auto task
|
// Wrap with animated border for currently running auto task (including stale status)
|
||||||
if (isActivelyRunning) {
|
if (showRunningVisuals) {
|
||||||
return <div className="animated-border-wrapper-row">{rowContent}</div>;
|
return <div className="animated-border-wrapper-row">{rowContent}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export interface RowActionsProps {
|
|||||||
handlers: RowActionHandlers;
|
handlers: RowActionHandlers;
|
||||||
/** Whether this feature is the current auto task (agent is running) */
|
/** Whether this feature is the current auto task (agent is running) */
|
||||||
isCurrentAutoTask?: boolean;
|
isCurrentAutoTask?: boolean;
|
||||||
|
/** Whether this feature is tracked as a running task (may be true even before status updates to in_progress) */
|
||||||
|
isRunningTask?: boolean;
|
||||||
/** Whether the dropdown menu is open */
|
/** Whether the dropdown menu is open */
|
||||||
isOpen?: boolean;
|
isOpen?: boolean;
|
||||||
/** Callback when the dropdown open state changes */
|
/** Callback when the dropdown open state changes */
|
||||||
@@ -115,7 +117,8 @@ const MenuItem = memo(function MenuItem({
|
|||||||
function getPrimaryAction(
|
function getPrimaryAction(
|
||||||
feature: Feature,
|
feature: Feature,
|
||||||
handlers: RowActionHandlers,
|
handlers: RowActionHandlers,
|
||||||
isCurrentAutoTask: boolean
|
isCurrentAutoTask: boolean,
|
||||||
|
isRunningTask: boolean = false
|
||||||
): {
|
): {
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -135,6 +138,24 @@ function getPrimaryAction(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Running task with stale status - show stop instead of Make
|
||||||
|
// This handles the race window where the feature is tracked as running
|
||||||
|
// but status hasn't updated to in_progress yet
|
||||||
|
if (
|
||||||
|
isRunningTask &&
|
||||||
|
(feature.status === 'backlog' ||
|
||||||
|
feature.status === 'ready' ||
|
||||||
|
feature.status === 'interrupted') &&
|
||||||
|
handlers.onForceStop
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
icon: StopCircle,
|
||||||
|
label: 'Stop',
|
||||||
|
onClick: handlers.onForceStop,
|
||||||
|
variant: 'destructive',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Backlog - implement is primary
|
// Backlog - implement is primary
|
||||||
if (feature.status === 'backlog' && handlers.onImplement) {
|
if (feature.status === 'backlog' && handlers.onImplement) {
|
||||||
return {
|
return {
|
||||||
@@ -263,6 +284,7 @@ export const RowActions = memo(function RowActions({
|
|||||||
feature,
|
feature,
|
||||||
handlers,
|
handlers,
|
||||||
isCurrentAutoTask = false,
|
isCurrentAutoTask = false,
|
||||||
|
isRunningTask = false,
|
||||||
isOpen,
|
isOpen,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
className,
|
className,
|
||||||
@@ -286,7 +308,7 @@ export const RowActions = memo(function RowActions({
|
|||||||
[setOpen]
|
[setOpen]
|
||||||
);
|
);
|
||||||
|
|
||||||
const primaryAction = getPrimaryAction(feature, handlers, isCurrentAutoTask);
|
const primaryAction = getPrimaryAction(feature, handlers, isCurrentAutoTask, isRunningTask);
|
||||||
const secondaryActions = getSecondaryActions(feature, handlers);
|
const secondaryActions = getSecondaryActions(feature, handlers);
|
||||||
|
|
||||||
// Helper to close menu after action
|
// Helper to close menu after action
|
||||||
@@ -403,7 +425,7 @@ export const RowActions = memo(function RowActions({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Backlog actions */}
|
{/* Backlog actions */}
|
||||||
{!isCurrentAutoTask && feature.status === 'backlog' && (
|
{!isCurrentAutoTask && !isRunningTask && feature.status === 'backlog' && (
|
||||||
<>
|
<>
|
||||||
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
|
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
|
||||||
{feature.planSpec?.content && handlers.onViewPlan && (
|
{feature.planSpec?.content && handlers.onViewPlan && (
|
||||||
|
|||||||
@@ -123,6 +123,18 @@ interface AddFeatureDialogProps {
|
|||||||
* This is used when the "Default to worktree mode" setting is disabled.
|
* This is used when the "Default to worktree mode" setting is disabled.
|
||||||
*/
|
*/
|
||||||
forceCurrentBranchMode?: boolean;
|
forceCurrentBranchMode?: boolean;
|
||||||
|
/**
|
||||||
|
* Pre-filled title for the feature (e.g., from a GitHub issue).
|
||||||
|
*/
|
||||||
|
prefilledTitle?: string;
|
||||||
|
/**
|
||||||
|
* Pre-filled description for the feature (e.g., from a GitHub issue).
|
||||||
|
*/
|
||||||
|
prefilledDescription?: string;
|
||||||
|
/**
|
||||||
|
* Pre-filled category for the feature (e.g., 'From GitHub').
|
||||||
|
*/
|
||||||
|
prefilledCategory?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,6 +161,9 @@ export function AddFeatureDialog({
|
|||||||
projectPath,
|
projectPath,
|
||||||
selectedNonMainWorktreeBranch,
|
selectedNonMainWorktreeBranch,
|
||||||
forceCurrentBranchMode,
|
forceCurrentBranchMode,
|
||||||
|
prefilledTitle,
|
||||||
|
prefilledDescription,
|
||||||
|
prefilledCategory,
|
||||||
}: AddFeatureDialogProps) {
|
}: AddFeatureDialogProps) {
|
||||||
const isSpawnMode = !!parentFeature;
|
const isSpawnMode = !!parentFeature;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -211,6 +226,11 @@ export function AddFeatureDialog({
|
|||||||
wasOpenRef.current = open;
|
wasOpenRef.current = open;
|
||||||
|
|
||||||
if (justOpened) {
|
if (justOpened) {
|
||||||
|
// Initialize with prefilled values if provided, otherwise use defaults
|
||||||
|
setTitle(prefilledTitle || '');
|
||||||
|
setDescription(prefilledDescription || '');
|
||||||
|
setCategory(prefilledCategory || '');
|
||||||
|
|
||||||
setSkipTests(defaultSkipTests);
|
setSkipTests(defaultSkipTests);
|
||||||
// When a non-main worktree is selected, use its branch name for custom mode
|
// When a non-main worktree is selected, use its branch name for custom mode
|
||||||
// Otherwise, use the default branch
|
// Otherwise, use the default branch
|
||||||
@@ -254,6 +274,9 @@ export function AddFeatureDialog({
|
|||||||
forceCurrentBranchMode,
|
forceCurrentBranchMode,
|
||||||
parentFeature,
|
parentFeature,
|
||||||
allFeatures,
|
allFeatures,
|
||||||
|
prefilledTitle,
|
||||||
|
prefilledDescription,
|
||||||
|
prefilledCategory,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Clear requirePlanApproval when planning mode is skip or lite
|
// Clear requirePlanApproval when planning mode is skip or lite
|
||||||
|
|||||||
@@ -321,11 +321,11 @@ export function AgentOutputModal({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onClose}>
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="w-full h-full max-w-full max-h-full sm:w-[60vw] sm:max-w-[60vw] sm:max-h-[80vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col"
|
className="w-full max-h-[85dvh] max-w-[calc(100%-2rem)] sm:w-[60vw] sm:max-w-[60vw] sm:max-h-[80vh] rounded-xl flex flex-col"
|
||||||
data-testid="agent-output-modal"
|
data-testid="agent-output-modal"
|
||||||
>
|
>
|
||||||
<DialogHeader className="shrink-0">
|
<DialogHeader className="shrink-0">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pr-8">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pr-10">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
|
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
|
||||||
<Spinner size="md" />
|
<Spinner size="md" />
|
||||||
|
|||||||
@@ -493,7 +493,7 @@ export function CherryPickDialog({
|
|||||||
if (step === 'select-commits') {
|
if (step === 'select-commits') {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
|
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[85dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Cherry className="w-5 h-5 text-foreground" />
|
<Cherry className="w-5 h-5 text-foreground" />
|
||||||
|
|||||||
@@ -11,8 +11,16 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import {
|
import {
|
||||||
GitCommit,
|
GitCommit,
|
||||||
|
GitMerge,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
FilePlus,
|
FilePlus,
|
||||||
FileX,
|
FileX,
|
||||||
@@ -21,6 +29,7 @@ import {
|
|||||||
File,
|
File,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Upload,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
@@ -28,9 +37,14 @@ import { toast } from 'sonner';
|
|||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
|
import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
|
||||||
import type { FileStatus } from '@/types/electron';
|
import type { FileStatus, MergeStateInfo } from '@/types/electron';
|
||||||
import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils';
|
import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils';
|
||||||
|
|
||||||
|
interface RemoteInfo {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface WorktreeInfo {
|
interface WorktreeInfo {
|
||||||
path: string;
|
path: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
@@ -103,6 +117,27 @@ const getStatusBadgeColor = (status: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getMergeTypeLabel = (mergeType?: string) => {
|
||||||
|
switch (mergeType) {
|
||||||
|
case 'both-modified':
|
||||||
|
return 'Both Modified';
|
||||||
|
case 'added-by-us':
|
||||||
|
return 'Added by Us';
|
||||||
|
case 'added-by-them':
|
||||||
|
return 'Added by Them';
|
||||||
|
case 'deleted-by-us':
|
||||||
|
return 'Deleted by Us';
|
||||||
|
case 'deleted-by-them':
|
||||||
|
return 'Deleted by Them';
|
||||||
|
case 'both-added':
|
||||||
|
return 'Both Added';
|
||||||
|
case 'both-deleted':
|
||||||
|
return 'Both Deleted';
|
||||||
|
default:
|
||||||
|
return 'Merge';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
function DiffLine({
|
function DiffLine({
|
||||||
type,
|
type,
|
||||||
content,
|
content,
|
||||||
@@ -177,6 +212,18 @@ export function CommitWorktreeDialog({
|
|||||||
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||||
const [expandedFile, setExpandedFile] = useState<string | null>(null);
|
const [expandedFile, setExpandedFile] = useState<string | null>(null);
|
||||||
const [isLoadingDiffs, setIsLoadingDiffs] = useState(false);
|
const [isLoadingDiffs, setIsLoadingDiffs] = useState(false);
|
||||||
|
const [mergeState, setMergeState] = useState<MergeStateInfo | undefined>(undefined);
|
||||||
|
|
||||||
|
// Push after commit state
|
||||||
|
const [pushAfterCommit, setPushAfterCommit] = useState(false);
|
||||||
|
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
|
||||||
|
const [selectedRemote, setSelectedRemote] = useState<string>('');
|
||||||
|
const [isLoadingRemotes, setIsLoadingRemotes] = useState(false);
|
||||||
|
const [isPushing, setIsPushing] = useState(false);
|
||||||
|
const [remotesFetched, setRemotesFetched] = useState(false);
|
||||||
|
const [remotesFetchError, setRemotesFetchError] = useState<string | null>(null);
|
||||||
|
// Track whether the commit already succeeded so retries can skip straight to push
|
||||||
|
const [commitSucceeded, setCommitSucceeded] = useState(false);
|
||||||
|
|
||||||
// Parse diffs
|
// Parse diffs
|
||||||
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
|
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
|
||||||
@@ -190,6 +237,58 @@ export function CommitWorktreeDialog({
|
|||||||
return map;
|
return map;
|
||||||
}, [parsedDiffs]);
|
}, [parsedDiffs]);
|
||||||
|
|
||||||
|
// Fetch remotes when push option is enabled
|
||||||
|
const fetchRemotesForWorktree = useCallback(
|
||||||
|
async (worktreePath: string, signal?: { cancelled: boolean }) => {
|
||||||
|
setIsLoadingRemotes(true);
|
||||||
|
setRemotesFetchError(null);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (api?.worktree?.listRemotes) {
|
||||||
|
const result = await api.worktree.listRemotes(worktreePath);
|
||||||
|
if (signal?.cancelled) return;
|
||||||
|
setRemotesFetched(true);
|
||||||
|
if (result.success && result.result) {
|
||||||
|
const remoteInfos = result.result.remotes.map((r) => ({
|
||||||
|
name: r.name,
|
||||||
|
url: r.url,
|
||||||
|
}));
|
||||||
|
setRemotes(remoteInfos);
|
||||||
|
// Auto-select 'origin' if available, otherwise first remote
|
||||||
|
if (remoteInfos.length > 0) {
|
||||||
|
const defaultRemote = remoteInfos.find((r) => r.name === 'origin') || remoteInfos[0];
|
||||||
|
setSelectedRemote(defaultRemote.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// API not available — mark fetch as complete with an error so the UI
|
||||||
|
// shows feedback instead of remaining in an empty/loading state.
|
||||||
|
setRemotesFetchError('Remote listing not available');
|
||||||
|
setRemotesFetched(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (signal?.cancelled) return;
|
||||||
|
// Don't mark as successfully fetched — show an error with retry instead
|
||||||
|
setRemotesFetchError(err instanceof Error ? err.message : 'Failed to fetch remotes');
|
||||||
|
console.warn('Failed to fetch remotes:', err);
|
||||||
|
} finally {
|
||||||
|
if (!signal?.cancelled) setIsLoadingRemotes(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pushAfterCommit && worktree && !remotesFetched && !remotesFetchError) {
|
||||||
|
const signal = { cancelled: false };
|
||||||
|
fetchRemotesForWorktree(worktree.path, signal);
|
||||||
|
return () => {
|
||||||
|
signal.cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [pushAfterCommit, worktree, remotesFetched, remotesFetchError, fetchRemotesForWorktree]);
|
||||||
|
|
||||||
// Load diffs when dialog opens
|
// Load diffs when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && worktree) {
|
if (open && worktree) {
|
||||||
@@ -198,6 +297,15 @@ export function CommitWorktreeDialog({
|
|||||||
setDiffContent('');
|
setDiffContent('');
|
||||||
setSelectedFiles(new Set());
|
setSelectedFiles(new Set());
|
||||||
setExpandedFile(null);
|
setExpandedFile(null);
|
||||||
|
setMergeState(undefined);
|
||||||
|
// Reset push state
|
||||||
|
setPushAfterCommit(false);
|
||||||
|
setRemotes([]);
|
||||||
|
setSelectedRemote('');
|
||||||
|
setIsPushing(false);
|
||||||
|
setRemotesFetched(false);
|
||||||
|
setRemotesFetchError(null);
|
||||||
|
setCommitSucceeded(false);
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -208,8 +316,20 @@ export function CommitWorktreeDialog({
|
|||||||
const result = await api.git.getDiffs(worktree.path);
|
const result = await api.git.getDiffs(worktree.path);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const fileList = result.files ?? [];
|
const fileList = result.files ?? [];
|
||||||
|
// Sort merge-affected files first when a merge is in progress
|
||||||
|
if (result.mergeState?.isMerging) {
|
||||||
|
const mergeSet = new Set(result.mergeState.mergeAffectedFiles);
|
||||||
|
fileList.sort((a, b) => {
|
||||||
|
const aIsMerge = mergeSet.has(a.path) || (a.isMergeAffected ?? false);
|
||||||
|
const bIsMerge = mergeSet.has(b.path) || (b.isMergeAffected ?? false);
|
||||||
|
if (aIsMerge && !bIsMerge) return -1;
|
||||||
|
if (!aIsMerge && bIsMerge) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
if (!cancelled) setFiles(fileList);
|
if (!cancelled) setFiles(fileList);
|
||||||
if (!cancelled) setDiffContent(result.diff ?? '');
|
if (!cancelled) setDiffContent(result.diff ?? '');
|
||||||
|
if (!cancelled) setMergeState(result.mergeState);
|
||||||
// If any files are already staged, pre-select only staged files
|
// If any files are already staged, pre-select only staged files
|
||||||
// Otherwise select all files by default
|
// Otherwise select all files by default
|
||||||
const stagedFiles = fileList.filter((f) => {
|
const stagedFiles = fileList.filter((f) => {
|
||||||
@@ -278,14 +398,64 @@ export function CommitWorktreeDialog({
|
|||||||
setExpandedFile((prev) => (prev === filePath ? null : filePath));
|
setExpandedFile((prev) => (prev === filePath ? null : filePath));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/** Shared push helper — returns true if the push succeeded */
|
||||||
|
const performPush = async (
|
||||||
|
api: ReturnType<typeof getElectronAPI>,
|
||||||
|
worktreePath: string,
|
||||||
|
remoteName: string
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (!api?.worktree?.push) {
|
||||||
|
toast.error('Push API not available');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setIsPushing(true);
|
||||||
|
try {
|
||||||
|
const pushResult = await api.worktree.push(worktreePath, false, remoteName);
|
||||||
|
if (pushResult.success && pushResult.result) {
|
||||||
|
toast.success('Pushed to remote', {
|
||||||
|
description: pushResult.result.message,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
toast.error(pushResult.error || 'Failed to push to remote');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (pushErr) {
|
||||||
|
toast.error(pushErr instanceof Error ? pushErr.message : 'Failed to push to remote');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsPushing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCommit = async () => {
|
const handleCommit = async () => {
|
||||||
if (!worktree || !message.trim() || selectedFiles.size === 0) return;
|
if (!worktree) return;
|
||||||
|
|
||||||
|
const api = getElectronAPI();
|
||||||
|
|
||||||
|
// If commit already succeeded on a previous attempt, skip straight to push (or close if no push needed)
|
||||||
|
if (commitSucceeded) {
|
||||||
|
if (pushAfterCommit && selectedRemote) {
|
||||||
|
const ok = await performPush(api, worktree.path, selectedRemote);
|
||||||
|
if (ok) {
|
||||||
|
onCommitted();
|
||||||
|
onOpenChange(false);
|
||||||
|
setMessage('');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onCommitted();
|
||||||
|
onOpenChange(false);
|
||||||
|
setMessage('');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message.trim() || selectedFiles.size === 0) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.worktree?.commit) {
|
if (!api?.worktree?.commit) {
|
||||||
setError('Worktree API not available');
|
setError('Worktree API not available');
|
||||||
return;
|
return;
|
||||||
@@ -299,12 +469,27 @@ export function CommitWorktreeDialog({
|
|||||||
|
|
||||||
if (result.success && result.result) {
|
if (result.success && result.result) {
|
||||||
if (result.result.committed) {
|
if (result.result.committed) {
|
||||||
|
setCommitSucceeded(true);
|
||||||
toast.success('Changes committed', {
|
toast.success('Changes committed', {
|
||||||
description: `Commit ${result.result.commitHash} on ${result.result.branch}`,
|
description: `Commit ${result.result.commitHash} on ${result.result.branch}`,
|
||||||
});
|
});
|
||||||
onCommitted();
|
|
||||||
onOpenChange(false);
|
// Push after commit if enabled
|
||||||
setMessage('');
|
let pushSucceeded = false;
|
||||||
|
if (pushAfterCommit && selectedRemote) {
|
||||||
|
pushSucceeded = await performPush(api, worktree.path, selectedRemote);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only close the dialog when no push was requested or the push completed successfully.
|
||||||
|
// If push failed, keep the dialog open so the user can retry.
|
||||||
|
if (!pushAfterCommit || pushSucceeded) {
|
||||||
|
onCommitted();
|
||||||
|
onOpenChange(false);
|
||||||
|
setMessage('');
|
||||||
|
} else {
|
||||||
|
// Commit succeeded but push failed — notify parent of commit but keep dialog open for retry
|
||||||
|
onCommitted();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.info('No changes to commit', {
|
toast.info('No changes to commit', {
|
||||||
description: result.result.message,
|
description: result.result.message,
|
||||||
@@ -320,16 +505,30 @@ export function CommitWorktreeDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// When the commit succeeded but push failed, allow retrying the push without
|
||||||
|
// requiring a commit message or file selection.
|
||||||
|
const isPushRetry = commitSucceeded && pushAfterCommit && !isPushing;
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (
|
if (
|
||||||
e.key === 'Enter' &&
|
e.key === 'Enter' &&
|
||||||
(e.metaKey || e.ctrlKey) &&
|
(e.metaKey || e.ctrlKey) &&
|
||||||
!isLoading &&
|
!isLoading &&
|
||||||
!isGenerating &&
|
!isPushing &&
|
||||||
message.trim() &&
|
!isGenerating
|
||||||
selectedFiles.size > 0
|
|
||||||
) {
|
) {
|
||||||
handleCommit();
|
if (isPushRetry) {
|
||||||
|
// Push retry only needs a selected remote
|
||||||
|
if (selectedRemote) {
|
||||||
|
handleCommit();
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
message.trim() &&
|
||||||
|
selectedFiles.size > 0 &&
|
||||||
|
!(pushAfterCommit && !selectedRemote)
|
||||||
|
) {
|
||||||
|
handleCommit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -390,8 +589,19 @@ export function CommitWorktreeDialog({
|
|||||||
|
|
||||||
const allSelected = selectedFiles.size === files.length && files.length > 0;
|
const allSelected = selectedFiles.size === files.length && files.length > 0;
|
||||||
|
|
||||||
|
// Prevent the dialog from being dismissed while a push is in progress.
|
||||||
|
// Overlay clicks and Escape key both route through onOpenChange(false); we
|
||||||
|
// intercept those here so the UI stays open until the push completes.
|
||||||
|
const handleOpenChange = (nextOpen: boolean) => {
|
||||||
|
if (!nextOpen && isPushing) {
|
||||||
|
// Ignore close requests during an active push.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onOpenChange(nextOpen);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[700px] max-h-[85vh] flex flex-col">
|
<DialogContent className="sm:max-w-[700px] max-h-[85vh] flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
@@ -405,6 +615,34 @@ export function CommitWorktreeDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 py-2 min-h-0 flex-1 overflow-hidden">
|
<div className="flex flex-col gap-3 py-2 min-h-0 flex-1 overflow-hidden">
|
||||||
|
{/* Merge state banner */}
|
||||||
|
{mergeState?.isMerging && (
|
||||||
|
<div className="flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20">
|
||||||
|
<GitMerge className="w-4 h-4 text-purple-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-medium text-purple-400">
|
||||||
|
{mergeState.mergeOperationType === 'cherry-pick'
|
||||||
|
? 'Cherry-pick'
|
||||||
|
: mergeState.mergeOperationType === 'rebase'
|
||||||
|
? 'Rebase'
|
||||||
|
: 'Merge'}{' '}
|
||||||
|
in progress
|
||||||
|
</span>
|
||||||
|
{mergeState.conflictFiles.length > 0 ? (
|
||||||
|
<span className="text-purple-400/80 ml-1">
|
||||||
|
— {mergeState.conflictFiles.length} file
|
||||||
|
{mergeState.conflictFiles.length !== 1 ? 's' : ''} with conflicts
|
||||||
|
</span>
|
||||||
|
) : mergeState.isCleanMerge ? (
|
||||||
|
<span className="text-purple-400/80 ml-1">
|
||||||
|
— Clean merge, {mergeState.mergeAffectedFiles.length} file
|
||||||
|
{mergeState.mergeAffectedFiles.length !== 1 ? 's' : ''} affected
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* File Selection */}
|
{/* File Selection */}
|
||||||
<div className="flex flex-col min-h-0">
|
<div className="flex flex-col min-h-0">
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
@@ -451,13 +689,25 @@ export function CommitWorktreeDialog({
|
|||||||
const isStaged = idx !== ' ' && idx !== '?';
|
const isStaged = idx !== ' ' && idx !== '?';
|
||||||
const isUnstaged = wt !== ' ' && wt !== '?';
|
const isUnstaged = wt !== ' ' && wt !== '?';
|
||||||
const isUntracked = idx === '?' && wt === '?';
|
const isUntracked = idx === '?' && wt === '?';
|
||||||
|
const isMergeFile =
|
||||||
|
file.isMergeAffected ||
|
||||||
|
(mergeState?.mergeAffectedFiles?.includes(file.path) ?? false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={file.path} className="border-b border-border last:border-b-0">
|
<div
|
||||||
|
key={file.path}
|
||||||
|
className={cn(
|
||||||
|
'border-b last:border-b-0',
|
||||||
|
isMergeFile ? 'border-purple-500/30' : 'border-border'
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 px-3 py-1.5 hover:bg-accent/50 transition-colors group',
|
'flex items-center gap-2 px-3 py-1.5 transition-colors group',
|
||||||
isExpanded && 'bg-accent/30'
|
isMergeFile
|
||||||
|
? 'bg-purple-500/5 hover:bg-purple-500/10'
|
||||||
|
: 'hover:bg-accent/50',
|
||||||
|
isExpanded && (isMergeFile ? 'bg-purple-500/10' : 'bg-accent/30')
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Checkbox */}
|
{/* Checkbox */}
|
||||||
@@ -477,11 +727,21 @@ export function CommitWorktreeDialog({
|
|||||||
) : (
|
) : (
|
||||||
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
{getFileIcon(file.status)}
|
{isMergeFile ? (
|
||||||
|
<GitMerge className="w-3.5 h-3.5 text-purple-500 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
getFileIcon(file.status)
|
||||||
|
)}
|
||||||
<TruncatedFilePath
|
<TruncatedFilePath
|
||||||
path={file.path}
|
path={file.path}
|
||||||
className="text-xs font-mono flex-1 text-foreground"
|
className="text-xs font-mono flex-1 text-foreground"
|
||||||
/>
|
/>
|
||||||
|
{isMergeFile && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0 bg-purple-500/15 text-purple-400 border-purple-500/30 inline-flex items-center gap-0.5">
|
||||||
|
<GitMerge className="w-2.5 h-2.5" />
|
||||||
|
{getMergeTypeLabel(file.mergeType)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0',
|
'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0',
|
||||||
@@ -580,9 +840,85 @@ export function CommitWorktreeDialog({
|
|||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Push after commit option */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="push-after-commit"
|
||||||
|
checked={pushAfterCommit}
|
||||||
|
onCheckedChange={(checked) => setPushAfterCommit(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="push-after-commit"
|
||||||
|
className="text-sm font-medium cursor-pointer flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5" />
|
||||||
|
Push to remote after commit
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pushAfterCommit && (
|
||||||
|
<div className="ml-6 flex flex-col gap-1.5">
|
||||||
|
{isLoadingRemotes || (!remotesFetched && !remotesFetchError) ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Spinner size="sm" />
|
||||||
|
<span>Loading remotes...</span>
|
||||||
|
</div>
|
||||||
|
) : remotesFetchError ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||||
|
<span>Failed to load remotes.</span>
|
||||||
|
<button
|
||||||
|
className="text-xs underline hover:text-foreground transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
if (worktree) {
|
||||||
|
setRemotesFetchError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : remotes.length === 0 && remotesFetched ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No remotes configured for this repository.
|
||||||
|
</p>
|
||||||
|
) : remotes.length > 0 ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="remote-select"
|
||||||
|
className="text-xs text-muted-foreground whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Remote:
|
||||||
|
</Label>
|
||||||
|
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
|
||||||
|
<SelectTrigger id="remote-select" className="h-8 text-xs flex-1">
|
||||||
|
<SelectValue placeholder="Select remote" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{remotes.map((remote) => (
|
||||||
|
<SelectItem
|
||||||
|
key={remote.name}
|
||||||
|
value={remote.name}
|
||||||
|
description={
|
||||||
|
<span className="text-xs text-muted-foreground truncate w-full block">
|
||||||
|
{remote.url}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{remote.name}</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Press <kbd className="px-1 py-0.5 bg-muted rounded text-xs">Cmd/Ctrl+Enter</kbd> to
|
Press <kbd className="px-1 py-0.5 bg-muted rounded text-xs">Cmd/Ctrl+Enter</kbd> to
|
||||||
commit
|
commit{pushAfterCommit ? ' & push' : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -590,23 +926,41 @@ export function CommitWorktreeDialog({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
disabled={isLoading || isGenerating}
|
disabled={isLoading || isPushing || isGenerating}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCommit}
|
onClick={handleCommit}
|
||||||
disabled={isLoading || isGenerating || !message.trim() || selectedFiles.size === 0}
|
disabled={
|
||||||
|
isLoading ||
|
||||||
|
isPushing ||
|
||||||
|
isGenerating ||
|
||||||
|
(isPushRetry
|
||||||
|
? !selectedRemote
|
||||||
|
: !message.trim() ||
|
||||||
|
selectedFiles.size === 0 ||
|
||||||
|
(pushAfterCommit && !selectedRemote))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading || isPushing ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Spinner size="sm" className="mr-2" />
|
||||||
Committing...
|
{isPushing ? 'Pushing...' : 'Committing...'}
|
||||||
|
</>
|
||||||
|
) : isPushRetry ? (
|
||||||
|
<>
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
Retry Push
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<GitCommit className="w-4 h-4 mr-2" />
|
{pushAfterCommit ? (
|
||||||
Commit
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
) : (
|
||||||
|
<GitCommit className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{pushAfterCommit ? 'Commit & Push' : 'Commit'}
|
||||||
{selectedFiles.size > 0 && selectedFiles.size < files.length
|
{selectedFiles.size > 0 && selectedFiles.size < files.length
|
||||||
? ` (${selectedFiles.size} file${selectedFiles.size > 1 ? 's' : ''})`
|
? ` (${selectedFiles.size} file${selectedFiles.size > 1 ? 's' : ''})`
|
||||||
: ''}
|
: ''}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { useWorktreeBranches } from '@/hooks/queries';
|
|||||||
interface RemoteInfo {
|
interface RemoteInfo {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
branches?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorktreeInfo {
|
interface WorktreeInfo {
|
||||||
@@ -74,13 +75,19 @@ export function CreatePRDialog({
|
|||||||
// Remote selection state
|
// Remote selection state
|
||||||
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
|
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
|
||||||
const [selectedRemote, setSelectedRemote] = useState<string>('');
|
const [selectedRemote, setSelectedRemote] = useState<string>('');
|
||||||
|
// Target remote: which remote to create the PR against (may differ from push remote)
|
||||||
|
const [selectedTargetRemote, setSelectedTargetRemote] = useState<string>('');
|
||||||
const [isLoadingRemotes, setIsLoadingRemotes] = useState(false);
|
const [isLoadingRemotes, setIsLoadingRemotes] = useState(false);
|
||||||
// Keep a ref in sync with selectedRemote so fetchRemotes can read the latest value
|
// Keep a ref in sync with selectedRemote so fetchRemotes can read the latest value
|
||||||
// without needing it in its dependency array (which would cause re-fetch loops)
|
// without needing it in its dependency array (which would cause re-fetch loops)
|
||||||
const selectedRemoteRef = useRef<string>(selectedRemote);
|
const selectedRemoteRef = useRef<string>(selectedRemote);
|
||||||
|
const selectedTargetRemoteRef = useRef<string>(selectedTargetRemote);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectedRemoteRef.current = selectedRemote;
|
selectedRemoteRef.current = selectedRemote;
|
||||||
}, [selectedRemote]);
|
}, [selectedRemote]);
|
||||||
|
useEffect(() => {
|
||||||
|
selectedTargetRemoteRef.current = selectedTargetRemote;
|
||||||
|
}, [selectedTargetRemote]);
|
||||||
|
|
||||||
// Generate description state
|
// Generate description state
|
||||||
const [isGeneratingDescription, setIsGeneratingDescription] = useState(false);
|
const [isGeneratingDescription, setIsGeneratingDescription] = useState(false);
|
||||||
@@ -91,11 +98,115 @@ export function CreatePRDialog({
|
|||||||
true // Include remote branches for PR base branch selection
|
true // Include remote branches for PR base branch selection
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter out current worktree branch from the list
|
// Determine if push remote selection is needed:
|
||||||
const branches = useMemo(() => {
|
// Show when there are unpushed commits, no remote tracking branch, or uncommitted changes
|
||||||
if (!branchesData?.branches) return [];
|
// (uncommitted changes will be committed first, then pushed)
|
||||||
return branchesData.branches.map((b) => b.name).filter((name) => name !== worktree?.branch);
|
const branchHasRemote = branchesData?.hasRemoteBranch ?? false;
|
||||||
}, [branchesData?.branches, worktree?.branch]);
|
const branchAheadCount = branchesData?.aheadCount ?? 0;
|
||||||
|
const needsPush = !branchHasRemote || branchAheadCount > 0 || !!worktree?.hasChanges;
|
||||||
|
|
||||||
|
// Determine the active remote to scope branches to.
|
||||||
|
// For multi-remote: use the selected target remote.
|
||||||
|
// For single remote: automatically scope to that remote.
|
||||||
|
const activeRemote = useMemo(() => {
|
||||||
|
if (remotes.length === 1) return remotes[0].name;
|
||||||
|
if (selectedTargetRemote) return selectedTargetRemote;
|
||||||
|
return '';
|
||||||
|
}, [remotes, selectedTargetRemote]);
|
||||||
|
|
||||||
|
// Filter branches by the active remote and strip remote prefixes for display.
|
||||||
|
// Returns display names (e.g. "main") without remote prefix.
|
||||||
|
// Also builds a map from display name → full ref (e.g. "origin/main") for PR creation.
|
||||||
|
const { branches, branchFullRefMap } = useMemo(() => {
|
||||||
|
if (!branchesData?.branches)
|
||||||
|
return { branches: [], branchFullRefMap: new Map<string, string>() };
|
||||||
|
|
||||||
|
const refMap = new Map<string, string>();
|
||||||
|
|
||||||
|
// If we have an active remote with branch info from the remotes endpoint, use that as the source
|
||||||
|
const activeRemoteInfo = activeRemote
|
||||||
|
? remotes.find((r) => r.name === activeRemote)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (activeRemoteInfo?.branches && activeRemoteInfo.branches.length > 0) {
|
||||||
|
// Use the remote's branch list — these are already short names (e.g. "main")
|
||||||
|
const filteredBranches = activeRemoteInfo.branches
|
||||||
|
.filter((branchName) => {
|
||||||
|
// Exclude the current worktree branch
|
||||||
|
return branchName !== worktree?.branch;
|
||||||
|
})
|
||||||
|
.map((branchName) => {
|
||||||
|
// Map display name to full ref
|
||||||
|
const fullRef = `${activeRemote}/${branchName}`;
|
||||||
|
refMap.set(branchName, fullRef);
|
||||||
|
return branchName;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { branches: filteredBranches, branchFullRefMap: refMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: if no remote info available, use the branches from the branches endpoint
|
||||||
|
// Filter and strip prefixes
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const filteredBranches: string[] = [];
|
||||||
|
|
||||||
|
for (const b of branchesData.branches) {
|
||||||
|
// Skip the current worktree branch
|
||||||
|
if (b.name === worktree?.branch) continue;
|
||||||
|
|
||||||
|
if (b.isRemote) {
|
||||||
|
// Remote branch: check if it belongs to the active remote
|
||||||
|
const slashIndex = b.name.indexOf('/');
|
||||||
|
if (slashIndex === -1) continue;
|
||||||
|
|
||||||
|
const remoteName = b.name.substring(0, slashIndex);
|
||||||
|
const branchName = b.name.substring(slashIndex + 1);
|
||||||
|
|
||||||
|
// If we have an active remote, only include branches from that remote
|
||||||
|
if (activeRemote && remoteName !== activeRemote) continue;
|
||||||
|
|
||||||
|
// Strip the remote prefix for display
|
||||||
|
if (!seen.has(branchName)) {
|
||||||
|
seen.add(branchName);
|
||||||
|
filteredBranches.push(branchName);
|
||||||
|
refMap.set(branchName, b.name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Local branch — only include if it has a remote counterpart on the active remote
|
||||||
|
// or if no active remote is set (no remotes at all)
|
||||||
|
if (!activeRemote) {
|
||||||
|
if (!seen.has(b.name)) {
|
||||||
|
seen.add(b.name);
|
||||||
|
filteredBranches.push(b.name);
|
||||||
|
refMap.set(b.name, b.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// When active remote is set, skip local-only branches — the remote version
|
||||||
|
// will be included from the remote branches above
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { branches: filteredBranches, branchFullRefMap: refMap };
|
||||||
|
}, [branchesData?.branches, worktree?.branch, activeRemote, remotes]);
|
||||||
|
|
||||||
|
// When branches change (e.g. target remote changed), reset base branch if current selection is no longer valid
|
||||||
|
useEffect(() => {
|
||||||
|
if (branches.length > 0 && baseBranch && !branches.includes(baseBranch)) {
|
||||||
|
// Current base branch is not in the filtered list — pick the best match
|
||||||
|
// Strip any existing remote prefix from the current base branch for comparison
|
||||||
|
const strippedBaseBranch = baseBranch.includes('/')
|
||||||
|
? baseBranch.substring(baseBranch.indexOf('/') + 1)
|
||||||
|
: baseBranch;
|
||||||
|
|
||||||
|
// Check if the stripped version exists in the list
|
||||||
|
if (branches.includes(strippedBaseBranch)) {
|
||||||
|
setBaseBranch(strippedBaseBranch);
|
||||||
|
} else {
|
||||||
|
const mainBranch = branches.find((b) => b === 'main' || b === 'master');
|
||||||
|
setBaseBranch(mainBranch || branches[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [branches, baseBranch]);
|
||||||
|
|
||||||
// Fetch remotes when dialog opens
|
// Fetch remotes when dialog opens
|
||||||
const fetchRemotes = useCallback(async () => {
|
const fetchRemotes = useCallback(async () => {
|
||||||
@@ -109,14 +220,15 @@ export function CreatePRDialog({
|
|||||||
|
|
||||||
if (result.success && result.result) {
|
if (result.success && result.result) {
|
||||||
const remoteInfos: RemoteInfo[] = result.result.remotes.map(
|
const remoteInfos: RemoteInfo[] = result.result.remotes.map(
|
||||||
(r: { name: string; url: string }) => ({
|
(r: { name: string; url: string; branches?: { name: string }[] }) => ({
|
||||||
name: r.name,
|
name: r.name,
|
||||||
url: r.url,
|
url: r.url,
|
||||||
|
branches: r.branches?.map((b: { name: string }) => b.name) || [],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
setRemotes(remoteInfos);
|
setRemotes(remoteInfos);
|
||||||
|
|
||||||
// Preserve existing selection if it's still valid; otherwise fall back to 'origin' or first remote
|
// Preserve existing push remote selection if it's still valid; otherwise fall back to 'origin' or first remote
|
||||||
if (remoteInfos.length > 0) {
|
if (remoteInfos.length > 0) {
|
||||||
const remoteNames = remoteInfos.map((r) => r.name);
|
const remoteNames = remoteInfos.map((r) => r.name);
|
||||||
const currentSelection = selectedRemoteRef.current;
|
const currentSelection = selectedRemoteRef.current;
|
||||||
@@ -126,6 +238,19 @@ export function CreatePRDialog({
|
|||||||
const defaultRemote = remoteInfos.find((r) => r.name === 'origin') || remoteInfos[0];
|
const defaultRemote = remoteInfos.find((r) => r.name === 'origin') || remoteInfos[0];
|
||||||
setSelectedRemote(defaultRemote.name);
|
setSelectedRemote(defaultRemote.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preserve existing target remote selection if it's still valid
|
||||||
|
const currentTargetSelection = selectedTargetRemoteRef.current;
|
||||||
|
const currentTargetStillExists =
|
||||||
|
currentTargetSelection !== '' && remoteNames.includes(currentTargetSelection);
|
||||||
|
if (!currentTargetStillExists) {
|
||||||
|
// Default target remote: 'upstream' if it exists (fork workflow), otherwise same as push remote
|
||||||
|
const defaultTarget =
|
||||||
|
remoteInfos.find((r) => r.name === 'upstream') ||
|
||||||
|
remoteInfos.find((r) => r.name === 'origin') ||
|
||||||
|
remoteInfos[0];
|
||||||
|
setSelectedTargetRemote(defaultTarget.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -154,6 +279,7 @@ export function CreatePRDialog({
|
|||||||
setShowBrowserFallback(false);
|
setShowBrowserFallback(false);
|
||||||
setRemotes([]);
|
setRemotes([]);
|
||||||
setSelectedRemote('');
|
setSelectedRemote('');
|
||||||
|
setSelectedTargetRemote('');
|
||||||
setIsGeneratingDescription(false);
|
setIsGeneratingDescription(false);
|
||||||
operationCompletedRef.current = false;
|
operationCompletedRef.current = false;
|
||||||
}, [defaultBaseBranch]);
|
}, [defaultBaseBranch]);
|
||||||
@@ -171,7 +297,12 @@ export function CreatePRDialog({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const api = getHttpApiClient();
|
const api = getHttpApiClient();
|
||||||
const result = await api.worktree.generatePRDescription(worktree.path, baseBranch);
|
// Resolve the display name to the actual branch name for the API
|
||||||
|
const resolvedRef = branchFullRefMap.get(baseBranch) || baseBranch;
|
||||||
|
const branchNameForApi = resolvedRef.includes('/')
|
||||||
|
? resolvedRef.substring(resolvedRef.indexOf('/') + 1)
|
||||||
|
: resolvedRef;
|
||||||
|
const result = await api.worktree.generatePRDescription(worktree.path, branchNameForApi);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (result.title) {
|
if (result.title) {
|
||||||
@@ -207,14 +338,27 @@ export function CreatePRDialog({
|
|||||||
setError('Worktree API not available');
|
setError('Worktree API not available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Resolve the display branch name to the full ref for the API call.
|
||||||
|
// The baseBranch state holds the display name (e.g. "main"), but the API
|
||||||
|
// may need the short name without the remote prefix. We pass the display name
|
||||||
|
// since the backend handles branch resolution. However, if the full ref is
|
||||||
|
// available, we can use it for more precise targeting.
|
||||||
|
const resolvedBaseBranch = branchFullRefMap.get(baseBranch) || baseBranch;
|
||||||
|
// Strip the remote prefix from the resolved ref for the API call
|
||||||
|
// (e.g. "origin/main" → "main") since the backend expects the branch name only
|
||||||
|
const baseBranchForApi = resolvedBaseBranch.includes('/')
|
||||||
|
? resolvedBaseBranch.substring(resolvedBaseBranch.indexOf('/') + 1)
|
||||||
|
: resolvedBaseBranch;
|
||||||
|
|
||||||
const result = await api.worktree.createPR(worktree.path, {
|
const result = await api.worktree.createPR(worktree.path, {
|
||||||
projectPath: projectPath || undefined,
|
projectPath: projectPath || undefined,
|
||||||
commitMessage: commitMessage || undefined,
|
commitMessage: commitMessage || undefined,
|
||||||
prTitle: title || worktree.branch,
|
prTitle: title || worktree.branch,
|
||||||
prBody: body || `Changes from branch ${worktree.branch}`,
|
prBody: body || `Changes from branch ${worktree.branch}`,
|
||||||
baseBranch,
|
baseBranch: baseBranchForApi,
|
||||||
draft: isDraft,
|
draft: isDraft,
|
||||||
remote: selectedRemote || undefined,
|
remote: selectedRemote || undefined,
|
||||||
|
targetRemote: remotes.length > 1 ? selectedTargetRemote || undefined : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success && result.result) {
|
if (result.success && result.result) {
|
||||||
@@ -348,7 +492,7 @@ export function CreatePRDialog({
|
|||||||
Create Pull Request
|
Create Pull Request
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="break-words">
|
<DialogDescription className="break-words">
|
||||||
Push changes and create a pull request from{' '}
|
{worktree.hasChanges ? 'Push changes and create' : 'Create'} a pull request from{' '}
|
||||||
<code className="font-mono bg-muted px-1 rounded break-all">{worktree.branch}</code>
|
<code className="font-mono bg-muted px-1 rounded break-all">{worktree.branch}</code>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -482,8 +626,8 @@ export function CreatePRDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* Remote selector - only show if multiple remotes are available */}
|
{/* Push remote selector - only show when multiple remotes and there are commits to push */}
|
||||||
{remotes.length > 1 && (
|
{remotes.length > 1 && needsPush && (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="remote-select">Push to Remote</Label>
|
<Label htmlFor="remote-select">Push to Remote</Label>
|
||||||
@@ -525,14 +669,50 @@ export function CreatePRDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Target remote selector - which remote to create PR against */}
|
||||||
|
{remotes.length > 1 && (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="target-remote-select">Create PR Against</Label>
|
||||||
|
<Select value={selectedTargetRemote} onValueChange={setSelectedTargetRemote}>
|
||||||
|
<SelectTrigger id="target-remote-select">
|
||||||
|
<SelectValue placeholder="Select target remote" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{remotes.map((remote) => (
|
||||||
|
<SelectItem
|
||||||
|
key={remote.name}
|
||||||
|
value={remote.name}
|
||||||
|
description={
|
||||||
|
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
|
||||||
|
{remote.url}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{remote.name}</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
The remote repository where the pull request will be created
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="base-branch">Base Branch</Label>
|
<Label htmlFor="base-branch">Base Remote Branch</Label>
|
||||||
<BranchAutocomplete
|
<BranchAutocomplete
|
||||||
value={baseBranch}
|
value={baseBranch}
|
||||||
onChange={setBaseBranch}
|
onChange={setBaseBranch}
|
||||||
branches={branches}
|
branches={branches}
|
||||||
placeholder="Select base branch..."
|
placeholder="Select base branch..."
|
||||||
disabled={isLoadingBranches}
|
disabled={isLoadingBranches || isLoadingRemotes}
|
||||||
|
allowCreate={false}
|
||||||
|
emptyMessage={
|
||||||
|
activeRemote
|
||||||
|
? `No branches found on remote "${activeRemote}".`
|
||||||
|
: 'No matching branches found.'
|
||||||
|
}
|
||||||
data-testid="base-branch-autocomplete"
|
data-testid="base-branch-autocomplete"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -10,9 +10,11 @@ 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, AlertCircle } from 'lucide-react';
|
import { GitBranch, AlertCircle, ChevronDown, ChevronRight, Globe, RefreshCw } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,6 +102,145 @@ export function CreateWorktreeDialog({
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<{ title: string; description?: string } | null>(null);
|
const [error, setError] = useState<{ title: string; description?: string } | null>(null);
|
||||||
|
|
||||||
|
// Base branch selection state
|
||||||
|
const [showBaseBranch, setShowBaseBranch] = useState(false);
|
||||||
|
const [baseBranch, setBaseBranch] = useState('');
|
||||||
|
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||||
|
const [availableBranches, setAvailableBranches] = useState<
|
||||||
|
Array<{ name: string; isRemote: boolean }>
|
||||||
|
>([]);
|
||||||
|
// When the branch list fetch fails, store a message to show the user and
|
||||||
|
// allow free-form branch entry via allowCreate as a fallback.
|
||||||
|
const [branchFetchError, setBranchFetchError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// AbortController ref so in-flight branch fetches can be cancelled when the dialog closes
|
||||||
|
const branchFetchAbortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// Fetch available branches (local + remote) when the base branch section is expanded
|
||||||
|
const fetchBranches = useCallback(
|
||||||
|
async (signal?: AbortSignal) => {
|
||||||
|
if (!projectPath) return;
|
||||||
|
|
||||||
|
setIsLoadingBranches(true);
|
||||||
|
try {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
|
||||||
|
// Fetch branches using the project path (use listBranches on the project root).
|
||||||
|
// Pass the AbortSignal so controller.abort() cancels the in-flight HTTP request.
|
||||||
|
const branchResult = await api.worktree.listBranches(projectPath, true, signal);
|
||||||
|
|
||||||
|
// If the fetch was aborted while awaiting, bail out to avoid stale state writes
|
||||||
|
if (signal?.aborted) return;
|
||||||
|
|
||||||
|
if (branchResult.success && branchResult.result) {
|
||||||
|
setBranchFetchError(null);
|
||||||
|
setAvailableBranches(
|
||||||
|
branchResult.result.branches.map((b: { name: string; isRemote: boolean }) => ({
|
||||||
|
name: b.name,
|
||||||
|
isRemote: b.isRemote,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// API returned success: false — treat as an error
|
||||||
|
const message =
|
||||||
|
branchResult.error || 'Failed to load branches. You can type a branch name manually.';
|
||||||
|
setBranchFetchError(message);
|
||||||
|
setAvailableBranches([{ name: 'main', isRemote: false }]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// If aborted, don't update state
|
||||||
|
if (signal?.aborted) return;
|
||||||
|
|
||||||
|
const message =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Failed to load branches. You can type a branch name manually.';
|
||||||
|
setBranchFetchError(message);
|
||||||
|
// Provide 'main' as a safe fallback so the autocomplete is not empty,
|
||||||
|
// and enable free-form entry (allowCreate) so the user can still type
|
||||||
|
// any branch name when the remote list is unavailable.
|
||||||
|
setAvailableBranches([{ name: 'main', isRemote: false }]);
|
||||||
|
} finally {
|
||||||
|
if (!signal?.aborted) {
|
||||||
|
setIsLoadingBranches(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectPath]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch branches when the base branch section is expanded
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && showBaseBranch) {
|
||||||
|
// Abort any previous in-flight fetch
|
||||||
|
branchFetchAbortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
branchFetchAbortRef.current = controller;
|
||||||
|
fetchBranches(controller.signal);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
branchFetchAbortRef.current?.abort();
|
||||||
|
branchFetchAbortRef.current = null;
|
||||||
|
};
|
||||||
|
}, [open, showBaseBranch, fetchBranches]);
|
||||||
|
|
||||||
|
// Reset state when dialog closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
// Abort any in-flight branch fetch to prevent stale writes
|
||||||
|
branchFetchAbortRef.current?.abort();
|
||||||
|
branchFetchAbortRef.current = null;
|
||||||
|
|
||||||
|
setBranchName('');
|
||||||
|
setBaseBranch('');
|
||||||
|
setShowBaseBranch(false);
|
||||||
|
setError(null);
|
||||||
|
setAvailableBranches([]);
|
||||||
|
setBranchFetchError(null);
|
||||||
|
setIsLoadingBranches(false);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Build branch name list for the autocomplete, with local branches first then remote
|
||||||
|
const branchNames = useMemo(() => {
|
||||||
|
const local: string[] = [];
|
||||||
|
const remote: string[] = [];
|
||||||
|
|
||||||
|
for (const b of availableBranches) {
|
||||||
|
if (b.isRemote) {
|
||||||
|
// Skip bare remote refs without a branch name (e.g. "origin" by itself)
|
||||||
|
if (!b.name.includes('/')) continue;
|
||||||
|
remote.push(b.name);
|
||||||
|
} else {
|
||||||
|
local.push(b.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local branches first, then remote branches
|
||||||
|
return [...local, ...remote];
|
||||||
|
}, [availableBranches]);
|
||||||
|
|
||||||
|
// Determine if the selected base branch is a remote branch.
|
||||||
|
// Also detect manually entered remote-style names (e.g. "origin/feature")
|
||||||
|
// so the UI shows the "Remote branch — will fetch latest" hint even when
|
||||||
|
// the branch isn't in the fetched availableBranches list.
|
||||||
|
const isRemoteBaseBranch = useMemo(() => {
|
||||||
|
if (!baseBranch) return false;
|
||||||
|
// If the branch list couldn't be fetched, availableBranches is a fallback
|
||||||
|
// and may not reflect reality — suppress the remote hint to avoid misleading the user.
|
||||||
|
if (branchFetchError) return false;
|
||||||
|
// Check fetched branch list first
|
||||||
|
const knownRemote = availableBranches.some((b) => b.name === baseBranch && b.isRemote);
|
||||||
|
if (knownRemote) return true;
|
||||||
|
// Heuristic: if the branch contains '/' and isn't a known local branch,
|
||||||
|
// treat it as a remote ref (e.g. "origin/main")
|
||||||
|
if (baseBranch.includes('/')) {
|
||||||
|
const isKnownLocal = availableBranches.some((b) => b.name === baseBranch && !b.isRemote);
|
||||||
|
return !isKnownLocal;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [baseBranch, availableBranches, branchFetchError]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!branchName.trim()) {
|
if (!branchName.trim()) {
|
||||||
setError({ title: 'Branch name is required' });
|
setError({ title: 'Branch name is required' });
|
||||||
@@ -116,6 +257,17 @@ export function CreateWorktreeDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate baseBranch using the same allowed-character check as branchName to prevent
|
||||||
|
// shell-special characters or invalid git ref names from reaching the API.
|
||||||
|
const trimmedBaseBranch = baseBranch.trim();
|
||||||
|
if (trimmedBaseBranch && !validBranchRegex.test(trimmedBaseBranch)) {
|
||||||
|
setError({
|
||||||
|
title: 'Invalid base branch name',
|
||||||
|
description: 'Use only letters, numbers, dots, underscores, hyphens, and slashes.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@@ -125,15 +277,22 @@ export function CreateWorktreeDialog({
|
|||||||
setError({ title: 'Worktree API not available' });
|
setError({ title: 'Worktree API not available' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await api.worktree.create(projectPath, branchName);
|
|
||||||
|
// Pass the validated baseBranch if one was selected (otherwise defaults to HEAD)
|
||||||
|
const effectiveBaseBranch = trimmedBaseBranch || undefined;
|
||||||
|
const result = await api.worktree.create(projectPath, branchName, effectiveBaseBranch);
|
||||||
|
|
||||||
if (result.success && result.worktree) {
|
if (result.success && result.worktree) {
|
||||||
|
const baseDesc = effectiveBaseBranch ? ` from ${effectiveBaseBranch}` : '';
|
||||||
toast.success(`Worktree created for branch "${result.worktree.branch}"`, {
|
toast.success(`Worktree created for branch "${result.worktree.branch}"`, {
|
||||||
description: result.worktree.isNew ? 'New branch created' : 'Using existing branch',
|
description: result.worktree.isNew
|
||||||
|
? `New branch created${baseDesc}`
|
||||||
|
: 'Using existing branch',
|
||||||
});
|
});
|
||||||
onCreated({ path: result.worktree.path, branch: result.worktree.branch });
|
onCreated({ path: result.worktree.path, branch: result.worktree.branch });
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
setBranchName('');
|
setBranchName('');
|
||||||
|
setBaseBranch('');
|
||||||
} else {
|
} else {
|
||||||
setError(parseWorktreeError(result.error || 'Failed to create worktree'));
|
setError(parseWorktreeError(result.error || 'Failed to create worktree'));
|
||||||
}
|
}
|
||||||
@@ -154,7 +313,7 @@ export function CreateWorktreeDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[480px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<GitBranch className="w-5 h-5" />
|
<GitBranch className="w-5 h-5" />
|
||||||
@@ -181,19 +340,96 @@ export function CreateWorktreeDialog({
|
|||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{error && (
|
</div>
|
||||||
<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" />
|
{/* Base Branch Section - collapsible */}
|
||||||
<div className="space-y-1">
|
<div className="grid gap-2">
|
||||||
<p className="text-sm font-medium text-destructive">{error.title}</p>
|
<button
|
||||||
{error.description && (
|
type="button"
|
||||||
<p className="text-xs text-destructive/80">{error.description}</p>
|
onClick={() => setShowBaseBranch(!showBaseBranch)}
|
||||||
)}
|
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
|
||||||
|
>
|
||||||
|
{showBaseBranch ? (
|
||||||
|
<ChevronDown className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
<span>Base Branch</span>
|
||||||
|
{baseBranch && !showBaseBranch && (
|
||||||
|
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono ml-1">
|
||||||
|
{baseBranch}
|
||||||
|
</code>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showBaseBranch && (
|
||||||
|
<div className="grid gap-2 pl-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Select a local or remote branch as the starting point
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
branchFetchAbortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
branchFetchAbortRef.current = controller;
|
||||||
|
void fetchBranches(controller.signal);
|
||||||
|
}}
|
||||||
|
disabled={isLoadingBranches}
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
>
|
||||||
|
{isLoadingBranches ? (
|
||||||
|
<Spinner size="xs" className="mr-1" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="w-3 h-3 mr-1" />
|
||||||
|
)}
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{branchFetchError && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-destructive">
|
||||||
|
<AlertCircle className="w-3 h-3 flex-shrink-0" />
|
||||||
|
<span>Could not load branches: {branchFetchError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<BranchAutocomplete
|
||||||
|
value={baseBranch}
|
||||||
|
onChange={(value) => {
|
||||||
|
setBaseBranch(value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
branches={branchNames}
|
||||||
|
placeholder="Select base branch (default: HEAD)..."
|
||||||
|
disabled={isLoadingBranches}
|
||||||
|
allowCreate={!!branchFetchError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isRemoteBaseBranch && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Globe className="w-3 h-3" />
|
||||||
|
<span>Remote branch — will fetch latest before creating worktree</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{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 className="text-xs text-muted-foreground space-y-1">
|
<div className="text-xs text-muted-foreground space-y-1">
|
||||||
<p>Examples:</p>
|
<p>Examples:</p>
|
||||||
<ul className="list-disc list-inside pl-2 space-y-0.5">
|
<ul className="list-disc list-inside pl-2 space-y-0.5">
|
||||||
@@ -218,7 +454,7 @@ export function CreateWorktreeDialog({
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Spinner size="sm" className="mr-2" />
|
||||||
Creating...
|
{isRemoteBaseBranch ? 'Fetching & Creating...' : 'Creating...'}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -17,11 +17,17 @@ import {
|
|||||||
FileWarning,
|
FileWarning,
|
||||||
Wrench,
|
Wrench,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
GitMerge,
|
||||||
|
GitCommitHorizontal,
|
||||||
|
FileText,
|
||||||
|
Settings,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
import type { MergeConflictInfo } from '../worktree-panel/types';
|
import type { MergeConflictInfo } from '../worktree-panel/types';
|
||||||
|
|
||||||
interface WorktreeInfo {
|
interface WorktreeInfo {
|
||||||
@@ -37,6 +43,7 @@ type PullPhase =
|
|||||||
| 'local-changes' // Local changes detected, asking user what to do
|
| 'local-changes' // Local changes detected, asking user what to do
|
||||||
| 'pulling' // Actively pulling (with or without stash)
|
| 'pulling' // Actively pulling (with or without stash)
|
||||||
| 'success' // Pull completed successfully
|
| 'success' // Pull completed successfully
|
||||||
|
| 'merge-complete' // Pull resulted in a merge (not fast-forward, no conflicts)
|
||||||
| 'conflict' // Merge conflicts detected
|
| 'conflict' // Merge conflicts detected
|
||||||
| 'error'; // Something went wrong
|
| 'error'; // Something went wrong
|
||||||
|
|
||||||
@@ -53,6 +60,9 @@ interface PullResult {
|
|||||||
stashed?: boolean;
|
stashed?: boolean;
|
||||||
stashRestored?: boolean;
|
stashRestored?: boolean;
|
||||||
stashRecoveryFailed?: boolean;
|
stashRecoveryFailed?: boolean;
|
||||||
|
isMerge?: boolean;
|
||||||
|
isFastForward?: boolean;
|
||||||
|
mergeAffectedFiles?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GitPullDialogProps {
|
interface GitPullDialogProps {
|
||||||
@@ -62,6 +72,8 @@ interface GitPullDialogProps {
|
|||||||
remote?: string;
|
remote?: string;
|
||||||
onPulled?: () => void;
|
onPulled?: () => void;
|
||||||
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
||||||
|
/** Called when user chooses to commit the merge — opens the commit dialog */
|
||||||
|
onCommitMerge?: (worktree: { path: string; branch: string; isMain: boolean }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GitPullDialog({
|
export function GitPullDialog({
|
||||||
@@ -71,10 +83,54 @@ export function GitPullDialog({
|
|||||||
remote,
|
remote,
|
||||||
onPulled,
|
onPulled,
|
||||||
onCreateConflictResolutionFeature,
|
onCreateConflictResolutionFeature,
|
||||||
|
onCommitMerge,
|
||||||
}: GitPullDialogProps) {
|
}: GitPullDialogProps) {
|
||||||
const [phase, setPhase] = useState<PullPhase>('checking');
|
const [phase, setPhase] = useState<PullPhase>('checking');
|
||||||
const [pullResult, setPullResult] = useState<PullResult | null>(null);
|
const [pullResult, setPullResult] = useState<PullResult | null>(null);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [rememberChoice, setRememberChoice] = useState(false);
|
||||||
|
const [showMergeFiles, setShowMergeFiles] = useState(false);
|
||||||
|
|
||||||
|
const mergePostAction = useAppStore((s) => s.mergePostAction);
|
||||||
|
const setMergePostAction = useAppStore((s) => s.setMergePostAction);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the appropriate phase after a successful pull.
|
||||||
|
* If the pull resulted in a merge (not fast-forward) and no conflicts,
|
||||||
|
* check user preference before deciding whether to show merge prompt.
|
||||||
|
*/
|
||||||
|
const handleSuccessfulPull = useCallback(
|
||||||
|
(result: PullResult) => {
|
||||||
|
setPullResult(result);
|
||||||
|
|
||||||
|
if (result.isMerge && !result.hasConflicts) {
|
||||||
|
// Merge happened — check user preference
|
||||||
|
if (mergePostAction === 'commit') {
|
||||||
|
// User preference: auto-commit
|
||||||
|
setPhase('success');
|
||||||
|
onPulled?.();
|
||||||
|
// Auto-trigger commit dialog
|
||||||
|
if (worktree && onCommitMerge) {
|
||||||
|
onCommitMerge(worktree);
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
} else if (mergePostAction === 'manual') {
|
||||||
|
// User preference: manual review
|
||||||
|
setPhase('success');
|
||||||
|
onPulled?.();
|
||||||
|
} else {
|
||||||
|
// No preference — show merge prompt; onPulled will be called from the
|
||||||
|
// user-action handlers (handleCommitMerge / handleMergeManually) once
|
||||||
|
// the user makes their choice, consistent with the conflict phase.
|
||||||
|
setPhase('merge-complete');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setPhase('success');
|
||||||
|
onPulled?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mergePostAction, worktree, onCommitMerge, onPulled, onOpenChange]
|
||||||
|
);
|
||||||
|
|
||||||
const checkForLocalChanges = useCallback(async () => {
|
const checkForLocalChanges = useCallback(async () => {
|
||||||
if (!worktree) return;
|
if (!worktree) return;
|
||||||
@@ -103,9 +159,7 @@ export function GitPullDialog({
|
|||||||
setPhase('local-changes');
|
setPhase('local-changes');
|
||||||
} else if (result.result?.pulled !== undefined) {
|
} else if (result.result?.pulled !== undefined) {
|
||||||
// No local changes, pull went through (or already up to date)
|
// No local changes, pull went through (or already up to date)
|
||||||
setPullResult(result.result);
|
handleSuccessfulPull(result.result);
|
||||||
setPhase('success');
|
|
||||||
onPulled?.();
|
|
||||||
} else {
|
} else {
|
||||||
// Unexpected response: success but no recognizable fields
|
// Unexpected response: success but no recognizable fields
|
||||||
setPullResult(result.result ?? null);
|
setPullResult(result.result ?? null);
|
||||||
@@ -116,18 +170,33 @@ export function GitPullDialog({
|
|||||||
setErrorMessage(err instanceof Error ? err.message : 'Failed to check for changes');
|
setErrorMessage(err instanceof Error ? err.message : 'Failed to check for changes');
|
||||||
setPhase('error');
|
setPhase('error');
|
||||||
}
|
}
|
||||||
}, [worktree, remote, onPulled]);
|
}, [worktree, remote, handleSuccessfulPull]);
|
||||||
|
|
||||||
// Reset state when dialog opens
|
// Keep a ref to the latest checkForLocalChanges to break the circular dependency
|
||||||
|
// between the "reset/start" effect and the callback chain. Without this, any
|
||||||
|
// change in onPulled (passed from the parent) would recreate handleSuccessfulPull
|
||||||
|
// → checkForLocalChanges → re-trigger the effect while the dialog is already open,
|
||||||
|
// causing the pull flow to restart unintentionally.
|
||||||
|
const checkForLocalChangesRef = useRef(checkForLocalChanges);
|
||||||
|
useEffect(() => {
|
||||||
|
checkForLocalChangesRef.current = checkForLocalChanges;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset state when dialog opens and start the initial pull check.
|
||||||
|
// Depends only on `open` and `worktree` — NOT on `checkForLocalChanges` —
|
||||||
|
// so that parent callback re-creations don't restart the pull flow mid-flight.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && worktree) {
|
if (open && worktree) {
|
||||||
setPhase('checking');
|
setPhase('checking');
|
||||||
setPullResult(null);
|
setPullResult(null);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
// Start the initial check
|
setRememberChoice(false);
|
||||||
checkForLocalChanges();
|
setShowMergeFiles(false);
|
||||||
|
// Start the initial check using the ref so we always call the latest version
|
||||||
|
// without making it a dependency of this effect.
|
||||||
|
checkForLocalChangesRef.current();
|
||||||
}
|
}
|
||||||
}, [open, worktree, checkForLocalChanges]);
|
}, [open, worktree]);
|
||||||
|
|
||||||
const handlePullWithStash = useCallback(async () => {
|
const handlePullWithStash = useCallback(async () => {
|
||||||
if (!worktree) return;
|
if (!worktree) return;
|
||||||
@@ -155,8 +224,7 @@ export function GitPullDialog({
|
|||||||
if (result.result?.hasConflicts) {
|
if (result.result?.hasConflicts) {
|
||||||
setPhase('conflict');
|
setPhase('conflict');
|
||||||
} else if (result.result?.pulled) {
|
} else if (result.result?.pulled) {
|
||||||
setPhase('success');
|
handleSuccessfulPull(result.result);
|
||||||
onPulled?.();
|
|
||||||
} else {
|
} else {
|
||||||
// Unrecognized response: no pulled flag and no conflicts
|
// Unrecognized response: no pulled flag and no conflicts
|
||||||
console.warn('handlePullWithStash: unrecognized response', result.result);
|
console.warn('handlePullWithStash: unrecognized response', result.result);
|
||||||
@@ -167,7 +235,7 @@ export function GitPullDialog({
|
|||||||
setErrorMessage(err instanceof Error ? err.message : 'Failed to pull');
|
setErrorMessage(err instanceof Error ? err.message : 'Failed to pull');
|
||||||
setPhase('error');
|
setPhase('error');
|
||||||
}
|
}
|
||||||
}, [worktree, remote, onPulled]);
|
}, [worktree, remote, handleSuccessfulPull]);
|
||||||
|
|
||||||
const handleResolveWithAI = useCallback(() => {
|
const handleResolveWithAI = useCallback(() => {
|
||||||
if (!worktree || !pullResult || !onCreateConflictResolutionFeature) return;
|
if (!worktree || !pullResult || !onCreateConflictResolutionFeature) return;
|
||||||
@@ -186,6 +254,35 @@ export function GitPullDialog({
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}, [worktree, pullResult, remote, onCreateConflictResolutionFeature, onOpenChange]);
|
}, [worktree, pullResult, remote, onCreateConflictResolutionFeature, onOpenChange]);
|
||||||
|
|
||||||
|
const handleCommitMerge = useCallback(() => {
|
||||||
|
if (!worktree || !onCommitMerge) {
|
||||||
|
// No handler available — show feedback and bail without persisting preference
|
||||||
|
toast.error('Commit merge is not available', {
|
||||||
|
description: 'The commit merge action is not configured for this context.',
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rememberChoice) {
|
||||||
|
setMergePostAction('commit');
|
||||||
|
}
|
||||||
|
onPulled?.();
|
||||||
|
onCommitMerge(worktree);
|
||||||
|
onOpenChange(false);
|
||||||
|
}, [rememberChoice, setMergePostAction, worktree, onCommitMerge, onPulled, onOpenChange]);
|
||||||
|
|
||||||
|
const handleMergeManually = useCallback(() => {
|
||||||
|
if (rememberChoice) {
|
||||||
|
setMergePostAction('manual');
|
||||||
|
}
|
||||||
|
toast.info('Merge left for manual review', {
|
||||||
|
description: 'Review the merged files and commit when ready.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
onPulled?.();
|
||||||
|
onOpenChange(false);
|
||||||
|
}, [rememberChoice, setMergePostAction, onPulled, onOpenChange]);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}, [onOpenChange]);
|
}, [onOpenChange]);
|
||||||
@@ -336,6 +433,137 @@ export function GitPullDialog({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Merge Complete Phase — post-merge prompt */}
|
||||||
|
{phase === 'merge-complete' && (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<GitMerge className="w-5 h-5 text-purple-500" />
|
||||||
|
Merge Complete
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<span className="block">
|
||||||
|
Pull resulted in a merge on{' '}
|
||||||
|
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
|
||||||
|
{pullResult?.mergeAffectedFiles && pullResult.mergeAffectedFiles.length > 0 && (
|
||||||
|
<span>
|
||||||
|
{' '}
|
||||||
|
affecting {pullResult.mergeAffectedFiles.length} file
|
||||||
|
{pullResult.mergeAffectedFiles.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
. How would you like to proceed?
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{pullResult?.mergeAffectedFiles && pullResult.mergeAffectedFiles.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMergeFiles(!showMergeFiles)}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<FileText className="w-3 h-3" />
|
||||||
|
{showMergeFiles ? 'Hide' : 'Show'} affected files (
|
||||||
|
{pullResult.mergeAffectedFiles.length})
|
||||||
|
</button>
|
||||||
|
{showMergeFiles && (
|
||||||
|
<div className="mt-1.5 border border-border rounded-lg overflow-hidden max-h-[150px] overflow-y-auto scrollbar-visible">
|
||||||
|
{pullResult.mergeAffectedFiles.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
|
||||||
|
>
|
||||||
|
<GitMerge className="w-3 h-3 text-purple-500 flex-shrink-0" />
|
||||||
|
<span className="truncate">{file}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pullResult?.stashed &&
|
||||||
|
pullResult?.stashRestored &&
|
||||||
|
!pullResult?.stashRecoveryFailed && (
|
||||||
|
<div className="flex items-start gap-2 p-3 rounded-md bg-green-500/10 border border-green-500/20">
|
||||||
|
<Archive className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="text-green-600 dark:text-green-400 text-sm">
|
||||||
|
Your stashed changes have been restored successfully.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
|
||||||
|
<p className="text-sm text-muted-foreground font-medium mb-2">
|
||||||
|
Choose how to proceed:
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
|
||||||
|
<li>
|
||||||
|
<strong>Commit Merge</strong> — Open the commit dialog with a merge
|
||||||
|
commit message
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Review Manually</strong> — Leave the working tree as-is for
|
||||||
|
manual review
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Remember choice option */}
|
||||||
|
<div className="flex items-center gap-2 px-1">
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={rememberChoice}
|
||||||
|
onCheckedChange={(checked) => setRememberChoice(checked === true)}
|
||||||
|
className="rounded border-border"
|
||||||
|
/>
|
||||||
|
<Settings className="w-3 h-3" />
|
||||||
|
Remember my choice for future merges
|
||||||
|
</label>
|
||||||
|
{(rememberChoice || mergePostAction) && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-auto flex items-center gap-2">
|
||||||
|
<span className="opacity-70">
|
||||||
|
Current:{' '}
|
||||||
|
{mergePostAction === 'commit'
|
||||||
|
? 'auto-commit'
|
||||||
|
: mergePostAction === 'manual'
|
||||||
|
? 'manual review'
|
||||||
|
: 'ask every time'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setMergePostAction(null);
|
||||||
|
setRememberChoice(false);
|
||||||
|
}}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Reset preference
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className={cn('flex-col sm:flex-row gap-2')}>
|
||||||
|
<Button variant="outline" onClick={handleMergeManually} className="w-full sm:w-auto">
|
||||||
|
<FileText className="w-4 h-4 mr-2" />
|
||||||
|
Review Manually
|
||||||
|
</Button>
|
||||||
|
{worktree && onCommitMerge && (
|
||||||
|
<Button
|
||||||
|
onClick={handleCommitMerge}
|
||||||
|
className="w-full sm:w-auto bg-purple-600 hover:bg-purple-700 text-white"
|
||||||
|
>
|
||||||
|
<GitCommitHorizontal className="w-4 h-4 mr-2" />
|
||||||
|
Commit Merge
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Conflict Phase */}
|
{/* Conflict Phase */}
|
||||||
{phase === 'conflict' && (
|
{phase === 'conflict' && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ interface MergeWorktreeDialogProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
worktree: WorktreeInfo | null;
|
worktree: WorktreeInfo | null;
|
||||||
/** Called when merge is successful. deletedBranch indicates if the branch was also deleted. */
|
/** Called when integration is successful. integratedWorktree indicates the integrated worktree and deletedBranch indicates if the branch was also deleted. */
|
||||||
onMerged: (mergedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
|
onIntegrated: (integratedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
|
||||||
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ export function MergeWorktreeDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
projectPath,
|
projectPath,
|
||||||
worktree,
|
worktree,
|
||||||
onMerged,
|
onIntegrated,
|
||||||
onCreateConflictResolutionFeature,
|
onCreateConflictResolutionFeature,
|
||||||
}: MergeWorktreeDialogProps) {
|
}: MergeWorktreeDialogProps) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -105,10 +105,10 @@ export function MergeWorktreeDialog({
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const description = deleteWorktreeAndBranch
|
const description = deleteWorktreeAndBranch
|
||||||
? `Branch "${worktree.branch}" has been merged into "${targetBranch}" and the worktree and branch were deleted`
|
? `Branch "${worktree.branch}" has been integrated into "${targetBranch}" and the worktree and branch were deleted`
|
||||||
: `Branch "${worktree.branch}" has been merged into "${targetBranch}"`;
|
: `Branch "${worktree.branch}" has been integrated into "${targetBranch}"`;
|
||||||
toast.success(`Branch merged to ${targetBranch}`, { description });
|
toast.success(`Branch integrated into ${targetBranch}`, { description });
|
||||||
onMerged(worktree, deleteWorktreeAndBranch);
|
onIntegrated(worktree, deleteWorktreeAndBranch);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} else {
|
} else {
|
||||||
// Check if the error indicates merge conflicts
|
// Check if the error indicates merge conflicts
|
||||||
@@ -128,11 +128,11 @@ export function MergeWorktreeDialog({
|
|||||||
conflictFiles: result.conflictFiles || [],
|
conflictFiles: result.conflictFiles || [],
|
||||||
operationType: 'merge',
|
operationType: 'merge',
|
||||||
});
|
});
|
||||||
toast.error('Merge conflicts detected', {
|
toast.error('Integrate conflicts detected', {
|
||||||
description: 'Choose how to resolve the conflicts below.',
|
description: 'Choose how to resolve the conflicts below.',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error('Failed to merge branch', {
|
toast.error('Failed to integrate branch', {
|
||||||
description: result.error,
|
description: result.error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -153,11 +153,11 @@ export function MergeWorktreeDialog({
|
|||||||
conflictFiles: [],
|
conflictFiles: [],
|
||||||
operationType: 'merge',
|
operationType: 'merge',
|
||||||
});
|
});
|
||||||
toast.error('Merge conflicts detected', {
|
toast.error('Integrate conflicts detected', {
|
||||||
description: 'Choose how to resolve the conflicts below.',
|
description: 'Choose how to resolve the conflicts below.',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error('Failed to merge branch', {
|
toast.error('Failed to integrate branch', {
|
||||||
description: errorMessage,
|
description: errorMessage,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -191,12 +191,12 @@ export function MergeWorktreeDialog({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||||
Merge Conflicts Detected
|
Integrate Conflicts Detected
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription asChild>
|
<DialogDescription asChild>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<span className="block">
|
<span className="block">
|
||||||
There are conflicts when merging{' '}
|
There are conflicts when integrating{' '}
|
||||||
<code className="font-mono bg-muted px-1 rounded">
|
<code className="font-mono bg-muted px-1 rounded">
|
||||||
{mergeConflict.sourceBranch}
|
{mergeConflict.sourceBranch}
|
||||||
</code>{' '}
|
</code>{' '}
|
||||||
@@ -274,12 +274,12 @@ export function MergeWorktreeDialog({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<GitMerge className="w-5 h-5 text-green-600" />
|
<GitMerge className="w-5 h-5 text-green-600" />
|
||||||
Merge Branch
|
Integrate Branch
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription asChild>
|
<DialogDescription asChild>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<span className="block">
|
<span className="block">
|
||||||
Merge <code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>{' '}
|
Integrate <code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>{' '}
|
||||||
into:
|
into:
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -308,7 +308,7 @@ export function MergeWorktreeDialog({
|
|||||||
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
|
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||||
<span className="text-yellow-500 text-sm">
|
<span className="text-yellow-500 text-sm">
|
||||||
This worktree has {worktree.changedFilesCount} uncommitted change(s). Please
|
This worktree has {worktree.changedFilesCount} uncommitted change(s). Please
|
||||||
commit or discard them before merging.
|
commit or discard them before integrating.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -327,7 +327,7 @@ export function MergeWorktreeDialog({
|
|||||||
className="text-sm cursor-pointer flex items-center gap-1.5"
|
className="text-sm cursor-pointer flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5 text-destructive" />
|
<Trash2 className="w-3.5 h-3.5 text-destructive" />
|
||||||
Delete worktree and branch after merging
|
Delete worktree and branch after integrating
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -353,12 +353,12 @@ export function MergeWorktreeDialog({
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" variant="foreground" className="mr-2" />
|
<Spinner size="sm" variant="foreground" className="mr-2" />
|
||||||
Merging...
|
Integrating...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<GitMerge className="w-4 h-4 mr-2" />
|
<GitMerge className="w-4 h-4 mr-2" />
|
||||||
Merge
|
Integrate
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* Post-Merge Prompt Dialog
|
||||||
|
*
|
||||||
|
* Shown after a pull or stash apply results in a clean merge (no conflicts).
|
||||||
|
* Presents the user with two options:
|
||||||
|
* 1. Commit the merge — automatically stage all merge-result files and open commit dialog
|
||||||
|
* 2. Merge manually — leave the working tree as-is for manual review
|
||||||
|
*
|
||||||
|
* The user's choice can be persisted as a preference to avoid repeated prompts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { GitMerge, GitCommitHorizontal, FileText, Settings } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export type MergePostAction = 'commit' | 'manual' | null;
|
||||||
|
|
||||||
|
interface PostMergePromptDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
/** Branch name where the merge happened */
|
||||||
|
branchName: string;
|
||||||
|
/** Number of files affected by the merge */
|
||||||
|
mergeFileCount: number;
|
||||||
|
/** List of files affected by the merge */
|
||||||
|
mergeAffectedFiles?: string[];
|
||||||
|
/** Called when the user chooses to commit the merge */
|
||||||
|
onCommitMerge: () => void;
|
||||||
|
/** Called when the user chooses to handle the merge manually */
|
||||||
|
onMergeManually: () => void;
|
||||||
|
/** Current saved preference (null = ask every time) */
|
||||||
|
savedPreference?: MergePostAction;
|
||||||
|
/** Called when the user changes the preference */
|
||||||
|
onSavePreference?: (preference: MergePostAction) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PostMergePromptDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
branchName,
|
||||||
|
mergeFileCount,
|
||||||
|
mergeAffectedFiles,
|
||||||
|
onCommitMerge,
|
||||||
|
onMergeManually,
|
||||||
|
savedPreference,
|
||||||
|
onSavePreference,
|
||||||
|
}: PostMergePromptDialogProps) {
|
||||||
|
const [rememberChoice, setRememberChoice] = useState(false);
|
||||||
|
const [showFiles, setShowFiles] = useState(false);
|
||||||
|
|
||||||
|
// Reset transient state each time the dialog is opened
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setRememberChoice(false);
|
||||||
|
setShowFiles(false);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleCommitMerge = useCallback(() => {
|
||||||
|
if (rememberChoice && onSavePreference) {
|
||||||
|
onSavePreference('commit');
|
||||||
|
}
|
||||||
|
onCommitMerge();
|
||||||
|
onOpenChange(false);
|
||||||
|
}, [rememberChoice, onSavePreference, onCommitMerge, onOpenChange]);
|
||||||
|
|
||||||
|
const handleMergeManually = useCallback(() => {
|
||||||
|
if (rememberChoice && onSavePreference) {
|
||||||
|
onSavePreference('manual');
|
||||||
|
}
|
||||||
|
onMergeManually();
|
||||||
|
onOpenChange(false);
|
||||||
|
}, [rememberChoice, onSavePreference, onMergeManually, onOpenChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[520px] w-full max-w-full sm:rounded-xl rounded-none dialog-fullscreen-mobile">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<GitMerge className="w-5 h-5 text-purple-500" />
|
||||||
|
Merge Complete
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<span className="block">
|
||||||
|
A merge was successfully completed on{' '}
|
||||||
|
<code className="font-mono bg-muted px-1 rounded">{branchName}</code>
|
||||||
|
{mergeFileCount > 0 && (
|
||||||
|
<span>
|
||||||
|
{' '}
|
||||||
|
affecting {mergeFileCount} file{mergeFileCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
. How would you like to proceed?
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{mergeAffectedFiles && mergeAffectedFiles.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFiles(!showFiles)}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<FileText className="w-3 h-3" />
|
||||||
|
{showFiles ? 'Hide' : 'Show'} affected files ({mergeAffectedFiles.length})
|
||||||
|
</button>
|
||||||
|
{showFiles && (
|
||||||
|
<div className="mt-1.5 border border-border rounded-lg overflow-hidden max-h-[150px] overflow-y-auto scrollbar-visible">
|
||||||
|
{mergeAffectedFiles.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
|
||||||
|
>
|
||||||
|
<GitMerge className="w-3 h-3 text-purple-500 flex-shrink-0" />
|
||||||
|
<span className="truncate">{file}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
|
||||||
|
<p className="text-sm text-muted-foreground font-medium mb-2">
|
||||||
|
Choose how to proceed:
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
|
||||||
|
<li>
|
||||||
|
<strong>Commit Merge</strong> — Stage all merge files and open the commit
|
||||||
|
dialog with a pre-populated merge commit message
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Review Manually</strong> — Leave the working tree as-is so you can
|
||||||
|
review changes and commit at your own pace
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Remember choice option */}
|
||||||
|
{onSavePreference && (
|
||||||
|
<div className="flex items-center gap-2 px-1">
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={rememberChoice}
|
||||||
|
onCheckedChange={(checked) => setRememberChoice(checked)}
|
||||||
|
className="rounded border-border"
|
||||||
|
/>
|
||||||
|
<Settings className="w-3 h-3" />
|
||||||
|
Remember my choice for future merges
|
||||||
|
</label>
|
||||||
|
{savedPreference && (
|
||||||
|
<button
|
||||||
|
onClick={() => onSavePreference(null)}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors ml-auto"
|
||||||
|
>
|
||||||
|
Reset preference
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className={cn('flex-col sm:flex-row gap-2')}>
|
||||||
|
<Button variant="outline" onClick={handleMergeManually} className="w-full sm:w-auto">
|
||||||
|
<FileText className="w-4 h-4 mr-2" />
|
||||||
|
Review Manually
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCommitMerge}
|
||||||
|
className="w-full sm:w-auto bg-purple-600 hover:bg-purple-700 text-white"
|
||||||
|
>
|
||||||
|
<GitCommitHorizontal className="w-4 h-4 mr-2" />
|
||||||
|
Commit Merge
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -251,7 +251,7 @@ export function ViewCommitsDialog({ open, onOpenChange, worktree }: ViewCommitsD
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
|
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[85dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<GitCommit className="w-5 h-5" />
|
<GitCommit className="w-5 h-5" />
|
||||||
@@ -263,7 +263,7 @@ export function ViewCommitsDialog({ open, onOpenChange, worktree }: ViewCommitsD
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
|
<div className="flex-1 min-h-0 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
|
||||||
<div className="h-full px-6 pb-6">
|
<div className="h-full px-6 pb-6">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
|
|||||||
@@ -367,7 +367,7 @@ export function ViewStashesDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
|
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[85dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Archive className="w-5 h-5" />
|
<Archive className="w-5 h-5" />
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function ViewWorktreeChangesDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
|
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[85dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<FileText className="w-5 h-5" />
|
<FileText className="w-5 h-5" />
|
||||||
@@ -54,7 +54,7 @@ export function ViewWorktreeChangesDialog({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 sm:min-h-[600px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
|
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-visible -mx-6 -mb-6">
|
||||||
<div className="h-full px-6 pb-6">
|
<div className="h-full px-6 pb-6">
|
||||||
<GitDiffPanel
|
<GitDiffPanel
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
|
|||||||
@@ -94,8 +94,6 @@ export function useBoardActions({
|
|||||||
skipVerificationInAutoMode,
|
skipVerificationInAutoMode,
|
||||||
isPrimaryWorktreeBranch,
|
isPrimaryWorktreeBranch,
|
||||||
getPrimaryWorktreeBranch,
|
getPrimaryWorktreeBranch,
|
||||||
getAutoModeState,
|
|
||||||
getMaxConcurrencyForWorktree,
|
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
const autoMode = useAutoMode();
|
const autoMode = useAutoMode();
|
||||||
|
|
||||||
@@ -123,6 +121,7 @@ export function useBoardActions({
|
|||||||
dependencies?: string[];
|
dependencies?: string[];
|
||||||
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
||||||
workMode?: 'current' | 'auto' | 'custom';
|
workMode?: 'current' | 'auto' | 'custom';
|
||||||
|
initialStatus?: 'backlog' | 'in_progress'; // Skip backlog flash when creating & starting immediately
|
||||||
}) => {
|
}) => {
|
||||||
const workMode = featureData.workMode || 'current';
|
const workMode = featureData.workMode || 'current';
|
||||||
|
|
||||||
@@ -218,13 +217,15 @@ export function useBoardActions({
|
|||||||
const needsTitleGeneration =
|
const needsTitleGeneration =
|
||||||
!titleWasGenerated && !featureData.title.trim() && featureData.description.trim();
|
!titleWasGenerated && !featureData.title.trim() && featureData.description.trim();
|
||||||
|
|
||||||
|
const initialStatus = featureData.initialStatus || 'backlog';
|
||||||
const newFeatureData = {
|
const newFeatureData = {
|
||||||
...featureData,
|
...featureData,
|
||||||
title: titleWasGenerated ? titleForBranch : featureData.title,
|
title: titleWasGenerated ? titleForBranch : featureData.title,
|
||||||
titleGenerating: needsTitleGeneration,
|
titleGenerating: needsTitleGeneration,
|
||||||
status: 'backlog' as const,
|
status: initialStatus,
|
||||||
branchName: finalBranchName,
|
branchName: finalBranchName,
|
||||||
dependencies: featureData.dependencies || [],
|
dependencies: featureData.dependencies || [],
|
||||||
|
...(initialStatus === 'in_progress' ? { startedAt: new Date().toISOString() } : {}),
|
||||||
};
|
};
|
||||||
const createdFeature = addFeature(newFeatureData);
|
const createdFeature = addFeature(newFeatureData);
|
||||||
// Must await to ensure feature exists on server before user can drag it
|
// Must await to ensure feature exists on server before user can drag it
|
||||||
@@ -558,38 +559,9 @@ export function useBoardActions({
|
|||||||
|
|
||||||
const handleStartImplementation = useCallback(
|
const handleStartImplementation = useCallback(
|
||||||
async (feature: Feature) => {
|
async (feature: Feature) => {
|
||||||
// Check capacity for the feature's specific worktree, not the current view
|
// Note: No concurrency limit check here. Manual feature starts should never
|
||||||
// Normalize the branch name: if the feature's branch is the primary worktree branch,
|
// be blocked by the auto mode concurrency limit. The concurrency limit only
|
||||||
// treat it as null (main worktree) to match how running tasks are stored
|
// governs how many features the auto-loop picks up automatically.
|
||||||
const rawBranchName = feature.branchName ?? null;
|
|
||||||
const featureBranchName =
|
|
||||||
currentProject?.path &&
|
|
||||||
rawBranchName &&
|
|
||||||
isPrimaryWorktreeBranch(currentProject.path, rawBranchName)
|
|
||||||
? null
|
|
||||||
: rawBranchName;
|
|
||||||
const featureWorktreeState = currentProject
|
|
||||||
? getAutoModeState(currentProject.id, featureBranchName)
|
|
||||||
: null;
|
|
||||||
// Use getMaxConcurrencyForWorktree which correctly falls back to global maxConcurrency
|
|
||||||
// instead of autoMode.maxConcurrency which only falls back to DEFAULT_MAX_CONCURRENCY (1)
|
|
||||||
const featureMaxConcurrency = currentProject
|
|
||||||
? getMaxConcurrencyForWorktree(currentProject.id, featureBranchName)
|
|
||||||
: autoMode.maxConcurrency;
|
|
||||||
const featureRunningCount = featureWorktreeState?.runningTasks?.length ?? 0;
|
|
||||||
const canStartInWorktree = featureRunningCount < featureMaxConcurrency;
|
|
||||||
|
|
||||||
if (!canStartInWorktree) {
|
|
||||||
const worktreeDesc = featureBranchName
|
|
||||||
? `worktree "${featureBranchName}"`
|
|
||||||
: 'main worktree';
|
|
||||||
toast.error('Concurrency limit reached', {
|
|
||||||
description: `${worktreeDesc} can only have ${featureMaxConcurrency} task${
|
|
||||||
featureMaxConcurrency > 1 ? 's' : ''
|
|
||||||
} running at a time. Wait for a task to complete or increase the limit.`,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for blocking dependencies and show warning if enabled
|
// Check for blocking dependencies and show warning if enabled
|
||||||
if (enableDependencyBlocking) {
|
if (enableDependencyBlocking) {
|
||||||
@@ -608,20 +580,51 @@ export function useBoardActions({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updates = {
|
// Skip status update if feature was already created with in_progress status
|
||||||
status: 'in_progress' as const,
|
// (e.g., via "Make" button which creates directly as in_progress to avoid backlog flash)
|
||||||
startedAt: new Date().toISOString(),
|
const alreadyInProgress = feature.status === 'in_progress';
|
||||||
};
|
|
||||||
updateFeature(feature.id, updates);
|
if (!alreadyInProgress) {
|
||||||
|
const updates = {
|
||||||
|
status: 'in_progress' as const,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
updateFeature(feature.id, updates);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Must await to ensure feature status is persisted before starting agent
|
||||||
|
await persistFeatureUpdate(feature.id, updates);
|
||||||
|
} catch (error) {
|
||||||
|
// Rollback to backlog if persist fails (e.g., server offline)
|
||||||
|
logger.error('Failed to update feature status, rolling back to backlog:', error);
|
||||||
|
const rollbackUpdates = {
|
||||||
|
status: 'backlog' as const,
|
||||||
|
startedAt: undefined,
|
||||||
|
};
|
||||||
|
updateFeature(feature.id, rollbackUpdates);
|
||||||
|
persistFeatureUpdate(feature.id, rollbackUpdates).catch((persistError) => {
|
||||||
|
logger.error('Failed to persist rollback:', persistError);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isConnectionError(error)) {
|
||||||
|
handleServerOffline();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error('Failed to start feature', {
|
||||||
|
description:
|
||||||
|
error instanceof Error ? error.message : 'Server may be offline. Please try again.',
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Must await to ensure feature status is persisted before starting agent
|
|
||||||
await persistFeatureUpdate(feature.id, updates);
|
|
||||||
logger.info('Feature moved to in_progress, starting agent...');
|
logger.info('Feature moved to in_progress, starting agent...');
|
||||||
await handleRunFeature(feature);
|
await handleRunFeature(feature);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Rollback to backlog if persist or run fails (e.g., server offline)
|
// Rollback to backlog if run fails
|
||||||
logger.error('Failed to start feature, rolling back to backlog:', error);
|
logger.error('Failed to start feature, rolling back to backlog:', error);
|
||||||
const rollbackUpdates = {
|
const rollbackUpdates = {
|
||||||
status: 'backlog' as const,
|
status: 'backlog' as const,
|
||||||
@@ -647,18 +650,7 @@ export function useBoardActions({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature]
|
||||||
autoMode,
|
|
||||||
enableDependencyBlocking,
|
|
||||||
features,
|
|
||||||
updateFeature,
|
|
||||||
persistFeatureUpdate,
|
|
||||||
handleRunFeature,
|
|
||||||
currentProject,
|
|
||||||
getAutoModeState,
|
|
||||||
getMaxConcurrencyForWorktree,
|
|
||||||
isPrimaryWorktreeBranch,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleVerifyFeature = useCallback(
|
const handleVerifyFeature = useCallback(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type ColumnId = Feature['status'];
|
|||||||
interface UseBoardColumnFeaturesProps {
|
interface UseBoardColumnFeaturesProps {
|
||||||
features: Feature[];
|
features: Feature[];
|
||||||
runningAutoTasks: string[];
|
runningAutoTasks: string[];
|
||||||
|
runningAutoTasksAllWorktrees: string[]; // Running tasks across ALL worktrees (prevents backlog flash during event timing gaps)
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
currentWorktreePath: string | null; // Currently selected worktree path
|
currentWorktreePath: string | null; // Currently selected worktree path
|
||||||
currentWorktreeBranch: string | null; // Branch name of the selected worktree (null = main)
|
currentWorktreeBranch: string | null; // Branch name of the selected worktree (null = main)
|
||||||
@@ -21,6 +22,7 @@ interface UseBoardColumnFeaturesProps {
|
|||||||
export function useBoardColumnFeatures({
|
export function useBoardColumnFeatures({
|
||||||
features,
|
features,
|
||||||
runningAutoTasks,
|
runningAutoTasks,
|
||||||
|
runningAutoTasksAllWorktrees,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
currentWorktreePath,
|
currentWorktreePath,
|
||||||
currentWorktreeBranch,
|
currentWorktreeBranch,
|
||||||
@@ -38,6 +40,10 @@ export function useBoardColumnFeatures({
|
|||||||
};
|
};
|
||||||
const featureMap = createFeatureMap(features);
|
const featureMap = createFeatureMap(features);
|
||||||
const runningTaskIds = new Set(runningAutoTasks);
|
const runningTaskIds = new Set(runningAutoTasks);
|
||||||
|
// Track ALL running tasks across all worktrees to prevent features from
|
||||||
|
// briefly appearing in backlog during the timing gap between when the server
|
||||||
|
// starts executing a feature and when the UI receives the event/status update.
|
||||||
|
const allRunningTaskIds = new Set(runningAutoTasksAllWorktrees);
|
||||||
|
|
||||||
// Filter features by search query (case-insensitive)
|
// Filter features by search query (case-insensitive)
|
||||||
const normalizedQuery = searchQuery.toLowerCase().trim();
|
const normalizedQuery = searchQuery.toLowerCase().trim();
|
||||||
@@ -138,11 +144,28 @@ export function useBoardColumnFeatures({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not running: place by status (and worktree filter)
|
// Not running (on this worktree): place by status (and worktree filter)
|
||||||
// Filter all items by worktree, including backlog
|
// Filter all items by worktree, including backlog
|
||||||
// This ensures backlog items with a branch assigned only show in that branch
|
// This ensures backlog items with a branch assigned only show in that branch
|
||||||
if (status === 'backlog') {
|
//
|
||||||
if (matchesWorktree) {
|
// 'ready' and 'interrupted' are transitional statuses that don't have dedicated columns:
|
||||||
|
// - 'ready': Feature has an approved plan, waiting to be picked up for execution
|
||||||
|
// - 'interrupted': Feature execution was aborted (e.g., user stopped it, server restart)
|
||||||
|
// Both display in the backlog column and need the same allRunningTaskIds race-condition
|
||||||
|
// protection as 'backlog' to prevent briefly flashing in backlog when already executing.
|
||||||
|
if (status === 'backlog' || status === 'ready' || status === 'interrupted') {
|
||||||
|
// IMPORTANT: Check if this feature is running on ANY worktree before placing in backlog.
|
||||||
|
// This prevents a race condition where the feature has started executing on the server
|
||||||
|
// (and is tracked in a different worktree's running list) but the disk status hasn't
|
||||||
|
// been updated yet or the UI hasn't received the worktree-scoped event.
|
||||||
|
// In that case, the feature would briefly flash in the backlog column.
|
||||||
|
if (allRunningTaskIds.has(f.id)) {
|
||||||
|
// Feature is running somewhere - show in in_progress if it matches this worktree,
|
||||||
|
// otherwise skip it (it will appear on the correct worktree's board)
|
||||||
|
if (matchesWorktree) {
|
||||||
|
map.in_progress.push(f);
|
||||||
|
}
|
||||||
|
} else if (matchesWorktree) {
|
||||||
map.backlog.push(f);
|
map.backlog.push(f);
|
||||||
}
|
}
|
||||||
} else if (map[status]) {
|
} else if (map[status]) {
|
||||||
@@ -159,8 +182,12 @@ export function useBoardColumnFeatures({
|
|||||||
map[status].push(f);
|
map[status].push(f);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Unknown status, default to backlog
|
// Unknown status - apply same allRunningTaskIds protection and default to backlog
|
||||||
if (matchesWorktree) {
|
if (allRunningTaskIds.has(f.id)) {
|
||||||
|
if (matchesWorktree) {
|
||||||
|
map.in_progress.push(f);
|
||||||
|
}
|
||||||
|
} else if (matchesWorktree) {
|
||||||
map.backlog.push(f);
|
map.backlog.push(f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,6 +226,7 @@ export function useBoardColumnFeatures({
|
|||||||
}, [
|
}, [
|
||||||
features,
|
features,
|
||||||
runningAutoTasks,
|
runningAutoTasks,
|
||||||
|
runningAutoTasksAllWorktrees,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
currentWorktreePath,
|
currentWorktreePath,
|
||||||
currentWorktreeBranch,
|
currentWorktreeBranch,
|
||||||
|
|||||||
@@ -163,13 +163,22 @@ export function useBoardDragDrop({
|
|||||||
|
|
||||||
let targetStatus: ColumnId | null = null;
|
let targetStatus: ColumnId | null = null;
|
||||||
|
|
||||||
|
// Normalize the over ID: strip 'column-header-' prefix if the card was dropped
|
||||||
|
// directly onto the column header droppable zone (e.g. 'column-header-backlog' → 'backlog')
|
||||||
|
const effectiveOverId = overId.startsWith('column-header-')
|
||||||
|
? overId.replace('column-header-', '')
|
||||||
|
: overId;
|
||||||
|
|
||||||
// Check if we dropped on a column
|
// Check if we dropped on a column
|
||||||
const column = COLUMNS.find((c) => c.id === overId);
|
const column = COLUMNS.find((c) => c.id === effectiveOverId);
|
||||||
if (column) {
|
if (column) {
|
||||||
targetStatus = column.id;
|
targetStatus = column.id;
|
||||||
|
} else if (effectiveOverId.startsWith('pipeline_')) {
|
||||||
|
// Pipeline step column (not in static COLUMNS list)
|
||||||
|
targetStatus = effectiveOverId as ColumnId;
|
||||||
} else {
|
} else {
|
||||||
// Dropped on another feature - find its column
|
// Dropped on another feature - find its column
|
||||||
const overFeature = features.find((f) => f.id === overId);
|
const overFeature = features.find((f) => f.id === effectiveOverId);
|
||||||
if (overFeature) {
|
if (overFeature) {
|
||||||
targetStatus = overFeature.status;
|
targetStatus = overFeature.status;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient, useIsRestoring } from '@tanstack/react-query';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -24,13 +24,24 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
|
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Track whether React Query's IDB persistence layer is still restoring.
|
||||||
|
// During the restore window (~100-500ms on mobile), queries report
|
||||||
|
// isLoading=true because no data is in the cache yet. We suppress
|
||||||
|
// the full-screen spinner during this period to avoid a visible flash
|
||||||
|
// on PWA memory-eviction cold starts.
|
||||||
|
const isRestoring = useIsRestoring();
|
||||||
|
|
||||||
// Use React Query for features
|
// Use React Query for features
|
||||||
const {
|
const {
|
||||||
data: features = [],
|
data: features = [],
|
||||||
isLoading,
|
isLoading: isQueryLoading,
|
||||||
refetch: loadFeatures,
|
refetch: loadFeatures,
|
||||||
} = useFeatures(currentProject?.path);
|
} = useFeatures(currentProject?.path);
|
||||||
|
|
||||||
|
// Don't report loading while IDB cache restore is in progress —
|
||||||
|
// features will appear momentarily once the restore completes.
|
||||||
|
const isLoading = isQueryLoading && !isRestoring;
|
||||||
|
|
||||||
// Load persisted categories from file
|
// Load persisted categories from file
|
||||||
const loadCategories = useCallback(async () => {
|
const loadCategories = useCallback(async () => {
|
||||||
if (!currentProject) return;
|
if (!currentProject) return;
|
||||||
|
|||||||
@@ -320,13 +320,13 @@ export function KanbanBoard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 overflow-x-auto px-5 pt-2 sm:pt-4 pb-1 sm:pb-4 relative',
|
'flex-1 overflow-x-auto px-5 pt-2 sm:pt-4 pb-0 sm:pb-4 relative',
|
||||||
'transition-opacity duration-200',
|
'transition-opacity duration-200',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={backgroundImageStyle}
|
style={backgroundImageStyle}
|
||||||
>
|
>
|
||||||
<div className="h-full py-1" style={containerStyle}>
|
<div className="h-full pt-1 pb-0 sm:pb-1" style={containerStyle}>
|
||||||
{columns.map((column) => {
|
{columns.map((column) => {
|
||||||
const columnFeatures = getColumnFeatures(column.id as ColumnId);
|
const columnFeatures = getColumnFeatures(column.id as ColumnId);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -131,12 +131,12 @@ export function DevServerLogsPanel({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="w-full h-full max-w-full max-h-full sm:w-[70vw] sm:max-w-[900px] sm:max-h-[85vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col gap-0 p-0 overflow-hidden"
|
className="w-full h-full max-w-full max-h-full sm:w-[70vw] sm:max-w-[900px] sm:max-h-[85vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col gap-0 p-0 overflow-hidden dialog-fullscreen-mobile"
|
||||||
data-testid="dev-server-logs-panel"
|
data-testid="dev-server-logs-panel"
|
||||||
compact
|
compact
|
||||||
>
|
>
|
||||||
{/* Compact Header */}
|
{/* Compact Header */}
|
||||||
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12">
|
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12 dialog-compact-header-mobile">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<DialogTitle className="flex items-center gap-2 text-base">
|
<DialogTitle className="flex items-center gap-2 text-base">
|
||||||
<Terminal className="w-4 h-4 text-primary" />
|
<Terminal className="w-4 h-4 text-primary" />
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
XCircle,
|
XCircle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
Settings2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -54,6 +55,7 @@ import {
|
|||||||
import { getEditorIcon } from '@/components/icons/editor-icons';
|
import { getEditorIcon } from '@/components/icons/editor-icons';
|
||||||
import { getTerminalIcon } from '@/components/icons/terminal-icons';
|
import { getTerminalIcon } from '@/components/icons/terminal-icons';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import type { TerminalScript } from '@/components/views/project-settings-view/terminal-scripts-constants';
|
||||||
|
|
||||||
interface WorktreeActionsDropdownProps {
|
interface WorktreeActionsDropdownProps {
|
||||||
worktree: WorktreeInfo;
|
worktree: WorktreeInfo;
|
||||||
@@ -81,10 +83,18 @@ interface WorktreeActionsDropdownProps {
|
|||||||
isTestRunning?: boolean;
|
isTestRunning?: boolean;
|
||||||
/** Active test session info for this worktree */
|
/** Active test session info for this worktree */
|
||||||
testSessionInfo?: TestSessionInfo;
|
testSessionInfo?: TestSessionInfo;
|
||||||
|
/** List of available remotes for this worktree (used to show remote submenu) */
|
||||||
|
remotes?: Array<{ name: string; url: string }>;
|
||||||
|
/** The name of the remote that the current branch is tracking (e.g. "origin"), if any */
|
||||||
|
trackingRemote?: string;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onPull: (worktree: WorktreeInfo) => void;
|
onPull: (worktree: WorktreeInfo) => void;
|
||||||
onPush: (worktree: WorktreeInfo) => void;
|
onPush: (worktree: WorktreeInfo) => void;
|
||||||
onPushNewBranch: (worktree: WorktreeInfo) => void;
|
onPushNewBranch: (worktree: WorktreeInfo) => void;
|
||||||
|
/** Pull from a specific remote, bypassing the remote selection dialog */
|
||||||
|
onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||||
|
/** Push to a specific remote, bypassing the remote selection dialog */
|
||||||
|
onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||||
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
||||||
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
||||||
@@ -94,6 +104,7 @@ interface WorktreeActionsDropdownProps {
|
|||||||
onCommit: (worktree: WorktreeInfo) => void;
|
onCommit: (worktree: WorktreeInfo) => void;
|
||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
|
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||||
@@ -120,6 +131,12 @@ interface WorktreeActionsDropdownProps {
|
|||||||
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
|
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
|
||||||
onContinueOperation?: (worktree: WorktreeInfo) => void;
|
onContinueOperation?: (worktree: WorktreeInfo) => void;
|
||||||
hasInitScript: boolean;
|
hasInitScript: boolean;
|
||||||
|
/** Terminal quick scripts configured for the project */
|
||||||
|
terminalScripts?: TerminalScript[];
|
||||||
|
/** Callback to run a terminal quick script in a new terminal session */
|
||||||
|
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
|
||||||
|
/** Callback to open the script editor UI */
|
||||||
|
onEditScripts?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorktreeActionsDropdown({
|
export function WorktreeActionsDropdown({
|
||||||
@@ -141,10 +158,14 @@ export function WorktreeActionsDropdown({
|
|||||||
isStartingTests = false,
|
isStartingTests = false,
|
||||||
isTestRunning = false,
|
isTestRunning = false,
|
||||||
testSessionInfo,
|
testSessionInfo,
|
||||||
|
remotes,
|
||||||
|
trackingRemote,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onPull,
|
onPull,
|
||||||
onPush,
|
onPush,
|
||||||
onPushNewBranch,
|
onPushNewBranch,
|
||||||
|
onPullWithRemote,
|
||||||
|
onPushWithRemote,
|
||||||
onOpenInEditor,
|
onOpenInEditor,
|
||||||
onOpenInIntegratedTerminal,
|
onOpenInIntegratedTerminal,
|
||||||
onOpenInExternalTerminal,
|
onOpenInExternalTerminal,
|
||||||
@@ -154,6 +175,7 @@ export function WorktreeActionsDropdown({
|
|||||||
onCommit,
|
onCommit,
|
||||||
onCreatePR,
|
onCreatePR,
|
||||||
onAddressPRComments,
|
onAddressPRComments,
|
||||||
|
onAutoAddressPRComments,
|
||||||
onResolveConflicts,
|
onResolveConflicts,
|
||||||
onDeleteWorktree,
|
onDeleteWorktree,
|
||||||
onStartDevServer,
|
onStartDevServer,
|
||||||
@@ -172,6 +194,9 @@ export function WorktreeActionsDropdown({
|
|||||||
onAbortOperation,
|
onAbortOperation,
|
||||||
onContinueOperation,
|
onContinueOperation,
|
||||||
hasInitScript,
|
hasInitScript,
|
||||||
|
terminalScripts,
|
||||||
|
onRunTerminalScript,
|
||||||
|
onEditScripts,
|
||||||
}: WorktreeActionsDropdownProps) {
|
}: WorktreeActionsDropdownProps) {
|
||||||
// Get available editors for the "Open In" submenu
|
// Get available editors for the "Open In" submenu
|
||||||
const { editors } = useAvailableEditors();
|
const { editors } = useAvailableEditors();
|
||||||
@@ -217,9 +242,11 @@ export function WorktreeActionsDropdown({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Determine if the changes/PR section has any visible items
|
// Determine if the changes/PR section has any visible items
|
||||||
const showCreatePR = (!worktree.isMain || worktree.hasChanges) && !hasPR;
|
// Show Create PR when no existing PR is linked
|
||||||
|
const showCreatePR = !hasPR;
|
||||||
const showPRInfo = hasPR && !!worktree.pr;
|
const showPRInfo = hasPR && !!worktree.pr;
|
||||||
const hasChangesSectionContent = worktree.hasChanges || showCreatePR || showPRInfo;
|
const hasChangesSectionContent =
|
||||||
|
worktree.hasChanges || showCreatePR || showPRInfo || !!(onStashChanges || onViewStashes);
|
||||||
|
|
||||||
// Determine if the destructive/bottom section has any visible items
|
// Determine if the destructive/bottom section has any visible items
|
||||||
const hasDestructiveSectionContent = worktree.hasChanges || !worktree.isMain;
|
const hasDestructiveSectionContent = worktree.hasChanges || !worktree.isMain;
|
||||||
@@ -317,20 +344,43 @@ export function WorktreeActionsDropdown({
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{/* Auto Mode toggle */}
|
||||||
|
{onToggleAutoMode && (
|
||||||
|
<>
|
||||||
|
{isAutoModeRunning ? (
|
||||||
|
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
|
||||||
|
<span className="flex items-center mr-2">
|
||||||
|
<Zap className="w-3.5 h-3.5 text-yellow-500" />
|
||||||
|
<span className="ml-1.5 h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||||
|
</span>
|
||||||
|
Stop Auto Mode
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
|
||||||
|
<Zap className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Start Auto Mode
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{isDevServerRunning ? (
|
{isDevServerRunning ? (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuLabel className="text-xs flex items-center gap-2">
|
<DropdownMenuLabel className="text-xs flex items-center gap-2">
|
||||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||||
Dev Server Running (:{devServerInfo?.port})
|
{devServerInfo?.urlDetected === false
|
||||||
|
? 'Dev Server Starting...'
|
||||||
|
: `Dev Server Running (:${devServerInfo?.port})`}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuItem
|
{devServerInfo?.urlDetected !== false && (
|
||||||
onClick={() => onOpenDevServerUrl(worktree)}
|
<DropdownMenuItem
|
||||||
className="text-xs"
|
onClick={() => onOpenDevServerUrl(worktree)}
|
||||||
aria-label={`Open dev server on port ${devServerInfo?.port} in browser`}
|
className="text-xs"
|
||||||
>
|
aria-label={`Open dev server on port ${devServerInfo?.port} in browser`}
|
||||||
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
|
>
|
||||||
Open in Browser
|
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
|
||||||
</DropdownMenuItem>
|
Open in Browser
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
|
<DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
|
||||||
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
||||||
View Logs
|
View Logs
|
||||||
@@ -416,188 +466,6 @@ export function WorktreeActionsDropdown({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Auto Mode toggle */}
|
|
||||||
{onToggleAutoMode && (
|
|
||||||
<>
|
|
||||||
{isAutoModeRunning ? (
|
|
||||||
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
|
|
||||||
<span className="flex items-center mr-2">
|
|
||||||
<Zap className="w-3.5 h-3.5 text-yellow-500" />
|
|
||||||
<span className="ml-1.5 h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
|
||||||
</span>
|
|
||||||
Stop Auto Mode
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : (
|
|
||||||
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
|
|
||||||
<Zap className="w-3.5 h-3.5 mr-2" />
|
|
||||||
Start Auto Mode
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => isGitOpsAvailable && onPull(worktree)}
|
|
||||||
disabled={isPulling || !isGitOpsAvailable}
|
|
||||||
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
|
||||||
>
|
|
||||||
<Download className={cn('w-3.5 h-3.5 mr-2', isPulling && 'animate-pulse')} />
|
|
||||||
{isPulling ? 'Pulling...' : 'Pull'}
|
|
||||||
{!isGitOpsAvailable && (
|
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
{isGitOpsAvailable && behindCount > 0 && (
|
|
||||||
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
|
||||||
{behindCount} behind
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</TooltipWrapper>
|
|
||||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
if (!isGitOpsAvailable) return;
|
|
||||||
if (!hasRemoteBranch) {
|
|
||||||
onPushNewBranch(worktree);
|
|
||||||
} else {
|
|
||||||
onPush(worktree);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isPushing || (hasRemoteBranch && aheadCount === 0) || !isGitOpsAvailable}
|
|
||||||
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
|
||||||
>
|
|
||||||
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
|
|
||||||
{isPushing ? 'Pushing...' : 'Push'}
|
|
||||||
{!isGitOpsAvailable && (
|
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
{isGitOpsAvailable && !hasRemoteBranch && (
|
|
||||||
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-amber-500/20 text-amber-600 dark:text-amber-400 px-1.5 py-0.5 rounded">
|
|
||||||
<CloudOff className="w-2.5 h-2.5" />
|
|
||||||
local only
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isGitOpsAvailable && hasRemoteBranch && aheadCount > 0 && (
|
|
||||||
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
|
||||||
{aheadCount} ahead
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</TooltipWrapper>
|
|
||||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => isGitOpsAvailable && onResolveConflicts(worktree)}
|
|
||||||
disabled={!isGitOpsAvailable}
|
|
||||||
className={cn(
|
|
||||||
'text-xs text-purple-500 focus:text-purple-600',
|
|
||||||
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
|
||||||
Merge & Rebase
|
|
||||||
{!isGitOpsAvailable && (
|
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</TooltipWrapper>
|
|
||||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => isGitOpsAvailable && onViewCommits(worktree)}
|
|
||||||
disabled={!isGitOpsAvailable}
|
|
||||||
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
|
||||||
>
|
|
||||||
<History className="w-3.5 h-3.5 mr-2" />
|
|
||||||
View Commits
|
|
||||||
{!isGitOpsAvailable && (
|
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</TooltipWrapper>
|
|
||||||
{/* Cherry-pick commits from another branch */}
|
|
||||||
{onCherryPick && (
|
|
||||||
<TooltipWrapper
|
|
||||||
showTooltip={!!gitOpsDisabledReason}
|
|
||||||
tooltipContent={gitOpsDisabledReason}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => isGitOpsAvailable && onCherryPick(worktree)}
|
|
||||||
disabled={!isGitOpsAvailable}
|
|
||||||
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
|
||||||
>
|
|
||||||
<Cherry className="w-3.5 h-3.5 mr-2" />
|
|
||||||
Cherry Pick
|
|
||||||
{!isGitOpsAvailable && (
|
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</TooltipWrapper>
|
|
||||||
)}
|
|
||||||
{/* Stash operations - combined submenu or simple item */}
|
|
||||||
{(onStashChanges || onViewStashes) && (
|
|
||||||
<TooltipWrapper showTooltip={!isGitOpsAvailable} tooltipContent={gitOpsDisabledReason}>
|
|
||||||
{onViewStashes && worktree.hasChanges && onStashChanges ? (
|
|
||||||
// Both "Stash Changes" (primary) and "View Stashes" (secondary) are available - show split submenu
|
|
||||||
<DropdownMenuSub>
|
|
||||||
<div className="flex items-center">
|
|
||||||
{/* Main clickable area - stash changes (primary action) */}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
if (!isGitOpsAvailable) return;
|
|
||||||
onStashChanges(worktree);
|
|
||||||
}}
|
|
||||||
disabled={!isGitOpsAvailable}
|
|
||||||
className={cn(
|
|
||||||
'text-xs flex-1 pr-0 rounded-r-none',
|
|
||||||
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Archive className="w-3.5 h-3.5 mr-2" />
|
|
||||||
Stash Changes
|
|
||||||
{!isGitOpsAvailable && (
|
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{/* Chevron trigger for submenu with stash options */}
|
|
||||||
<DropdownMenuSubTrigger
|
|
||||||
className={cn(
|
|
||||||
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
|
|
||||||
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
|
||||||
)}
|
|
||||||
disabled={!isGitOpsAvailable}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DropdownMenuSubContent>
|
|
||||||
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
|
|
||||||
<Eye className="w-3.5 h-3.5 mr-2" />
|
|
||||||
View Stashes
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
) : (
|
|
||||||
// Only one action is meaningful - render a simple menu item without submenu
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
if (!isGitOpsAvailable) return;
|
|
||||||
if (worktree.hasChanges && onStashChanges) {
|
|
||||||
onStashChanges(worktree);
|
|
||||||
} else if (onViewStashes) {
|
|
||||||
onViewStashes(worktree);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!isGitOpsAvailable}
|
|
||||||
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
|
||||||
>
|
|
||||||
<Archive className="w-3.5 h-3.5 mr-2" />
|
|
||||||
{worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'}
|
|
||||||
{!isGitOpsAvailable && (
|
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
</TooltipWrapper>
|
|
||||||
)}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{/* Open in editor - split button: click main area for default, chevron for other options */}
|
{/* Open in editor - split button: click main area for default, chevron for other options */}
|
||||||
{effectiveDefaultEditor && (
|
{effectiveDefaultEditor && (
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
@@ -717,19 +585,414 @@ export function WorktreeActionsDropdown({
|
|||||||
})}
|
})}
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
{!worktree.isMain && hasInitScript && (
|
{/* Scripts submenu - consolidates init script and terminal quick scripts */}
|
||||||
<DropdownMenuItem onClick={() => onRunInitScript(worktree)} className="text-xs">
|
<DropdownMenuSub>
|
||||||
<RefreshCw className="w-3.5 h-3.5 mr-2" />
|
<DropdownMenuSubTrigger className="text-xs">
|
||||||
Re-run Init Script
|
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Scripts
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent className="w-52">
|
||||||
|
{/* Re-run Init Script - always shown for non-main worktrees, disabled when no init script configured */}
|
||||||
|
{!worktree.isMain && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onRunInitScript(worktree)}
|
||||||
|
className="text-xs"
|
||||||
|
disabled={!hasInitScript}
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Re-run Init Script
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Terminal quick scripts */}
|
||||||
|
{terminalScripts && terminalScripts.length > 0 ? (
|
||||||
|
terminalScripts.map((script) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={script.id}
|
||||||
|
onClick={() => onRunTerminalScript?.(worktree, script.command)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Play className="w-3.5 h-3.5 mr-2 shrink-0" />
|
||||||
|
<span className="truncate">{script.name}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem disabled className="text-xs text-muted-foreground">
|
||||||
|
No scripts configured
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{/* Divider before Edit Commands & Scripts */}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => onEditScripts?.()} className="text-xs">
|
||||||
|
<Settings2 className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Edit Commands & Scripts
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||||
|
{remotes && remotes.length > 1 && onPullWithRemote ? (
|
||||||
|
// Multiple remotes - show split button: click main area to pull (default behavior),
|
||||||
|
// chevron opens submenu showing individual remotes to pull from
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => isGitOpsAvailable && onPull(worktree)}
|
||||||
|
disabled={isPulling || !isGitOpsAvailable}
|
||||||
|
className={cn(
|
||||||
|
'text-xs flex-1 pr-0 rounded-r-none',
|
||||||
|
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Download className={cn('w-3.5 h-3.5 mr-2', isPulling && 'animate-pulse')} />
|
||||||
|
{isPulling ? 'Pulling...' : 'Pull'}
|
||||||
|
{!isGitOpsAvailable && (
|
||||||
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{isGitOpsAvailable && behindCount > 0 && (
|
||||||
|
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
||||||
|
{behindCount} behind
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSubTrigger
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
|
||||||
|
(!isGitOpsAvailable || isPulling) && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
disabled={!isGitOpsAvailable || isPulling}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">
|
||||||
|
Pull from remote
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{remotes.map((remote) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={remote.name}
|
||||||
|
onClick={() => isGitOpsAvailable && onPullWithRemote(worktree, remote.name)}
|
||||||
|
disabled={isPulling || !isGitOpsAvailable}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5 mr-2" />
|
||||||
|
{remote.name}
|
||||||
|
<span className="ml-auto text-[10px] text-muted-foreground max-w-[100px] truncate">
|
||||||
|
{remote.url}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
) : (
|
||||||
|
// Single remote or no remotes - show simple menu item
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => isGitOpsAvailable && onPull(worktree)}
|
||||||
|
disabled={isPulling || !isGitOpsAvailable}
|
||||||
|
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||||
|
>
|
||||||
|
<Download className={cn('w-3.5 h-3.5 mr-2', isPulling && 'animate-pulse')} />
|
||||||
|
{isPulling ? 'Pulling...' : 'Pull'}
|
||||||
|
{!isGitOpsAvailable && (
|
||||||
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{isGitOpsAvailable && behindCount > 0 && (
|
||||||
|
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
||||||
|
{behindCount} behind
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</TooltipWrapper>
|
||||||
|
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||||
|
{remotes && remotes.length > 1 && onPushWithRemote ? (
|
||||||
|
// Multiple remotes - show split button: click main area for default push behavior,
|
||||||
|
// chevron opens submenu showing individual remotes to push to
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
if (!isGitOpsAvailable) return;
|
||||||
|
if (!hasRemoteBranch) {
|
||||||
|
onPushNewBranch(worktree);
|
||||||
|
} else {
|
||||||
|
onPush(worktree);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
isPushing || (hasRemoteBranch && aheadCount === 0) || !isGitOpsAvailable
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'text-xs flex-1 pr-0 rounded-r-none',
|
||||||
|
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
|
||||||
|
{isPushing ? 'Pushing...' : 'Push'}
|
||||||
|
{!isGitOpsAvailable && (
|
||||||
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{isGitOpsAvailable && !hasRemoteBranch && (
|
||||||
|
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-amber-500/20 text-amber-600 dark:text-amber-400 px-1.5 py-0.5 rounded">
|
||||||
|
<CloudOff className="w-2.5 h-2.5" />
|
||||||
|
local only
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isGitOpsAvailable && hasRemoteBranch && aheadCount > 0 && (
|
||||||
|
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
||||||
|
{aheadCount} ahead
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isGitOpsAvailable && hasRemoteBranch && trackingRemote && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded',
|
||||||
|
aheadCount > 0 ? 'ml-1' : 'ml-auto'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{trackingRemote}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSubTrigger
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
|
||||||
|
(!isGitOpsAvailable || isPushing) && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
disabled={!isGitOpsAvailable || isPushing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">
|
||||||
|
Push to remote
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{remotes.map((remote) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={remote.name}
|
||||||
|
onClick={() => isGitOpsAvailable && onPushWithRemote(worktree, remote.name)}
|
||||||
|
disabled={isPushing || !isGitOpsAvailable}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5 mr-2" />
|
||||||
|
{remote.name}
|
||||||
|
<span className="ml-auto text-[10px] text-muted-foreground max-w-[100px] truncate">
|
||||||
|
{remote.url}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
) : (
|
||||||
|
// Single remote or no remotes - show simple menu item
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
if (!isGitOpsAvailable) return;
|
||||||
|
if (!hasRemoteBranch) {
|
||||||
|
onPushNewBranch(worktree);
|
||||||
|
} else {
|
||||||
|
onPush(worktree);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isPushing || (hasRemoteBranch && aheadCount === 0) || !isGitOpsAvailable}
|
||||||
|
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||||
|
>
|
||||||
|
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
|
||||||
|
{isPushing ? 'Pushing...' : 'Push'}
|
||||||
|
{!isGitOpsAvailable && (
|
||||||
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{isGitOpsAvailable && !hasRemoteBranch && (
|
||||||
|
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-amber-500/20 text-amber-600 dark:text-amber-400 px-1.5 py-0.5 rounded">
|
||||||
|
<CloudOff className="w-2.5 h-2.5" />
|
||||||
|
local only
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isGitOpsAvailable && hasRemoteBranch && aheadCount > 0 && (
|
||||||
|
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
||||||
|
{aheadCount} ahead
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isGitOpsAvailable && hasRemoteBranch && trackingRemote && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded',
|
||||||
|
aheadCount > 0 ? 'ml-1' : 'ml-auto'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{trackingRemote}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</TooltipWrapper>
|
||||||
|
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => isGitOpsAvailable && onResolveConflicts(worktree)}
|
||||||
|
disabled={!isGitOpsAvailable}
|
||||||
|
className={cn(
|
||||||
|
'text-xs text-purple-500 focus:text-purple-600',
|
||||||
|
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Merge & Rebase
|
||||||
|
{!isGitOpsAvailable && (
|
||||||
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
</TooltipWrapper>
|
||||||
|
{!worktree.isMain && (
|
||||||
|
<TooltipWrapper
|
||||||
|
showTooltip={!!gitOpsDisabledReason}
|
||||||
|
tooltipContent={gitOpsDisabledReason}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => isGitOpsAvailable && onMerge(worktree)}
|
||||||
|
disabled={!isGitOpsAvailable}
|
||||||
|
className={cn(
|
||||||
|
'text-xs text-green-600 focus:text-green-700',
|
||||||
|
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Integrate Branch
|
||||||
|
{!isGitOpsAvailable && (
|
||||||
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</TooltipWrapper>
|
||||||
|
)}
|
||||||
|
{/* View Commits - split button when Cherry Pick is available:
|
||||||
|
click main area to view commits directly, chevron opens sub-menu with Cherry Pick */}
|
||||||
|
{onCherryPick ? (
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<TooltipWrapper
|
||||||
|
showTooltip={!!gitOpsDisabledReason}
|
||||||
|
tooltipContent={gitOpsDisabledReason}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{/* Main clickable area - opens commit history directly */}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => isGitOpsAvailable && onViewCommits(worktree)}
|
||||||
|
disabled={!isGitOpsAvailable}
|
||||||
|
className={cn(
|
||||||
|
'text-xs flex-1 pr-0 rounded-r-none',
|
||||||
|
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<History className="w-3.5 h-3.5 mr-2" />
|
||||||
|
View Commits
|
||||||
|
{!isGitOpsAvailable && (
|
||||||
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{/* Chevron trigger for sub-menu containing Cherry Pick */}
|
||||||
|
<DropdownMenuSubTrigger
|
||||||
|
disabled={!isGitOpsAvailable}
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
|
||||||
|
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TooltipWrapper>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
{/* Cherry-pick commits from another branch */}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => isGitOpsAvailable && onCherryPick(worktree)}
|
||||||
|
disabled={!isGitOpsAvailable}
|
||||||
|
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||||
|
>
|
||||||
|
<Cherry className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Cherry Pick
|
||||||
|
{!isGitOpsAvailable && (
|
||||||
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
) : (
|
||||||
|
<TooltipWrapper
|
||||||
|
showTooltip={!!gitOpsDisabledReason}
|
||||||
|
tooltipContent={gitOpsDisabledReason}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => isGitOpsAvailable && onViewCommits(worktree)}
|
||||||
|
disabled={!isGitOpsAvailable}
|
||||||
|
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||||
|
>
|
||||||
|
<History className="w-3.5 h-3.5 mr-2" />
|
||||||
|
View Commits
|
||||||
|
{!isGitOpsAvailable && (
|
||||||
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</TooltipWrapper>
|
||||||
)}
|
)}
|
||||||
{(hasChangesSectionContent || hasDestructiveSectionContent) && <DropdownMenuSeparator />}
|
{(hasChangesSectionContent || hasDestructiveSectionContent) && <DropdownMenuSeparator />}
|
||||||
|
|
||||||
{worktree.hasChanges && (
|
{/* View Changes split button - main action views changes directly, chevron reveals stash options.
|
||||||
<DropdownMenuItem onClick={() => onViewChanges(worktree)} className="text-xs">
|
Only render when at least one action is meaningful:
|
||||||
<Eye className="w-3.5 h-3.5 mr-2" />
|
- worktree.hasChanges: View Changes action is available
|
||||||
View Changes
|
- (worktree.hasChanges && onStashChanges): Create Stash action is possible
|
||||||
</DropdownMenuItem>
|
- onViewStashes: viewing existing stashes is possible */}
|
||||||
|
{(worktree.hasChanges || onViewStashes) && (
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{/* Main clickable area - view changes (primary action) */}
|
||||||
|
{worktree.hasChanges ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onViewChanges(worktree)}
|
||||||
|
className="text-xs flex-1 pr-0 rounded-r-none"
|
||||||
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||||
|
View Changes
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onViewStashes!(worktree)}
|
||||||
|
className="text-xs flex-1 pr-0 rounded-r-none"
|
||||||
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||||
|
View Stashes
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{/* Chevron trigger for submenu with stash options */}
|
||||||
|
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
{worktree.hasChanges && onStashChanges && (
|
||||||
|
<TooltipWrapper
|
||||||
|
showTooltip={!isGitOpsAvailable}
|
||||||
|
tooltipContent={gitOpsDisabledReason}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
if (!isGitOpsAvailable) return;
|
||||||
|
onStashChanges(worktree);
|
||||||
|
}}
|
||||||
|
disabled={!isGitOpsAvailable}
|
||||||
|
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||||
|
>
|
||||||
|
<Archive className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Create Stash
|
||||||
|
{!isGitOpsAvailable && (
|
||||||
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</TooltipWrapper>
|
||||||
|
)}
|
||||||
|
{onViewStashes && (
|
||||||
|
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
|
||||||
|
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||||
|
View Stashes
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
)}
|
)}
|
||||||
{worktree.hasChanges && (
|
{worktree.hasChanges && (
|
||||||
<TooltipWrapper
|
<TooltipWrapper
|
||||||
@@ -749,7 +1012,7 @@ export function WorktreeActionsDropdown({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
)}
|
)}
|
||||||
{/* Show PR option for non-primary worktrees, or primary worktree with changes */}
|
{/* Show PR option when there is no existing PR (showCreatePR === !hasPR) */}
|
||||||
{showCreatePR && (
|
{showCreatePR && (
|
||||||
<TooltipWrapper
|
<TooltipWrapper
|
||||||
showTooltip={!!gitOpsDisabledReason}
|
showTooltip={!!gitOpsDisabledReason}
|
||||||
@@ -768,43 +1031,69 @@ export function WorktreeActionsDropdown({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
)}
|
)}
|
||||||
{/* Show PR info and Address Comments button if PR exists */}
|
{/* Show PR info with Address Comments in sub-menu if PR exists */}
|
||||||
{showPRInfo && worktree.pr && (
|
{showPRInfo && worktree.pr && (
|
||||||
<>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuItem
|
<div className="flex items-center">
|
||||||
onClick={() => {
|
{/* Main clickable area - opens PR in browser */}
|
||||||
window.open(worktree.pr!.url, '_blank', 'noopener,noreferrer');
|
<DropdownMenuItem
|
||||||
}}
|
onClick={() => {
|
||||||
className="text-xs"
|
window.open(worktree.pr!.url, '_blank', 'noopener,noreferrer');
|
||||||
>
|
}}
|
||||||
<GitPullRequest className="w-3 h-3 mr-2" />
|
className="text-xs flex-1 pr-0 rounded-r-none"
|
||||||
PR #{worktree.pr.number}
|
>
|
||||||
<span className="ml-auto text-[10px] bg-green-500/20 text-green-600 px-1.5 py-0.5 rounded uppercase">
|
<GitPullRequest className="w-3 h-3 mr-2" />
|
||||||
{worktree.pr.state}
|
PR #{worktree.pr.number}
|
||||||
</span>
|
<span className="ml-auto mr-1 text-[10px] bg-green-500/20 text-green-600 px-1.5 py-0.5 rounded uppercase">
|
||||||
</DropdownMenuItem>
|
{worktree.pr.state}
|
||||||
<DropdownMenuItem
|
</span>
|
||||||
onClick={() => {
|
</DropdownMenuItem>
|
||||||
// Convert stored PR info to the full PRInfo format for the handler
|
{/* Chevron trigger for submenu with PR actions */}
|
||||||
// The handler will fetch full comments from GitHub
|
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||||
const prInfo: PRInfo = {
|
</div>
|
||||||
number: worktree.pr!.number,
|
<DropdownMenuSubContent>
|
||||||
title: worktree.pr!.title,
|
<DropdownMenuItem
|
||||||
url: worktree.pr!.url,
|
onClick={() => {
|
||||||
state: worktree.pr!.state,
|
// Convert stored PR info to the full PRInfo format for the handler
|
||||||
author: '', // Will be fetched
|
// The handler will fetch full comments from GitHub
|
||||||
body: '', // Will be fetched
|
const prInfo: PRInfo = {
|
||||||
comments: [],
|
number: worktree.pr!.number,
|
||||||
reviewComments: [],
|
title: worktree.pr!.title,
|
||||||
};
|
url: worktree.pr!.url,
|
||||||
onAddressPRComments(worktree, prInfo);
|
state: worktree.pr!.state,
|
||||||
}}
|
author: '', // Will be fetched
|
||||||
className="text-xs text-blue-500 focus:text-blue-600"
|
body: '', // Will be fetched
|
||||||
>
|
comments: [],
|
||||||
<MessageSquare className="w-3.5 h-3.5 mr-2" />
|
reviewComments: [],
|
||||||
Address PR Comments
|
};
|
||||||
</DropdownMenuItem>
|
onAddressPRComments(worktree, prInfo);
|
||||||
</>
|
}}
|
||||||
|
className="text-xs text-blue-500 focus:text-blue-600"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Manage PR Comments
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
const prInfo: PRInfo = {
|
||||||
|
number: worktree.pr!.number,
|
||||||
|
title: worktree.pr!.title,
|
||||||
|
url: worktree.pr!.url,
|
||||||
|
state: worktree.pr!.state,
|
||||||
|
author: '',
|
||||||
|
body: '',
|
||||||
|
comments: [],
|
||||||
|
reviewComments: [],
|
||||||
|
};
|
||||||
|
onAutoAddressPRComments(worktree, prInfo);
|
||||||
|
}}
|
||||||
|
className="text-xs text-blue-500 focus:text-blue-600"
|
||||||
|
>
|
||||||
|
<Zap className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Address PR Comments
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
)}
|
)}
|
||||||
{hasChangesSectionContent && hasDestructiveSectionContent && <DropdownMenuSeparator />}
|
{hasChangesSectionContent && hasDestructiveSectionContent && <DropdownMenuSeparator />}
|
||||||
{worktree.hasChanges && (
|
{worktree.hasChanges && (
|
||||||
@@ -829,35 +1118,13 @@ export function WorktreeActionsDropdown({
|
|||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
)}
|
)}
|
||||||
{!worktree.isMain && (
|
{!worktree.isMain && (
|
||||||
<>
|
<DropdownMenuItem
|
||||||
<TooltipWrapper
|
onClick={() => onDeleteWorktree(worktree)}
|
||||||
showTooltip={!!gitOpsDisabledReason}
|
className="text-xs text-destructive focus:text-destructive"
|
||||||
tooltipContent={gitOpsDisabledReason}
|
>
|
||||||
>
|
<Trash2 className="w-3.5 h-3.5 mr-2" />
|
||||||
<DropdownMenuItem
|
Delete Worktree
|
||||||
onClick={() => isGitOpsAvailable && onMerge(worktree)}
|
</DropdownMenuItem>
|
||||||
disabled={!isGitOpsAvailable}
|
|
||||||
className={cn(
|
|
||||||
'text-xs text-green-600 focus:text-green-700',
|
|
||||||
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
|
||||||
Merge Branch
|
|
||||||
{!isGitOpsAvailable && (
|
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</TooltipWrapper>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => onDeleteWorktree(worktree)}
|
|
||||||
className="text-xs text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5 mr-2" />
|
|
||||||
Delete Worktree
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -144,8 +144,8 @@ export function WorktreeDropdownItem({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dev server indicator */}
|
{/* Dev server indicator - only shown when port is confirmed detected */}
|
||||||
{devServerRunning && (
|
{devServerRunning && devServerInfo?.urlDetected !== false && (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center justify-center h-4 w-4 text-green-500"
|
className="inline-flex items-center justify-center h-4 w-4 text-green-500"
|
||||||
title={`Dev server running on port ${devServerInfo?.port}`}
|
title={`Dev server running on port ${devServerInfo?.port}`}
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ export interface WorktreeDropdownProps {
|
|||||||
aheadCount: number;
|
aheadCount: number;
|
||||||
behindCount: number;
|
behindCount: number;
|
||||||
hasRemoteBranch: boolean;
|
hasRemoteBranch: boolean;
|
||||||
|
/** The name of the remote that the current branch is tracking (e.g. "origin"), if any */
|
||||||
|
trackingRemote?: string;
|
||||||
|
/** Per-worktree tracking remote lookup */
|
||||||
|
getTrackingRemote?: (worktreePath: string) => string | undefined;
|
||||||
gitRepoStatus: GitRepoStatus;
|
gitRepoStatus: GitRepoStatus;
|
||||||
hasTestCommand: boolean;
|
hasTestCommand: boolean;
|
||||||
isStartingTests: boolean;
|
isStartingTests: boolean;
|
||||||
@@ -99,6 +103,7 @@ export interface WorktreeDropdownProps {
|
|||||||
onCommit: (worktree: WorktreeInfo) => void;
|
onCommit: (worktree: WorktreeInfo) => void;
|
||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
|
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||||
onMerge: (worktree: WorktreeInfo) => void;
|
onMerge: (worktree: WorktreeInfo) => void;
|
||||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||||
@@ -121,6 +126,18 @@ export interface WorktreeDropdownProps {
|
|||||||
onAbortOperation?: (worktree: WorktreeInfo) => void;
|
onAbortOperation?: (worktree: WorktreeInfo) => void;
|
||||||
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
|
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
|
||||||
onContinueOperation?: (worktree: WorktreeInfo) => void;
|
onContinueOperation?: (worktree: WorktreeInfo) => void;
|
||||||
|
/** Remotes cache: maps worktree path to list of remotes */
|
||||||
|
remotesCache?: Record<string, Array<{ name: string; url: string }>>;
|
||||||
|
/** Pull from a specific remote, bypassing the remote selection dialog */
|
||||||
|
onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||||
|
/** Push to a specific remote, bypassing the remote selection dialog */
|
||||||
|
onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||||
|
/** Terminal quick scripts configured for the project */
|
||||||
|
terminalScripts?: import('@/components/views/project-settings-view/terminal-scripts-constants').TerminalScript[];
|
||||||
|
/** Callback to run a terminal quick script in a new terminal session */
|
||||||
|
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
|
||||||
|
/** Callback to open the script editor UI */
|
||||||
|
onEditScripts?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -170,6 +187,8 @@ export function WorktreeDropdown({
|
|||||||
aheadCount,
|
aheadCount,
|
||||||
behindCount,
|
behindCount,
|
||||||
hasRemoteBranch,
|
hasRemoteBranch,
|
||||||
|
trackingRemote,
|
||||||
|
getTrackingRemote,
|
||||||
gitRepoStatus,
|
gitRepoStatus,
|
||||||
hasTestCommand,
|
hasTestCommand,
|
||||||
isStartingTests,
|
isStartingTests,
|
||||||
@@ -187,6 +206,7 @@ export function WorktreeDropdown({
|
|||||||
onCommit,
|
onCommit,
|
||||||
onCreatePR,
|
onCreatePR,
|
||||||
onAddressPRComments,
|
onAddressPRComments,
|
||||||
|
onAutoAddressPRComments,
|
||||||
onResolveConflicts,
|
onResolveConflicts,
|
||||||
onMerge,
|
onMerge,
|
||||||
onDeleteWorktree,
|
onDeleteWorktree,
|
||||||
@@ -204,6 +224,12 @@ export function WorktreeDropdown({
|
|||||||
onCherryPick,
|
onCherryPick,
|
||||||
onAbortOperation,
|
onAbortOperation,
|
||||||
onContinueOperation,
|
onContinueOperation,
|
||||||
|
remotesCache,
|
||||||
|
onPullWithRemote,
|
||||||
|
onPushWithRemote,
|
||||||
|
terminalScripts,
|
||||||
|
onRunTerminalScript,
|
||||||
|
onEditScripts,
|
||||||
}: WorktreeDropdownProps) {
|
}: WorktreeDropdownProps) {
|
||||||
// Find the currently selected worktree to display in the trigger
|
// Find the currently selected worktree to display in the trigger
|
||||||
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
|
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
|
||||||
@@ -289,8 +315,8 @@ export function WorktreeDropdown({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dev server indicator */}
|
{/* Dev server indicator - only shown when port is confirmed detected */}
|
||||||
{selectedStatus.devServerRunning && (
|
{selectedStatus.devServerRunning && selectedStatus.devServerInfo?.urlDetected !== false && (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center justify-center h-4 w-4 text-green-500 shrink-0"
|
className="inline-flex items-center justify-center h-4 w-4 text-green-500 shrink-0"
|
||||||
title={`Dev server running on port ${selectedStatus.devServerInfo?.port}`}
|
title={`Dev server running on port ${selectedStatus.devServerInfo?.port}`}
|
||||||
@@ -470,6 +496,9 @@ export function WorktreeDropdown({
|
|||||||
aheadCount={aheadCount}
|
aheadCount={aheadCount}
|
||||||
behindCount={behindCount}
|
behindCount={behindCount}
|
||||||
hasRemoteBranch={hasRemoteBranch}
|
hasRemoteBranch={hasRemoteBranch}
|
||||||
|
trackingRemote={
|
||||||
|
getTrackingRemote ? getTrackingRemote(selectedWorktree.path) : trackingRemote
|
||||||
|
}
|
||||||
isPulling={isPulling}
|
isPulling={isPulling}
|
||||||
isPushing={isPushing}
|
isPushing={isPushing}
|
||||||
isStartingDevServer={isStartingDevServer}
|
isStartingDevServer={isStartingDevServer}
|
||||||
@@ -482,10 +511,13 @@ export function WorktreeDropdown({
|
|||||||
isStartingTests={isStartingTests}
|
isStartingTests={isStartingTests}
|
||||||
isTestRunning={isTestRunningForWorktree(selectedWorktree)}
|
isTestRunning={isTestRunningForWorktree(selectedWorktree)}
|
||||||
testSessionInfo={getTestSessionInfo(selectedWorktree)}
|
testSessionInfo={getTestSessionInfo(selectedWorktree)}
|
||||||
|
remotes={remotesCache?.[selectedWorktree.path]}
|
||||||
onOpenChange={onActionsDropdownOpenChange(selectedWorktree)}
|
onOpenChange={onActionsDropdownOpenChange(selectedWorktree)}
|
||||||
onPull={onPull}
|
onPull={onPull}
|
||||||
onPush={onPush}
|
onPush={onPush}
|
||||||
onPushNewBranch={onPushNewBranch}
|
onPushNewBranch={onPushNewBranch}
|
||||||
|
onPullWithRemote={onPullWithRemote}
|
||||||
|
onPushWithRemote={onPushWithRemote}
|
||||||
onOpenInEditor={onOpenInEditor}
|
onOpenInEditor={onOpenInEditor}
|
||||||
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
|
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
|
||||||
onOpenInExternalTerminal={onOpenInExternalTerminal}
|
onOpenInExternalTerminal={onOpenInExternalTerminal}
|
||||||
@@ -495,6 +527,7 @@ export function WorktreeDropdown({
|
|||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
|
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={onMerge}
|
onMerge={onMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
@@ -513,6 +546,9 @@ export function WorktreeDropdown({
|
|||||||
onAbortOperation={onAbortOperation}
|
onAbortOperation={onAbortOperation}
|
||||||
onContinueOperation={onContinueOperation}
|
onContinueOperation={onContinueOperation}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
|
terminalScripts={terminalScripts}
|
||||||
|
onRunTerminalScript={onRunTerminalScript}
|
||||||
|
onEditScripts={onEditScripts}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ interface WorktreeTabProps {
|
|||||||
aheadCount: number;
|
aheadCount: number;
|
||||||
behindCount: number;
|
behindCount: number;
|
||||||
hasRemoteBranch: boolean;
|
hasRemoteBranch: boolean;
|
||||||
|
/** The name of the remote that the current branch is tracking (e.g. "origin"), if any */
|
||||||
|
trackingRemote?: string;
|
||||||
gitRepoStatus: GitRepoStatus;
|
gitRepoStatus: GitRepoStatus;
|
||||||
/** Whether auto mode is running for this worktree */
|
/** Whether auto mode is running for this worktree */
|
||||||
isAutoModeRunning?: boolean;
|
isAutoModeRunning?: boolean;
|
||||||
@@ -65,6 +67,7 @@ interface WorktreeTabProps {
|
|||||||
onCommit: (worktree: WorktreeInfo) => void;
|
onCommit: (worktree: WorktreeInfo) => void;
|
||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
|
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||||
onMerge: (worktree: WorktreeInfo) => void;
|
onMerge: (worktree: WorktreeInfo) => void;
|
||||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||||
@@ -93,6 +96,18 @@ interface WorktreeTabProps {
|
|||||||
hasInitScript: boolean;
|
hasInitScript: boolean;
|
||||||
/** Whether a test command is configured in project settings */
|
/** Whether a test command is configured in project settings */
|
||||||
hasTestCommand?: boolean;
|
hasTestCommand?: boolean;
|
||||||
|
/** List of available remotes for this worktree (used to show remote submenu) */
|
||||||
|
remotes?: Array<{ name: string; url: string }>;
|
||||||
|
/** Pull from a specific remote, bypassing the remote selection dialog */
|
||||||
|
onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||||
|
/** Push to a specific remote, bypassing the remote selection dialog */
|
||||||
|
onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||||
|
/** Terminal quick scripts configured for the project */
|
||||||
|
terminalScripts?: import('@/components/views/project-settings-view/terminal-scripts-constants').TerminalScript[];
|
||||||
|
/** Callback to run a terminal quick script in a new terminal session */
|
||||||
|
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
|
||||||
|
/** Callback to open the script editor UI */
|
||||||
|
onEditScripts?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorktreeTab({
|
export function WorktreeTab({
|
||||||
@@ -116,6 +131,7 @@ export function WorktreeTab({
|
|||||||
aheadCount,
|
aheadCount,
|
||||||
behindCount,
|
behindCount,
|
||||||
hasRemoteBranch,
|
hasRemoteBranch,
|
||||||
|
trackingRemote,
|
||||||
gitRepoStatus,
|
gitRepoStatus,
|
||||||
isAutoModeRunning = false,
|
isAutoModeRunning = false,
|
||||||
isStartingTests = false,
|
isStartingTests = false,
|
||||||
@@ -139,6 +155,7 @@ export function WorktreeTab({
|
|||||||
onCommit,
|
onCommit,
|
||||||
onCreatePR,
|
onCreatePR,
|
||||||
onAddressPRComments,
|
onAddressPRComments,
|
||||||
|
onAutoAddressPRComments,
|
||||||
onResolveConflicts,
|
onResolveConflicts,
|
||||||
onMerge,
|
onMerge,
|
||||||
onDeleteWorktree,
|
onDeleteWorktree,
|
||||||
@@ -158,6 +175,12 @@ export function WorktreeTab({
|
|||||||
onContinueOperation,
|
onContinueOperation,
|
||||||
hasInitScript,
|
hasInitScript,
|
||||||
hasTestCommand = false,
|
hasTestCommand = false,
|
||||||
|
remotes,
|
||||||
|
onPullWithRemote,
|
||||||
|
onPushWithRemote,
|
||||||
|
terminalScripts,
|
||||||
|
onRunTerminalScript,
|
||||||
|
onEditScripts,
|
||||||
}: WorktreeTabProps) {
|
}: WorktreeTabProps) {
|
||||||
// Make the worktree tab a drop target for feature cards
|
// Make the worktree tab a drop target for feature cards
|
||||||
const { setNodeRef, isOver } = useDroppable({
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
@@ -428,7 +451,7 @@ export function WorktreeTab({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isDevServerRunning && (
|
{isDevServerRunning && devServerInfo?.urlDetected !== false && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -476,6 +499,7 @@ export function WorktreeTab({
|
|||||||
aheadCount={aheadCount}
|
aheadCount={aheadCount}
|
||||||
behindCount={behindCount}
|
behindCount={behindCount}
|
||||||
hasRemoteBranch={hasRemoteBranch}
|
hasRemoteBranch={hasRemoteBranch}
|
||||||
|
trackingRemote={trackingRemote}
|
||||||
isPulling={isPulling}
|
isPulling={isPulling}
|
||||||
isPushing={isPushing}
|
isPushing={isPushing}
|
||||||
isStartingDevServer={isStartingDevServer}
|
isStartingDevServer={isStartingDevServer}
|
||||||
@@ -488,10 +512,13 @@ export function WorktreeTab({
|
|||||||
isStartingTests={isStartingTests}
|
isStartingTests={isStartingTests}
|
||||||
isTestRunning={isTestRunning}
|
isTestRunning={isTestRunning}
|
||||||
testSessionInfo={testSessionInfo}
|
testSessionInfo={testSessionInfo}
|
||||||
|
remotes={remotes}
|
||||||
onOpenChange={onActionsDropdownOpenChange}
|
onOpenChange={onActionsDropdownOpenChange}
|
||||||
onPull={onPull}
|
onPull={onPull}
|
||||||
onPush={onPush}
|
onPush={onPush}
|
||||||
onPushNewBranch={onPushNewBranch}
|
onPushNewBranch={onPushNewBranch}
|
||||||
|
onPullWithRemote={onPullWithRemote}
|
||||||
|
onPushWithRemote={onPushWithRemote}
|
||||||
onOpenInEditor={onOpenInEditor}
|
onOpenInEditor={onOpenInEditor}
|
||||||
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
|
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
|
||||||
onOpenInExternalTerminal={onOpenInExternalTerminal}
|
onOpenInExternalTerminal={onOpenInExternalTerminal}
|
||||||
@@ -501,6 +528,7 @@ export function WorktreeTab({
|
|||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
|
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={onMerge}
|
onMerge={onMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
@@ -519,6 +547,9 @@ export function WorktreeTab({
|
|||||||
onAbortOperation={onAbortOperation}
|
onAbortOperation={onAbortOperation}
|
||||||
onContinueOperation={onContinueOperation}
|
onContinueOperation={onContinueOperation}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
|
terminalScripts={terminalScripts}
|
||||||
|
onRunTerminalScript={onRunTerminalScript}
|
||||||
|
onEditScripts={onEditScripts}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,32 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import { useWorktreeBranches } from '@/hooks/queries';
|
import { useWorktreeBranches } from '@/hooks/queries';
|
||||||
import type { GitRepoStatus } from '../types';
|
import type { GitRepoStatus } from '../types';
|
||||||
|
|
||||||
|
/** Explicit return type for the useBranches hook */
|
||||||
|
export interface UseBranchesReturn {
|
||||||
|
branches: Array<{ name: string; isCurrent: boolean; isRemote: boolean }>;
|
||||||
|
filteredBranches: Array<{ name: string; isCurrent: boolean; isRemote: boolean }>;
|
||||||
|
aheadCount: number;
|
||||||
|
behindCount: number;
|
||||||
|
hasRemoteBranch: boolean;
|
||||||
|
/**
|
||||||
|
* @deprecated Use {@link getTrackingRemote}(worktreePath) instead — this value
|
||||||
|
* only reflects the last-queried worktree and is unreliable when multiple panels
|
||||||
|
* share the hook.
|
||||||
|
*/
|
||||||
|
trackingRemote: string | undefined;
|
||||||
|
/** Per-worktree tracking remote lookup — avoids stale values when multiple panels share the hook */
|
||||||
|
getTrackingRemote: (worktreePath: string) => string | undefined;
|
||||||
|
isLoadingBranches: boolean;
|
||||||
|
branchFilter: string;
|
||||||
|
setBranchFilter: (filter: string) => void;
|
||||||
|
resetBranchFilter: () => void;
|
||||||
|
fetchBranches: (worktreePath: string) => void;
|
||||||
|
/** Prune cached tracking-remote entries for worktree paths that no longer exist */
|
||||||
|
pruneStaleEntries: (activePaths: Set<string>) => void;
|
||||||
|
gitRepoStatus: GitRepoStatus;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for managing branch data with React Query
|
* Hook for managing branch data with React Query
|
||||||
*
|
*
|
||||||
@@ -9,7 +34,7 @@ import type { GitRepoStatus } from '../types';
|
|||||||
* the current interface for backward compatibility. Tracks which
|
* the current interface for backward compatibility. Tracks which
|
||||||
* worktree path is currently being viewed and fetches branches on demand.
|
* worktree path is currently being viewed and fetches branches on demand.
|
||||||
*/
|
*/
|
||||||
export function useBranches() {
|
export function useBranches(): UseBranchesReturn {
|
||||||
const [currentWorktreePath, setCurrentWorktreePath] = useState<string | undefined>();
|
const [currentWorktreePath, setCurrentWorktreePath] = useState<string | undefined>();
|
||||||
const [branchFilter, setBranchFilter] = useState('');
|
const [branchFilter, setBranchFilter] = useState('');
|
||||||
|
|
||||||
@@ -23,6 +48,31 @@ export function useBranches() {
|
|||||||
const aheadCount = branchData?.aheadCount ?? 0;
|
const aheadCount = branchData?.aheadCount ?? 0;
|
||||||
const behindCount = branchData?.behindCount ?? 0;
|
const behindCount = branchData?.behindCount ?? 0;
|
||||||
const hasRemoteBranch = branchData?.hasRemoteBranch ?? false;
|
const hasRemoteBranch = branchData?.hasRemoteBranch ?? false;
|
||||||
|
const trackingRemote = branchData?.trackingRemote;
|
||||||
|
|
||||||
|
// Per-worktree tracking remote cache: keeps results from previous fetchBranches()
|
||||||
|
// calls so multiple WorktreePanel instances don't all share a single stale value.
|
||||||
|
const trackingRemoteByPathRef = useRef<Record<string, string | undefined>>({});
|
||||||
|
|
||||||
|
// Update cache whenever query data changes for the current path
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentWorktreePath && branchData) {
|
||||||
|
trackingRemoteByPathRef.current[currentWorktreePath] = branchData.trackingRemote;
|
||||||
|
}
|
||||||
|
}, [currentWorktreePath, branchData]);
|
||||||
|
|
||||||
|
const getTrackingRemote = useCallback(
|
||||||
|
(worktreePath: string): string | undefined => {
|
||||||
|
// If asking about the currently active query path, use fresh data
|
||||||
|
if (worktreePath === currentWorktreePath) {
|
||||||
|
return trackingRemote;
|
||||||
|
}
|
||||||
|
// Otherwise fall back to the cached value from a previous fetch
|
||||||
|
return trackingRemoteByPathRef.current[worktreePath];
|
||||||
|
},
|
||||||
|
[currentWorktreePath, trackingRemote]
|
||||||
|
);
|
||||||
|
|
||||||
// Use conservative defaults (false) until data is confirmed
|
// Use conservative defaults (false) until data is confirmed
|
||||||
// This prevents the UI from assuming git capabilities before the query completes
|
// This prevents the UI from assuming git capabilities before the query completes
|
||||||
const gitRepoStatus: GitRepoStatus = {
|
const gitRepoStatus: GitRepoStatus = {
|
||||||
@@ -47,6 +97,16 @@ export function useBranches() {
|
|||||||
setBranchFilter('');
|
setBranchFilter('');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/** Remove cached tracking-remote entries for worktree paths that no longer exist. */
|
||||||
|
const pruneStaleEntries = useCallback((activePaths: Set<string>) => {
|
||||||
|
const cache = trackingRemoteByPathRef.current;
|
||||||
|
for (const key of Object.keys(cache)) {
|
||||||
|
if (!activePaths.has(key)) {
|
||||||
|
delete cache[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const filteredBranches = branches.filter((b) =>
|
const filteredBranches = branches.filter((b) =>
|
||||||
b.name.toLowerCase().includes(branchFilter.toLowerCase())
|
b.name.toLowerCase().includes(branchFilter.toLowerCase())
|
||||||
);
|
);
|
||||||
@@ -57,11 +117,14 @@ export function useBranches() {
|
|||||||
aheadCount,
|
aheadCount,
|
||||||
behindCount,
|
behindCount,
|
||||||
hasRemoteBranch,
|
hasRemoteBranch,
|
||||||
|
trackingRemote,
|
||||||
|
getTrackingRemote,
|
||||||
isLoadingBranches,
|
isLoadingBranches,
|
||||||
branchFilter,
|
branchFilter,
|
||||||
setBranchFilter,
|
setBranchFilter,
|
||||||
resetBranchFilter,
|
resetBranchFilter,
|
||||||
fetchBranches,
|
fetchBranches,
|
||||||
|
pruneStaleEntries,
|
||||||
gitRepoStatus,
|
gitRepoStatus,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,6 +206,16 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
|
|||||||
}));
|
}));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'dev-server:url-detected': {
|
||||||
|
const { payload } = event;
|
||||||
|
logger.info('Dev server URL detected:', payload);
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
url: payload.url,
|
||||||
|
port: payload.port,
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { normalizePath } from '@/lib/utils';
|
import { normalizePath } from '@/lib/utils';
|
||||||
@@ -11,10 +11,32 @@ interface UseDevServersOptions {
|
|||||||
projectPath: string;
|
projectPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to build the browser-accessible dev server URL by rewriting the hostname
|
||||||
|
* to match the current window's hostname (supports remote access).
|
||||||
|
* Returns null if the URL is invalid or uses an unsupported protocol.
|
||||||
|
*/
|
||||||
|
function buildDevServerBrowserUrl(serverUrl: string): string | null {
|
||||||
|
try {
|
||||||
|
const devServerUrl = new URL(serverUrl);
|
||||||
|
// Security: Only allow http/https protocols
|
||||||
|
if (devServerUrl.protocol !== 'http:' && devServerUrl.protocol !== 'https:') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
devServerUrl.hostname = window.location.hostname;
|
||||||
|
return devServerUrl.toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useDevServers({ projectPath }: UseDevServersOptions) {
|
export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||||
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
|
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
|
||||||
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(new Map());
|
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(new Map());
|
||||||
|
|
||||||
|
// Track which worktrees have had their url-detected toast shown to prevent re-triggering
|
||||||
|
const toastShownForRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
const fetchDevServers = useCallback(async () => {
|
const fetchDevServers = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
@@ -25,7 +47,16 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
if (result.success && result.result?.servers) {
|
if (result.success && result.result?.servers) {
|
||||||
const serversMap = new Map<string, DevServerInfo>();
|
const serversMap = new Map<string, DevServerInfo>();
|
||||||
for (const server of result.result.servers) {
|
for (const server of result.result.servers) {
|
||||||
serversMap.set(server.worktreePath, server);
|
const key = normalizePath(server.worktreePath);
|
||||||
|
serversMap.set(key, {
|
||||||
|
...server,
|
||||||
|
urlDetected: server.urlDetected ?? true,
|
||||||
|
});
|
||||||
|
// Mark already-detected servers as having shown the toast
|
||||||
|
// so we don't re-trigger on initial load
|
||||||
|
if (server.urlDetected !== false) {
|
||||||
|
toastShownForRef.current.add(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setRunningDevServers(serversMap);
|
setRunningDevServers(serversMap);
|
||||||
}
|
}
|
||||||
@@ -38,6 +69,86 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
fetchDevServers();
|
fetchDevServers();
|
||||||
}, [fetchDevServers]);
|
}, [fetchDevServers]);
|
||||||
|
|
||||||
|
// Subscribe to all dev server lifecycle events for reactive state updates
|
||||||
|
useEffect(() => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.onDevServerLogEvent) return;
|
||||||
|
|
||||||
|
const unsubscribe = api.worktree.onDevServerLogEvent((event) => {
|
||||||
|
if (event.type === 'dev-server:url-detected') {
|
||||||
|
const { worktreePath, url, port } = event.payload;
|
||||||
|
const key = normalizePath(worktreePath);
|
||||||
|
let didUpdate = false;
|
||||||
|
setRunningDevServers((prev) => {
|
||||||
|
const existing = prev.get(key);
|
||||||
|
if (!existing) return prev;
|
||||||
|
// Avoid updating if already detected with same url/port
|
||||||
|
if (existing.urlDetected && existing.url === url && existing.port === port) return prev;
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(key, {
|
||||||
|
...existing,
|
||||||
|
url,
|
||||||
|
port,
|
||||||
|
urlDetected: true,
|
||||||
|
});
|
||||||
|
didUpdate = true;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (didUpdate) {
|
||||||
|
logger.info(`Dev server URL detected for ${worktreePath}: ${url} (port ${port})`);
|
||||||
|
// Only show toast on the transition from undetected → detected (not on re-renders/polls)
|
||||||
|
if (!toastShownForRef.current.has(key)) {
|
||||||
|
toastShownForRef.current.add(key);
|
||||||
|
const browserUrl = buildDevServerBrowserUrl(url);
|
||||||
|
toast.success(`Dev server running on port ${port}`, {
|
||||||
|
description: browserUrl ? browserUrl : url,
|
||||||
|
action: browserUrl
|
||||||
|
? {
|
||||||
|
label: 'Open in Browser',
|
||||||
|
onClick: () => {
|
||||||
|
window.open(browserUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
duration: 8000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (event.type === 'dev-server:stopped') {
|
||||||
|
// Reactively remove the server from state when it stops
|
||||||
|
const { worktreePath } = event.payload;
|
||||||
|
const key = normalizePath(worktreePath);
|
||||||
|
setRunningDevServers((prev) => {
|
||||||
|
if (!prev.has(key)) return prev;
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
// Clear the toast tracking so a fresh detection will show a new toast
|
||||||
|
toastShownForRef.current.delete(key);
|
||||||
|
logger.info(`Dev server stopped for ${worktreePath} (reactive update)`);
|
||||||
|
} else if (event.type === 'dev-server:started') {
|
||||||
|
// Reactively add/update the server when it starts
|
||||||
|
const { worktreePath, port, url } = event.payload;
|
||||||
|
const key = normalizePath(worktreePath);
|
||||||
|
// Clear previous toast tracking for this key so a new detection triggers a fresh toast
|
||||||
|
toastShownForRef.current.delete(key);
|
||||||
|
setRunningDevServers((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(key, {
|
||||||
|
worktreePath,
|
||||||
|
port,
|
||||||
|
url,
|
||||||
|
urlDetected: false,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const getWorktreeKey = useCallback(
|
const getWorktreeKey = useCallback(
|
||||||
(worktree: WorktreeInfo) => {
|
(worktree: WorktreeInfo) => {
|
||||||
const path = worktree.isMain ? projectPath : worktree.path;
|
const path = worktree.isMain ? projectPath : worktree.path;
|
||||||
@@ -62,16 +173,20 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
const result = await api.worktree.startDevServer(projectPath, targetPath);
|
const result = await api.worktree.startDevServer(projectPath, targetPath);
|
||||||
|
|
||||||
if (result.success && result.result) {
|
if (result.success && result.result) {
|
||||||
|
const key = normalizePath(targetPath);
|
||||||
|
// Clear toast tracking so the new port detection shows a fresh toast
|
||||||
|
toastShownForRef.current.delete(key);
|
||||||
setRunningDevServers((prev) => {
|
setRunningDevServers((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(normalizePath(targetPath), {
|
next.set(key, {
|
||||||
worktreePath: result.result!.worktreePath,
|
worktreePath: result.result!.worktreePath,
|
||||||
port: result.result!.port,
|
port: result.result!.port,
|
||||||
url: result.result!.url,
|
url: result.result!.url,
|
||||||
|
urlDetected: false,
|
||||||
});
|
});
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
toast.success(`Dev server started on port ${result.result.port}`);
|
toast.success('Dev server started, detecting port...');
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || 'Failed to start dev server');
|
toast.error(result.error || 'Failed to start dev server');
|
||||||
}
|
}
|
||||||
@@ -98,11 +213,14 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
const result = await api.worktree.stopDevServer(targetPath);
|
const result = await api.worktree.stopDevServer(targetPath);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
const key = normalizePath(targetPath);
|
||||||
setRunningDevServers((prev) => {
|
setRunningDevServers((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.delete(normalizePath(targetPath));
|
next.delete(key);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
// Clear toast tracking so future restarts get a fresh toast
|
||||||
|
toastShownForRef.current.delete(key);
|
||||||
toast.success(result.result?.message || 'Dev server stopped');
|
toast.success(result.result?.message || 'Dev server stopped');
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || 'Failed to stop dev server');
|
toast.error(result.error || 'Failed to stop dev server');
|
||||||
@@ -126,30 +244,16 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const browserUrl = buildDevServerBrowserUrl(serverInfo.url);
|
||||||
// Rewrite URL hostname to match the current browser's hostname.
|
if (!browserUrl) {
|
||||||
// This ensures dev server URLs work when accessing Automaker from
|
logger.error('Invalid dev server URL:', serverInfo.url);
|
||||||
// remote machines (e.g., 192.168.x.x or hostname.local instead of localhost).
|
toast.error('Invalid dev server URL', {
|
||||||
const devServerUrl = new URL(serverInfo.url);
|
description: 'The server returned an unsupported URL protocol.',
|
||||||
|
|
||||||
// Security: Only allow http/https protocols to prevent potential attacks
|
|
||||||
// via data:, javascript:, file:, or other dangerous URL schemes
|
|
||||||
if (devServerUrl.protocol !== 'http:' && devServerUrl.protocol !== 'https:') {
|
|
||||||
logger.error('Invalid dev server URL protocol:', devServerUrl.protocol);
|
|
||||||
toast.error('Invalid dev server URL', {
|
|
||||||
description: 'The server returned an unsupported URL protocol.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
devServerUrl.hostname = window.location.hostname;
|
|
||||||
window.open(devServerUrl.toString(), '_blank', 'noopener,noreferrer');
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to parse dev server URL:', error);
|
|
||||||
toast.error('Failed to open dev server', {
|
|
||||||
description: 'The server URL could not be processed. Please try again.',
|
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.open(browserUrl, '_blank', 'noopener,noreferrer');
|
||||||
},
|
},
|
||||||
[runningDevServers, getWorktreeKey]
|
[runningDevServers, getWorktreeKey]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -163,6 +163,24 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
|
|||||||
[navigate]
|
[navigate]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleRunTerminalScript = useCallback(
|
||||||
|
(worktree: WorktreeInfo, command: string) => {
|
||||||
|
// Navigate to the terminal view with the worktree path, branch, and command to run
|
||||||
|
// The terminal view will create a new terminal and automatically execute the command
|
||||||
|
navigate({
|
||||||
|
to: '/terminal',
|
||||||
|
search: {
|
||||||
|
cwd: worktree.path,
|
||||||
|
branch: worktree.branch,
|
||||||
|
mode: 'tab' as const,
|
||||||
|
nonce: Date.now(),
|
||||||
|
command,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[navigate]
|
||||||
|
);
|
||||||
|
|
||||||
const handleOpenInEditor = useCallback(
|
const handleOpenInEditor = useCallback(
|
||||||
async (worktree: WorktreeInfo, editorCommand?: string) => {
|
async (worktree: WorktreeInfo, editorCommand?: string) => {
|
||||||
openInEditorMutation.mutate({
|
openInEditorMutation.mutate({
|
||||||
@@ -204,6 +222,7 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
|
|||||||
handlePull,
|
handlePull,
|
||||||
handlePush,
|
handlePush,
|
||||||
handleOpenInIntegratedTerminal,
|
handleOpenInIntegratedTerminal,
|
||||||
|
handleRunTerminalScript,
|
||||||
handleOpenInEditor,
|
handleOpenInEditor,
|
||||||
handleOpenInExternalTerminal,
|
handleOpenInExternalTerminal,
|
||||||
// Stash confirmation state for branch switching
|
// Stash confirmation state for branch switching
|
||||||
|
|||||||
@@ -90,8 +90,16 @@ export function useWorktrees({
|
|||||||
const handleSelectWorktree = useCallback(
|
const handleSelectWorktree = useCallback(
|
||||||
(worktree: WorktreeInfo) => {
|
(worktree: WorktreeInfo) => {
|
||||||
setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch);
|
setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch);
|
||||||
|
|
||||||
|
// Invalidate feature queries when switching worktrees to ensure fresh data.
|
||||||
|
// Without this, feature cards that remount after the worktree switch may have stale
|
||||||
|
// or missing planSpec/task data, causing todo lists to appear empty until the next
|
||||||
|
// polling cycle or user interaction triggers a re-render.
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(projectPath),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[projectPath, setCurrentWorktree]
|
[projectPath, setCurrentWorktree, queryClient]
|
||||||
);
|
);
|
||||||
|
|
||||||
// fetchWorktrees for backward compatibility - now just triggers a refetch
|
// fetchWorktrees for backward compatibility - now just triggers a refetch
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface DevServerInfo {
|
|||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
port: number;
|
port: number;
|
||||||
url: string;
|
url: string;
|
||||||
|
/** Whether the actual URL/port has been detected from server output */
|
||||||
|
urlDetected?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TestSessionInfo {
|
export interface TestSessionInfo {
|
||||||
@@ -120,6 +122,7 @@ export interface WorktreePanelProps {
|
|||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
|
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||||
onCreateMergeConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
onCreateMergeConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
||||||
/** Called when branch switch stash reapply results in merge conflicts */
|
/** Called when branch switch stash reapply results in merge conflicts */
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
import { useEffect, useRef, useCallback, useState, useMemo } from 'react';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
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 { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
@@ -9,6 +10,7 @@ import { useIsMobile } from '@/hooks/use-media-query';
|
|||||||
import { useWorktreeInitScript, useProjectSettings } from '@/hooks/queries';
|
import { useWorktreeInitScript, useProjectSettings } from '@/hooks/queries';
|
||||||
import { useTestRunnerEvents } from '@/hooks/use-test-runners';
|
import { useTestRunnerEvents } from '@/hooks/use-test-runners';
|
||||||
import { useTestRunnersStore } from '@/store/test-runners-store';
|
import { useTestRunnersStore } from '@/store/test-runners-store';
|
||||||
|
import { DEFAULT_TERMINAL_SCRIPTS } from '@/components/views/project-settings-view/terminal-scripts-constants';
|
||||||
import type {
|
import type {
|
||||||
TestRunnerStartedEvent,
|
TestRunnerStartedEvent,
|
||||||
TestRunnerOutputEvent,
|
TestRunnerOutputEvent,
|
||||||
@@ -59,6 +61,7 @@ export function WorktreePanel({
|
|||||||
onCreatePR,
|
onCreatePR,
|
||||||
onCreateBranch,
|
onCreateBranch,
|
||||||
onAddressPRComments,
|
onAddressPRComments,
|
||||||
|
onAutoAddressPRComments,
|
||||||
onResolveConflicts,
|
onResolveConflicts,
|
||||||
onCreateMergeConflictResolutionFeature,
|
onCreateMergeConflictResolutionFeature,
|
||||||
onBranchSwitchConflict,
|
onBranchSwitchConflict,
|
||||||
@@ -96,11 +99,14 @@ export function WorktreePanel({
|
|||||||
aheadCount,
|
aheadCount,
|
||||||
behindCount,
|
behindCount,
|
||||||
hasRemoteBranch,
|
hasRemoteBranch,
|
||||||
|
trackingRemote,
|
||||||
|
getTrackingRemote,
|
||||||
isLoadingBranches,
|
isLoadingBranches,
|
||||||
branchFilter,
|
branchFilter,
|
||||||
setBranchFilter,
|
setBranchFilter,
|
||||||
resetBranchFilter,
|
resetBranchFilter,
|
||||||
fetchBranches,
|
fetchBranches,
|
||||||
|
pruneStaleEntries,
|
||||||
gitRepoStatus,
|
gitRepoStatus,
|
||||||
} = useBranches();
|
} = useBranches();
|
||||||
|
|
||||||
@@ -113,6 +119,7 @@ export function WorktreePanel({
|
|||||||
handlePull: _handlePull,
|
handlePull: _handlePull,
|
||||||
handlePush,
|
handlePush,
|
||||||
handleOpenInIntegratedTerminal,
|
handleOpenInIntegratedTerminal,
|
||||||
|
handleRunTerminalScript,
|
||||||
handleOpenInEditor,
|
handleOpenInEditor,
|
||||||
handleOpenInExternalTerminal,
|
handleOpenInExternalTerminal,
|
||||||
pendingSwitch,
|
pendingSwitch,
|
||||||
@@ -206,6 +213,21 @@ export function WorktreePanel({
|
|||||||
const { data: projectSettings } = useProjectSettings(projectPath);
|
const { data: projectSettings } = useProjectSettings(projectPath);
|
||||||
const hasTestCommand = !!projectSettings?.testCommand;
|
const hasTestCommand = !!projectSettings?.testCommand;
|
||||||
|
|
||||||
|
// Get terminal quick scripts from project settings (or fall back to defaults)
|
||||||
|
const terminalScripts = useMemo(() => {
|
||||||
|
const configured = projectSettings?.terminalScripts;
|
||||||
|
if (configured && configured.length > 0) {
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
return DEFAULT_TERMINAL_SCRIPTS;
|
||||||
|
}, [projectSettings?.terminalScripts]);
|
||||||
|
|
||||||
|
// Navigate to project settings to edit scripts
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const handleEditScripts = useCallback(() => {
|
||||||
|
navigate({ to: '/project-settings', search: { section: 'commands-scripts' } });
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
// Test runner state management
|
// Test runner state management
|
||||||
// Use the test runners store to get global state for all worktrees
|
// Use the test runners store to get global state for all worktrees
|
||||||
const testRunnersStore = useTestRunnersStore();
|
const testRunnersStore = useTestRunnersStore();
|
||||||
@@ -410,7 +432,7 @@ export function WorktreePanel({
|
|||||||
const [pushToRemoteDialogOpen, setPushToRemoteDialogOpen] = useState(false);
|
const [pushToRemoteDialogOpen, setPushToRemoteDialogOpen] = useState(false);
|
||||||
const [pushToRemoteWorktree, setPushToRemoteWorktree] = useState<WorktreeInfo | null>(null);
|
const [pushToRemoteWorktree, setPushToRemoteWorktree] = useState<WorktreeInfo | null>(null);
|
||||||
|
|
||||||
// Merge branch dialog state
|
// Integrate branch dialog state
|
||||||
const [mergeDialogOpen, setMergeDialogOpen] = useState(false);
|
const [mergeDialogOpen, setMergeDialogOpen] = useState(false);
|
||||||
const [mergeWorktree, setMergeWorktree] = useState<WorktreeInfo | null>(null);
|
const [mergeWorktree, setMergeWorktree] = useState<WorktreeInfo | null>(null);
|
||||||
|
|
||||||
@@ -434,6 +456,11 @@ export function WorktreePanel({
|
|||||||
const [pullDialogWorktree, setPullDialogWorktree] = useState<WorktreeInfo | null>(null);
|
const [pullDialogWorktree, setPullDialogWorktree] = useState<WorktreeInfo | null>(null);
|
||||||
const [pullDialogRemote, setPullDialogRemote] = useState<string | undefined>(undefined);
|
const [pullDialogRemote, setPullDialogRemote] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
// Remotes cache: maps worktree path -> list of remotes (fetched when dropdown opens)
|
||||||
|
const [remotesCache, setRemotesCache] = useState<
|
||||||
|
Record<string, Array<{ name: string; url: string }>>
|
||||||
|
>({});
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// Periodic interval check (30 seconds) to detect branch changes on disk
|
// Periodic interval check (30 seconds) to detect branch changes on disk
|
||||||
@@ -451,6 +478,21 @@ export function WorktreePanel({
|
|||||||
};
|
};
|
||||||
}, [fetchWorktrees]);
|
}, [fetchWorktrees]);
|
||||||
|
|
||||||
|
// Prune stale tracking-remote cache entries and remotes cache when worktrees change
|
||||||
|
useEffect(() => {
|
||||||
|
const activePaths = new Set(worktrees.map((w) => w.path));
|
||||||
|
pruneStaleEntries(activePaths);
|
||||||
|
setRemotesCache((prev) => {
|
||||||
|
const next: typeof prev = {};
|
||||||
|
for (const key of Object.keys(prev)) {
|
||||||
|
if (activePaths.has(key)) {
|
||||||
|
next[key] = prev[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [worktrees, pruneStaleEntries]);
|
||||||
|
|
||||||
const isWorktreeSelected = (worktree: WorktreeInfo) => {
|
const isWorktreeSelected = (worktree: WorktreeInfo) => {
|
||||||
return worktree.isMain
|
return worktree.isMain
|
||||||
? currentWorktree === null || currentWorktree === undefined || currentWorktree.path === null
|
? currentWorktree === null || currentWorktree === undefined || currentWorktree.path === null
|
||||||
@@ -467,6 +509,23 @@ export function WorktreePanel({
|
|||||||
const handleActionsDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
|
const handleActionsDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
fetchBranches(worktree.path);
|
fetchBranches(worktree.path);
|
||||||
|
// Fetch remotes for the submenu when the dropdown opens, but only if not already cached
|
||||||
|
if (!remotesCache[worktree.path]) {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
api.worktree
|
||||||
|
.listRemotes(worktree.path)
|
||||||
|
.then((result) => {
|
||||||
|
if (result.success && result.result) {
|
||||||
|
setRemotesCache((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[worktree.path]: result.result!.remotes.map((r) => ({ name: r.name, url: r.url })),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('Failed to fetch remotes for worktree:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -606,52 +665,101 @@ export function WorktreePanel({
|
|||||||
setPushToRemoteDialogOpen(true);
|
setPushToRemoteDialogOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle pull completed - refresh worktrees
|
// Keep a ref to pullDialogWorktree so handlePullCompleted can access the current
|
||||||
|
// value without including it in the dependency array. If pullDialogWorktree were
|
||||||
|
// a dep of handlePullCompleted, changing it would recreate the callback, which
|
||||||
|
// would propagate into GitPullDialog's onPulled prop and ultimately re-trigger
|
||||||
|
// the pull-check effect inside the dialog (causing the flow to run twice).
|
||||||
|
const pullDialogWorktreeRef = useRef(pullDialogWorktree);
|
||||||
|
useEffect(() => {
|
||||||
|
pullDialogWorktreeRef.current = pullDialogWorktree;
|
||||||
|
}, [pullDialogWorktree]);
|
||||||
|
|
||||||
|
// Handle pull completed - refresh branches and worktrees
|
||||||
const handlePullCompleted = useCallback(() => {
|
const handlePullCompleted = useCallback(() => {
|
||||||
|
// Refresh branch data (ahead/behind counts, tracking) and worktree list
|
||||||
|
// after GitPullDialog completes the pull operation
|
||||||
|
if (pullDialogWorktreeRef.current) {
|
||||||
|
fetchBranches(pullDialogWorktreeRef.current.path);
|
||||||
|
}
|
||||||
fetchWorktrees({ silent: true });
|
fetchWorktrees({ silent: true });
|
||||||
}, [fetchWorktrees]);
|
}, [fetchWorktrees, fetchBranches]);
|
||||||
|
|
||||||
|
// Wrapper for onCommit that works with the pull dialog's simpler WorktreeInfo.
|
||||||
|
// Uses the full pullDialogWorktree when available (via ref to avoid making it
|
||||||
|
// a dep that would cascade into handleSuccessfulPull → checkForLocalChanges recreations).
|
||||||
|
const handleCommitMerge = useCallback(
|
||||||
|
(_simpleWorktree: { path: string; branch: string; isMain: boolean }) => {
|
||||||
|
// Prefer the full worktree object we already have (from ref)
|
||||||
|
if (pullDialogWorktreeRef.current) {
|
||||||
|
onCommit(pullDialogWorktreeRef.current);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onCommit]
|
||||||
|
);
|
||||||
|
|
||||||
// Handle pull with remote selection when multiple remotes exist
|
// Handle pull with remote selection when multiple remotes exist
|
||||||
// Now opens the pull dialog which handles stash management and conflict resolution
|
// Now opens the pull dialog which handles stash management and conflict resolution
|
||||||
const handlePullWithRemoteSelection = useCallback(async (worktree: WorktreeInfo) => {
|
// If the branch has a tracked remote, pull from it directly (skip the remote selection dialog)
|
||||||
try {
|
const handlePullWithRemoteSelection = useCallback(
|
||||||
const api = getHttpApiClient();
|
|
||||||
const result = await api.worktree.listRemotes(worktree.path);
|
|
||||||
|
|
||||||
if (result.success && result.result && result.result.remotes.length > 1) {
|
|
||||||
// Multiple remotes - show selection dialog first
|
|
||||||
setSelectRemoteWorktree(worktree);
|
|
||||||
setSelectRemoteOperation('pull');
|
|
||||||
setSelectRemoteDialogOpen(true);
|
|
||||||
} else if (result.success && result.result && result.result.remotes.length === 1) {
|
|
||||||
// Exactly one remote - open pull dialog directly with that remote
|
|
||||||
const remoteName = result.result.remotes[0].name;
|
|
||||||
setPullDialogRemote(remoteName);
|
|
||||||
setPullDialogWorktree(worktree);
|
|
||||||
setPullDialogOpen(true);
|
|
||||||
} else {
|
|
||||||
// No remotes - open pull dialog with default
|
|
||||||
setPullDialogRemote(undefined);
|
|
||||||
setPullDialogWorktree(worktree);
|
|
||||||
setPullDialogOpen(true);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// If listing remotes fails, open pull dialog with default
|
|
||||||
setPullDialogRemote(undefined);
|
|
||||||
setPullDialogWorktree(worktree);
|
|
||||||
setPullDialogOpen(true);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle push with remote selection when multiple remotes exist
|
|
||||||
const handlePushWithRemoteSelection = useCallback(
|
|
||||||
async (worktree: WorktreeInfo) => {
|
async (worktree: WorktreeInfo) => {
|
||||||
|
// If the branch already tracks a remote, pull from it directly — no dialog needed
|
||||||
|
const tracked = getTrackingRemote(worktree.path);
|
||||||
|
if (tracked) {
|
||||||
|
setPullDialogRemote(tracked);
|
||||||
|
setPullDialogWorktree(worktree);
|
||||||
|
setPullDialogOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const api = getHttpApiClient();
|
const api = getHttpApiClient();
|
||||||
const result = await api.worktree.listRemotes(worktree.path);
|
const result = await api.worktree.listRemotes(worktree.path);
|
||||||
|
|
||||||
if (result.success && result.result && result.result.remotes.length > 1) {
|
if (result.success && result.result && result.result.remotes.length > 1) {
|
||||||
// Multiple remotes - show selection dialog
|
// Multiple remotes and no tracking remote - show selection dialog
|
||||||
|
setSelectRemoteWorktree(worktree);
|
||||||
|
setSelectRemoteOperation('pull');
|
||||||
|
setSelectRemoteDialogOpen(true);
|
||||||
|
} else if (result.success && result.result && result.result.remotes.length === 1) {
|
||||||
|
// Exactly one remote - open pull dialog directly with that remote
|
||||||
|
const remoteName = result.result.remotes[0].name;
|
||||||
|
setPullDialogRemote(remoteName);
|
||||||
|
setPullDialogWorktree(worktree);
|
||||||
|
setPullDialogOpen(true);
|
||||||
|
} else {
|
||||||
|
// No remotes - open pull dialog with default
|
||||||
|
setPullDialogRemote(undefined);
|
||||||
|
setPullDialogWorktree(worktree);
|
||||||
|
setPullDialogOpen(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If listing remotes fails, open pull dialog with default
|
||||||
|
setPullDialogRemote(undefined);
|
||||||
|
setPullDialogWorktree(worktree);
|
||||||
|
setPullDialogOpen(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getTrackingRemote]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle push with remote selection when multiple remotes exist
|
||||||
|
// If the branch has a tracked remote, push to it directly (skip the remote selection dialog)
|
||||||
|
const handlePushWithRemoteSelection = useCallback(
|
||||||
|
async (worktree: WorktreeInfo) => {
|
||||||
|
// If the branch already tracks a remote, push to it directly — no dialog needed
|
||||||
|
const tracked = getTrackingRemote(worktree.path);
|
||||||
|
if (tracked) {
|
||||||
|
handlePush(worktree, tracked);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
const result = await api.worktree.listRemotes(worktree.path);
|
||||||
|
|
||||||
|
if (result.success && result.result && result.result.remotes.length > 1) {
|
||||||
|
// Multiple remotes and no tracking remote - show selection dialog
|
||||||
setSelectRemoteWorktree(worktree);
|
setSelectRemoteWorktree(worktree);
|
||||||
setSelectRemoteOperation('push');
|
setSelectRemoteOperation('push');
|
||||||
setSelectRemoteDialogOpen(true);
|
setSelectRemoteDialogOpen(true);
|
||||||
@@ -668,25 +776,44 @@ export function WorktreePanel({
|
|||||||
handlePush(worktree);
|
handlePush(worktree);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handlePush]
|
[handlePush, getTrackingRemote]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle confirming remote selection for pull/push
|
// Handle confirming remote selection for pull/push
|
||||||
const handleConfirmSelectRemote = useCallback(
|
const handleConfirmSelectRemote = useCallback(
|
||||||
async (worktree: WorktreeInfo, remote: string) => {
|
async (worktree: WorktreeInfo, remote: string) => {
|
||||||
if (selectRemoteOperation === 'pull') {
|
if (selectRemoteOperation === 'pull') {
|
||||||
// Open the pull dialog with the selected remote
|
// Open the pull dialog — let GitPullDialog manage the pull operation
|
||||||
|
// via its useEffect and onPulled callback (handlePullCompleted)
|
||||||
setPullDialogRemote(remote);
|
setPullDialogRemote(remote);
|
||||||
setPullDialogWorktree(worktree);
|
setPullDialogWorktree(worktree);
|
||||||
setPullDialogOpen(true);
|
setPullDialogOpen(true);
|
||||||
await _handlePull(worktree, remote);
|
|
||||||
} else {
|
} else {
|
||||||
await handlePush(worktree, remote);
|
await handlePush(worktree, remote);
|
||||||
|
fetchBranches(worktree.path);
|
||||||
|
fetchWorktrees({ silent: true });
|
||||||
}
|
}
|
||||||
fetchBranches(worktree.path);
|
|
||||||
fetchWorktrees();
|
|
||||||
},
|
},
|
||||||
[selectRemoteOperation, _handlePull, handlePush, fetchBranches, fetchWorktrees]
|
[selectRemoteOperation, handlePush, fetchBranches, fetchWorktrees]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle pull with a specific remote selected from the submenu (bypasses the remote selection dialog)
|
||||||
|
const handlePullWithSpecificRemote = useCallback((worktree: WorktreeInfo, remote: string) => {
|
||||||
|
// Open the pull dialog — let GitPullDialog manage the pull operation
|
||||||
|
// via its useEffect and onPulled callback (handlePullCompleted)
|
||||||
|
setPullDialogRemote(remote);
|
||||||
|
setPullDialogWorktree(worktree);
|
||||||
|
setPullDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle push to a specific remote selected from the submenu (bypasses the remote selection dialog)
|
||||||
|
const handlePushWithSpecificRemote = useCallback(
|
||||||
|
async (worktree: WorktreeInfo, remote: string) => {
|
||||||
|
await handlePush(worktree, remote);
|
||||||
|
fetchBranches(worktree.path);
|
||||||
|
fetchWorktrees({ silent: true });
|
||||||
|
},
|
||||||
|
[handlePush, fetchBranches, fetchWorktrees]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle confirming the push to remote dialog
|
// Handle confirming the push to remote dialog
|
||||||
@@ -719,13 +846,13 @@ export function WorktreePanel({
|
|||||||
setMergeDialogOpen(true);
|
setMergeDialogOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle merge completion - refresh worktrees and reassign features if branch was deleted
|
// Handle integration completion - refresh worktrees and reassign features if branch was deleted
|
||||||
const handleMerged = useCallback(
|
const handleIntegrated = useCallback(
|
||||||
(mergedWorktree: WorktreeInfo, deletedBranch: boolean) => {
|
(integratedWorktree: WorktreeInfo, deletedBranch: boolean) => {
|
||||||
fetchWorktrees();
|
fetchWorktrees();
|
||||||
// If the branch was deleted, notify parent to reassign features to main
|
// If the branch was deleted, notify parent to reassign features to main
|
||||||
if (deletedBranch && onBranchDeletedDuringMerge) {
|
if (deletedBranch && onBranchDeletedDuringMerge) {
|
||||||
onBranchDeletedDuringMerge(mergedWorktree.branch);
|
onBranchDeletedDuringMerge(integratedWorktree.branch);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[fetchWorktrees, onBranchDeletedDuringMerge]
|
[fetchWorktrees, onBranchDeletedDuringMerge]
|
||||||
@@ -777,6 +904,7 @@ export function WorktreePanel({
|
|||||||
aheadCount={aheadCount}
|
aheadCount={aheadCount}
|
||||||
behindCount={behindCount}
|
behindCount={behindCount}
|
||||||
hasRemoteBranch={hasRemoteBranch}
|
hasRemoteBranch={hasRemoteBranch}
|
||||||
|
trackingRemote={getTrackingRemote(selectedWorktree.path)}
|
||||||
isPulling={isPulling}
|
isPulling={isPulling}
|
||||||
isPushing={isPushing}
|
isPushing={isPushing}
|
||||||
isStartingDevServer={isStartingDevServer}
|
isStartingDevServer={isStartingDevServer}
|
||||||
@@ -789,10 +917,13 @@ export function WorktreePanel({
|
|||||||
isStartingTests={isStartingTests}
|
isStartingTests={isStartingTests}
|
||||||
isTestRunning={isTestRunningForWorktree(selectedWorktree)}
|
isTestRunning={isTestRunningForWorktree(selectedWorktree)}
|
||||||
testSessionInfo={getTestSessionInfo(selectedWorktree)}
|
testSessionInfo={getTestSessionInfo(selectedWorktree)}
|
||||||
|
remotes={remotesCache[selectedWorktree.path]}
|
||||||
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
|
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
|
||||||
onPull={handlePullWithRemoteSelection}
|
onPull={handlePullWithRemoteSelection}
|
||||||
onPush={handlePushWithRemoteSelection}
|
onPush={handlePushWithRemoteSelection}
|
||||||
onPushNewBranch={handlePushNewBranch}
|
onPushNewBranch={handlePushNewBranch}
|
||||||
|
onPullWithRemote={handlePullWithSpecificRemote}
|
||||||
|
onPushWithRemote={handlePushWithSpecificRemote}
|
||||||
onOpenInEditor={handleOpenInEditor}
|
onOpenInEditor={handleOpenInEditor}
|
||||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||||
@@ -802,6 +933,7 @@ export function WorktreePanel({
|
|||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
|
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={handleMerge}
|
onMerge={handleMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
@@ -820,6 +952,9 @@ export function WorktreePanel({
|
|||||||
onAbortOperation={handleAbortOperation}
|
onAbortOperation={handleAbortOperation}
|
||||||
onContinueOperation={handleContinueOperation}
|
onContinueOperation={handleContinueOperation}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
|
terminalScripts={terminalScripts}
|
||||||
|
onRunTerminalScript={handleRunTerminalScript}
|
||||||
|
onEditScripts={handleEditScripts}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -924,6 +1059,7 @@ export function WorktreePanel({
|
|||||||
remote={pullDialogRemote}
|
remote={pullDialogRemote}
|
||||||
onPulled={handlePullCompleted}
|
onPulled={handlePullCompleted}
|
||||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||||
|
onCommitMerge={handleCommitMerge}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dev Server Logs Panel */}
|
{/* Dev Server Logs Panel */}
|
||||||
@@ -952,13 +1088,13 @@ export function WorktreePanel({
|
|||||||
onConfirm={handleConfirmSelectRemote}
|
onConfirm={handleConfirmSelectRemote}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Merge Branch Dialog */}
|
{/* Integrate Branch Dialog */}
|
||||||
<MergeWorktreeDialog
|
<MergeWorktreeDialog
|
||||||
open={mergeDialogOpen}
|
open={mergeDialogOpen}
|
||||||
onOpenChange={setMergeDialogOpen}
|
onOpenChange={setMergeDialogOpen}
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
worktree={mergeWorktree}
|
worktree={mergeWorktree}
|
||||||
onMerged={handleMerged}
|
onIntegrated={handleIntegrated}
|
||||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1019,6 +1155,8 @@ export function WorktreePanel({
|
|||||||
aheadCount={aheadCount}
|
aheadCount={aheadCount}
|
||||||
behindCount={behindCount}
|
behindCount={behindCount}
|
||||||
hasRemoteBranch={hasRemoteBranch}
|
hasRemoteBranch={hasRemoteBranch}
|
||||||
|
trackingRemote={trackingRemote}
|
||||||
|
getTrackingRemote={getTrackingRemote}
|
||||||
gitRepoStatus={gitRepoStatus}
|
gitRepoStatus={gitRepoStatus}
|
||||||
hasTestCommand={hasTestCommand}
|
hasTestCommand={hasTestCommand}
|
||||||
isStartingTests={isStartingTests}
|
isStartingTests={isStartingTests}
|
||||||
@@ -1027,6 +1165,9 @@ export function WorktreePanel({
|
|||||||
onPull={handlePullWithRemoteSelection}
|
onPull={handlePullWithRemoteSelection}
|
||||||
onPush={handlePushWithRemoteSelection}
|
onPush={handlePushWithRemoteSelection}
|
||||||
onPushNewBranch={handlePushNewBranch}
|
onPushNewBranch={handlePushNewBranch}
|
||||||
|
onPullWithRemote={handlePullWithSpecificRemote}
|
||||||
|
onPushWithRemote={handlePushWithSpecificRemote}
|
||||||
|
remotesCache={remotesCache}
|
||||||
onOpenInEditor={handleOpenInEditor}
|
onOpenInEditor={handleOpenInEditor}
|
||||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||||
@@ -1036,6 +1177,7 @@ export function WorktreePanel({
|
|||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
|
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={handleMerge}
|
onMerge={handleMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
@@ -1053,6 +1195,9 @@ export function WorktreePanel({
|
|||||||
onCherryPick={handleCherryPick}
|
onCherryPick={handleCherryPick}
|
||||||
onAbortOperation={handleAbortOperation}
|
onAbortOperation={handleAbortOperation}
|
||||||
onContinueOperation={handleContinueOperation}
|
onContinueOperation={handleContinueOperation}
|
||||||
|
terminalScripts={terminalScripts}
|
||||||
|
onRunTerminalScript={handleRunTerminalScript}
|
||||||
|
onEditScripts={handleEditScripts}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{useWorktreesEnabled && (
|
{useWorktreesEnabled && (
|
||||||
@@ -1112,6 +1257,7 @@ export function WorktreePanel({
|
|||||||
aheadCount={aheadCount}
|
aheadCount={aheadCount}
|
||||||
behindCount={behindCount}
|
behindCount={behindCount}
|
||||||
hasRemoteBranch={hasRemoteBranch}
|
hasRemoteBranch={hasRemoteBranch}
|
||||||
|
trackingRemote={getTrackingRemote(mainWorktree.path)}
|
||||||
gitRepoStatus={gitRepoStatus}
|
gitRepoStatus={gitRepoStatus}
|
||||||
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
||||||
isStartingTests={isStartingTests}
|
isStartingTests={isStartingTests}
|
||||||
@@ -1126,6 +1272,9 @@ export function WorktreePanel({
|
|||||||
onPull={handlePullWithRemoteSelection}
|
onPull={handlePullWithRemoteSelection}
|
||||||
onPush={handlePushWithRemoteSelection}
|
onPush={handlePushWithRemoteSelection}
|
||||||
onPushNewBranch={handlePushNewBranch}
|
onPushNewBranch={handlePushNewBranch}
|
||||||
|
onPullWithRemote={handlePullWithSpecificRemote}
|
||||||
|
onPushWithRemote={handlePushWithSpecificRemote}
|
||||||
|
remotes={remotesCache[mainWorktree.path]}
|
||||||
onOpenInEditor={handleOpenInEditor}
|
onOpenInEditor={handleOpenInEditor}
|
||||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||||
@@ -1135,6 +1284,7 @@ export function WorktreePanel({
|
|||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
|
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={handleMerge}
|
onMerge={handleMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
@@ -1154,6 +1304,9 @@ export function WorktreePanel({
|
|||||||
onContinueOperation={handleContinueOperation}
|
onContinueOperation={handleContinueOperation}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
hasTestCommand={hasTestCommand}
|
hasTestCommand={hasTestCommand}
|
||||||
|
terminalScripts={terminalScripts}
|
||||||
|
onRunTerminalScript={handleRunTerminalScript}
|
||||||
|
onEditScripts={handleEditScripts}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1191,6 +1344,7 @@ export function WorktreePanel({
|
|||||||
aheadCount={aheadCount}
|
aheadCount={aheadCount}
|
||||||
behindCount={behindCount}
|
behindCount={behindCount}
|
||||||
hasRemoteBranch={hasRemoteBranch}
|
hasRemoteBranch={hasRemoteBranch}
|
||||||
|
trackingRemote={getTrackingRemote(worktree.path)}
|
||||||
gitRepoStatus={gitRepoStatus}
|
gitRepoStatus={gitRepoStatus}
|
||||||
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
||||||
isStartingTests={isStartingTests}
|
isStartingTests={isStartingTests}
|
||||||
@@ -1205,6 +1359,9 @@ export function WorktreePanel({
|
|||||||
onPull={handlePullWithRemoteSelection}
|
onPull={handlePullWithRemoteSelection}
|
||||||
onPush={handlePushWithRemoteSelection}
|
onPush={handlePushWithRemoteSelection}
|
||||||
onPushNewBranch={handlePushNewBranch}
|
onPushNewBranch={handlePushNewBranch}
|
||||||
|
onPullWithRemote={handlePullWithSpecificRemote}
|
||||||
|
onPushWithRemote={handlePushWithSpecificRemote}
|
||||||
|
remotes={remotesCache[worktree.path]}
|
||||||
onOpenInEditor={handleOpenInEditor}
|
onOpenInEditor={handleOpenInEditor}
|
||||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||||
@@ -1214,6 +1371,7 @@ export function WorktreePanel({
|
|||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
|
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={handleMerge}
|
onMerge={handleMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
@@ -1233,6 +1391,9 @@ export function WorktreePanel({
|
|||||||
onContinueOperation={handleContinueOperation}
|
onContinueOperation={handleContinueOperation}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
hasTestCommand={hasTestCommand}
|
hasTestCommand={hasTestCommand}
|
||||||
|
terminalScripts={terminalScripts}
|
||||||
|
onRunTerminalScript={handleRunTerminalScript}
|
||||||
|
onEditScripts={handleEditScripts}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1317,13 +1478,13 @@ export function WorktreePanel({
|
|||||||
onConfirm={handleConfirmSelectRemote}
|
onConfirm={handleConfirmSelectRemote}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Merge Branch Dialog */}
|
{/* Integrate Branch Dialog */}
|
||||||
<MergeWorktreeDialog
|
<MergeWorktreeDialog
|
||||||
open={mergeDialogOpen}
|
open={mergeDialogOpen}
|
||||||
onOpenChange={setMergeDialogOpen}
|
onOpenChange={setMergeDialogOpen}
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
worktree={mergeWorktree}
|
worktree={mergeWorktree}
|
||||||
onMerged={handleMerged}
|
onIntegrated={handleIntegrated}
|
||||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1364,6 +1525,7 @@ export function WorktreePanel({
|
|||||||
onOpenChange={setViewStashesDialogOpen}
|
onOpenChange={setViewStashesDialogOpen}
|
||||||
worktree={viewStashesWorktree}
|
worktree={viewStashesWorktree}
|
||||||
onStashApplied={handleStashApplied}
|
onStashApplied={handleStashApplied}
|
||||||
|
onStashApplyConflict={onStashApplyConflict}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Cherry Pick Dialog */}
|
{/* Cherry Pick Dialog */}
|
||||||
@@ -1382,6 +1544,7 @@ export function WorktreePanel({
|
|||||||
worktree={pullDialogWorktree}
|
worktree={pullDialogWorktree}
|
||||||
remote={pullDialogRemote}
|
remote={pullDialogRemote}
|
||||||
onPulled={handlePullCompleted}
|
onPulled={handlePullCompleted}
|
||||||
|
onCommitMerge={handleCommitMerge}
|
||||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,8 +42,6 @@ import {
|
|||||||
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 { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const logger = createLogger('ContextView');
|
|
||||||
import { sanitizeFilename } from '@/lib/image-utils';
|
import { sanitizeFilename } from '@/lib/image-utils';
|
||||||
import { Markdown } from '../ui/markdown';
|
import { Markdown } from '../ui/markdown';
|
||||||
import {
|
import {
|
||||||
@@ -54,6 +52,8 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|
||||||
|
const logger = createLogger('ContextView');
|
||||||
|
|
||||||
interface ContextFile {
|
interface ContextFile {
|
||||||
name: string;
|
name: string;
|
||||||
type: 'text' | 'image';
|
type: 'text' | 'image';
|
||||||
@@ -973,7 +973,7 @@ export function ContextView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content area */}
|
{/* Content area */}
|
||||||
<div className="flex-1 overflow-hidden px-4 pb-4">
|
<div className="flex-1 overflow-hidden px-4 pb-2 sm:pb-4">
|
||||||
{selectedFile.type === 'image' ? (
|
{selectedFile.type === 'image' ? (
|
||||||
<div
|
<div
|
||||||
className="h-full flex items-center justify-center bg-card rounded-lg"
|
className="h-full flex items-center justify-center bg-card rounded-lg"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user