mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
Improve pull request flow, add branch selection for worktree creation, fix auto-mode concurrency count (#787)
* Changes from fix/fetch-before-pull-fetch * feat: Improve pull request flow, add branch selection for worktree creation, fix for automode concurrency count * feat: Add validation for remote names and improve error handling * Address PR comments and mobile layout fixes * ``` refactor: Extract PR target resolution logic into dedicated service ``` * feat: Add app shell UI and improve service imports. Address PR comments * fix: Improve security validation and cache handling in git operations * feat: Add GET /list endpoint and improve parameter handling * chore: Improve validation, accessibility, and error handling across apps * chore: Format vite server port configuration * fix: Add error handling for gh pr list command and improve offline fallbacks * fix: Preserve existing PR creation time and improve remote handling
This commit is contained in:
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",}
|
||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
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(viewOutput);
|
}
|
||||||
|
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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,41 @@ async function findExistingWorktreeForBranch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether a base branch reference is a remote branch (e.g. "origin/main").
|
||||||
|
* Returns the remote name if it matches a known remote, otherwise null.
|
||||||
|
*/
|
||||||
|
async function detectRemoteBranch(
|
||||||
|
projectPath: string,
|
||||||
|
baseBranch: string
|
||||||
|
): Promise<{ remote: string; branch: string } | null> {
|
||||||
|
const slashIndex = baseBranch.indexOf('/');
|
||||||
|
if (slashIndex <= 0) return null;
|
||||||
|
|
||||||
|
const possibleRemote = baseBranch.substring(0, slashIndex);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if this is actually a remote name by listing remotes
|
||||||
|
const stdout = await execGitCommand(['remote'], projectPath);
|
||||||
|
const remotes = stdout
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.map((r: string) => r.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (remotes.includes(possibleRemote)) {
|
||||||
|
return {
|
||||||
|
remote: possibleRemote,
|
||||||
|
branch: baseBranch.substring(slashIndex + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not a git repo or no remotes — fall through
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function createCreateHandler(events: EventEmitter, settingsService?: SettingsService) {
|
export function createCreateHandler(events: EventEmitter, settingsService?: SettingsService) {
|
||||||
const worktreeService = new WorktreeService();
|
const worktreeService = new WorktreeService();
|
||||||
|
|
||||||
@@ -91,7 +126,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 +206,28 @@ 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 });
|
||||||
|
|
||||||
|
// If a base branch is specified and it's a remote branch, fetch from that remote first
|
||||||
|
// This ensures we have the latest refs before creating the worktree
|
||||||
|
if (baseBranch && baseBranch !== 'HEAD') {
|
||||||
|
const remoteBranchInfo = await detectRemoteBranch(projectPath, baseBranch);
|
||||||
|
if (remoteBranchInfo) {
|
||||||
|
logger.info(
|
||||||
|
`Fetching from remote "${remoteBranchInfo.remote}" before creating worktree (base: ${baseBranch})`
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await execGitCommand(
|
||||||
|
['fetch', remoteBranchInfo.remote, remoteBranchInfo.branch],
|
||||||
|
projectPath
|
||||||
|
);
|
||||||
|
} catch (fetchErr) {
|
||||||
|
// Non-fatal: log but continue — the ref might already be cached locally
|
||||||
|
logger.warn(
|
||||||
|
`Failed to fetch from remote "${remoteBranchInfo.remote}": ${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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
// 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');
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
// Primary key used by current builds for pre-React theme persistence.
|
||||||
|
var theme = localStorage.getItem('automaker:theme');
|
||||||
|
|
||||||
|
// Backward compatibility: older builds stored theme in the Zustand blob.
|
||||||
|
if (!theme) {
|
||||||
var stored = localStorage.getItem('automaker-storage');
|
var stored = localStorage.getItem('automaker-storage');
|
||||||
if (stored) {
|
if (stored) {
|
||||||
var data = JSON.parse(stored);
|
var data = JSON.parse(stored);
|
||||||
var theme = data.state?.theme;
|
theme = data?.state?.theme || data?.theme || null;
|
||||||
if (theme && theme !== 'system' && theme !== 'light') {
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
document.documentElement.classList.add(theme);
|
||||||
|
if (lightThemes.indexOf(theme) !== -1) {
|
||||||
|
document.documentElement.setAttribute('data-theme-type', 'light');
|
||||||
|
}
|
||||||
} else if (
|
} else if (
|
||||||
theme === 'system' &&
|
theme === 'system' &&
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
) {
|
) {
|
||||||
document.documentElement.classList.add('dark');
|
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>
|
||||||
|
|||||||
@@ -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,9 +298,22 @@ 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.
|
||||||
|
// Ensure persisted mobileMode is restored before deciding strategy so the
|
||||||
|
// very first API requests after restart can hit cache immediately.
|
||||||
|
try {
|
||||||
|
await mobileModeRestorePromise;
|
||||||
|
} catch (_e) {
|
||||||
|
// Best-effort restore — keep default mobileMode value on failure.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(mobileMode && isCacheableApiRequest(url))) {
|
||||||
|
// Non-mobile or non-cacheable API: skip SW caching and use network.
|
||||||
|
return fetch(event.request);
|
||||||
|
}
|
||||||
|
|
||||||
const cache = await caches.open(API_CACHE);
|
const cache = await caches.open(API_CACHE);
|
||||||
const cachedResponse = await cache.match(event.request);
|
const cachedResponse = await cache.match(event.request);
|
||||||
|
|
||||||
@@ -219,7 +327,11 @@ self.addEventListener('fetch', (event) => {
|
|||||||
// Store with timestamp for freshness checking
|
// Store with timestamp for freshness checking
|
||||||
const timestampedResponse = await addCacheTimestamp(networkResponse);
|
const timestampedResponse = await addCacheTimestamp(networkResponse);
|
||||||
cache.put(event.request, timestampedResponse);
|
cache.put(event.request, timestampedResponse);
|
||||||
|
return networkResponse;
|
||||||
}
|
}
|
||||||
|
// Non-ok response (e.g. 5xx) — don't resolve with it for the race
|
||||||
|
// so the caller falls back to cachedResponse instead of showing an error page.
|
||||||
|
if (cachedResponse) return null;
|
||||||
return networkResponse;
|
return networkResponse;
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -247,6 +359,11 @@ self.addEventListener('fetch', (event) => {
|
|||||||
fetchPromise,
|
fetchPromise,
|
||||||
new Promise((resolve) => setTimeout(() => resolve(null), 2000)),
|
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;
|
return networkResult || cachedResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,16 +373,20 @@ self.addEventListener('fetch', (event) => {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Non-mobile or non-cacheable API: skip SW, let browser handle normally
|
|
||||||
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 () => {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
const cachedResponse = (await cache.match(event.request)) || (await cache.match('/'));
|
||||||
|
|
||||||
|
// Start a background fetch to update the cache for next time.
|
||||||
|
// Uses Navigation Preload if available (already in-flight, no extra cost).
|
||||||
|
const updateCache = async () => {
|
||||||
try {
|
try {
|
||||||
// Use the preloaded response if available (fired during SW boot)
|
|
||||||
// This is the key mobile performance win - no waiting for SW to start
|
|
||||||
const preloadResponse = event.preloadResponse && (await event.preloadResponse);
|
const preloadResponse = event.preloadResponse && (await event.preloadResponse);
|
||||||
if (preloadResponse) {
|
const freshResponse = preloadResponse || (await fetch(event.request));
|
||||||
// Cache the preloaded response for offline use
|
if (freshResponse.ok && freshResponse.type === 'basic') {
|
||||||
if (preloadResponse.ok && preloadResponse.type === 'basic') {
|
await cache.put(event.request, freshResponse.clone());
|
||||||
const clone = preloadResponse.clone();
|
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
|
||||||
}
|
}
|
||||||
return preloadResponse;
|
} 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to regular fetch if Navigation Preload is not available
|
// No cache yet (first visit) — must go to network
|
||||||
const response = await fetch(event.request);
|
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,14 +597,20 @@ 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)) {
|
||||||
|
event.waitUntil(
|
||||||
caches.open(IMMUTABLE_CACHE).then((cache) => {
|
caches.open(IMMUTABLE_CACHE).then((cache) => {
|
||||||
event.data.urls.forEach((url) => {
|
return Promise.all(
|
||||||
cache.match(url).then((existing) => {
|
event.data.urls.map((url) => {
|
||||||
|
// Use ignoreVary so we find assets regardless of the request mode
|
||||||
|
// they were originally cached with (cors vs no-cors).
|
||||||
|
return cache.match(url, { ignoreVary: true }).then((existing) => {
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
fetch(url, { priority: 'low' })
|
// Fetch with cors mode to match how <script crossorigin> and
|
||||||
|
// <link rel="modulepreload" crossorigin> request these assets.
|
||||||
|
return fetch(url, { mode: 'cors', priority: 'low' })
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
cache.put(url, response);
|
return cache.put(url, response);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -419,7 +618,9 @@ self.addEventListener('message', (event) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
})
|
||||||
});
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -350,7 +350,8 @@ function FileDiffSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-border rounded-lg overflow-hidden">
|
<div className="border border-border rounded-lg overflow-hidden">
|
||||||
<div className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card hover:bg-accent/50 transition-colors">
|
<div className="w-full px-3 py-2 flex flex-col gap-1 text-left bg-card hover:bg-accent/50 transition-colors sm:flex-row sm:items-center sm:gap-2">
|
||||||
|
{/* 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" />
|
||||||
@@ -367,7 +368,8 @@ 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">
|
||||||
{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">
|
||||||
@@ -776,7 +778,7 @@ export function GitDiffPanel({
|
|||||||
<div>
|
<div>
|
||||||
{/* 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
|
||||||
@@ -817,7 +819,7 @@ export function GitDiffPanel({
|
|||||||
));
|
));
|
||||||
})()}
|
})()}
|
||||||
</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 +879,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>
|
||||||
@@ -922,12 +924,17 @@ export function GitDiffPanel({
|
|||||||
key={file.path}
|
key={file.path}
|
||||||
className="border border-border rounded-lg overflow-hidden"
|
className="border border-border rounded-lg overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card">
|
<div className="w-full px-3 py-2 flex flex-col gap-1 text-left bg-card sm:flex-row sm:items-center sm:gap-2">
|
||||||
|
{/* File name row */}
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
{getFileIcon(file.status)}
|
{getFileIcon(file.status)}
|
||||||
<TruncatedFilePath
|
<TruncatedFilePath
|
||||||
path={file.path}
|
path={file.path}
|
||||||
className="flex-1 text-sm font-mono text-foreground"
|
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">
|
||||||
{enableStaging && <StagingBadge state={stagingState} />}
|
{enableStaging && <StagingBadge state={stagingState} />}
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -946,7 +953,7 @@ export function GitDiffPanel({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 px-2 text-xs"
|
className="h-6 px-2 text-xs"
|
||||||
onClick={() => handleUnstageFile(file.path)}
|
onClick={() => void handleUnstageFile(file.path)}
|
||||||
title="Unstage file"
|
title="Unstage file"
|
||||||
>
|
>
|
||||||
<Minus className="w-3 h-3 mr-1" />
|
<Minus className="w-3 h-3 mr-1" />
|
||||||
@@ -957,7 +964,7 @@ export function GitDiffPanel({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 px-2 text-xs"
|
className="h-6 px-2 text-xs"
|
||||||
onClick={() => handleStageFile(file.path)}
|
onClick={() => void handleStageFile(file.path)}
|
||||||
title="Stage file"
|
title="Stage file"
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
<Plus className="w-3 h-3 mr-1" />
|
||||||
@@ -967,6 +974,7 @@ export function GitDiffPanel({
|
|||||||
</div>
|
</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 === '?' ? (
|
||||||
<span>New file - content preview not available</span>
|
<span>New file - content preview not available</span>
|
||||||
|
|||||||
@@ -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,11 +44,24 @@ 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>
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
{onCreateSession && (
|
||||||
|
<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">
|
<Button onClick={onShowSessionManager} variant="outline" className="gap-2">
|
||||||
<PanelLeft className="w-4 h-4" />
|
<PanelLeft className="w-4 h-4" />
|
||||||
{showSessionManager ? 'View' : 'Show'} Sessions
|
{showSessionManager ? 'View' : 'Show'} Sessions
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ 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 { 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';
|
||||||
@@ -880,7 +879,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 +894,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) {
|
||||||
|
try {
|
||||||
await handleStartImplementation(newFeature);
|
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', {
|
||||||
@@ -1225,6 +1232,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 +1401,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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -96,8 +96,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 +109,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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ export const ListRow = memo(function ListRow({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-medium truncate',
|
'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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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-[100dvh] 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,6 +11,13 @@ 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,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
@@ -21,6 +28,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';
|
||||||
@@ -31,6 +39,11 @@ import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
|
|||||||
import type { FileStatus } from '@/types/electron';
|
import type { FileStatus } 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;
|
||||||
@@ -178,6 +191,17 @@ export function CommitWorktreeDialog({
|
|||||||
const [expandedFile, setExpandedFile] = useState<string | null>(null);
|
const [expandedFile, setExpandedFile] = useState<string | null>(null);
|
||||||
const [isLoadingDiffs, setIsLoadingDiffs] = useState(false);
|
const [isLoadingDiffs, setIsLoadingDiffs] = useState(false);
|
||||||
|
|
||||||
|
// 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 +214,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 +274,14 @@ export function CommitWorktreeDialog({
|
|||||||
setDiffContent('');
|
setDiffContent('');
|
||||||
setSelectedFiles(new Set());
|
setSelectedFiles(new Set());
|
||||||
setExpandedFile(null);
|
setExpandedFile(null);
|
||||||
|
// Reset push state
|
||||||
|
setPushAfterCommit(false);
|
||||||
|
setRemotes([]);
|
||||||
|
setSelectedRemote('');
|
||||||
|
setIsPushing(false);
|
||||||
|
setRemotesFetched(false);
|
||||||
|
setRemotesFetchError(null);
|
||||||
|
setCommitSucceeded(false);
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -278,14 +362,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 +433,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}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Push after commit if enabled
|
||||||
|
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();
|
onCommitted();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
setMessage('');
|
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,17 +469,31 @@ 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 &&
|
||||||
|
!isGenerating
|
||||||
|
) {
|
||||||
|
if (isPushRetry) {
|
||||||
|
// Push retry only needs a selected remote
|
||||||
|
if (selectedRemote) {
|
||||||
|
handleCommit();
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
message.trim() &&
|
message.trim() &&
|
||||||
selectedFiles.size > 0
|
selectedFiles.size > 0 &&
|
||||||
|
!(pushAfterCommit && !selectedRemote)
|
||||||
) {
|
) {
|
||||||
handleCommit();
|
handleCommit();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate AI commit message when dialog opens (if enabled)
|
// Generate AI commit message when dialog opens (if enabled)
|
||||||
@@ -390,8 +553,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">
|
||||||
@@ -580,9 +754,80 @@ 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}>
|
||||||
|
<span className="font-medium">{remote.name}</span>
|
||||||
|
<span className="ml-2 text-muted-foreground text-xs inline-block truncate max-w-[200px] align-bottom">
|
||||||
|
{remote.url}
|
||||||
|
</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 +835,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
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{pushAfterCommit ? (
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
) : (
|
||||||
<GitCommit className="w-4 h-4 mr-2" />
|
<GitCommit className="w-4 h-4 mr-2" />
|
||||||
Commit
|
)}
|
||||||
|
{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,52 @@ export function CreatePRDialog({
|
|||||||
true // Include remote branches for PR base branch selection
|
true // Include remote branches for PR base branch selection
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Determine if push remote selection is needed:
|
||||||
|
// Show when there are unpushed commits, no remote tracking branch, or uncommitted changes
|
||||||
|
// (uncommitted changes will be committed first, then pushed)
|
||||||
|
const branchHasRemote = branchesData?.hasRemoteBranch ?? false;
|
||||||
|
const branchAheadCount = branchesData?.aheadCount ?? 0;
|
||||||
|
const needsPush = !branchHasRemote || branchAheadCount > 0 || !!worktree?.hasChanges;
|
||||||
|
|
||||||
// Filter out current worktree branch from the list
|
// Filter out current worktree branch from the list
|
||||||
|
// When a target remote is selected, only show branches from that remote
|
||||||
const branches = useMemo(() => {
|
const branches = useMemo(() => {
|
||||||
if (!branchesData?.branches) return [];
|
if (!branchesData?.branches) return [];
|
||||||
return branchesData.branches.map((b) => b.name).filter((name) => name !== worktree?.branch);
|
const allBranches = branchesData.branches
|
||||||
}, [branchesData?.branches, worktree?.branch]);
|
.map((b) => b.name)
|
||||||
|
.filter((name) => name !== worktree?.branch);
|
||||||
|
|
||||||
|
// If a target remote is selected and we have remote info with branches,
|
||||||
|
// only show that remote's branches (not branches from other remotes)
|
||||||
|
if (selectedTargetRemote) {
|
||||||
|
const targetRemoteInfo = remotes.find((r) => r.name === selectedTargetRemote);
|
||||||
|
if (targetRemoteInfo?.branches && targetRemoteInfo.branches.length > 0) {
|
||||||
|
const targetBranchNames = new Set(targetRemoteInfo.branches);
|
||||||
|
// Filter to only include branches that exist on the target remote
|
||||||
|
// Match both short names (e.g. "main") and prefixed names (e.g. "upstream/main")
|
||||||
|
return allBranches.filter((name) => {
|
||||||
|
// Check if the branch name matches a target remote branch directly
|
||||||
|
if (targetBranchNames.has(name)) return true;
|
||||||
|
// Check if it's a prefixed remote branch (e.g. "upstream/main")
|
||||||
|
const prefix = `${selectedTargetRemote}/`;
|
||||||
|
if (name.startsWith(prefix) && targetBranchNames.has(name.slice(prefix.length)))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allBranches;
|
||||||
|
}, [branchesData?.branches, worktree?.branch, selectedTargetRemote, 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
|
||||||
|
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 +157,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 +175,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 +216,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]);
|
||||||
@@ -215,6 +278,7 @@ export function CreatePRDialog({
|
|||||||
baseBranch,
|
baseBranch,
|
||||||
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 +412,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 +546,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 +589,46 @@ export function CreatePRDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Target remote selector - which remote to create PR against */}
|
||||||
|
{remotes.length > 1 && (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="base-branch">Base Branch</Label>
|
<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">
|
||||||
|
<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}
|
||||||
|
allowCreate={false}
|
||||||
|
emptyMessage="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,6 +340,84 @@ export function CreateWorktreeDialog({
|
|||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Base Branch Section - collapsible */}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-start gap-2 p-3 rounded-md bg-destructive/10 border border-destructive/20">
|
<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" />
|
<AlertCircle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
|
||||||
@@ -192,7 +429,6 @@ export function CreateWorktreeDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
@@ -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...'}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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-[100dvh] 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" />
|
||||||
|
|||||||
@@ -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-[100dvh] 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-[100dvh] 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" />
|
||||||
|
|||||||
@@ -123,6 +123,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 +219,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
|
||||||
@@ -608,6 +611,11 @@ export function useBoardActions({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip status update if feature was already created with in_progress status
|
||||||
|
// (e.g., via "Make" button which creates directly as in_progress to avoid backlog flash)
|
||||||
|
const alreadyInProgress = feature.status === 'in_progress';
|
||||||
|
|
||||||
|
if (!alreadyInProgress) {
|
||||||
const updates = {
|
const updates = {
|
||||||
status: 'in_progress' as const,
|
status: 'in_progress' as const,
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
@@ -617,11 +625,37 @@ export function useBoardActions({
|
|||||||
try {
|
try {
|
||||||
// Must await to ensure feature status is persisted before starting agent
|
// Must await to ensure feature status is persisted before starting agent
|
||||||
await persistFeatureUpdate(feature.id, updates);
|
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 {
|
||||||
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,
|
||||||
|
|||||||
@@ -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') {
|
//
|
||||||
|
// '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) {
|
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 (allRunningTaskIds.has(f.id)) {
|
||||||
if (matchesWorktree) {
|
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,
|
||||||
|
|||||||
@@ -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,7 +131,7 @@ 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
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -81,10 +81,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;
|
||||||
@@ -141,10 +149,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,
|
||||||
@@ -217,9 +229,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,6 +331,25 @@ 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">
|
||||||
@@ -416,188 +449,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>
|
||||||
@@ -723,6 +574,272 @@ export function WorktreeActionsDropdown({
|
|||||||
Re-run Init Script
|
Re-run Init Script
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
{(hasChangesSectionContent || hasDestructiveSectionContent) && <DropdownMenuSeparator />}
|
{(hasChangesSectionContent || hasDestructiveSectionContent) && <DropdownMenuSeparator />}
|
||||||
|
|
||||||
{worktree.hasChanges && (
|
{worktree.hasChanges && (
|
||||||
@@ -731,6 +848,75 @@ export function WorktreeActionsDropdown({
|
|||||||
View Changes
|
View Changes
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{/* Stash operations - combined submenu or simple item.
|
||||||
|
Only render when at least one action is meaningful:
|
||||||
|
- (worktree.hasChanges && onStashChanges): stashing changes is possible
|
||||||
|
- onViewStashes: viewing existing stashes is possible
|
||||||
|
Without this guard, the item would appear clickable but be a silent no-op
|
||||||
|
when hasChanges is false and onViewStashes is undefined. */}
|
||||||
|
{((worktree.hasChanges && 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>
|
||||||
|
)}
|
||||||
{worktree.hasChanges && (
|
{worktree.hasChanges && (
|
||||||
<TooltipWrapper
|
<TooltipWrapper
|
||||||
showTooltip={!!gitOpsDisabledReason}
|
showTooltip={!!gitOpsDisabledReason}
|
||||||
@@ -749,7 +935,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}
|
||||||
@@ -829,27 +1015,6 @@ export function WorktreeActionsDropdown({
|
|||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
)}
|
)}
|
||||||
{!worktree.isMain && (
|
{!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" />
|
|
||||||
Merge Branch
|
|
||||||
{!isGitOpsAvailable && (
|
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</TooltipWrapper>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => onDeleteWorktree(worktree)}
|
onClick={() => onDeleteWorktree(worktree)}
|
||||||
className="text-xs text-destructive focus:text-destructive"
|
className="text-xs text-destructive focus:text-destructive"
|
||||||
@@ -857,7 +1022,6 @@ export function WorktreeActionsDropdown({
|
|||||||
<Trash2 className="w-3.5 h-3.5 mr-2" />
|
<Trash2 className="w-3.5 h-3.5 mr-2" />
|
||||||
Delete Worktree
|
Delete Worktree
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -121,6 +125,12 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -170,6 +180,8 @@ export function WorktreeDropdown({
|
|||||||
aheadCount,
|
aheadCount,
|
||||||
behindCount,
|
behindCount,
|
||||||
hasRemoteBranch,
|
hasRemoteBranch,
|
||||||
|
trackingRemote,
|
||||||
|
getTrackingRemote,
|
||||||
gitRepoStatus,
|
gitRepoStatus,
|
||||||
hasTestCommand,
|
hasTestCommand,
|
||||||
isStartingTests,
|
isStartingTests,
|
||||||
@@ -204,6 +216,9 @@ export function WorktreeDropdown({
|
|||||||
onCherryPick,
|
onCherryPick,
|
||||||
onAbortOperation,
|
onAbortOperation,
|
||||||
onContinueOperation,
|
onContinueOperation,
|
||||||
|
remotesCache,
|
||||||
|
onPullWithRemote,
|
||||||
|
onPushWithRemote,
|
||||||
}: 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));
|
||||||
@@ -470,6 +485,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 +500,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}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -93,6 +95,12 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorktreeTab({
|
export function WorktreeTab({
|
||||||
@@ -116,6 +124,7 @@ export function WorktreeTab({
|
|||||||
aheadCount,
|
aheadCount,
|
||||||
behindCount,
|
behindCount,
|
||||||
hasRemoteBranch,
|
hasRemoteBranch,
|
||||||
|
trackingRemote,
|
||||||
gitRepoStatus,
|
gitRepoStatus,
|
||||||
isAutoModeRunning = false,
|
isAutoModeRunning = false,
|
||||||
isStartingTests = false,
|
isStartingTests = false,
|
||||||
@@ -158,6 +167,9 @@ export function WorktreeTab({
|
|||||||
onContinueOperation,
|
onContinueOperation,
|
||||||
hasInitScript,
|
hasInitScript,
|
||||||
hasTestCommand = false,
|
hasTestCommand = false,
|
||||||
|
remotes,
|
||||||
|
onPullWithRemote,
|
||||||
|
onPushWithRemote,
|
||||||
}: 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({
|
||||||
@@ -476,6 +488,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 +501,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}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,11 +96,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();
|
||||||
|
|
||||||
@@ -410,7 +413,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 +437,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 +459,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 +490,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,10 +646,15 @@ export function WorktreePanel({
|
|||||||
setPushToRemoteDialogOpen(true);
|
setPushToRemoteDialogOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle pull completed - refresh worktrees
|
// 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 (pullDialogWorktree) {
|
||||||
|
fetchBranches(pullDialogWorktree.path);
|
||||||
|
}
|
||||||
fetchWorktrees({ silent: true });
|
fetchWorktrees({ silent: true });
|
||||||
}, [fetchWorktrees]);
|
}, [fetchWorktrees, fetchBranches, pullDialogWorktree]);
|
||||||
|
|
||||||
// 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
|
||||||
@@ -675,18 +720,37 @@ export function WorktreePanel({
|
|||||||
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);
|
fetchBranches(worktree.path);
|
||||||
fetchWorktrees();
|
fetchWorktrees({ silent: true });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[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 +783,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 +841,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 +854,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}
|
||||||
@@ -952,13 +1020,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 +1087,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 +1097,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}
|
||||||
@@ -1112,6 +1185,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 +1200,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}
|
||||||
@@ -1191,6 +1268,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 +1283,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}
|
||||||
@@ -1317,13 +1398,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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -313,24 +313,69 @@ export function GraphViewPage() {
|
|||||||
// Handle add and start feature
|
// Handle add and start feature
|
||||||
const handleAddAndStartFeature = useCallback(
|
const handleAddAndStartFeature = useCallback(
|
||||||
async (featureData: Parameters<typeof handleAddFeature>[0]) => {
|
async (featureData: Parameters<typeof handleAddFeature>[0]) => {
|
||||||
try {
|
|
||||||
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
|
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
|
||||||
await handleAddFeature(featureData);
|
try {
|
||||||
|
// Create feature directly with in_progress status to avoid brief backlog flash
|
||||||
|
await handleAddFeature({ ...featureData, initialStatus: 'in_progress' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to create feature:', error);
|
||||||
|
toast.error(
|
||||||
|
`Failed to create feature: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const latestFeatures = useAppStore.getState().features;
|
const latestFeatures = useAppStore.getState().features;
|
||||||
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
|
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
|
||||||
|
|
||||||
if (newFeature) {
|
if (newFeature) {
|
||||||
|
try {
|
||||||
await handleStartImplementation(newFeature);
|
await handleStartImplementation(newFeature);
|
||||||
|
} catch (startError) {
|
||||||
|
logger.error('Failed to start implementation, rolling back feature status:', startError);
|
||||||
|
// Rollback: revert the newly created feature back to backlog so it isn't stuck in in_progress
|
||||||
|
try {
|
||||||
|
const { updateFeature } = useAppStore.getState();
|
||||||
|
updateFeature(newFeature.id, { status: 'backlog' });
|
||||||
|
// Also persist the rollback so it survives page refresh
|
||||||
|
await persistFeatureUpdate(newFeature.id, { status: 'backlog' });
|
||||||
|
logger.info(`Rolled back feature ${newFeature.id} status to backlog`);
|
||||||
|
} catch (rollbackErr) {
|
||||||
|
logger.error('Failed to rollback feature status:', rollbackErr);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to add and start feature:', error);
|
|
||||||
toast.error(
|
toast.error(
|
||||||
`Failed to add and start feature: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to start feature: ${startError instanceof Error ? startError.message : String(startError)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Feature was not found in the store after creation — it may have been
|
||||||
|
// persisted but not yet visible in the snapshot. Attempt to locate it
|
||||||
|
// and roll it back so it doesn't remain stuck in 'in_progress'.
|
||||||
|
logger.error(
|
||||||
|
'Newly created feature not found in store after handleAddFeature completed. ' +
|
||||||
|
`Store has ${latestFeatures.length} features, expected a new entry.`
|
||||||
|
);
|
||||||
|
// Best-effort: re-read the store to find any feature still in 'in_progress'
|
||||||
|
// that wasn't in the original set. We must use a fresh snapshot here because
|
||||||
|
// latestFeatures was captured before the async gap and may not contain the new entry.
|
||||||
|
const freshFeatures = useAppStore.getState().features;
|
||||||
|
const stuckFeature = freshFeatures.find(
|
||||||
|
(f) => f.status === 'in_progress' && !featuresBeforeIds.has(f.id)
|
||||||
|
);
|
||||||
|
if (stuckFeature) {
|
||||||
|
try {
|
||||||
|
const { updateFeature } = useAppStore.getState();
|
||||||
|
updateFeature(stuckFeature.id, { status: 'backlog' });
|
||||||
|
await persistFeatureUpdate(stuckFeature.id, { status: 'backlog' });
|
||||||
|
logger.info(`Rolled back orphaned feature ${stuckFeature.id} status to backlog`);
|
||||||
|
} catch (rollbackErr) {
|
||||||
|
logger.error('Failed to rollback orphaned feature status:', rollbackErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.error('Feature was created but could not be started. Please try again.');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[handleAddFeature, handleStartImplementation]
|
[handleAddFeature, handleStartImplementation, persistFeatureUpdate]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!currentProject) {
|
if (!currentProject) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Workflow,
|
Workflow,
|
||||||
Database,
|
Database,
|
||||||
Terminal,
|
Terminal,
|
||||||
|
ScrollText,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
|||||||
{ id: 'identity', label: 'Identity', icon: User },
|
{ id: 'identity', label: 'Identity', icon: User },
|
||||||
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
||||||
{ id: 'commands', label: 'Commands', icon: Terminal },
|
{ id: 'commands', label: 'Commands', icon: Terminal },
|
||||||
|
{ id: 'scripts', label: 'Terminal Scripts', icon: ScrollText },
|
||||||
{ id: 'theme', label: 'Theme', icon: Palette },
|
{ id: 'theme', label: 'Theme', icon: Palette },
|
||||||
{ id: 'claude', label: 'Models', icon: Workflow },
|
{ id: 'claude', label: 'Models', icon: Workflow },
|
||||||
{ id: 'data', label: 'Data', icon: Database },
|
{ id: 'data', label: 'Data', icon: Database },
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type ProjectSettingsViewId =
|
|||||||
| 'theme'
|
| 'theme'
|
||||||
| 'worktrees'
|
| 'worktrees'
|
||||||
| 'commands'
|
| 'commands'
|
||||||
|
| 'scripts'
|
||||||
| 'claude'
|
| 'claude'
|
||||||
| 'data'
|
| 'data'
|
||||||
| 'danger';
|
| 'danger';
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ export { ProjectIdentitySection } from './project-identity-section';
|
|||||||
export { ProjectThemeSection } from './project-theme-section';
|
export { ProjectThemeSection } from './project-theme-section';
|
||||||
export { WorktreePreferencesSection } from './worktree-preferences-section';
|
export { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||||
export { CommandsSection } from './commands-section';
|
export { CommandsSection } from './commands-section';
|
||||||
|
export { TerminalScriptsSection } from './terminal-scripts-section';
|
||||||
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
|
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
|
||||||
export { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
export { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ProjectIdentitySection } from './project-identity-section';
|
|||||||
import { ProjectThemeSection } from './project-theme-section';
|
import { ProjectThemeSection } from './project-theme-section';
|
||||||
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||||
import { CommandsSection } from './commands-section';
|
import { CommandsSection } from './commands-section';
|
||||||
|
import { TerminalScriptsSection } from './terminal-scripts-section';
|
||||||
import { ProjectModelsSection } from './project-models-section';
|
import { ProjectModelsSection } from './project-models-section';
|
||||||
import { DataManagementSection } from './data-management-section';
|
import { DataManagementSection } from './data-management-section';
|
||||||
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
||||||
@@ -91,6 +92,8 @@ export function ProjectSettingsView() {
|
|||||||
return <WorktreePreferencesSection project={currentProject} />;
|
return <WorktreePreferencesSection project={currentProject} />;
|
||||||
case 'commands':
|
case 'commands':
|
||||||
return <CommandsSection project={currentProject} />;
|
return <CommandsSection project={currentProject} />;
|
||||||
|
case 'scripts':
|
||||||
|
return <TerminalScriptsSection project={currentProject} />;
|
||||||
case 'claude':
|
case 'claude':
|
||||||
return <ProjectModelsSection project={currentProject} />;
|
return <ProjectModelsSection project={currentProject} />;
|
||||||
case 'data':
|
case 'data':
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Shared terminal script constants used by both the settings section
|
||||||
|
* (terminal-scripts-section.tsx) and the terminal header dropdown
|
||||||
|
* (terminal-scripts-dropdown.tsx).
|
||||||
|
*
|
||||||
|
* Centralising the default scripts here ensures both components show
|
||||||
|
* the same fallback list and removes the duplicated definition.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TerminalScript {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
command: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default scripts shown when the user has not configured any custom scripts yet. */
|
||||||
|
export const DEFAULT_TERMINAL_SCRIPTS: TerminalScript[] = [
|
||||||
|
{ id: 'default-dev', name: 'Dev Server', command: 'npm run dev' },
|
||||||
|
{ id: 'default-format', name: 'Format', command: 'npm run format' },
|
||||||
|
{ id: 'default-test', name: 'Test', command: 'npm run test' },
|
||||||
|
{ id: 'default-lint', name: 'Lint', command: 'npm run lint' },
|
||||||
|
];
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import { useState, useEffect, useCallback, type KeyboardEvent } from 'react';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ScrollText, Save, RotateCcw, Info, Plus, GripVertical, Trash2 } from 'lucide-react';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useProjectSettings } from '@/hooks/queries';
|
||||||
|
import { useUpdateProjectSettings } from '@/hooks/mutations';
|
||||||
|
import type { Project } from '@/lib/electron';
|
||||||
|
import { DEFAULT_TERMINAL_SCRIPTS } from './terminal-scripts-constants';
|
||||||
|
|
||||||
|
/** Preset scripts for quick addition */
|
||||||
|
const SCRIPT_PRESETS = [
|
||||||
|
{ name: 'Dev Server', command: 'npm run dev' },
|
||||||
|
{ name: 'Build', command: 'npm run build' },
|
||||||
|
{ name: 'Test', command: 'npm run test' },
|
||||||
|
{ name: 'Lint', command: 'npm run lint' },
|
||||||
|
{ name: 'Format', command: 'npm run format' },
|
||||||
|
{ name: 'Type Check', command: 'npm run typecheck' },
|
||||||
|
{ name: 'Start', command: 'npm start' },
|
||||||
|
{ name: 'Clean', command: 'npm run clean' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface ScriptEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
command: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TerminalScriptsSectionProps {
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a unique ID for a new script */
|
||||||
|
function generateId(): string {
|
||||||
|
return `script-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TerminalScriptsSection({ project }: TerminalScriptsSectionProps) {
|
||||||
|
// Fetch project settings using TanStack Query
|
||||||
|
const { data: projectSettings, isLoading, isError } = useProjectSettings(project.path);
|
||||||
|
|
||||||
|
// Mutation hook for updating project settings
|
||||||
|
const updateSettingsMutation = useUpdateProjectSettings(project.path);
|
||||||
|
|
||||||
|
// Local state for scripts
|
||||||
|
const [scripts, setScripts] = useState<ScriptEntry[]>([]);
|
||||||
|
const [originalScripts, setOriginalScripts] = useState<ScriptEntry[]>([]);
|
||||||
|
|
||||||
|
// Dragging state
|
||||||
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Reset local state when project changes
|
||||||
|
useEffect(() => {
|
||||||
|
setScripts([]);
|
||||||
|
setOriginalScripts([]);
|
||||||
|
}, [project.path]);
|
||||||
|
|
||||||
|
// Sync local state when project settings load or project path changes.
|
||||||
|
// Including project.path ensures originalScripts is re-populated after a
|
||||||
|
// project switch even if projectSettings is cached from a previous render.
|
||||||
|
useEffect(() => {
|
||||||
|
if (projectSettings) {
|
||||||
|
const configured = projectSettings.terminalScripts;
|
||||||
|
const scriptList =
|
||||||
|
configured && configured.length > 0
|
||||||
|
? configured.map((s) => ({ id: s.id, name: s.name, command: s.command }))
|
||||||
|
: DEFAULT_TERMINAL_SCRIPTS.map((s) => ({ ...s }));
|
||||||
|
setScripts(scriptList);
|
||||||
|
setOriginalScripts(JSON.parse(JSON.stringify(scriptList)));
|
||||||
|
}
|
||||||
|
}, [projectSettings, project.path]);
|
||||||
|
|
||||||
|
// Check if there are unsaved changes
|
||||||
|
const hasChanges = JSON.stringify(scripts) !== JSON.stringify(originalScripts);
|
||||||
|
const isSaving = updateSettingsMutation.isPending;
|
||||||
|
|
||||||
|
// Save scripts
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
// Filter out scripts with empty names or commands
|
||||||
|
const validScripts = scripts.filter((s) => s.name.trim() && s.command.trim());
|
||||||
|
const normalizedScripts = validScripts.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name.trim(),
|
||||||
|
command: s.command.trim(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
updateSettingsMutation.mutate(
|
||||||
|
{ terminalScripts: normalizedScripts },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setScripts(normalizedScripts);
|
||||||
|
setOriginalScripts(JSON.parse(JSON.stringify(normalizedScripts)));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [scripts, updateSettingsMutation]);
|
||||||
|
|
||||||
|
// Reset to original values
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
setScripts(JSON.parse(JSON.stringify(originalScripts)));
|
||||||
|
}, [originalScripts]);
|
||||||
|
|
||||||
|
// Add a new empty script entry
|
||||||
|
const handleAddScript = useCallback(() => {
|
||||||
|
setScripts((prev) => [...prev, { id: generateId(), name: '', command: '' }]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Add a preset script
|
||||||
|
const handleAddPreset = useCallback((preset: { name: string; command: string }) => {
|
||||||
|
setScripts((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id: generateId(), name: preset.name, command: preset.command },
|
||||||
|
]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Remove a script by index
|
||||||
|
const handleRemoveScript = useCallback((index: number) => {
|
||||||
|
setScripts((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update a script field
|
||||||
|
const handleUpdateScript = useCallback(
|
||||||
|
(index: number, field: 'name' | 'command', value: string) => {
|
||||||
|
setScripts((prev) => prev.map((s, i) => (i === index ? { ...s, [field]: value } : s)));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle keyboard shortcuts (Enter to save)
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter' && hasChanges && !isSaving) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[hasChanges, isSaving, handleSave]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drag and drop handlers for reordering
|
||||||
|
const handleDragStart = useCallback((index: number) => {
|
||||||
|
setDraggedIndex(index);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback(
|
||||||
|
(e: React.DragEvent, index: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (draggedIndex === null || draggedIndex === index) return;
|
||||||
|
setDragOverIndex(index);
|
||||||
|
},
|
||||||
|
[draggedIndex]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Accept the drop so the browser sets dropEffect correctly (prevents 'none')
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (draggedIndex !== null && dragOverIndex !== null && draggedIndex !== dragOverIndex) {
|
||||||
|
setScripts((prev) => {
|
||||||
|
const newScripts = [...prev];
|
||||||
|
const [removed] = newScripts.splice(draggedIndex, 1);
|
||||||
|
newScripts.splice(dragOverIndex, 0, removed);
|
||||||
|
return newScripts;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setDraggedIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
},
|
||||||
|
[draggedIndex, dragOverIndex]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback((_e: React.DragEvent) => {
|
||||||
|
// The reorder is already performed in handleDrop. This handler only
|
||||||
|
// needs to reset the drag state (e.g. when the drop was cancelled by
|
||||||
|
// releasing outside a valid target or pressing Escape).
|
||||||
|
setDraggedIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-2xl overflow-hidden',
|
||||||
|
'border border-border/50',
|
||||||
|
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||||
|
'shadow-sm shadow-black/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||||
|
<ScrollText className="w-5 h-5 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||||
|
Terminal Quick Scripts
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||||
|
Configure quick-access scripts that appear in the terminal header dropdown. Click any
|
||||||
|
script to run it instantly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Spinner size="md" />
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-destructive">
|
||||||
|
Failed to load project settings. Please try again.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Scripts List */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{scripts.map((script, index) => (
|
||||||
|
<div
|
||||||
|
key={script.id}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 p-2 rounded-lg border border-border/30 bg-accent/10 transition-all',
|
||||||
|
draggedIndex === index && 'opacity-50',
|
||||||
|
dragOverIndex === index && 'border-brand-500/50 bg-brand-500/5'
|
||||||
|
)}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => handleDragStart(index)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDrop={(e) => handleDrop(e)}
|
||||||
|
onDragEnd={(e) => handleDragEnd(e)}
|
||||||
|
>
|
||||||
|
{/* Drag handle */}
|
||||||
|
<div
|
||||||
|
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground shrink-0 p-0.5"
|
||||||
|
title="Drag to reorder"
|
||||||
|
>
|
||||||
|
<GripVertical className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Script name */}
|
||||||
|
<Input
|
||||||
|
value={script.name}
|
||||||
|
onChange={(e) => handleUpdateScript(index, 'name', e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Script name"
|
||||||
|
className="h-8 text-sm flex-[0.4] min-w-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Script command */}
|
||||||
|
<Input
|
||||||
|
value={script.command}
|
||||||
|
onChange={(e) => handleUpdateScript(index, 'command', e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Command to run"
|
||||||
|
className="h-8 text-sm font-mono flex-[0.6] min-w-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Remove button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemoveScript(index)}
|
||||||
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive shrink-0"
|
||||||
|
aria-label={`Remove ${script.name || 'script'}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{scripts.length === 0 && (
|
||||||
|
<div className="text-center py-6 text-sm text-muted-foreground">
|
||||||
|
No scripts configured. Add some below or use a preset.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Script Button */}
|
||||||
|
<Button variant="outline" size="sm" onClick={handleAddScript} className="gap-1.5">
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
Add Script
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="border-t border-border/30" />
|
||||||
|
|
||||||
|
{/* Presets */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-foreground">Quick Add Presets</h3>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{SCRIPT_PRESETS.map((preset) => (
|
||||||
|
<Button
|
||||||
|
key={preset.command}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAddPreset(preset)}
|
||||||
|
className="text-xs font-mono h-7 px-2"
|
||||||
|
>
|
||||||
|
{preset.command}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded-lg bg-accent/20 border border-border/30">
|
||||||
|
<Info className="w-4 h-4 text-brand-500 mt-0.5 shrink-0" />
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
<p className="font-medium text-foreground mb-1">Terminal Quick Scripts</p>
|
||||||
|
<p>
|
||||||
|
These scripts appear in the terminal header as a dropdown menu (the{' '}
|
||||||
|
<ScrollText className="inline-block w-3 h-3 mx-0.5 align-middle" /> icon).
|
||||||
|
Clicking a script will type the command into the active terminal and press Enter.
|
||||||
|
Drag to reorder scripts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={!hasChanges || isSaving}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges || isSaving}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{isSaving ? <Spinner size="xs" /> : <Save className="w-3.5 h-3.5" />}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ import {
|
|||||||
applyStickyModifier,
|
applyStickyModifier,
|
||||||
type StickyModifier,
|
type StickyModifier,
|
||||||
} from './sticky-modifier-keys';
|
} from './sticky-modifier-keys';
|
||||||
|
import { TerminalScriptsDropdown } from './terminal-scripts-dropdown';
|
||||||
|
|
||||||
const logger = createLogger('Terminal');
|
const logger = createLogger('Terminal');
|
||||||
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
|
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
|
||||||
@@ -156,6 +157,11 @@ export function TerminalPanel({
|
|||||||
const [isImageDragOver, setIsImageDragOver] = useState(false);
|
const [isImageDragOver, setIsImageDragOver] = useState(false);
|
||||||
const [isProcessingImage, setIsProcessingImage] = useState(false);
|
const [isProcessingImage, setIsProcessingImage] = useState(false);
|
||||||
const hasRunInitialCommandRef = useRef(false);
|
const hasRunInitialCommandRef = useRef(false);
|
||||||
|
// Tracks whether the connected shell is a Windows shell (PowerShell, cmd, etc.).
|
||||||
|
// Maintained as a ref (not state) so sendCommand can read the current value without
|
||||||
|
// causing unnecessary re-renders or stale closure issues. Set inside ws.onmessage
|
||||||
|
// when the 'connected' message is received (see isWindowsShell detection below).
|
||||||
|
const isWindowsShellRef = useRef(false);
|
||||||
const searchAddonRef = useRef<XSearchAddon | null>(null);
|
const searchAddonRef = useRef<XSearchAddon | null>(null);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [showSearch, setShowSearch] = useState(false);
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
@@ -376,6 +382,17 @@ export function TerminalPanel({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Send a command to the terminal (types the command and presses Enter).
|
||||||
|
// Uses isWindowsShellRef.current to pick the correct line ending:
|
||||||
|
// Windows shells (PowerShell, cmd) expect '\r\n'; Unix/macOS shells expect '\n'.
|
||||||
|
// isWindowsShellRef is set in ws.onmessage when the 'connected' message arrives.
|
||||||
|
const sendCommand = useCallback((command: string) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
const lineEnding = isWindowsShellRef.current ? '\r\n' : '\n';
|
||||||
|
wsRef.current.send(JSON.stringify({ type: 'input', data: command + lineEnding }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Paste from clipboard
|
// Paste from clipboard
|
||||||
const pasteFromClipboard = useCallback(async () => {
|
const pasteFromClipboard = useCallback(async () => {
|
||||||
const terminal = xtermRef.current;
|
const terminal = xtermRef.current;
|
||||||
@@ -1090,6 +1107,9 @@ export function TerminalPanel({
|
|||||||
shellPath.includes('powershell') ||
|
shellPath.includes('powershell') ||
|
||||||
shellPath.includes('pwsh') ||
|
shellPath.includes('pwsh') ||
|
||||||
shellPath.includes('cmd.exe');
|
shellPath.includes('cmd.exe');
|
||||||
|
// Keep the component-level ref in sync so sendCommand and
|
||||||
|
// runCommandOnConnect both use the correct line ending ('\r\n' vs '\n').
|
||||||
|
isWindowsShellRef.current = isWindowsShell;
|
||||||
const isPowerShell = shellPath.includes('powershell') || shellPath.includes('pwsh');
|
const isPowerShell = shellPath.includes('powershell') || shellPath.includes('pwsh');
|
||||||
|
|
||||||
if (msg.shell) {
|
if (msg.shell) {
|
||||||
@@ -1903,6 +1923,12 @@ export function TerminalPanel({
|
|||||||
<ZoomIn className="h-3 w-3" />
|
<ZoomIn className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Quick scripts dropdown */}
|
||||||
|
<TerminalScriptsDropdown
|
||||||
|
onRunCommand={sendCommand}
|
||||||
|
isConnected={connectionStatus === 'connected'}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Settings popover */}
|
{/* Settings popover */}
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { ScrollText, Play, Settings2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { useProjectSettings } from '@/hooks/queries';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { DEFAULT_TERMINAL_SCRIPTS } from '../project-settings-view/terminal-scripts-constants';
|
||||||
|
|
||||||
|
interface TerminalScriptsDropdownProps {
|
||||||
|
/** Callback to send a command + newline to the terminal */
|
||||||
|
onRunCommand: (command: string) => void;
|
||||||
|
/** Whether the terminal is connected and ready */
|
||||||
|
isConnected: boolean;
|
||||||
|
/** Optional callback to navigate to project settings scripts section */
|
||||||
|
onOpenSettings?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dropdown menu in the terminal header bar that provides quick-access
|
||||||
|
* to user-configured project scripts. Clicking a script inserts the
|
||||||
|
* command into the terminal and presses Enter.
|
||||||
|
*/
|
||||||
|
export function TerminalScriptsDropdown({
|
||||||
|
onRunCommand,
|
||||||
|
isConnected,
|
||||||
|
onOpenSettings,
|
||||||
|
}: TerminalScriptsDropdownProps) {
|
||||||
|
const currentProject = useAppStore((state) => state.currentProject);
|
||||||
|
const { data: projectSettings } = useProjectSettings(currentProject?.path);
|
||||||
|
|
||||||
|
// Use project-configured scripts or fall back to defaults
|
||||||
|
const scripts = useMemo(() => {
|
||||||
|
const configured = projectSettings?.terminalScripts;
|
||||||
|
if (configured && configured.length > 0) {
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
return DEFAULT_TERMINAL_SCRIPTS;
|
||||||
|
}, [projectSettings?.terminalScripts]);
|
||||||
|
|
||||||
|
const handleRunScript = useCallback(
|
||||||
|
(command: string) => {
|
||||||
|
if (!isConnected) return;
|
||||||
|
onRunCommand(command);
|
||||||
|
},
|
||||||
|
[isConnected, onRunCommand]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
title="Quick Scripts"
|
||||||
|
disabled={!isConnected}
|
||||||
|
>
|
||||||
|
<ScrollText className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
side="bottom"
|
||||||
|
className="w-56"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">
|
||||||
|
Quick Scripts
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{scripts.map((script) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={script.id}
|
||||||
|
onClick={() => handleRunScript(script.command)}
|
||||||
|
disabled={!isConnected}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Play className={cn('h-3.5 w-3.5 shrink-0 text-brand-500')} />
|
||||||
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
|
<span className="text-sm truncate">{script.name}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground font-mono truncate">
|
||||||
|
{script.command}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
{onOpenSettings && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={onOpenSettings} className="gap-2 text-muted-foreground">
|
||||||
|
<Settings2 className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="text-sm">Configure Scripts...</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
* automatic caching, deduplication, and background refetching.
|
* automatic caching, deduplication, and background refetching.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { queryKeys } from '@/lib/query-keys';
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
@@ -18,6 +19,117 @@ const FEATURES_REFETCH_ON_RECONNECT = false;
|
|||||||
const FEATURES_POLLING_INTERVAL = 30000;
|
const FEATURES_POLLING_INTERVAL = 30000;
|
||||||
/** Default polling interval for agent output when WebSocket is inactive */
|
/** Default polling interval for agent output when WebSocket is inactive */
|
||||||
const AGENT_OUTPUT_POLLING_INTERVAL = 5000;
|
const AGENT_OUTPUT_POLLING_INTERVAL = 5000;
|
||||||
|
const FEATURES_CACHE_PREFIX = 'automaker:features-cache:';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bump this version whenever the Feature shape changes so stale localStorage
|
||||||
|
* entries with incompatible schemas are automatically discarded.
|
||||||
|
*/
|
||||||
|
const FEATURES_CACHE_VERSION = 1;
|
||||||
|
|
||||||
|
/** Maximum number of per-project cache entries to keep in localStorage (LRU). */
|
||||||
|
const MAX_FEATURES_CACHE_ENTRIES = 10;
|
||||||
|
|
||||||
|
interface PersistedFeaturesCache {
|
||||||
|
/** Schema version — mismatched versions are treated as stale and discarded. */
|
||||||
|
schemaVersion: number;
|
||||||
|
timestamp: number;
|
||||||
|
features: Feature[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPersistedFeatures(projectPath: string): PersistedFeaturesCache | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(`${FEATURES_CACHE_PREFIX}${projectPath}`);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as PersistedFeaturesCache;
|
||||||
|
if (!parsed || !Array.isArray(parsed.features) || typeof parsed.timestamp !== 'number') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Reject entries written by an older (or newer) schema version
|
||||||
|
if (parsed.schemaVersion !== FEATURES_CACHE_VERSION) {
|
||||||
|
// Remove the stale entry so it doesn't accumulate
|
||||||
|
window.localStorage.removeItem(`${FEATURES_CACHE_PREFIX}${projectPath}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePersistedFeatures(projectPath: string, features: Feature[]): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
const payload: PersistedFeaturesCache = {
|
||||||
|
schemaVersion: FEATURES_CACHE_VERSION,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
features,
|
||||||
|
};
|
||||||
|
window.localStorage.setItem(`${FEATURES_CACHE_PREFIX}${projectPath}`, JSON.stringify(payload));
|
||||||
|
} catch {
|
||||||
|
// Best effort cache only.
|
||||||
|
}
|
||||||
|
// Run lightweight eviction after every write to keep localStorage bounded
|
||||||
|
evictStaleFeaturesCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan localStorage for feature-cache entries, sort by timestamp (LRU),
|
||||||
|
* and remove entries beyond MAX_FEATURES_CACHE_ENTRIES so orphaned project
|
||||||
|
* caches don't accumulate indefinitely.
|
||||||
|
*/
|
||||||
|
function evictStaleFeaturesCache(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
// First pass: collect all matching keys without mutating localStorage.
|
||||||
|
// Iterating forward while calling removeItem() shifts indexes and can skip keys.
|
||||||
|
const allKeys: string[] = [];
|
||||||
|
for (let i = 0; i < window.localStorage.length; i++) {
|
||||||
|
const key = window.localStorage.key(i);
|
||||||
|
if (key && key.startsWith(FEATURES_CACHE_PREFIX)) {
|
||||||
|
allKeys.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: classify collected keys — remove stale/corrupt, keep valid.
|
||||||
|
const validEntries: Array<{ key: string; timestamp: number }> = [];
|
||||||
|
const keysToRemove: string[] = [];
|
||||||
|
for (const key of allKeys) {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(key);
|
||||||
|
if (!raw) continue;
|
||||||
|
const parsed = JSON.parse(raw) as { timestamp?: number; schemaVersion?: number };
|
||||||
|
// Evict entries with wrong schema version
|
||||||
|
if (parsed.schemaVersion !== FEATURES_CACHE_VERSION) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
validEntries.push({
|
||||||
|
key,
|
||||||
|
timestamp: typeof parsed.timestamp === 'number' ? parsed.timestamp : 0,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Corrupt entry — mark for removal
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove stale/corrupt entries
|
||||||
|
for (const key of keysToRemove) {
|
||||||
|
window.localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce max entries: sort by timestamp (newest first), remove excess oldest
|
||||||
|
if (validEntries.length <= MAX_FEATURES_CACHE_ENTRIES) return;
|
||||||
|
validEntries.sort((a, b) => b.timestamp - a.timestamp);
|
||||||
|
for (let i = MAX_FEATURES_CACHE_ENTRIES; i < validEntries.length; i++) {
|
||||||
|
window.localStorage.removeItem(validEntries[i].key);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best effort — never break the app for cache housekeeping failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all features for a project
|
* Fetch all features for a project
|
||||||
@@ -31,6 +143,14 @@ const AGENT_OUTPUT_POLLING_INTERVAL = 5000;
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function useFeatures(projectPath: string | undefined) {
|
export function useFeatures(projectPath: string | undefined) {
|
||||||
|
// Memoize the persisted cache read so it only runs when projectPath changes,
|
||||||
|
// not on every render. Both initialData and initialDataUpdatedAt reference
|
||||||
|
// the same memoized value to avoid a redundant second localStorage read.
|
||||||
|
const persisted = useMemo(
|
||||||
|
() => (projectPath ? readPersistedFeatures(projectPath) : null),
|
||||||
|
[projectPath]
|
||||||
|
);
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.features.all(projectPath ?? ''),
|
queryKey: queryKeys.features.all(projectPath ?? ''),
|
||||||
queryFn: async (): Promise<Feature[]> => {
|
queryFn: async (): Promise<Feature[]> => {
|
||||||
@@ -40,9 +160,13 @@ export function useFeatures(projectPath: string | undefined) {
|
|||||||
if (!result?.success) {
|
if (!result?.success) {
|
||||||
throw new Error(result?.error || 'Failed to fetch features');
|
throw new Error(result?.error || 'Failed to fetch features');
|
||||||
}
|
}
|
||||||
return (result.features ?? []) as Feature[];
|
const features = (result.features ?? []) as Feature[];
|
||||||
|
writePersistedFeatures(projectPath, features);
|
||||||
|
return features;
|
||||||
},
|
},
|
||||||
enabled: !!projectPath,
|
enabled: !!projectPath,
|
||||||
|
initialData: () => persisted?.features,
|
||||||
|
initialDataUpdatedAt: () => persisted?.timestamp,
|
||||||
staleTime: STALE_TIMES.FEATURES,
|
staleTime: STALE_TIMES.FEATURES,
|
||||||
refetchInterval: createSmartPollingInterval(FEATURES_POLLING_INTERVAL),
|
refetchInterval: createSmartPollingInterval(FEATURES_POLLING_INTERVAL),
|
||||||
refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
|
refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ interface BranchesResult {
|
|||||||
hasAnyRemotes: boolean;
|
hasAnyRemotes: boolean;
|
||||||
isGitRepo: boolean;
|
isGitRepo: boolean;
|
||||||
hasCommits: boolean;
|
hasCommits: boolean;
|
||||||
|
/** The name of the remote that the current branch is tracking (e.g. "origin"), if any */
|
||||||
|
trackingRemote?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -242,6 +244,7 @@ export function useWorktreeBranches(worktreePath: string | undefined, includeRem
|
|||||||
hasAnyRemotes: result.result?.hasAnyRemotes ?? false,
|
hasAnyRemotes: result.result?.hasAnyRemotes ?? false,
|
||||||
isGitRepo: true,
|
isGitRepo: true,
|
||||||
hasCommits: true,
|
hasCommits: true,
|
||||||
|
trackingRemote: result.result?.trackingRemote,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
enabled: !!worktreePath,
|
enabled: !!worktreePath,
|
||||||
|
|||||||
@@ -431,18 +431,38 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If projects array changed (by reference, meaning content changed), sync immediately
|
// If projects array changed *meaningfully*, sync immediately.
|
||||||
// This is critical - projects list changes must sync right away to prevent loss
|
// This is critical — projects list changes must sync right away to prevent loss
|
||||||
// when switching between Electron and web modes or closing the app
|
// when switching between Electron and web modes or closing the app.
|
||||||
|
//
|
||||||
|
// We compare by content (IDs, names, and paths), NOT by reference. The background
|
||||||
|
// reconcile in __root.tsx calls hydrateStoreFromSettings() with server data,
|
||||||
|
// which always creates a new projects array (.map() produces a new reference).
|
||||||
|
// A reference-only check would trigger an immediate sync-back to the server
|
||||||
|
// with identical data, causing a visible re-render flash on mobile.
|
||||||
if (newState.projects !== prevState.projects) {
|
if (newState.projects !== prevState.projects) {
|
||||||
|
const prevIds = prevState.projects
|
||||||
|
?.map((p) => JSON.stringify([p.id, p.name, p.path]))
|
||||||
|
.join(',');
|
||||||
|
const newIds = newState.projects
|
||||||
|
?.map((p) => JSON.stringify([p.id, p.name, p.path]))
|
||||||
|
.join(',');
|
||||||
|
if (prevIds !== newIds) {
|
||||||
logger.info('[PROJECTS_CHANGED] Projects array changed, syncing immediately', {
|
logger.info('[PROJECTS_CHANGED] Projects array changed, syncing immediately', {
|
||||||
prevCount: prevState.projects?.length ?? 0,
|
prevCount: prevState.projects?.length ?? 0,
|
||||||
newCount: newState.projects?.length ?? 0,
|
newCount: newState.projects?.length ?? 0,
|
||||||
prevProjects: prevState.projects?.map((p) => p.name) ?? [],
|
|
||||||
newProjects: newState.projects?.map((p) => p.name) ?? [],
|
|
||||||
});
|
});
|
||||||
syncNow();
|
syncNow();
|
||||||
return;
|
// Don't return here — fall through so the general loop below can still
|
||||||
|
// detect and schedule a debounced sync for other project-field mutations
|
||||||
|
// (e.g. lastOpened) that the id/name/path comparison above doesn't cover.
|
||||||
|
} else {
|
||||||
|
// The projects array reference changed but id/name/path are identical.
|
||||||
|
// This means nested project fields mutated (e.g. lastOpened, remotes).
|
||||||
|
// Schedule a debounced sync so these mutations reach the server.
|
||||||
|
logger.debug('[PROJECTS_NESTED_CHANGE] Projects nested fields changed, scheduling sync');
|
||||||
|
scheduleSyncToServer();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any other synced field changed
|
// Check if any other synced field changed
|
||||||
|
|||||||
@@ -203,6 +203,8 @@ export interface CreatePROptions {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export types from electron.d.ts for external use
|
// Re-export types from electron.d.ts for external use
|
||||||
|
|||||||
@@ -727,7 +727,10 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
this.reconnectTimer = null;
|
this.reconnectTimer = null;
|
||||||
}
|
}
|
||||||
this.reconnectAttempts = 0; // Reset backoff on visibility change
|
this.reconnectAttempts = 0; // Reset backoff on visibility change
|
||||||
this.connectWebSocket();
|
// Use silent mode: a 401 during visibility-change reconnect should NOT
|
||||||
|
// trigger a full logout cascade. The session is verified separately via
|
||||||
|
// verifySession() in __root.tsx's fast-hydrate path.
|
||||||
|
this.connectWebSocket({ silent: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -737,10 +740,15 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch a short-lived WebSocket token from the server
|
* Fetch a short-lived WebSocket token from the server.
|
||||||
* Used for secure WebSocket authentication without exposing session tokens in URLs
|
* Used for secure WebSocket authentication without exposing session tokens in URLs.
|
||||||
|
*
|
||||||
|
* @param options.silent - When true, a 401/403 will NOT trigger handleUnauthorized().
|
||||||
|
* Use this for background reconnections (e.g., visibility-change) where a transient
|
||||||
|
* auth failure should not force a full logout cascade. The actual session validity
|
||||||
|
* is verified separately via verifySession() in the fast-hydrate path.
|
||||||
*/
|
*/
|
||||||
private async fetchWsToken(): Promise<string | null> {
|
private async fetchWsToken(options?: { silent?: boolean }): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -759,7 +767,11 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
if (options?.silent) {
|
||||||
|
logger.debug('fetchWsToken: 401/403 during silent reconnect — skipping logout');
|
||||||
|
} else {
|
||||||
handleUnauthorized();
|
handleUnauthorized();
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -780,7 +792,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private connectWebSocket(): void {
|
private connectWebSocket(options?: { silent?: boolean }): void {
|
||||||
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
|
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -790,14 +802,14 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
// Wait for API key initialization to complete before attempting connection
|
// Wait for API key initialization to complete before attempting connection
|
||||||
// This prevents race conditions during app startup
|
// This prevents race conditions during app startup
|
||||||
waitForApiKeyInit()
|
waitForApiKeyInit()
|
||||||
.then(() => this.doConnectWebSocketInternal())
|
.then(() => this.doConnectWebSocketInternal(options))
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('Failed to initialize for WebSocket connection:', error);
|
logger.error('Failed to initialize for WebSocket connection:', error);
|
||||||
this.isConnecting = false;
|
this.isConnecting = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private doConnectWebSocketInternal(): void {
|
private doConnectWebSocketInternal(options?: { silent?: boolean }): void {
|
||||||
// Electron mode typically authenticates with the injected API key.
|
// Electron mode typically authenticates with the injected API key.
|
||||||
// However, in external-server/cookie-auth flows, the API key may be unavailable.
|
// However, in external-server/cookie-auth flows, the API key may be unavailable.
|
||||||
// In that case, fall back to the same wsToken/cookie authentication used in web mode
|
// In that case, fall back to the same wsToken/cookie authentication used in web mode
|
||||||
@@ -806,7 +818,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
const apiKey = getApiKey();
|
const apiKey = getApiKey();
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
logger.warn('Electron mode: API key missing, attempting wsToken/cookie auth for WebSocket');
|
logger.warn('Electron mode: API key missing, attempting wsToken/cookie auth for WebSocket');
|
||||||
this.fetchWsToken()
|
this.fetchWsToken(options)
|
||||||
.then((wsToken) => {
|
.then((wsToken) => {
|
||||||
const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
|
const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
|
||||||
if (wsToken) {
|
if (wsToken) {
|
||||||
@@ -830,7 +842,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// In web mode, fetch a short-lived wsToken first
|
// In web mode, fetch a short-lived wsToken first
|
||||||
this.fetchWsToken()
|
this.fetchWsToken(options)
|
||||||
.then((wsToken) => {
|
.then((wsToken) => {
|
||||||
const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
|
const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
|
||||||
if (wsToken) {
|
if (wsToken) {
|
||||||
@@ -961,7 +973,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async post<T>(endpoint: string, body?: unknown): Promise<T> {
|
private async post<T>(endpoint: string, body?: unknown, signal?: AbortSignal): Promise<T> {
|
||||||
// Ensure API key is initialized before making request
|
// Ensure API key is initialized before making request
|
||||||
await waitForApiKeyInit();
|
await waitForApiKeyInit();
|
||||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||||
@@ -969,6 +981,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
credentials: 'include', // Include cookies for session auth
|
credentials: 'include', // Include cookies for session auth
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
@@ -1899,7 +1912,8 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
} = {
|
} = {
|
||||||
getAll: (projectPath: string) => this.post('/api/features/list', { projectPath }),
|
getAll: (projectPath: string) =>
|
||||||
|
this.get(`/api/features/list?projectPath=${encodeURIComponent(projectPath)}`),
|
||||||
get: (projectPath: string, featureId: string) =>
|
get: (projectPath: string, featureId: string) =>
|
||||||
this.post('/api/features/get', { projectPath, featureId }),
|
this.post('/api/features/get', { projectPath, featureId }),
|
||||||
create: (projectPath: string, feature: Feature) =>
|
create: (projectPath: string, feature: Feature) =>
|
||||||
@@ -2155,8 +2169,8 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
}),
|
}),
|
||||||
checkChanges: (worktreePath: string) =>
|
checkChanges: (worktreePath: string) =>
|
||||||
this.post('/api/worktree/check-changes', { worktreePath }),
|
this.post('/api/worktree/check-changes', { worktreePath }),
|
||||||
listBranches: (worktreePath: string, includeRemote?: boolean) =>
|
listBranches: (worktreePath: string, includeRemote?: boolean, signal?: AbortSignal) =>
|
||||||
this.post('/api/worktree/list-branches', { worktreePath, includeRemote }),
|
this.post('/api/worktree/list-branches', { worktreePath, includeRemote }, signal),
|
||||||
switchBranch: (worktreePath: string, branchName: string) =>
|
switchBranch: (worktreePath: string, branchName: string) =>
|
||||||
this.post('/api/worktree/switch-branch', { worktreePath, branchName }),
|
this.post('/api/worktree/switch-branch', { worktreePath, branchName }),
|
||||||
listRemotes: (worktreePath: string) =>
|
listRemotes: (worktreePath: string) =>
|
||||||
|
|||||||
@@ -63,6 +63,26 @@ export function isSlowConnection(): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect if the app is running as an installed PWA (standalone mode).
|
||||||
|
* Checks both the standard display-mode media query and the iOS-specific
|
||||||
|
* navigator.standalone property for comprehensive detection.
|
||||||
|
*
|
||||||
|
* When running as a PWA, the browser chrome is hidden so safe area insets
|
||||||
|
* can be reduced further to maximize usable screen space.
|
||||||
|
*/
|
||||||
|
export const isPwaStandalone: boolean = (() => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
|
||||||
|
// Standard: works on Chrome, Edge, Firefox, and modern Safari
|
||||||
|
const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
|
||||||
|
|
||||||
|
// iOS Safari: navigator.standalone is true when launched from home screen
|
||||||
|
const isIOSStandalone = (navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||||||
|
|
||||||
|
return isStandalone || isIOSStandalone;
|
||||||
|
})();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Multiplier for polling intervals on mobile.
|
* Multiplier for polling intervals on mobile.
|
||||||
* Mobile devices benefit from less frequent polling to save battery and bandwidth.
|
* Mobile devices benefit from less frequent polling to save battery and bandwidth.
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import App from './app';
|
import App from './app';
|
||||||
import { isMobileDevice } from './lib/mobile-detect';
|
import { isMobileDevice, isPwaStandalone } from './lib/mobile-detect';
|
||||||
|
|
||||||
|
// Defensive fallback: index.html's inline script already applies data-pwa="standalone"
|
||||||
|
// before first paint. This re-applies it in case the inline script failed (e.g.
|
||||||
|
// CSP restrictions or unexpected errors). setAttribute is a no-op if already set.
|
||||||
|
if (isPwaStandalone) {
|
||||||
|
document.documentElement.setAttribute('data-pwa', 'standalone');
|
||||||
|
}
|
||||||
|
|
||||||
// Register service worker for PWA support (web mode only)
|
// Register service worker for PWA support (web mode only)
|
||||||
// Uses optimized registration strategy for faster mobile loading:
|
// Registers immediately (not deferred to load event) so the SW can intercept
|
||||||
// - Registers after load event to avoid competing with critical resources
|
// and cache JS/CSS bundle requests during the current page load. When the SW is
|
||||||
// - Handles updates gracefully with skipWaiting support
|
// registered inside a 'load' listener, all assets have already downloaded before
|
||||||
// - Triggers cache cleanup on activation
|
// the SW installs, so they can't be cached until warmAssetCache runs later.
|
||||||
// - Prefetches likely-needed route chunks during idle time
|
// Registering early allows the SW to serve bundles from cache on the NEXT visit.
|
||||||
// - Enables mobile-specific API caching when on a mobile device
|
//
|
||||||
|
// Note: The SW itself does NOT call skipWaiting() on install, so a newly
|
||||||
|
// registered SW won't disrupt a live page — it waits for SKIP_WAITING from the
|
||||||
|
// main thread or for all old-SW tabs to close before activating.
|
||||||
if ('serviceWorker' in navigator && !window.location.protocol.startsWith('file')) {
|
if ('serviceWorker' in navigator && !window.location.protocol.startsWith('file')) {
|
||||||
window.addEventListener('load', () => {
|
|
||||||
navigator.serviceWorker
|
navigator.serviceWorker
|
||||||
.register('/sw.js', {
|
.register('/sw.js', {
|
||||||
// Check for updates on every page load for PWA freshness
|
// Check for updates on every page load for PWA freshness
|
||||||
@@ -28,11 +37,24 @@ if ('serviceWorker' in navigator && !window.location.protocol.startsWith('file')
|
|||||||
});
|
});
|
||||||
}, updateInterval);
|
}, updateInterval);
|
||||||
|
|
||||||
// When a new service worker takes over, trigger cache cleanup
|
// When a new service worker is found, DON'T activate it immediately.
|
||||||
|
// Instead, wait until the user navigates away or refreshes. This prevents
|
||||||
|
// the brief reload/flash that occurs when skipWaiting() + clients.claim()
|
||||||
|
// swaps the SW under a live page (especially noticeable when switching back
|
||||||
|
// to the PWA on mobile).
|
||||||
|
//
|
||||||
|
// The new SW will naturally activate when all tabs using the old SW are closed.
|
||||||
|
// For urgent updates, we send SKIP_WAITING on fresh page loads (see below).
|
||||||
registration.addEventListener('updatefound', () => {
|
registration.addEventListener('updatefound', () => {
|
||||||
const newWorker = registration.installing;
|
const newWorker = registration.installing;
|
||||||
if (newWorker) {
|
if (newWorker) {
|
||||||
newWorker.addEventListener('statechange', () => {
|
newWorker.addEventListener('statechange', () => {
|
||||||
|
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||||
|
// A new SW is waiting — show an update banner so long-lived
|
||||||
|
// sessions can immediately pick up new deployments.
|
||||||
|
console.debug('[SW] New service worker installed and waiting to activate');
|
||||||
|
showUpdateNotification(registration);
|
||||||
|
}
|
||||||
if (newWorker.state === 'activated') {
|
if (newWorker.state === 'activated') {
|
||||||
// New service worker is active - clean up old immutable cache entries
|
// New service worker is active - clean up old immutable cache entries
|
||||||
newWorker.postMessage({ type: 'CACHE_CLEANUP' });
|
newWorker.postMessage({ type: 'CACHE_CLEANUP' });
|
||||||
@@ -41,6 +63,13 @@ if ('serviceWorker' in navigator && !window.location.protocol.startsWith('file')
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// On fresh page loads (not tab-switch-back), if there's a waiting SW,
|
||||||
|
// tell it to activate now. This is safe because the page is freshly loaded
|
||||||
|
// and won't flash. This ensures updates are picked up within one page visit.
|
||||||
|
if (registration.waiting) {
|
||||||
|
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||||
|
}
|
||||||
|
|
||||||
// Notify the service worker about mobile mode.
|
// Notify the service worker about mobile mode.
|
||||||
// This enables stale-while-revalidate caching for API responses,
|
// This enables stale-while-revalidate caching for API responses,
|
||||||
// preventing blank screens caused by failed/slow API fetches on mobile.
|
// preventing blank screens caused by failed/slow API fetches on mobile.
|
||||||
@@ -61,73 +90,163 @@ if ('serviceWorker' in navigator && !window.location.protocol.startsWith('file')
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Prefetch likely-needed route chunks during idle time.
|
// Warm the SW's immutable cache with all page assets during idle time.
|
||||||
// On mobile, this means subsequent navigations are instant from cache
|
// This ensures that when the PWA is evicted from memory and reopened,
|
||||||
// instead of requiring network round-trips over slow cellular connections.
|
// all JS/CSS can be served instantly from SW cache instead of re-downloading.
|
||||||
prefetchRouteChunks(registration);
|
warmAssetCache(registration);
|
||||||
|
|
||||||
|
// Request persistent storage to protect caches from OS eviction.
|
||||||
|
// Without this, iOS Safari can purge Cache Storage under memory pressure
|
||||||
|
// or after 7 days of inactivity, forcing a full network reload on next visit.
|
||||||
|
// This is a best-effort request — the browser may deny it, but it's a no-op
|
||||||
|
// on browsers that don't support it (no error thrown).
|
||||||
|
if (navigator.storage?.persist) {
|
||||||
|
navigator.storage
|
||||||
|
.persist()
|
||||||
|
.then((granted) => {
|
||||||
|
if (granted) {
|
||||||
|
console.debug('[SW] Persistent storage granted — caches protected from eviction');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Silently ignore — persistent storage is a nice-to-have
|
||||||
|
});
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Service worker registration failed; app still works without it
|
// Service worker registration failed; app still works without it
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a user-visible notification when a new service worker version is detected.
|
||||||
|
* The notification offers a "Reload" action that sends SKIP_WAITING to the waiting
|
||||||
|
* SW and reloads the page once the new SW activates. This ensures long-lived
|
||||||
|
* sessions can immediately pick up new deployments.
|
||||||
|
*/
|
||||||
|
function showUpdateNotification(registration: ServiceWorkerRegistration): void {
|
||||||
|
// Create a simple DOM-based notification (avoids depending on React rendering)
|
||||||
|
const banner = document.createElement('div');
|
||||||
|
banner.setAttribute('role', 'alert');
|
||||||
|
|
||||||
|
// Read theme-aware colors from CSS custom properties with sensible fallbacks
|
||||||
|
// so the banner matches the current dark/light theme.
|
||||||
|
const rootStyle = getComputedStyle(document.documentElement);
|
||||||
|
const bgColor = rootStyle.getPropertyValue('--background').trim() || '#1a1a2e';
|
||||||
|
const fgColor = rootStyle.getPropertyValue('--foreground').trim() || '#e0e0e0';
|
||||||
|
const accentColor = rootStyle.getPropertyValue('--primary').trim() || '#6366f1';
|
||||||
|
const mutedColor = rootStyle.getPropertyValue('--muted-foreground').trim() || '#888';
|
||||||
|
|
||||||
|
banner.style.cssText =
|
||||||
|
'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);z-index:99999;' +
|
||||||
|
`background:hsl(${bgColor});color:hsl(${fgColor});padding:12px 20px;border-radius:10px;` +
|
||||||
|
'display:flex;align-items:center;gap:12px;font-size:14px;' +
|
||||||
|
'box-shadow:0 4px 24px rgba(0,0,0,0.3);font-family:system-ui,sans-serif;';
|
||||||
|
banner.innerHTML =
|
||||||
|
'<span>A new version is available.</span>' +
|
||||||
|
`<button id="sw-update-btn" style="background:hsl(${accentColor});color:hsl(${bgColor});border:none;` +
|
||||||
|
'padding:6px 14px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:500;">Reload</button>' +
|
||||||
|
`<button id="sw-dismiss-btn" style="background:transparent;color:hsl(${mutedColor});border:none;` +
|
||||||
|
'padding:4px 8px;cursor:pointer;font-size:18px;line-height:1;" aria-label="Dismiss">×</button>';
|
||||||
|
document.body.appendChild(banner);
|
||||||
|
|
||||||
|
// Listen for controllerchange to reload after the new SW activates.
|
||||||
|
// Named handler so it can be cleaned up when the banner is dismissed or after reload.
|
||||||
|
let reloading = false;
|
||||||
|
const onControllerChange = () => {
|
||||||
|
if (!reloading) {
|
||||||
|
reloading = true;
|
||||||
|
navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange);
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
navigator.serviceWorker.addEventListener('controllerchange', onControllerChange);
|
||||||
|
|
||||||
|
banner.querySelector('#sw-update-btn')?.addEventListener('click', () => {
|
||||||
|
// Send SKIP_WAITING to the waiting SW — it will call skipWaiting() and
|
||||||
|
// the controllerchange listener above will reload the page.
|
||||||
|
registration.waiting?.postMessage({ type: 'SKIP_WAITING' });
|
||||||
|
const btn = banner.querySelector('#sw-update-btn') as HTMLButtonElement | null;
|
||||||
|
if (btn) {
|
||||||
|
btn.textContent = 'Updating…';
|
||||||
|
btn.disabled = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
banner.querySelector('#sw-dismiss-btn')?.addEventListener('click', () => {
|
||||||
|
navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange);
|
||||||
|
banner.remove();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prefetch route JS chunks that the user is likely to navigate to.
|
* Warm the service worker's immutable cache with all critical page assets.
|
||||||
* Uses requestIdleCallback to avoid competing with the initial render,
|
* Collects URLs from modulepreload, prefetch, stylesheet, and script tags
|
||||||
* and sends URLs to the service worker for background caching.
|
* and sends them to the SW via PRECACHE_ASSETS for background caching.
|
||||||
* This is especially impactful on mobile where network latency is high.
|
*
|
||||||
|
* This is critical for PWA cold-start performance: when iOS/Android evicts
|
||||||
|
* the PWA from memory, reopening it needs assets from SW cache. The SW's
|
||||||
|
* fetch handler caches assets on first access, but on the very first visit
|
||||||
|
* the SW isn't active yet when assets load. This function bridges that gap
|
||||||
|
* by explicitly telling the SW "cache these URLs" after registration.
|
||||||
*/
|
*/
|
||||||
function prefetchRouteChunks(registration: ServiceWorkerRegistration): void {
|
function warmAssetCache(registration: ServiceWorkerRegistration): void {
|
||||||
const idleCallback =
|
const idleCallback =
|
||||||
typeof requestIdleCallback !== 'undefined'
|
typeof requestIdleCallback !== 'undefined'
|
||||||
? requestIdleCallback
|
? requestIdleCallback
|
||||||
: (cb: () => void) => setTimeout(cb, 2000);
|
: (cb: () => void) => setTimeout(cb, 2000);
|
||||||
|
|
||||||
// On mobile, wait a bit longer before prefetching to let the critical path complete.
|
// CRITICAL_ASSETS are precached at SW install time (see sw.js install handler).
|
||||||
// Mobile connections are often slower and we don't want to compete with initial data fetches.
|
// This function is a complementary backup: it collects asset URLs from the live
|
||||||
const prefetchDelay = isMobileDevice ? 4000 : 0;
|
// DOM (which includes any assets the browser already fetched on this visit) and
|
||||||
|
// sends them to the SW for caching via PRECACHE_ASSETS. This covers:
|
||||||
|
// - Assets that were fetched before the SW was active on the first visit
|
||||||
|
// - Any assets the install-time precaching missed due to transient failures
|
||||||
|
//
|
||||||
|
// No delay needed — warmAssetCache is called after the SW registers (which is
|
||||||
|
// already deferred until renderer.tsx module evaluation, post-parse). Asset URLs
|
||||||
|
// are already in the DOM at that point and the SW processes PRECACHE_ASSETS
|
||||||
|
// asynchronously without blocking the render path.
|
||||||
|
const doWarm = () => {
|
||||||
|
const assetUrls: string[] = [];
|
||||||
|
|
||||||
const doPrefetch = () => {
|
// Collect ALL asset URLs from the page that should be in the SW cache:
|
||||||
// Find all modulepreload links already in the document (Vite injects these)
|
// 1. modulepreload links (critical vendor chunks: react, tanstack, radix, state)
|
||||||
// and any route chunks that might be linked
|
// 2. prefetch links (deferred chunks: icons, reactflow, xterm, codemirror, markdown)
|
||||||
const existingPreloads = new Set(
|
// 3. stylesheet links (main CSS bundle)
|
||||||
Array.from(document.querySelectorAll('link[rel="modulepreload"]')).map(
|
// 4. script tags with asset paths (entry point JS)
|
||||||
(link) => (link as HTMLLinkElement).href
|
document.querySelectorAll('link[rel="modulepreload"], link[rel="prefetch"]').forEach((link) => {
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Also collect prefetch links (Vite mobile optimization converts some to prefetch)
|
|
||||||
Array.from(document.querySelectorAll('link[rel="prefetch"]')).forEach((link) => {
|
|
||||||
const href = (link as HTMLLinkElement).href;
|
const href = (link as HTMLLinkElement).href;
|
||||||
if (href) existingPreloads.add(href);
|
if (href && href.includes('/assets/')) assetUrls.push(href);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('link[rel="stylesheet"]').forEach((link) => {
|
||||||
|
const href = (link as HTMLLinkElement).href;
|
||||||
|
if (href && href.includes('/assets/')) assetUrls.push(href);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Discover route chunk URLs from the document's script tags
|
|
||||||
// These are the code-split route bundles that TanStack Router will lazy-load
|
|
||||||
const routeChunkUrls: string[] = [];
|
|
||||||
document.querySelectorAll('script[src*="/assets/"]').forEach((script) => {
|
document.querySelectorAll('script[src*="/assets/"]').forEach((script) => {
|
||||||
const src = (script as HTMLScriptElement).src;
|
const src = (script as HTMLScriptElement).src;
|
||||||
if (src && !existingPreloads.has(src)) {
|
if (src) assetUrls.push(src);
|
||||||
routeChunkUrls.push(src);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send URLs to service worker for background caching
|
// Send all discovered URLs to the SW for background caching.
|
||||||
if (routeChunkUrls.length > 0 && registration.active) {
|
// The SW's PRECACHE_ASSETS handler checks cache.match() first, so URLs
|
||||||
registration.active.postMessage({
|
// already in the immutable cache won't be re-fetched.
|
||||||
|
//
|
||||||
|
// Target the active SW if available; otherwise fall back to the installing/waiting
|
||||||
|
// SW. On first visit, the SW may still be in 'installing' state when this runs,
|
||||||
|
// but it can still receive messages and process them once it activates.
|
||||||
|
const target = registration.active || registration.waiting || registration.installing;
|
||||||
|
if (assetUrls.length > 0 && target) {
|
||||||
|
target.postMessage({
|
||||||
type: 'PRECACHE_ASSETS',
|
type: 'PRECACHE_ASSETS',
|
||||||
urls: routeChunkUrls,
|
urls: assetUrls,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wait for idle time after the app is interactive
|
idleCallback(doWarm);
|
||||||
if (prefetchDelay > 0) {
|
|
||||||
setTimeout(() => idleCallback(doPrefetch), prefetchDelay);
|
|
||||||
} else {
|
|
||||||
idleCallback(doPrefetch);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render the app - prioritize First Contentful Paint
|
// Render the app - prioritize First Contentful Paint
|
||||||
|
|||||||
@@ -31,12 +31,7 @@ import {
|
|||||||
performSettingsMigration,
|
performSettingsMigration,
|
||||||
} from '@/hooks/use-settings-migration';
|
} from '@/hooks/use-settings-migration';
|
||||||
import { queryClient } from '@/lib/query-client';
|
import { queryClient } from '@/lib/query-client';
|
||||||
import {
|
import { createIDBPersister, PERSIST_MAX_AGE_MS, PERSIST_THROTTLE_MS } from '@/lib/query-persist';
|
||||||
createIDBPersister,
|
|
||||||
hasWarmIDBCache,
|
|
||||||
PERSIST_MAX_AGE_MS,
|
|
||||||
PERSIST_THROTTLE_MS,
|
|
||||||
} from '@/lib/query-persist';
|
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import { ThemeOption, themeOptions } from '@/config/theme-options';
|
import { ThemeOption, themeOptions } from '@/config/theme-options';
|
||||||
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
|
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
|
||||||
@@ -47,6 +42,7 @@ import { useIsCompact } from '@/hooks/use-media-query';
|
|||||||
import type { Project } from '@/lib/electron';
|
import type { Project } from '@/lib/electron';
|
||||||
import type { GlobalSettings } from '@automaker/types';
|
import type { GlobalSettings } from '@automaker/types';
|
||||||
import { syncUICache, restoreFromUICache } from '@/store/ui-cache-store';
|
import { syncUICache, restoreFromUICache } from '@/store/ui-cache-store';
|
||||||
|
import { setItem } from '@/lib/storage';
|
||||||
|
|
||||||
const logger = createLogger('RootLayout');
|
const logger = createLogger('RootLayout');
|
||||||
const IS_DEV = import.meta.env.DEV;
|
const IS_DEV = import.meta.env.DEV;
|
||||||
@@ -232,7 +228,10 @@ function RootLayoutContent() {
|
|||||||
!isLoggedOutRoute &&
|
!isLoggedOutRoute &&
|
||||||
!isSetupRoute &&
|
!isSetupRoute &&
|
||||||
!!autoOpenCandidate;
|
!!autoOpenCandidate;
|
||||||
const shouldAutoOpen = canAutoOpen && autoOpenStatus !== AUTO_OPEN_STATUS.done;
|
// Only block the UI with "Opening project..." when on the root route.
|
||||||
|
// When already on /board or /dashboard, auto-open runs silently in the background —
|
||||||
|
// blocking here would cause a visible flash when switching back to the PWA.
|
||||||
|
const shouldAutoOpen = canAutoOpen && autoOpenStatus !== AUTO_OPEN_STATUS.done && isRootRoute;
|
||||||
const shouldBlockForSettings =
|
const shouldBlockForSettings =
|
||||||
authChecked && isAuthenticated && !settingsLoaded && !isLoginRoute && !isLoggedOutRoute;
|
authChecked && isAuthenticated && !settingsLoaded && !isLoginRoute && !isLoggedOutRoute;
|
||||||
|
|
||||||
@@ -467,35 +466,21 @@ function RootLayoutContent() {
|
|||||||
optimisticallyHydrated = true;
|
optimisticallyHydrated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize API key for Electron mode
|
// OPTIMIZATION: Take the fast path BEFORE any async work when localStorage is warm.
|
||||||
await initApiKey();
|
|
||||||
|
|
||||||
// OPTIMIZATION: Skip blocking on server health check when both caches are warm.
|
|
||||||
//
|
//
|
||||||
// On a normal cold start, we must wait for the server to be ready before
|
// Previously the fast path check came after `await initApiKey()`. Even though
|
||||||
// making auth/settings requests. But on a tab restore or page reload, the
|
// initApiKey() is a no-op in web mode, the `await` still yields to the microtask
|
||||||
// server is almost certainly already running — waiting up to ~12s for health
|
// queue — adding one unnecessary event loop tick before authChecked becomes true.
|
||||||
// check retries just shows a blank loading screen when the user has data cached.
|
// By moving this check before any `await`, we set authChecked synchronously within
|
||||||
|
// the same React render cycle, eliminating a frame of spinner on mobile.
|
||||||
//
|
//
|
||||||
// When BOTH of these are true:
|
// The background verify (waitForServerReady + verifySession) still runs after the
|
||||||
// 1. localStorage settings cache has valid project data (optimisticallyHydrated)
|
// `await initApiKey()` below, so Electron mode still gets its server URL before
|
||||||
// 2. IndexedDB React Query cache exists and is recent (< 24h old)
|
// any API calls are made.
|
||||||
//
|
if (optimisticallyHydrated) {
|
||||||
// ...we mark auth as complete immediately with the cached data, then verify
|
logger.info(
|
||||||
// the session in the background. If the session turns out to be invalid, the
|
'[FAST_HYDRATE] localStorage settings warm — marking auth complete optimistically'
|
||||||
// 401 handler in http-api-client.ts will fire automaker:logged-out and redirect.
|
);
|
||||||
// If the server isn't reachable, automaker:server-offline will redirect to /login.
|
|
||||||
//
|
|
||||||
// This turns tab-restore from: blank screen → 1-3s wait → board
|
|
||||||
// into: board renders instantly → silent background verify
|
|
||||||
// Pass the current buster so hasWarmIDBCache can verify the cache is still
|
|
||||||
// valid for this build. If the buster changed (new deployment or dev restart),
|
|
||||||
// PersistQueryClientProvider will wipe the IDB cache — we must not treat
|
|
||||||
// it as warm in that case or we'll render the board with empty queries.
|
|
||||||
const currentBuster = typeof __APP_BUILD_HASH__ !== 'undefined' ? __APP_BUILD_HASH__ : '';
|
|
||||||
const idbWarm = optimisticallyHydrated && (await hasWarmIDBCache(currentBuster));
|
|
||||||
if (idbWarm) {
|
|
||||||
logger.info('[FAST_HYDRATE] Warm caches detected — marking auth complete optimistically');
|
|
||||||
signalMigrationComplete();
|
signalMigrationComplete();
|
||||||
useAuthStore.getState().setAuthState({
|
useAuthStore.getState().setAuthState({
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
@@ -503,37 +488,107 @@ function RootLayoutContent() {
|
|||||||
settingsLoaded: true,
|
settingsLoaded: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify session + fetch fresh settings in the background.
|
// OPTIMIZATION: Skip the blocking "Opening project..." auto-open screen
|
||||||
// The UI is already rendered; this reconciles any stale data.
|
// when restoring from cache. On a warm restart (PWA memory eviction, tab
|
||||||
|
// discard, page reload), currentProject is already restored from the UI
|
||||||
|
// cache (restoreFromUICache ran above). The auto-open effect calls
|
||||||
|
// initializeProject() which makes 5+ blocking HTTP calls to verify the
|
||||||
|
// .automaker directory structure — this is needed for first-time opens
|
||||||
|
// but redundant for returning users. Marking auto-open as done lets the
|
||||||
|
// routing effect navigate to /board immediately without the detour.
|
||||||
|
const restoredProject = useAppStore.getState().currentProject;
|
||||||
|
if (restoredProject) {
|
||||||
|
logger.info(
|
||||||
|
'[FAST_HYDRATE] Project already restored from cache — skipping auto-open',
|
||||||
|
restoredProject.name
|
||||||
|
);
|
||||||
|
setAutoOpenStatus(AUTO_OPEN_STATUS.done);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize API key then start background verification.
|
||||||
|
// We do this AFTER marking auth complete so the spinner is already gone.
|
||||||
|
// In web mode initApiKey() is a no-op; in Electron it fetches the IPC server URL.
|
||||||
|
await initApiKey();
|
||||||
|
|
||||||
|
// Background verify: confirm session is still valid + fetch fresh settings.
|
||||||
|
// The UI is already rendered from cached data — this reconciles stale state.
|
||||||
|
//
|
||||||
|
// IMPORTANT: We skip waitForServerReady() here intentionally.
|
||||||
|
// waitForServerReady() uses cache:'no-store' (bypasses the service worker)
|
||||||
|
// and makes a dedicated /api/health round trip before any real work.
|
||||||
|
// On mobile cellular (100-300ms RTT) that pre-flight adds visible delay.
|
||||||
|
// Instead we fire verifySession + getGlobal directly — both already handle
|
||||||
|
// server-down gracefully via their .catch() wrappers. If the server isn't
|
||||||
|
// up yet the catches return null/failure and we simply keep the cached session.
|
||||||
|
//
|
||||||
|
// IMPORTANT: Distinguish definitive auth failures (401/403 → false) from
|
||||||
|
// transient errors (timeouts, network failures → null/throw). Only a definitive
|
||||||
|
// failure should reset isAuthenticated — transient errors keep the user logged in.
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const serverReady = await waitForServerReady();
|
|
||||||
if (!serverReady) {
|
|
||||||
// Server is down — the server-offline event handler in __root will redirect
|
|
||||||
handleServerOffline();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const api = getHttpApiClient();
|
const api = getHttpApiClient();
|
||||||
const [sessionValid, settingsResult] = await Promise.all([
|
const [sessionValid, settingsResult] = await Promise.all([
|
||||||
verifySession().catch(() => false),
|
// verifySession() returns true (valid), false (401/403), or throws (transient).
|
||||||
|
// Map throws → null so we can distinguish "definitively invalid" from "couldn't check".
|
||||||
|
verifySession().catch((err) => {
|
||||||
|
logger.debug('[FAST_HYDRATE] Background verify threw (transient):', err?.message);
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
api.settings.getGlobal().catch(() => ({ success: false, settings: null }) as const),
|
api.settings.getGlobal().catch(() => ({ success: false, settings: null }) as const),
|
||||||
]);
|
]);
|
||||||
if (!sessionValid) {
|
|
||||||
// Session expired while user was away — log them out
|
if (sessionValid === false) {
|
||||||
|
// Session is definitively expired (server returned 401/403) — log them out
|
||||||
logger.warn('[FAST_HYDRATE] Background verify: session invalid, logging out');
|
logger.warn('[FAST_HYDRATE] Background verify: session invalid, logging out');
|
||||||
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Server responded — mark IPC connected (replaces the separate health check)
|
||||||
|
if (sessionValid === true) {
|
||||||
|
setIpcConnected(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionValid === null) {
|
||||||
|
// Transient error (timeout, network, 5xx) — keep the user logged in.
|
||||||
|
// The next real API call will detect an expired session if needed.
|
||||||
|
logger.info(
|
||||||
|
'[FAST_HYDRATE] Background verify inconclusive — keeping session active'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Update the localStorage cache with fresh server data so the NEXT
|
||||||
|
// cold start uses up-to-date settings. But do NOT call
|
||||||
|
// hydrateStoreFromSettings() here — the store was already hydrated
|
||||||
|
// from localStorage cache moments ago. Re-hydrating from the server
|
||||||
|
// response would create new object references for projects, settings
|
||||||
|
// arrays, etc., which triggers useSettingsSync's store subscriber
|
||||||
|
// to fire an immediate sync-back POST, causing a visible re-render
|
||||||
|
// flash (board → spinner → board) on mobile.
|
||||||
|
//
|
||||||
|
// The localStorage cache and server data are nearly always identical
|
||||||
|
// (the sync hook wrote the cache from the last successful sync).
|
||||||
|
// Any genuine differences (e.g., settings changed on another device)
|
||||||
|
// will be picked up on the next user interaction or the sync hook's
|
||||||
|
// periodic reconciliation.
|
||||||
if (settingsResult.success && settingsResult.settings) {
|
if (settingsResult.success && settingsResult.settings) {
|
||||||
|
try {
|
||||||
const { settings: finalSettings } = await performSettingsMigration(
|
const { settings: finalSettings } = await performSettingsMigration(
|
||||||
settingsResult.settings as unknown as Parameters<
|
settingsResult.settings as unknown as Parameters<
|
||||||
typeof performSettingsMigration
|
typeof performSettingsMigration
|
||||||
>[0]
|
>[0]
|
||||||
);
|
);
|
||||||
hydrateStoreFromSettings(finalSettings);
|
// Persist fresh server data to localStorage for the next cold start
|
||||||
logger.info('[FAST_HYDRATE] Background reconcile complete');
|
setItem('automaker-settings-cache', JSON.stringify(finalSettings));
|
||||||
|
logger.info(
|
||||||
|
'[FAST_HYDRATE] Background reconcile: cache updated (store untouched)'
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('[FAST_HYDRATE] Failed to update cache:', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Outer catch for unexpected errors — do NOT reset auth state.
|
||||||
|
// If the session is truly expired, the next API call will handle it.
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'[FAST_HYDRATE] Background verify failed (server may be restarting):',
|
'[FAST_HYDRATE] Background verify failed (server may be restarting):',
|
||||||
error
|
error
|
||||||
@@ -544,7 +599,11 @@ function RootLayoutContent() {
|
|||||||
return; // Auth is done — foreground initAuth exits here
|
return; // Auth is done — foreground initAuth exits here
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize API key for Electron mode (needed before any server calls)
|
||||||
|
await initApiKey();
|
||||||
|
|
||||||
// Cold start path: server not yet confirmed running, wait for it
|
// Cold start path: server not yet confirmed running, wait for it
|
||||||
|
// (Only reached when localStorage has no cached settings)
|
||||||
const serverReady = await waitForServerReady();
|
const serverReady = await waitForServerReady();
|
||||||
if (!serverReady) {
|
if (!serverReady) {
|
||||||
handleServerOffline();
|
handleServerOffline();
|
||||||
@@ -555,9 +614,12 @@ function RootLayoutContent() {
|
|||||||
// instead of waiting for session verification before fetching settings
|
// instead of waiting for session verification before fetching settings
|
||||||
const api = getHttpApiClient();
|
const api = getHttpApiClient();
|
||||||
const [sessionValid, settingsResult] = await Promise.all([
|
const [sessionValid, settingsResult] = await Promise.all([
|
||||||
|
// verifySession() returns true (valid), false (401/403), or throws (transient).
|
||||||
|
// Map throws → null (matching background verify behaviour) so transient
|
||||||
|
// failures don't cause unnecessary logouts on cold start.
|
||||||
verifySession().catch((error) => {
|
verifySession().catch((error) => {
|
||||||
logger.warn('Session verification failed (likely network/server issue):', error);
|
logger.warn('Session verification threw (transient, keeping session):', error?.message);
|
||||||
return false;
|
return null;
|
||||||
}),
|
}),
|
||||||
api.settings.getGlobal().catch((error) => {
|
api.settings.getGlobal().catch((error) => {
|
||||||
logger.warn('Settings fetch failed during parallel init:', error);
|
logger.warn('Settings fetch failed during parallel init:', error);
|
||||||
@@ -565,7 +627,7 @@ function RootLayoutContent() {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (sessionValid) {
|
if (sessionValid === true || sessionValid === null) {
|
||||||
// Settings were fetched in parallel - use them directly
|
// Settings were fetched in parallel - use them directly
|
||||||
if (settingsResult.success && settingsResult.settings) {
|
if (settingsResult.success && settingsResult.settings) {
|
||||||
const { settings: finalSettings, migrated } = await performSettingsMigration(
|
const { settings: finalSettings, migrated } = await performSettingsMigration(
|
||||||
@@ -669,7 +731,7 @@ function RootLayoutContent() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Session is invalid or expired - treat as not authenticated
|
// Session is definitively invalid (server returned 401/403) - treat as not authenticated
|
||||||
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||||
// Signal migration complete so sync hook doesn't hang (nothing to sync when not authenticated)
|
// Signal migration complete so sync hook doesn't hang (nothing to sync when not authenticated)
|
||||||
signalMigrationComplete();
|
signalMigrationComplete();
|
||||||
@@ -791,8 +853,23 @@ function RootLayoutContent() {
|
|||||||
setGlobalFileBrowser(openFileBrowser);
|
setGlobalFileBrowser(openFileBrowser);
|
||||||
}, [openFileBrowser]);
|
}, [openFileBrowser]);
|
||||||
|
|
||||||
// Test IPC connection on mount
|
// Test IPC connection on mount.
|
||||||
|
// For returning users on the fast-hydrate path, the background IIFE in initAuth
|
||||||
|
// already calls waitForServerReady() which performs a health check. Doing a second
|
||||||
|
// concurrent health check wastes a connection slot on mobile's limited TCP pool.
|
||||||
|
// Instead, set ipcConnected optimistically for returning users (auth already marked
|
||||||
|
// true at module load time) and let the background verify surface any real failures.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Returning users: auth store was pre-populated from localStorage at module load.
|
||||||
|
// The background verify IIFE in initAuth handles the real health check.
|
||||||
|
// Optimistically mark connected — if the server is truly down, the next API call
|
||||||
|
// (triggered by the background verify) will surface the error.
|
||||||
|
const { authChecked: alreadyChecked, isAuthenticated: alreadyAuthed } = useAuthStore.getState();
|
||||||
|
if (!isElectron() && alreadyChecked && alreadyAuthed) {
|
||||||
|
setIpcConnected(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const testConnection = async () => {
|
const testConnection = async () => {
|
||||||
try {
|
try {
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
@@ -956,11 +1033,41 @@ function RootLayoutContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for auth check before rendering protected routes (ALL modes - unified flow)
|
// Wait for auth check before rendering protected routes (ALL modes - unified flow).
|
||||||
|
// The visual here intentionally matches the inline HTML app shell (index.html)
|
||||||
|
// so the transition from HTML → React is seamless — no layout shift, no flash.
|
||||||
if (!authChecked) {
|
if (!authChecked) {
|
||||||
return (
|
return (
|
||||||
<main className="flex h-full items-center justify-center" data-testid="app-container">
|
<main
|
||||||
<LoadingState message="Loading..." />
|
className="flex h-full flex-col items-center justify-center gap-6"
|
||||||
|
data-testid="app-container"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-14 w-14 opacity-90"
|
||||||
|
viewBox="0 0 256 256"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect className="fill-foreground/[0.08]" x="16" y="16" width="224" height="224" rx="56" />
|
||||||
|
<g
|
||||||
|
className="stroke-foreground/70"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="20"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="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>
|
||||||
|
{/* Pure CSS spinner — no icon dependencies, so vendor-icons can be deferred/prefetched.
|
||||||
|
Matches the HTML app shell in index.html for a seamless HTML→React transition. */}
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading"
|
||||||
|
className="h-4 w-4 animate-spin rounded-full border-2 border-foreground/10 border-t-foreground/50"
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,6 +217,52 @@ export { defaultBackgroundSettings, defaultTerminalState, MAX_INIT_OUTPUT_LINES
|
|||||||
// Type definitions are imported from ./types/state-types.ts
|
// Type definitions are imported from ./types/state-types.ts
|
||||||
// AppActions interface is defined in ./types/state-types.ts
|
// AppActions interface is defined in ./types/state-types.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-populate sidebar/UI state from the UI cache at module load time.
|
||||||
|
* This runs synchronously before createRoot().render(), so the very first
|
||||||
|
* React render uses the correct sidebar width — eliminating the layout shift
|
||||||
|
* (wide sidebar → collapsed) that was visible when auth was pre-populated
|
||||||
|
* but sidebar state wasn't.
|
||||||
|
*/
|
||||||
|
function getInitialUIState(): {
|
||||||
|
sidebarOpen: boolean;
|
||||||
|
sidebarStyle: 'unified' | 'discord';
|
||||||
|
collapsedNavSections: Record<string, boolean>;
|
||||||
|
} {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('automaker-ui-cache');
|
||||||
|
if (raw) {
|
||||||
|
const wrapper = JSON.parse(raw);
|
||||||
|
// zustand/persist wraps state under a "state" key
|
||||||
|
const cache = wrapper?.state;
|
||||||
|
if (cache) {
|
||||||
|
return {
|
||||||
|
sidebarOpen:
|
||||||
|
typeof cache.cachedSidebarOpen === 'boolean' ? cache.cachedSidebarOpen : true,
|
||||||
|
sidebarStyle: cache.cachedSidebarStyle === 'discord' ? 'discord' : 'unified',
|
||||||
|
collapsedNavSections: (() => {
|
||||||
|
const raw = cache.cachedCollapsedNavSections;
|
||||||
|
if (
|
||||||
|
raw &&
|
||||||
|
typeof raw === 'object' &&
|
||||||
|
!Array.isArray(raw) &&
|
||||||
|
Object.getOwnPropertyNames(raw).every((k) => typeof raw[k] === 'boolean')
|
||||||
|
) {
|
||||||
|
return raw as Record<string, boolean>;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
})(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to defaults
|
||||||
|
}
|
||||||
|
return { sidebarOpen: true, sidebarStyle: 'unified', collapsedNavSections: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedUI = getInitialUIState();
|
||||||
|
|
||||||
const initialState: AppState = {
|
const initialState: AppState = {
|
||||||
projects: [],
|
projects: [],
|
||||||
currentProject: null,
|
currentProject: null,
|
||||||
@@ -224,9 +270,9 @@ const initialState: AppState = {
|
|||||||
projectHistory: [],
|
projectHistory: [],
|
||||||
projectHistoryIndex: -1,
|
projectHistoryIndex: -1,
|
||||||
currentView: 'welcome',
|
currentView: 'welcome',
|
||||||
sidebarOpen: true,
|
sidebarOpen: cachedUI.sidebarOpen,
|
||||||
sidebarStyle: 'unified',
|
sidebarStyle: cachedUI.sidebarStyle,
|
||||||
collapsedNavSections: {},
|
collapsedNavSections: cachedUI.collapsedNavSections,
|
||||||
mobileSidebarHidden: false,
|
mobileSidebarHidden: false,
|
||||||
lastSelectedSessionByProject: {},
|
lastSelectedSessionByProject: {},
|
||||||
theme: getStoredTheme() || 'dark',
|
theme: getStoredTheme() || 'dark',
|
||||||
@@ -942,6 +988,13 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
runningTasks: [],
|
runningTasks: [],
|
||||||
branchName,
|
branchName,
|
||||||
};
|
};
|
||||||
|
// Prevent duplicate entries - the same feature can trigger multiple
|
||||||
|
// auto_mode_feature_start events (e.g., from execution-service and
|
||||||
|
// pipeline-orchestrator), so we must guard against adding the same
|
||||||
|
// taskId more than once.
|
||||||
|
if (current.runningTasks.includes(taskId)) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
autoModeByWorktree: {
|
autoModeByWorktree: {
|
||||||
...state.autoModeByWorktree,
|
...state.autoModeByWorktree,
|
||||||
|
|||||||
@@ -14,21 +14,63 @@ interface AuthActions {
|
|||||||
resetAuth: () => void;
|
resetAuth: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: AuthState = {
|
/**
|
||||||
authChecked: false,
|
* Pre-flight check: if localStorage has cached settings with projects AND setup is
|
||||||
isAuthenticated: false,
|
* complete, we can optimistically mark auth as complete on the very first render,
|
||||||
settingsLoaded: false,
|
* skipping the spinner entirely. The background verify in __root.tsx will correct
|
||||||
|
* this if the session is invalid.
|
||||||
|
*
|
||||||
|
* This runs synchronously at module load time — before createRoot().render() —
|
||||||
|
* so the first React render never shows the !authChecked spinner for returning users.
|
||||||
|
*
|
||||||
|
* We only set settingsLoaded=true when setupComplete is also true in the cache.
|
||||||
|
* If setupComplete is false, settingsLoaded stays false so the routing effect in
|
||||||
|
* __root.tsx doesn't immediately redirect to /setup before the setup store is hydrated.
|
||||||
|
* In practice, returning users who completed setup have both flags in their cache.
|
||||||
|
*
|
||||||
|
* Intentionally minimal: only checks for the key existence and basic structure.
|
||||||
|
* Full hydration (project data, settings) is handled by __root.tsx after mount.
|
||||||
|
*/
|
||||||
|
function getInitialAuthState(): AuthState {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('automaker-settings-cache');
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw) as {
|
||||||
|
projects?: unknown[];
|
||||||
|
setupComplete?: boolean;
|
||||||
};
|
};
|
||||||
|
if (parsed?.projects && Array.isArray(parsed.projects) && parsed.projects.length > 0) {
|
||||||
|
// Returning user with cached settings — optimistically mark as authenticated.
|
||||||
|
// Only mark settingsLoaded=true when setupComplete is confirmed in cache,
|
||||||
|
// preventing premature /setup redirects before the setup store is hydrated.
|
||||||
|
// Background verify in __root.tsx will fix isAuthenticated if session expired.
|
||||||
|
const setupDone = parsed.setupComplete === true;
|
||||||
|
return {
|
||||||
|
authChecked: true,
|
||||||
|
isAuthenticated: true,
|
||||||
|
settingsLoaded: setupDone,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Corrupted localStorage or JSON parse error — fall through to cold start
|
||||||
|
}
|
||||||
|
return { authChecked: false, isAuthenticated: false, settingsLoaded: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: AuthState = getInitialAuthState();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Web authentication state.
|
* Web authentication state.
|
||||||
*
|
*
|
||||||
* Intentionally NOT persisted: source of truth is server session cookie.
|
* Intentionally NOT persisted: source of truth is server session cookie.
|
||||||
|
* Initial state is optimistically set from localStorage cache for returning users,
|
||||||
|
* then verified against the server in the background by __root.tsx.
|
||||||
*/
|
*/
|
||||||
export const useAuthStore = create<AuthState & AuthActions>((set) => ({
|
export const useAuthStore = create<AuthState & AuthActions>((set) => ({
|
||||||
...initialState,
|
...initialState,
|
||||||
setAuthState: (state) => {
|
setAuthState: (state) => {
|
||||||
set({ ...state });
|
set({ ...state });
|
||||||
},
|
},
|
||||||
resetAuth: () => set(initialState),
|
resetAuth: () => set({ authChecked: false, isAuthenticated: false, settingsLoaded: false }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -270,10 +270,39 @@ const initialInstallProgress: InstallProgress = {
|
|||||||
// Check if setup should be skipped (for E2E testing)
|
// Check if setup should be skipped (for E2E testing)
|
||||||
const shouldSkipSetup = import.meta.env.VITE_SKIP_SETUP === 'true';
|
const shouldSkipSetup = import.meta.env.VITE_SKIP_SETUP === 'true';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-flight check: read setupComplete from localStorage settings cache so that
|
||||||
|
* the routing effect in __root.tsx doesn't flash /setup for returning users.
|
||||||
|
*
|
||||||
|
* The setup store is intentionally NOT persisted (settings sync via API), but on
|
||||||
|
* first render the routing check fires before the initAuth useEffect can call
|
||||||
|
* hydrateStoreFromSettings(). If setupComplete starts as false, returning users
|
||||||
|
* who have completed setup see a /setup redirect flash.
|
||||||
|
*
|
||||||
|
* Reading from localStorage here is safe: it's the same key used by
|
||||||
|
* parseLocalStorageSettings() and written by the settings sync hook.
|
||||||
|
* On first-ever visit (no cache), this returns false as expected.
|
||||||
|
*/
|
||||||
|
function getInitialSetupComplete(): boolean {
|
||||||
|
if (shouldSkipSetup) return true;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('automaker-settings-cache');
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw) as { setupComplete?: boolean };
|
||||||
|
if (parsed?.setupComplete === true) return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable or JSON invalid — fall through
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialSetupComplete = getInitialSetupComplete();
|
||||||
|
|
||||||
const initialState: SetupState = {
|
const initialState: SetupState = {
|
||||||
isFirstRun: !shouldSkipSetup,
|
isFirstRun: !shouldSkipSetup && !initialSetupComplete,
|
||||||
setupComplete: shouldSkipSetup,
|
setupComplete: initialSetupComplete,
|
||||||
currentStep: shouldSkipSetup ? 'complete' : 'welcome',
|
currentStep: initialSetupComplete ? 'complete' : 'welcome',
|
||||||
|
|
||||||
claudeCliStatus: null,
|
claudeCliStatus: null,
|
||||||
claudeAuthStatus: null,
|
claudeAuthStatus: null,
|
||||||
@@ -316,7 +345,11 @@ export const useSetupStore = create<SetupState & SetupActions>()((set, get) => (
|
|||||||
resetSetup: () =>
|
resetSetup: () =>
|
||||||
set({
|
set({
|
||||||
...initialState,
|
...initialState,
|
||||||
isFirstRun: false, // Don't reset first run flag
|
// Explicitly override runtime-critical fields that may be stale in the
|
||||||
|
// module-level initialState (captured at import time from localStorage).
|
||||||
|
setupComplete: false,
|
||||||
|
currentStep: 'welcome',
|
||||||
|
isFirstRun: false, // Don't reset first run flag — user has visited before
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setIsFirstRun: (isFirstRun) => set({ isFirstRun }),
|
setIsFirstRun: (isFirstRun) => set({ isFirstRun }),
|
||||||
|
|||||||
@@ -410,7 +410,8 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
padding-top: env(safe-area-inset-top, 0px);
|
padding-top: env(safe-area-inset-top, 0px);
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
/* Default bottom inset: reduced multiplier to balance home-indicator clearance with screen space */
|
||||||
|
padding-bottom: calc(env(safe-area-inset-bottom, 0px) * 0.1);
|
||||||
padding-left: env(safe-area-inset-left, 0px);
|
padding-left: env(safe-area-inset-left, 0px);
|
||||||
padding-right: env(safe-area-inset-right, 0px);
|
padding-right: env(safe-area-inset-right, 0px);
|
||||||
}
|
}
|
||||||
@@ -553,6 +554,24 @@
|
|||||||
-webkit-touch-callout: none;
|
-webkit-touch-callout: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Safe-area padding for full-screen mobile dialogs (e.g. view changes, commits).
|
||||||
|
Prevents overlap with the Dynamic Island / notch on iOS.
|
||||||
|
Uses calc() to ADD safe-area insets to the existing 1.5rem (p-6) padding from
|
||||||
|
DialogContent, so on non-notched devices the base p-6 padding is preserved.
|
||||||
|
On sm+ (desktop), the dialog is centered and not full-screen, so restore
|
||||||
|
the plain 1.5rem top/bottom padding. */
|
||||||
|
.dialog-fullscreen-mobile {
|
||||||
|
padding-top: calc(env(safe-area-inset-top, 0px) + 1.5rem);
|
||||||
|
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.dialog-fullscreen-mobile {
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.glass-subtle {
|
.glass-subtle {
|
||||||
@apply backdrop-blur-sm border-white/5;
|
@apply backdrop-blur-sm border-white/5;
|
||||||
}
|
}
|
||||||
|
|||||||
10
apps/ui/src/types/electron.d.ts
vendored
10
apps/ui/src/types/electron.d.ts
vendored
@@ -804,7 +804,8 @@ export interface WorktreeAPI {
|
|||||||
// Rebase the current branch onto a target branch
|
// Rebase the current branch onto a target branch
|
||||||
rebase: (
|
rebase: (
|
||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
ontoBranch: string
|
ontoBranch: string,
|
||||||
|
remote?: string
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
result?: {
|
result?: {
|
||||||
@@ -960,6 +961,8 @@ export interface WorktreeAPI {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -1064,7 +1067,8 @@ export interface WorktreeAPI {
|
|||||||
// List branches (local and optionally remote)
|
// List branches (local and optionally remote)
|
||||||
listBranches: (
|
listBranches: (
|
||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
includeRemote?: boolean
|
includeRemote?: boolean,
|
||||||
|
signal?: AbortSignal
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
result?: {
|
result?: {
|
||||||
@@ -1078,6 +1082,8 @@ export interface WorktreeAPI {
|
|||||||
behindCount: number;
|
behindCount: number;
|
||||||
hasRemoteBranch: boolean;
|
hasRemoteBranch: boolean;
|
||||||
hasAnyRemotes: boolean;
|
hasAnyRemotes: boolean;
|
||||||
|
/** The name of the remote that the current branch is tracking (e.g. "origin"), if any */
|
||||||
|
trackingRemote?: string;
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
code?: 'NOT_GIT_REPO' | 'NO_COMMITS'; // Error codes for git status issues
|
code?: 'NOT_GIT_REPO' | 'NO_COMMITS'; // Error codes for git status issues
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ import {
|
|||||||
|
|
||||||
const TEST_TEMP_DIR = createTempDirPath('manual-review-test');
|
const TEST_TEMP_DIR = createTempDirPath('manual-review-test');
|
||||||
|
|
||||||
|
// Generate deterministic projectId once at test module load so both
|
||||||
|
// setupRealProject and the route interceptor use the same ID
|
||||||
|
const TEST_PROJECT_ID = `project-manual-review-${Date.now()}`;
|
||||||
|
|
||||||
test.describe('Feature Manual Review Flow', () => {
|
test.describe('Feature Manual Review Flow', () => {
|
||||||
let projectPath: string;
|
let projectPath: string;
|
||||||
const projectName = `test-project-${Date.now()}`;
|
const projectName = `test-project-${Date.now()}`;
|
||||||
@@ -71,34 +75,45 @@ test.describe('Feature Manual Review Flow', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should manually verify a feature in waiting_approval column', async ({ page }) => {
|
test('should manually verify a feature in waiting_approval column', async ({ page }) => {
|
||||||
// Set up the project in localStorage
|
// Set up the project in localStorage with a deterministic projectId
|
||||||
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
|
await setupRealProject(page, projectPath, projectName, {
|
||||||
|
setAsCurrent: true,
|
||||||
|
projectId: TEST_PROJECT_ID,
|
||||||
|
});
|
||||||
|
|
||||||
// Intercept settings API to ensure our test project remains current
|
// Intercept settings API to ensure our test project remains current
|
||||||
// and doesn't get overridden by server settings
|
// and doesn't get overridden by server settings.
|
||||||
await page.route('**/api/settings/global', async (route) => {
|
await page.route('**/api/settings/global', async (route) => {
|
||||||
|
const method = route.request().method();
|
||||||
|
if (method === 'PUT') {
|
||||||
|
// Allow settings sync writes to pass through
|
||||||
|
return route.continue();
|
||||||
|
}
|
||||||
const response = await route.fetch();
|
const response = await route.fetch();
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
if (json.settings) {
|
if (json.settings) {
|
||||||
// Set our test project as the current project
|
const existingProjects = json.settings.projects || [];
|
||||||
const testProject = {
|
|
||||||
id: `project-${projectName}`,
|
// Find existing project by path to preserve any server-generated IDs
|
||||||
|
let testProject = existingProjects.find((p: { path: string }) => p.path === projectPath);
|
||||||
|
|
||||||
|
if (!testProject) {
|
||||||
|
// Project not in server response yet — use the same deterministic TEST_PROJECT_ID
|
||||||
|
// that was seeded in automaker-settings-cache.currentProjectId via setupRealProject.
|
||||||
|
testProject = {
|
||||||
|
id: TEST_PROJECT_ID,
|
||||||
name: projectName,
|
name: projectName,
|
||||||
path: projectPath,
|
path: projectPath,
|
||||||
lastOpened: new Date().toISOString(),
|
lastOpened: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add to projects if not already there
|
|
||||||
const existingProjects = json.settings.projects || [];
|
|
||||||
const hasProject = existingProjects.some(
|
|
||||||
(p: { id: string; path: string }) => p.path === projectPath
|
|
||||||
);
|
|
||||||
if (!hasProject) {
|
|
||||||
json.settings.projects = [testProject, ...existingProjects];
|
json.settings.projects = [testProject, ...existingProjects];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set as current project
|
// Set as current project using the matching project's ID
|
||||||
json.settings.currentProjectId = testProject.id;
|
json.settings.currentProjectId = testProject.id;
|
||||||
|
// Ensure CI runs don't redirect to /setup
|
||||||
|
json.settings.setupComplete = true;
|
||||||
|
json.settings.isFirstRun = false;
|
||||||
}
|
}
|
||||||
await route.fulfill({ response, json });
|
await route.fulfill({ response, json });
|
||||||
});
|
});
|
||||||
@@ -140,7 +155,7 @@ test.describe('Feature Manual Review Flow', () => {
|
|||||||
priority: 2,
|
priority: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
const API_BASE_URL = process.env.VITE_SERVER_URL || 'http://localhost:3008';
|
const API_BASE_URL = process.env.SERVER_URL || 'http://localhost:3008';
|
||||||
const createResponse = await page.request.post(`${API_BASE_URL}/api/features/create`, {
|
const createResponse = await page.request.post(`${API_BASE_URL}/api/features/create`, {
|
||||||
data: { projectPath, feature },
|
data: { projectPath, feature },
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -35,16 +35,42 @@ test.describe('Project Creation', () => {
|
|||||||
// Intercept settings API BEFORE authenticateForTests (which navigates to the page)
|
// Intercept settings API BEFORE authenticateForTests (which navigates to the page)
|
||||||
// This prevents settings hydration from restoring a project and disables auto-open
|
// This prevents settings hydration from restoring a project and disables auto-open
|
||||||
await page.route('**/api/settings/global', async (route) => {
|
await page.route('**/api/settings/global', async (route) => {
|
||||||
|
const method = route.request().method();
|
||||||
|
if (method === 'PUT') {
|
||||||
|
// Allow settings sync writes to pass through
|
||||||
|
return route.continue();
|
||||||
|
}
|
||||||
const response = await route.fetch();
|
const response = await route.fetch();
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
// Remove currentProjectId and clear projects to prevent auto-open
|
// Remove currentProjectId and clear projects to prevent auto-open
|
||||||
if (json.settings) {
|
if (json.settings) {
|
||||||
json.settings.currentProjectId = null;
|
json.settings.currentProjectId = null;
|
||||||
json.settings.projects = [];
|
json.settings.projects = [];
|
||||||
|
// Ensure setup is marked complete to prevent redirect to /setup on fresh CI
|
||||||
|
json.settings.setupComplete = true;
|
||||||
|
json.settings.isFirstRun = false;
|
||||||
|
// Preserve lastProjectDir so the new project modal knows where to create projects
|
||||||
|
json.settings.lastProjectDir = TEST_TEMP_DIR;
|
||||||
}
|
}
|
||||||
await route.fulfill({ response, json });
|
await route.fulfill({ response, json });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mock workspace config API to return a valid default directory.
|
||||||
|
// In CI, ALLOWED_ROOT_DIRECTORY is unset and Documents path is unavailable,
|
||||||
|
// so without this mock, getDefaultWorkspaceDirectory() returns null and the
|
||||||
|
// "Will be created at:" text never renders in the new project modal.
|
||||||
|
await page.route('**/api/workspace/config', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
configured: false,
|
||||||
|
defaultDir: TEST_TEMP_DIR,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await authenticateForTests(page);
|
await authenticateForTests(page);
|
||||||
|
|
||||||
// Navigate directly to dashboard to avoid auto-open logic
|
// Navigate directly to dashboard to avoid auto-open logic
|
||||||
|
|||||||
@@ -20,6 +20,67 @@ test.describe('Projects Overview Dashboard', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
// Set up mock projects state
|
// Set up mock projects state
|
||||||
await setupMockMultipleProjects(page, 3);
|
await setupMockMultipleProjects(page, 3);
|
||||||
|
|
||||||
|
// Intercept settings API to preserve mock project data and prevent
|
||||||
|
// the server's settings from overriding our test setup.
|
||||||
|
// Without this, background reconciliation can clear the mock projects.
|
||||||
|
await page.route('**/api/settings/global', async (route) => {
|
||||||
|
const method = route.request().method();
|
||||||
|
if (method === 'PUT') {
|
||||||
|
// Allow settings sync writes to pass through
|
||||||
|
return route.continue();
|
||||||
|
}
|
||||||
|
const response = await route.fetch();
|
||||||
|
const json = await response.json();
|
||||||
|
if (json.settings) {
|
||||||
|
// Always overwrite projects with mock data so CI-provided projects
|
||||||
|
// that don't contain 'test-project-1' can't break hydration.
|
||||||
|
json.settings.projects = [
|
||||||
|
{
|
||||||
|
id: 'test-project-1',
|
||||||
|
name: 'Test Project 1',
|
||||||
|
path: '/mock/test-project-1',
|
||||||
|
lastOpened: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'test-project-2',
|
||||||
|
name: 'Test Project 2',
|
||||||
|
path: '/mock/test-project-2',
|
||||||
|
lastOpened: new Date(Date.now() - 86400000).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'test-project-3',
|
||||||
|
name: 'Test Project 3',
|
||||||
|
path: '/mock/test-project-3',
|
||||||
|
lastOpened: new Date(Date.now() - 172800000).toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
json.settings.currentProjectId = 'test-project-1';
|
||||||
|
json.settings.setupComplete = true;
|
||||||
|
json.settings.isFirstRun = false;
|
||||||
|
}
|
||||||
|
await route.fulfill({ response, json });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock the initialize-project endpoint for mock paths that don't exist on disk.
|
||||||
|
// This prevents auto-open from failing when it tries to verify the project directory.
|
||||||
|
await page.route('**/api/project/initialize', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ success: true }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock features list for mock project paths (they don't exist on disk)
|
||||||
|
await page.route('**/api/features/list**', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ success: true, features: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await authenticateForTests(page);
|
await authenticateForTests(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ export async function gotoWithAuth(page: Page, url: string): Promise<void> {
|
|||||||
await page.goto(url);
|
await page.goto(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Selector matching any top-level app view by data-testid, used to detect that the app has loaded. */
|
||||||
|
const APP_CONTENT_SELECTOR =
|
||||||
|
'[data-testid="welcome-view"], [data-testid="dashboard-view"], [data-testid="board-view"], [data-testid="context-view"], [data-testid="agent-view"], [data-testid="overview-view"]';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle login screen if it appears after navigation
|
* Handle login screen if it appears after navigation
|
||||||
* Returns true if login was handled, false if no login screen was found
|
* Returns true if login was handled, false if no login screen was found
|
||||||
@@ -74,9 +78,7 @@ export async function handleLoginScreenIfPresent(page: Page): Promise<boolean> {
|
|||||||
const loginInput = page
|
const loginInput = page
|
||||||
.locator('[data-testid="login-api-key-input"], input[type="password"][placeholder*="API key"]')
|
.locator('[data-testid="login-api-key-input"], input[type="password"][placeholder*="API key"]')
|
||||||
.first();
|
.first();
|
||||||
const appContent = page.locator(
|
const appContent = page.locator(APP_CONTENT_SELECTOR);
|
||||||
'[data-testid="welcome-view"], [data-testid="dashboard-view"], [data-testid="board-view"], [data-testid="context-view"], [data-testid="agent-view"]'
|
|
||||||
);
|
|
||||||
const loggedOutPage = page.getByRole('heading', { name: /logged out/i });
|
const loggedOutPage = page.getByRole('heading', { name: /logged out/i });
|
||||||
const goToLoginButton = page.locator('button:has-text("Go to login")');
|
const goToLoginButton = page.locator('button:has-text("Go to login")');
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,27 @@ export async function setupWelcomeView(
|
|||||||
};
|
};
|
||||||
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
|
|
||||||
// Set workspace directory if provided
|
// Set settings cache to ensure setupComplete is recognized on cold start.
|
||||||
|
// This prevents the server's setupComplete value (which may be false on fresh CI)
|
||||||
|
// from overriding the setup store and causing a redirect to /setup.
|
||||||
|
const settingsCache: Record<string, unknown> = {
|
||||||
|
setupComplete: true,
|
||||||
|
isFirstRun: false,
|
||||||
|
projects: opts?.recentProjects || [],
|
||||||
|
theme: 'dark',
|
||||||
|
sidebarOpen: true,
|
||||||
|
maxConcurrency: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Include lastProjectDir in settings cache so it's available during fast-hydrate.
|
||||||
|
// The standalone localStorage key is a legacy fallback; the cache is the primary source.
|
||||||
|
if (opts?.workspaceDir) {
|
||||||
|
settingsCache.lastProjectDir = opts.workspaceDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
|
||||||
|
// Set workspace directory if provided (legacy fallback key)
|
||||||
if (opts?.workspaceDir) {
|
if (opts?.workspaceDir) {
|
||||||
localStorage.setItem('automaker:lastProjectDir', opts.workspaceDir);
|
localStorage.setItem('automaker:lastProjectDir', opts.workspaceDir);
|
||||||
}
|
}
|
||||||
@@ -129,6 +149,8 @@ export async function setupRealProject(
|
|||||||
setAsCurrent?: boolean;
|
setAsCurrent?: boolean;
|
||||||
/** Additional recent projects to include */
|
/** Additional recent projects to include */
|
||||||
additionalProjects?: TestProject[];
|
additionalProjects?: TestProject[];
|
||||||
|
/** Optional project ID to use (if not provided, generates timestamp-based ID) */
|
||||||
|
projectId?: string;
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
@@ -143,7 +165,7 @@ export async function setupRealProject(
|
|||||||
opts: typeof options;
|
opts: typeof options;
|
||||||
versions: typeof STORE_VERSIONS;
|
versions: typeof STORE_VERSIONS;
|
||||||
}) => {
|
}) => {
|
||||||
const projectId = `project-${Date.now()}`;
|
const projectId = opts?.projectId || `project-${Date.now()}`;
|
||||||
const project: TestProject = {
|
const project: TestProject = {
|
||||||
id: projectId,
|
id: projectId,
|
||||||
name: name,
|
name: name,
|
||||||
@@ -182,6 +204,27 @@ export async function setupRealProject(
|
|||||||
};
|
};
|
||||||
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
|
|
||||||
|
// Set settings cache to ensure setupComplete is recognized on cold start.
|
||||||
|
// This prevents the server's setupComplete value (which may be false on fresh CI)
|
||||||
|
// from overriding the setup store and causing a redirect to /setup.
|
||||||
|
const settingsCache = {
|
||||||
|
setupComplete: true,
|
||||||
|
isFirstRun: false,
|
||||||
|
projects: allProjects.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
path: p.path,
|
||||||
|
lastOpened: p.lastOpened,
|
||||||
|
})),
|
||||||
|
// Include currentProjectId so hydrateStoreFromSettings can restore
|
||||||
|
// the current project directly (without relying on auto-open logic)
|
||||||
|
currentProjectId: currentProject ? currentProject.id : null,
|
||||||
|
theme: 'dark',
|
||||||
|
sidebarOpen: true,
|
||||||
|
maxConcurrency: 3,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
sessionStorage.setItem('automaker-splash-shown', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
},
|
},
|
||||||
@@ -194,7 +237,7 @@ export async function setupRealProject(
|
|||||||
* This simulates having opened a project before
|
* This simulates having opened a project before
|
||||||
*/
|
*/
|
||||||
export async function setupMockProject(page: Page): Promise<void> {
|
export async function setupMockProject(page: Page): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript((versions: typeof STORE_VERSIONS) => {
|
||||||
const mockProject = {
|
const mockProject = {
|
||||||
id: 'test-project-1',
|
id: 'test-project-1',
|
||||||
name: 'Test Project',
|
name: 'Test Project',
|
||||||
@@ -213,14 +256,43 @@ export async function setupMockProject(page: Page): Promise<void> {
|
|||||||
chatHistoryOpen: false,
|
chatHistoryOpen: false,
|
||||||
maxConcurrency: 3,
|
maxConcurrency: 3,
|
||||||
},
|
},
|
||||||
version: 2, // Must match app-store.ts persist version
|
version: versions.APP_STORE,
|
||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
||||||
|
|
||||||
|
// Mark setup as complete to prevent redirect to /setup
|
||||||
|
const setupState = {
|
||||||
|
state: {
|
||||||
|
isFirstRun: false,
|
||||||
|
setupComplete: true,
|
||||||
|
skipClaudeSetup: false,
|
||||||
|
},
|
||||||
|
version: versions.SETUP_STORE,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
|
|
||||||
|
// Set settings cache so the fast hydrate path is taken on page load.
|
||||||
|
const settingsCache = {
|
||||||
|
setupComplete: true,
|
||||||
|
isFirstRun: false,
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
id: mockProject.id,
|
||||||
|
name: mockProject.name,
|
||||||
|
path: mockProject.path,
|
||||||
|
lastOpened: mockProject.lastOpened,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
theme: 'dark',
|
||||||
|
sidebarOpen: true,
|
||||||
|
maxConcurrency: 3,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
sessionStorage.setItem('automaker-splash-shown', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
});
|
}, STORE_VERSIONS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -230,7 +302,8 @@ export async function setupMockProjectWithConcurrency(
|
|||||||
page: Page,
|
page: Page,
|
||||||
concurrency: number
|
concurrency: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await page.addInitScript((maxConcurrency: number) => {
|
await page.addInitScript(
|
||||||
|
({ maxConcurrency, versions }: { maxConcurrency: number; versions: typeof STORE_VERSIONS }) => {
|
||||||
const mockProject = {
|
const mockProject = {
|
||||||
id: 'test-project-1',
|
id: 'test-project-1',
|
||||||
name: 'Test Project',
|
name: 'Test Project',
|
||||||
@@ -249,11 +322,37 @@ export async function setupMockProjectWithConcurrency(
|
|||||||
chatHistoryOpen: false,
|
chatHistoryOpen: false,
|
||||||
maxConcurrency: maxConcurrency,
|
maxConcurrency: maxConcurrency,
|
||||||
},
|
},
|
||||||
version: 2, // Must match app-store.ts persist version
|
version: versions.APP_STORE,
|
||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
||||||
}, concurrency);
|
|
||||||
|
// Mark setup as complete to prevent redirect to /setup
|
||||||
|
const setupState = {
|
||||||
|
state: { isFirstRun: false, setupComplete: true, skipClaudeSetup: false },
|
||||||
|
version: versions.SETUP_STORE,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
|
|
||||||
|
const settingsCache = {
|
||||||
|
setupComplete: true,
|
||||||
|
isFirstRun: false,
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
id: mockProject.id,
|
||||||
|
name: mockProject.name,
|
||||||
|
path: mockProject.path,
|
||||||
|
lastOpened: mockProject.lastOpened,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
theme: 'dark',
|
||||||
|
sidebarOpen: true,
|
||||||
|
maxConcurrency: maxConcurrency,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
},
|
||||||
|
{ maxConcurrency: concurrency, versions: STORE_VERSIONS }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -265,7 +364,15 @@ export async function setupMockProjectAtConcurrencyLimit(
|
|||||||
runningTasks: string[] = ['running-task-1']
|
runningTasks: string[] = ['running-task-1']
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ maxConcurrency, runningTasks }: { maxConcurrency: number; runningTasks: string[] }) => {
|
({
|
||||||
|
maxConcurrency,
|
||||||
|
runningTasks,
|
||||||
|
versions,
|
||||||
|
}: {
|
||||||
|
maxConcurrency: number;
|
||||||
|
runningTasks: string[];
|
||||||
|
versions: typeof STORE_VERSIONS;
|
||||||
|
}) => {
|
||||||
const mockProject = {
|
const mockProject = {
|
||||||
id: 'test-project-1',
|
id: 'test-project-1',
|
||||||
name: 'Test Project',
|
name: 'Test Project',
|
||||||
@@ -287,15 +394,38 @@ export async function setupMockProjectAtConcurrencyLimit(
|
|||||||
runningAutoTasks: runningTasks,
|
runningAutoTasks: runningTasks,
|
||||||
autoModeActivityLog: [],
|
autoModeActivityLog: [],
|
||||||
},
|
},
|
||||||
version: 2, // Must match app-store.ts persist version
|
version: versions.APP_STORE,
|
||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
||||||
|
|
||||||
|
const setupState = {
|
||||||
|
state: { isFirstRun: false, setupComplete: true, skipClaudeSetup: false },
|
||||||
|
version: versions.SETUP_STORE,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
|
|
||||||
|
const settingsCache = {
|
||||||
|
setupComplete: true,
|
||||||
|
isFirstRun: false,
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
id: mockProject.id,
|
||||||
|
name: mockProject.name,
|
||||||
|
path: mockProject.path,
|
||||||
|
lastOpened: mockProject.lastOpened,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
theme: 'dark',
|
||||||
|
sidebarOpen: true,
|
||||||
|
maxConcurrency: maxConcurrency,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
sessionStorage.setItem('automaker-splash-shown', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
},
|
},
|
||||||
{ maxConcurrency, runningTasks }
|
{ maxConcurrency, runningTasks, versions: STORE_VERSIONS }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +446,8 @@ export async function setupMockProjectWithFeatures(
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await page.addInitScript((opts: typeof options) => {
|
await page.addInitScript(
|
||||||
|
({ opts, versions }: { opts: typeof options; versions: typeof STORE_VERSIONS }) => {
|
||||||
const mockProject = {
|
const mockProject = {
|
||||||
id: 'test-project-1',
|
id: 'test-project-1',
|
||||||
name: 'Test Project',
|
name: 'Test Project',
|
||||||
@@ -341,18 +472,43 @@ export async function setupMockProjectWithFeatures(
|
|||||||
autoModeActivityLog: [],
|
autoModeActivityLog: [],
|
||||||
features: mockFeatures,
|
features: mockFeatures,
|
||||||
},
|
},
|
||||||
version: 2, // Must match app-store.ts persist version
|
version: versions.APP_STORE,
|
||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
||||||
|
|
||||||
|
const setupState = {
|
||||||
|
state: { isFirstRun: false, setupComplete: true, skipClaudeSetup: false },
|
||||||
|
version: versions.SETUP_STORE,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
|
|
||||||
|
const settingsCache = {
|
||||||
|
setupComplete: true,
|
||||||
|
isFirstRun: false,
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
id: mockProject.id,
|
||||||
|
name: mockProject.name,
|
||||||
|
path: mockProject.path,
|
||||||
|
lastOpened: mockProject.lastOpened,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
theme: 'dark',
|
||||||
|
sidebarOpen: true,
|
||||||
|
maxConcurrency: opts?.maxConcurrency ?? 3,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
|
||||||
// Also store features in a global variable that the mock electron API can use
|
// Also store features in a global variable that the mock electron API can use
|
||||||
// This is needed because the board-view loads features from the file system
|
// This is needed because the board-view loads features from the file system
|
||||||
(window as { __mockFeatures?: unknown[] }).__mockFeatures = mockFeatures;
|
(window as { __mockFeatures?: unknown[] }).__mockFeatures = mockFeatures;
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
sessionStorage.setItem('automaker-splash-shown', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
}, options);
|
},
|
||||||
|
{ opts: options, versions: STORE_VERSIONS }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -365,7 +521,15 @@ export async function setupMockProjectWithContextFile(
|
|||||||
contextContent: string = '# Agent Context\n\nPrevious implementation work...'
|
contextContent: string = '# Agent Context\n\nPrevious implementation work...'
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ featureId, contextContent }: { featureId: string; contextContent: string }) => {
|
({
|
||||||
|
featureId,
|
||||||
|
contextContent,
|
||||||
|
versions,
|
||||||
|
}: {
|
||||||
|
featureId: string;
|
||||||
|
contextContent: string;
|
||||||
|
versions: typeof STORE_VERSIONS;
|
||||||
|
}) => {
|
||||||
const mockProject = {
|
const mockProject = {
|
||||||
id: 'test-project-1',
|
id: 'test-project-1',
|
||||||
name: 'Test Project',
|
name: 'Test Project',
|
||||||
@@ -384,11 +548,34 @@ export async function setupMockProjectWithContextFile(
|
|||||||
chatHistoryOpen: false,
|
chatHistoryOpen: false,
|
||||||
maxConcurrency: 3,
|
maxConcurrency: 3,
|
||||||
},
|
},
|
||||||
version: 2, // Must match app-store.ts persist version
|
version: versions.APP_STORE,
|
||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
||||||
|
|
||||||
|
const setupState = {
|
||||||
|
state: { isFirstRun: false, setupComplete: true, skipClaudeSetup: false },
|
||||||
|
version: versions.SETUP_STORE,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
|
|
||||||
|
const settingsCache = {
|
||||||
|
setupComplete: true,
|
||||||
|
isFirstRun: false,
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
id: mockProject.id,
|
||||||
|
name: mockProject.name,
|
||||||
|
path: mockProject.path,
|
||||||
|
lastOpened: mockProject.lastOpened,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
theme: 'dark',
|
||||||
|
sidebarOpen: true,
|
||||||
|
maxConcurrency: 3,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
sessionStorage.setItem('automaker-splash-shown', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
|
|
||||||
@@ -403,7 +590,7 @@ export async function setupMockProjectWithContextFile(
|
|||||||
content: contextContent,
|
content: contextContent,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
{ featureId, contextContent }
|
{ featureId, contextContent, versions: STORE_VERSIONS }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,7 +612,8 @@ export async function setupMockProjectWithInProgressFeatures(
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await page.addInitScript((opts: typeof options) => {
|
await page.addInitScript(
|
||||||
|
({ opts, versions }: { opts: typeof options; versions: typeof STORE_VERSIONS }) => {
|
||||||
const mockProject = {
|
const mockProject = {
|
||||||
id: 'test-project-1',
|
id: 'test-project-1',
|
||||||
name: 'Test Project',
|
name: 'Test Project',
|
||||||
@@ -450,22 +638,48 @@ export async function setupMockProjectWithInProgressFeatures(
|
|||||||
autoModeActivityLog: [],
|
autoModeActivityLog: [],
|
||||||
features: mockFeatures,
|
features: mockFeatures,
|
||||||
},
|
},
|
||||||
version: 2, // Must match app-store.ts persist version
|
version: versions.APP_STORE,
|
||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
||||||
|
|
||||||
|
const setupState = {
|
||||||
|
state: { isFirstRun: false, setupComplete: true, skipClaudeSetup: false },
|
||||||
|
version: versions.SETUP_STORE,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
|
|
||||||
|
const settingsCache = {
|
||||||
|
setupComplete: true,
|
||||||
|
isFirstRun: false,
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
id: mockProject.id,
|
||||||
|
name: mockProject.name,
|
||||||
|
path: mockProject.path,
|
||||||
|
lastOpened: mockProject.lastOpened,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
theme: 'dark',
|
||||||
|
sidebarOpen: true,
|
||||||
|
maxConcurrency: opts?.maxConcurrency ?? 3,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
|
||||||
// Also store features in a global variable that the mock electron API can use
|
// Also store features in a global variable that the mock electron API can use
|
||||||
// This is needed because the board-view loads features from the file system
|
// This is needed because the board-view loads features from the file system
|
||||||
(window as { __mockFeatures?: unknown[] }).__mockFeatures = mockFeatures;
|
(window as { __mockFeatures?: unknown[] }).__mockFeatures = mockFeatures;
|
||||||
}, options);
|
},
|
||||||
|
{ opts: options, versions: STORE_VERSIONS }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up a mock project with a specific current view for route persistence testing
|
* Set up a mock project with a specific current view for route persistence testing
|
||||||
*/
|
*/
|
||||||
export async function setupMockProjectWithView(page: Page, view: string): Promise<void> {
|
export async function setupMockProjectWithView(page: Page, view: string): Promise<void> {
|
||||||
await page.addInitScript((currentView: string) => {
|
await page.addInitScript(
|
||||||
|
({ currentView, versions }: { currentView: string; versions: typeof STORE_VERSIONS }) => {
|
||||||
const mockProject = {
|
const mockProject = {
|
||||||
id: 'test-project-1',
|
id: 'test-project-1',
|
||||||
name: 'Test Project',
|
name: 'Test Project',
|
||||||
@@ -485,18 +699,43 @@ export async function setupMockProjectWithView(page: Page, view: string): Promis
|
|||||||
chatHistoryOpen: false,
|
chatHistoryOpen: false,
|
||||||
maxConcurrency: 3,
|
maxConcurrency: 3,
|
||||||
},
|
},
|
||||||
version: 2, // Must match app-store.ts persist version
|
version: versions.APP_STORE,
|
||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
||||||
}, view);
|
|
||||||
|
const setupState = {
|
||||||
|
state: { isFirstRun: false, setupComplete: true, skipClaudeSetup: false },
|
||||||
|
version: versions.SETUP_STORE,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
|
|
||||||
|
const settingsCache = {
|
||||||
|
setupComplete: true,
|
||||||
|
isFirstRun: false,
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
id: mockProject.id,
|
||||||
|
name: mockProject.name,
|
||||||
|
path: mockProject.path,
|
||||||
|
lastOpened: mockProject.lastOpened,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
theme: 'dark',
|
||||||
|
sidebarOpen: true,
|
||||||
|
maxConcurrency: 3,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
},
|
||||||
|
{ currentView: view, versions: STORE_VERSIONS }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up an empty localStorage (no projects) to show welcome screen
|
* Set up an empty localStorage (no projects) to show welcome screen
|
||||||
*/
|
*/
|
||||||
export async function setupEmptyLocalStorage(page: Page): Promise<void> {
|
export async function setupEmptyLocalStorage(page: Page): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript((versions: typeof STORE_VERSIONS) => {
|
||||||
const mockState = {
|
const mockState = {
|
||||||
state: {
|
state: {
|
||||||
projects: [],
|
projects: [],
|
||||||
@@ -509,20 +748,36 @@ export async function setupEmptyLocalStorage(page: Page): Promise<void> {
|
|||||||
chatHistoryOpen: false,
|
chatHistoryOpen: false,
|
||||||
maxConcurrency: 3,
|
maxConcurrency: 3,
|
||||||
},
|
},
|
||||||
version: 2, // Must match app-store.ts persist version
|
version: versions.APP_STORE,
|
||||||
};
|
};
|
||||||
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
||||||
|
|
||||||
|
const setupState = {
|
||||||
|
state: { isFirstRun: false, setupComplete: true, skipClaudeSetup: false },
|
||||||
|
version: versions.SETUP_STORE,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
|
|
||||||
|
const settingsCache = {
|
||||||
|
setupComplete: true,
|
||||||
|
isFirstRun: false,
|
||||||
|
projects: [],
|
||||||
|
theme: 'dark',
|
||||||
|
sidebarOpen: true,
|
||||||
|
maxConcurrency: 3,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
sessionStorage.setItem('automaker-splash-shown', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
});
|
}, STORE_VERSIONS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up mock projects in localStorage but with no current project (for recent projects list)
|
* Set up mock projects in localStorage but with no current project (for recent projects list)
|
||||||
*/
|
*/
|
||||||
export async function setupMockProjectsWithoutCurrent(page: Page): Promise<void> {
|
export async function setupMockProjectsWithoutCurrent(page: Page): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript((versions: typeof STORE_VERSIONS) => {
|
||||||
const mockProjects = [
|
const mockProjects = [
|
||||||
{
|
{
|
||||||
id: 'test-project-1',
|
id: 'test-project-1',
|
||||||
@@ -550,14 +805,35 @@ export async function setupMockProjectsWithoutCurrent(page: Page): Promise<void>
|
|||||||
chatHistoryOpen: false,
|
chatHistoryOpen: false,
|
||||||
maxConcurrency: 3,
|
maxConcurrency: 3,
|
||||||
},
|
},
|
||||||
version: 2, // Must match app-store.ts persist version
|
version: versions.APP_STORE,
|
||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
||||||
|
|
||||||
|
const setupState = {
|
||||||
|
state: { isFirstRun: false, setupComplete: true, skipClaudeSetup: false },
|
||||||
|
version: versions.SETUP_STORE,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
|
|
||||||
|
const settingsCache = {
|
||||||
|
setupComplete: true,
|
||||||
|
isFirstRun: false,
|
||||||
|
projects: mockProjects.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
path: p.path,
|
||||||
|
lastOpened: p.lastOpened,
|
||||||
|
})),
|
||||||
|
theme: 'dark',
|
||||||
|
sidebarOpen: true,
|
||||||
|
maxConcurrency: 3,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
sessionStorage.setItem('automaker-splash-shown', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
});
|
}, STORE_VERSIONS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -579,7 +855,8 @@ export async function setupMockProjectWithSkipTestsFeatures(
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await page.addInitScript((opts: typeof options) => {
|
await page.addInitScript(
|
||||||
|
({ opts, versions }: { opts: typeof options; versions: typeof STORE_VERSIONS }) => {
|
||||||
const mockProject = {
|
const mockProject = {
|
||||||
id: 'test-project-1',
|
id: 'test-project-1',
|
||||||
name: 'Test Project',
|
name: 'Test Project',
|
||||||
@@ -604,14 +881,39 @@ export async function setupMockProjectWithSkipTestsFeatures(
|
|||||||
autoModeActivityLog: [],
|
autoModeActivityLog: [],
|
||||||
features: mockFeatures,
|
features: mockFeatures,
|
||||||
},
|
},
|
||||||
version: 2, // Must match app-store.ts persist version
|
version: versions.APP_STORE,
|
||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
||||||
|
|
||||||
|
const setupState = {
|
||||||
|
state: { isFirstRun: false, setupComplete: true, skipClaudeSetup: false },
|
||||||
|
version: versions.SETUP_STORE,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
|
|
||||||
|
const settingsCache = {
|
||||||
|
setupComplete: true,
|
||||||
|
isFirstRun: false,
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
id: mockProject.id,
|
||||||
|
name: mockProject.name,
|
||||||
|
path: mockProject.path,
|
||||||
|
lastOpened: mockProject.lastOpened,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
theme: 'dark',
|
||||||
|
sidebarOpen: true,
|
||||||
|
maxConcurrency: opts?.maxConcurrency ?? 3,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
sessionStorage.setItem('automaker-splash-shown', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
}, options);
|
},
|
||||||
|
{ opts: options, versions: STORE_VERSIONS }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -621,7 +923,8 @@ export async function setupMockMultipleProjects(
|
|||||||
page: Page,
|
page: Page,
|
||||||
projectCount: number = 3
|
projectCount: number = 3
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await page.addInitScript((count: number) => {
|
await page.addInitScript(
|
||||||
|
({ count, versions }: { count: number; versions: typeof STORE_VERSIONS }) => {
|
||||||
const mockProjects: TestProject[] = [];
|
const mockProjects: TestProject[] = [];
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
mockProjects.push({
|
mockProjects.push({
|
||||||
@@ -644,11 +947,48 @@ export async function setupMockMultipleProjects(
|
|||||||
chatHistoryOpen: false,
|
chatHistoryOpen: false,
|
||||||
maxConcurrency: 3,
|
maxConcurrency: 3,
|
||||||
},
|
},
|
||||||
version: 2, // Must match app-store.ts persist version
|
version: versions.APP_STORE,
|
||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
||||||
}, projectCount);
|
|
||||||
|
// Mark setup as complete to prevent redirect to /setup
|
||||||
|
const setupState = {
|
||||||
|
state: {
|
||||||
|
isFirstRun: false,
|
||||||
|
setupComplete: true,
|
||||||
|
skipClaudeSetup: false,
|
||||||
|
},
|
||||||
|
version: versions.SETUP_STORE,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
|
|
||||||
|
// Set settings cache so the fast hydrate path is taken on page load.
|
||||||
|
// This prevents the server's setupComplete value (which may be false on fresh CI)
|
||||||
|
// from overwriting the setup store and causing a redirect to /setup.
|
||||||
|
const settingsCache = {
|
||||||
|
setupComplete: true,
|
||||||
|
isFirstRun: false,
|
||||||
|
projects: mockProjects.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
path: p.path,
|
||||||
|
lastOpened: p.lastOpened,
|
||||||
|
})),
|
||||||
|
// Include currentProjectId so hydrateStoreFromSettings can restore
|
||||||
|
// the current project directly (without relying on auto-open logic)
|
||||||
|
currentProjectId: mockProjects[0]?.id ?? null,
|
||||||
|
theme: 'dark',
|
||||||
|
sidebarOpen: true,
|
||||||
|
maxConcurrency: 3,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
|
||||||
|
// Disable splash screen in tests
|
||||||
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
|
},
|
||||||
|
{ count: projectCount, versions: STORE_VERSIONS }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -660,7 +1000,15 @@ export async function setupMockProjectWithAgentOutput(
|
|||||||
outputContent: string
|
outputContent: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ featureId, outputContent }: { featureId: string; outputContent: string }) => {
|
({
|
||||||
|
featureId,
|
||||||
|
outputContent,
|
||||||
|
versions,
|
||||||
|
}: {
|
||||||
|
featureId: string;
|
||||||
|
outputContent: string;
|
||||||
|
versions: typeof STORE_VERSIONS;
|
||||||
|
}) => {
|
||||||
const mockProject = {
|
const mockProject = {
|
||||||
id: 'test-project-1',
|
id: 'test-project-1',
|
||||||
name: 'Test Project',
|
name: 'Test Project',
|
||||||
@@ -679,11 +1027,34 @@ export async function setupMockProjectWithAgentOutput(
|
|||||||
chatHistoryOpen: false,
|
chatHistoryOpen: false,
|
||||||
maxConcurrency: 3,
|
maxConcurrency: 3,
|
||||||
},
|
},
|
||||||
version: 2, // Must match app-store.ts persist version
|
version: versions.APP_STORE,
|
||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
||||||
|
|
||||||
|
const setupState = {
|
||||||
|
state: { isFirstRun: false, setupComplete: true, skipClaudeSetup: false },
|
||||||
|
version: versions.SETUP_STORE,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
|
|
||||||
|
const settingsCache = {
|
||||||
|
setupComplete: true,
|
||||||
|
isFirstRun: false,
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
id: mockProject.id,
|
||||||
|
name: mockProject.name,
|
||||||
|
path: mockProject.path,
|
||||||
|
lastOpened: mockProject.lastOpened,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
theme: 'dark',
|
||||||
|
sidebarOpen: true,
|
||||||
|
maxConcurrency: 3,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
sessionStorage.setItem('automaker-splash-shown', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
|
|
||||||
@@ -697,7 +1068,7 @@ export async function setupMockProjectWithAgentOutput(
|
|||||||
content: outputContent,
|
content: outputContent,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
{ featureId, outputContent }
|
{ featureId, outputContent, versions: STORE_VERSIONS }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,7 +1091,8 @@ export async function setupMockProjectWithWaitingApprovalFeatures(
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await page.addInitScript((opts: typeof options) => {
|
await page.addInitScript(
|
||||||
|
({ opts, versions }: { opts: typeof options; versions: typeof STORE_VERSIONS }) => {
|
||||||
const mockProject = {
|
const mockProject = {
|
||||||
id: 'test-project-1',
|
id: 'test-project-1',
|
||||||
name: 'Test Project',
|
name: 'Test Project',
|
||||||
@@ -745,21 +1117,46 @@ export async function setupMockProjectWithWaitingApprovalFeatures(
|
|||||||
autoModeActivityLog: [],
|
autoModeActivityLog: [],
|
||||||
features: mockFeatures,
|
features: mockFeatures,
|
||||||
},
|
},
|
||||||
version: 2, // Must match app-store.ts persist version
|
version: versions.APP_STORE,
|
||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
||||||
|
|
||||||
|
const setupState = {
|
||||||
|
state: { isFirstRun: false, setupComplete: true, skipClaudeSetup: false },
|
||||||
|
version: versions.SETUP_STORE,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
|
|
||||||
|
const settingsCache = {
|
||||||
|
setupComplete: true,
|
||||||
|
isFirstRun: false,
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
id: mockProject.id,
|
||||||
|
name: mockProject.name,
|
||||||
|
path: mockProject.path,
|
||||||
|
lastOpened: mockProject.lastOpened,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
theme: 'dark',
|
||||||
|
sidebarOpen: true,
|
||||||
|
maxConcurrency: opts?.maxConcurrency ?? 3,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
|
||||||
// Also store features in a global variable that the mock electron API can use
|
// Also store features in a global variable that the mock electron API can use
|
||||||
(window as { __mockFeatures?: unknown[] }).__mockFeatures = mockFeatures;
|
(window as { __mockFeatures?: unknown[] }).__mockFeatures = mockFeatures;
|
||||||
}, options);
|
},
|
||||||
|
{ opts: options, versions: STORE_VERSIONS }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up the app store to show setup view (simulate first run)
|
* Set up the app store to show setup view (simulate first run)
|
||||||
*/
|
*/
|
||||||
export async function setupFirstRun(page: Page): Promise<void> {
|
export async function setupFirstRun(page: Page): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript((versions: typeof STORE_VERSIONS) => {
|
||||||
// Clear any existing setup state to simulate first run
|
// Clear any existing setup state to simulate first run
|
||||||
localStorage.removeItem('automaker-setup');
|
localStorage.removeItem('automaker-setup');
|
||||||
localStorage.removeItem('automaker-storage');
|
localStorage.removeItem('automaker-storage');
|
||||||
@@ -780,7 +1177,7 @@ export async function setupFirstRun(page: Page): Promise<void> {
|
|||||||
},
|
},
|
||||||
skipClaudeSetup: false,
|
skipClaudeSetup: false,
|
||||||
},
|
},
|
||||||
version: 2, // Must match app-store.ts persist version
|
version: versions.SETUP_STORE, // Must match setup-store.ts persist version
|
||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
@@ -801,14 +1198,25 @@ export async function setupFirstRun(page: Page): Promise<void> {
|
|||||||
autoModeActivityLog: [],
|
autoModeActivityLog: [],
|
||||||
currentView: 'setup',
|
currentView: 'setup',
|
||||||
},
|
},
|
||||||
version: 2, // Must match app-store.ts persist version
|
version: versions.APP_STORE, // Must match app-store.ts persist version
|
||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem('automaker-storage', JSON.stringify(appState));
|
localStorage.setItem('automaker-storage', JSON.stringify(appState));
|
||||||
|
|
||||||
|
// Anchor the settings cache so CI cannot hydrate a conflicting setupComplete value.
|
||||||
|
const settingsCache = {
|
||||||
|
setupComplete: false,
|
||||||
|
isFirstRun: true,
|
||||||
|
projects: [],
|
||||||
|
theme: 'dark',
|
||||||
|
sidebarOpen: true,
|
||||||
|
maxConcurrency: 3,
|
||||||
|
};
|
||||||
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
sessionStorage.setItem('automaker-splash-shown', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
});
|
}, STORE_VERSIONS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const buildHash = getBuildHash();
|
|||||||
/**
|
/**
|
||||||
* Vite plugin to inject the build hash into sw.js for cache busting.
|
* Vite plugin to inject the build hash into sw.js for cache busting.
|
||||||
*
|
*
|
||||||
* Problem: CACHE_NAME = 'automaker-v3' is hardcoded in the service worker.
|
* Problem: CACHE_NAME is hardcoded in the service worker.
|
||||||
* After a deployment, users may continue getting stale HTML from the SW cache
|
* After a deployment, users may continue getting stale HTML from the SW cache
|
||||||
* if someone forgets to manually bump the version.
|
* if someone forgets to manually bump the version.
|
||||||
*
|
*
|
||||||
@@ -44,7 +44,10 @@ const buildHash = getBuildHash();
|
|||||||
* SW cache is automatically invalidated on each deployment.
|
* SW cache is automatically invalidated on each deployment.
|
||||||
*/
|
*/
|
||||||
function swCacheBuster(): Plugin {
|
function swCacheBuster(): Plugin {
|
||||||
const CACHE_NAME_PATTERN = /const CACHE_NAME = 'automaker-v3';/;
|
// Single constant for the cache name prefix — bump this when changing the SW cache version.
|
||||||
|
const CACHE_NAME_BASE = 'automaker-v5';
|
||||||
|
const CACHE_NAME_PATTERN = new RegExp(`const CACHE_NAME = '${CACHE_NAME_BASE}';`);
|
||||||
|
const CRITICAL_ASSETS_PATTERN = /const CRITICAL_ASSETS = \[\];/;
|
||||||
return {
|
return {
|
||||||
name: 'sw-cache-buster',
|
name: 'sw-cache-buster',
|
||||||
// In build mode: copy sw.js to output with hash injected
|
// In build mode: copy sw.js to output with hash injected
|
||||||
@@ -56,21 +59,65 @@ function swCacheBuster(): Plugin {
|
|||||||
console.warn('[sw-cache-buster] sw.js not found in dist/ — skipping cache bust');
|
console.warn('[sw-cache-buster] sw.js not found in dist/ — skipping cache bust');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const swContent = fs.readFileSync(swPath, 'utf-8');
|
let swContent = fs.readFileSync(swPath, 'utf-8');
|
||||||
if (!CACHE_NAME_PATTERN.test(swContent)) {
|
if (!CACHE_NAME_PATTERN.test(swContent)) {
|
||||||
console.error(
|
console.error(
|
||||||
'[sw-cache-buster] Could not find CACHE_NAME declaration in sw.js. ' +
|
'[sw-cache-buster] Could not find CACHE_NAME declaration in sw.js. ' +
|
||||||
'The service worker cache will NOT be busted on this deploy! ' +
|
'The service worker cache will NOT be busted on this deploy! ' +
|
||||||
"Check that public/sw.js still contains: const CACHE_NAME = 'automaker-v3';"
|
`Check that public/sw.js still contains: const CACHE_NAME = '${CACHE_NAME_BASE}';`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const updated = swContent.replace(
|
swContent = swContent.replace(
|
||||||
CACHE_NAME_PATTERN,
|
CACHE_NAME_PATTERN,
|
||||||
`const CACHE_NAME = 'automaker-v3-${buildHash}';`
|
`const CACHE_NAME = '${CACHE_NAME_BASE}-${buildHash}';`
|
||||||
);
|
);
|
||||||
fs.writeFileSync(swPath, updated, 'utf-8');
|
console.log(`[sw-cache-buster] Injected build hash: ${CACHE_NAME_BASE}-${buildHash}`);
|
||||||
console.log(`[sw-cache-buster] Injected build hash: automaker-v3-${buildHash}`);
|
|
||||||
|
// Extract critical asset URLs from the built index.html and inject them
|
||||||
|
// into the SW so it can precache them on install (not just after the main
|
||||||
|
// thread sends PRECACHE_ASSETS). This ensures the very first visit populates
|
||||||
|
// the immutable cache, so PWA cold starts after memory eviction serve from cache.
|
||||||
|
const indexHtmlPath = path.resolve(__dirname, 'dist', 'index.html');
|
||||||
|
if (fs.existsSync(indexHtmlPath)) {
|
||||||
|
if (!CRITICAL_ASSETS_PATTERN.test(swContent)) {
|
||||||
|
console.warn(
|
||||||
|
'[sw-cache-buster] CRITICAL_ASSETS placeholder not found in sw.js — ' +
|
||||||
|
'precaching of critical assets was not injected. ' +
|
||||||
|
'Check that public/sw.js still contains: const CRITICAL_ASSETS = [];'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const indexHtml = fs.readFileSync(indexHtmlPath, 'utf-8');
|
||||||
|
// Use a Set to deduplicate — assetRegex may match the same path in both href and src.
|
||||||
|
const criticalAssetsSet = new Set<string>();
|
||||||
|
|
||||||
|
// Extract hashed asset URLs from all link and script tags.
|
||||||
|
// These are the JS/CSS bundles Vite produces with content hashes.
|
||||||
|
// Match: href="./assets/..." or src="./assets/..."
|
||||||
|
const assetRegex = /(?:href|src)="(\.\/(assets\/[^"]+))"/g;
|
||||||
|
let match;
|
||||||
|
while ((match = assetRegex.exec(indexHtml)) !== null) {
|
||||||
|
const assetPath = '/' + match[2]; // Convert ./assets/... to /assets/...
|
||||||
|
// Only include JS and CSS — skip images, fonts, etc. to keep cache small
|
||||||
|
if (assetPath.endsWith('.js') || assetPath.endsWith('.css')) {
|
||||||
|
criticalAssetsSet.add(assetPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const criticalAssets = Array.from(criticalAssetsSet);
|
||||||
|
if (criticalAssets.length > 0) {
|
||||||
|
swContent = swContent.replace(
|
||||||
|
CRITICAL_ASSETS_PATTERN,
|
||||||
|
`const CRITICAL_ASSETS = ${JSON.stringify(criticalAssets)};`
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`[sw-cache-buster] Injected ${criticalAssets.length} critical assets for install-time precaching`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(swPath, swContent, 'utf-8');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -97,11 +144,16 @@ function mobilePreloadOptimizer(): Plugin {
|
|||||||
// - vendor-xterm: /terminal route only
|
// - vendor-xterm: /terminal route only
|
||||||
// - vendor-codemirror: spec/XML editor routes only
|
// - vendor-codemirror: spec/XML editor routes only
|
||||||
// - vendor-markdown: agent view, wiki, and other markdown-rendering routes
|
// - vendor-markdown: agent view, wiki, and other markdown-rendering routes
|
||||||
|
// - vendor-icons: lucide-react icons (587 KB) — not needed before React mounts.
|
||||||
|
// The !authChecked loading state uses a pure CSS spinner instead of a Lucide icon,
|
||||||
|
// so icons are not required until the authenticated UI renders (by which time this
|
||||||
|
// prefetch has usually completed on typical connections).
|
||||||
const deferredChunks = [
|
const deferredChunks = [
|
||||||
'vendor-reactflow',
|
'vendor-reactflow',
|
||||||
'vendor-xterm',
|
'vendor-xterm',
|
||||||
'vendor-codemirror',
|
'vendor-codemirror',
|
||||||
'vendor-markdown',
|
'vendor-markdown',
|
||||||
|
'vendor-icons',
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -193,11 +245,11 @@ export default defineConfig(({ command }) => {
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
host: process.env.HOST || '0.0.0.0',
|
host: process.env.HOST || '0.0.0.0',
|
||||||
port: parseInt(process.env.TEST_PORT || '3007', 10),
|
port: parseInt(process.env.TEST_PORT || process.env.AUTOMAKER_WEB_PORT || '3007', 10),
|
||||||
allowedHosts: true,
|
allowedHosts: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3008',
|
target: 'http://localhost:' + (process.env.AUTOMAKER_SERVER_PORT ?? '5008'),
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
ws: true,
|
ws: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1444,6 +1444,21 @@ export interface ProjectSettings {
|
|||||||
*/
|
*/
|
||||||
defaultFeatureModel?: PhaseModelEntry;
|
defaultFeatureModel?: PhaseModelEntry;
|
||||||
|
|
||||||
|
// Terminal Quick Scripts (per-project)
|
||||||
|
/**
|
||||||
|
* Quick-access terminal scripts shown in the terminal header dropdown.
|
||||||
|
* Each script is a command that can be run with one click.
|
||||||
|
* Examples: "npm run dev", "npm run test", "npm run lint", "npm run format"
|
||||||
|
*/
|
||||||
|
terminalScripts?: Array<{
|
||||||
|
/** Unique identifier for this script */
|
||||||
|
id: string;
|
||||||
|
/** Display name shown in the dropdown menu */
|
||||||
|
name: string;
|
||||||
|
/** The command to execute in the terminal */
|
||||||
|
command: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
// Terminal Configuration Override (per-project)
|
// Terminal Configuration Override (per-project)
|
||||||
/** Project-specific terminal config overrides */
|
/** Project-specific terminal config overrides */
|
||||||
terminalConfig?: {
|
terminalConfig?: {
|
||||||
@@ -1514,8 +1529,8 @@ export const DEFAULT_PHASE_MODELS: PhaseModelConfig = {
|
|||||||
export const SETTINGS_VERSION = 6;
|
export const SETTINGS_VERSION = 6;
|
||||||
/** Current version of the credentials schema */
|
/** Current version of the credentials schema */
|
||||||
export const CREDENTIALS_VERSION = 1;
|
export const CREDENTIALS_VERSION = 1;
|
||||||
/** Current version of the project settings schema */
|
/** Current version of the project settings schema (bumped for terminalScripts field) */
|
||||||
export const PROJECT_SETTINGS_VERSION = 1;
|
export const PROJECT_SETTINGS_VERSION = 2;
|
||||||
|
|
||||||
/** Default maximum concurrent agents for auto mode */
|
/** Default maximum concurrent agents for auto mode */
|
||||||
export const DEFAULT_MAX_CONCURRENCY = 1;
|
export const DEFAULT_MAX_CONCURRENCY = 1;
|
||||||
|
|||||||
@@ -41,3 +41,33 @@ export function isValidBranchName(name: string): boolean {
|
|||||||
// Must be within the length limit.
|
// Must be within the length limit.
|
||||||
return /^[a-zA-Z0-9._/][a-zA-Z0-9._\-/]*$/.test(name) && name.length < MAX_BRANCH_NAME_LENGTH;
|
return /^[a-zA-Z0-9._/][a-zA-Z0-9._\-/]*$/.test(name) && name.length < MAX_BRANCH_NAME_LENGTH;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*
|
||||||
|
* @param name - The remote name to validate
|
||||||
|
* @returns `true` when the name is safe to pass to git commands
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* isValidRemoteName('origin'); // true
|
||||||
|
* isValidRemoteName('upstream'); // true
|
||||||
|
* isValidRemoteName('-flag'); // false (starts with dash)
|
||||||
|
* isValidRemoteName('a/b'); // false (contains slash)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function isValidRemoteName(name: string): boolean {
|
||||||
|
if (!name || 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -119,4 +119,4 @@ export {
|
|||||||
} from './debounce.js';
|
} from './debounce.js';
|
||||||
|
|
||||||
// Git validation utilities
|
// Git validation utilities
|
||||||
export { isValidBranchName, MAX_BRANCH_NAME_LENGTH } from './git-validation.js';
|
export { isValidBranchName, isValidRemoteName, MAX_BRANCH_NAME_LENGTH } from './git-validation.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user