mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
Comprehensive set of mobile and all improvements phase 1
This commit is contained in:
@@ -1,27 +1,47 @@
|
|||||||
/**
|
/**
|
||||||
* POST /discard-changes endpoint - Discard all uncommitted changes in a worktree
|
* POST /discard-changes endpoint - Discard uncommitted changes in a worktree
|
||||||
*
|
*
|
||||||
* This performs a destructive operation that:
|
* Supports two modes:
|
||||||
* 1. Resets staged changes (git reset HEAD)
|
* 1. Discard ALL changes (when no files array is provided)
|
||||||
* 2. Discards modified tracked files (git checkout .)
|
* - Resets staged changes (git reset HEAD)
|
||||||
* 3. Removes untracked files and directories (git clean -fd)
|
* - Discards modified tracked files (git checkout .)
|
||||||
|
* - Removes untracked files and directories (git clean -fd)
|
||||||
|
*
|
||||||
|
* 2. Discard SELECTED files (when files array is provided)
|
||||||
|
* - Unstages selected staged files (git reset HEAD -- <files>)
|
||||||
|
* - Reverts selected tracked file changes (git checkout -- <files>)
|
||||||
|
* - Removes selected untracked files (git clean -fd -- <files>)
|
||||||
*
|
*
|
||||||
* Note: Git repository validation (isGitRepo) is handled by
|
* Note: Git repository validation (isGitRepo) is handled by
|
||||||
* the requireGitRepoOnly middleware in index.ts
|
* the requireGitRepoOnly middleware in index.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { exec } from 'child_process';
|
import { execFile } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
|
import * as path from 'path';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a file path does not escape the worktree directory.
|
||||||
|
* Prevents path traversal attacks (e.g., ../../etc/passwd).
|
||||||
|
*/
|
||||||
|
function validateFilePath(filePath: string, worktreePath: string): boolean {
|
||||||
|
// Resolve the full path relative to the worktree
|
||||||
|
const resolved = path.resolve(worktreePath, filePath);
|
||||||
|
const normalizedWorktree = path.resolve(worktreePath);
|
||||||
|
// Ensure the resolved path starts with the worktree path
|
||||||
|
return resolved.startsWith(normalizedWorktree + path.sep) || resolved === normalizedWorktree;
|
||||||
|
}
|
||||||
|
|
||||||
export function createDiscardChangesHandler() {
|
export function createDiscardChangesHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { worktreePath } = req.body as {
|
const { worktreePath, files } = req.body as {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
|
files?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!worktreePath) {
|
if (!worktreePath) {
|
||||||
@@ -33,7 +53,7 @@ export function createDiscardChangesHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for uncommitted changes first
|
// Check for uncommitted changes first
|
||||||
const { stdout: status } = await execAsync('git status --porcelain', {
|
const { stdout: status } = await execFileAsync('git', ['status', '--porcelain'], {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,61 +68,197 @@ export function createDiscardChangesHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count the files that will be affected
|
|
||||||
const lines = status.trim().split('\n').filter(Boolean);
|
|
||||||
const fileCount = lines.length;
|
|
||||||
|
|
||||||
// Get branch name before discarding
|
// Get branch name before discarding
|
||||||
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
const { stdout: branchOutput } = await execFileAsync(
|
||||||
cwd: worktreePath,
|
'git',
|
||||||
});
|
['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||||
|
{
|
||||||
|
cwd: worktreePath,
|
||||||
|
}
|
||||||
|
);
|
||||||
const branchName = branchOutput.trim();
|
const branchName = branchOutput.trim();
|
||||||
|
|
||||||
// Discard all changes:
|
// Parse the status output to categorize files
|
||||||
// 1. Reset any staged changes
|
const statusLines = status.trim().split('\n').filter(Boolean);
|
||||||
await execAsync('git reset HEAD', { cwd: worktreePath }).catch(() => {
|
const allFiles = statusLines.map((line) => {
|
||||||
// Ignore errors - might fail if there's nothing staged
|
const fileStatus = line.substring(0, 2).trim();
|
||||||
|
const filePath = line.substring(3).trim();
|
||||||
|
return { status: fileStatus, path: filePath };
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Discard changes in tracked files
|
// Determine which files to discard
|
||||||
await execAsync('git checkout .', { cwd: worktreePath }).catch(() => {
|
const isSelectiveDiscard = files && files.length > 0 && files.length < allFiles.length;
|
||||||
// Ignore errors - might fail if there are no tracked changes
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Remove untracked files and directories
|
if (isSelectiveDiscard) {
|
||||||
await execAsync('git clean -fd', { cwd: worktreePath }).catch(() => {
|
// Selective discard: only discard the specified files
|
||||||
// Ignore errors - might fail if there are no untracked files
|
const filesToDiscard = new Set(files);
|
||||||
});
|
|
||||||
|
|
||||||
// Verify all changes were discarded
|
// Validate all requested file paths stay within the worktree
|
||||||
const { stdout: finalStatus } = await execAsync('git status --porcelain', {
|
const invalidPaths = files.filter((f) => !validateFilePath(f, worktreePath));
|
||||||
cwd: worktreePath,
|
if (invalidPaths.length > 0) {
|
||||||
});
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Invalid file paths detected (path traversal): ${invalidPaths.join(', ')}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate files into categories for proper git operations
|
||||||
|
const trackedModified: string[] = []; // Modified/deleted tracked files
|
||||||
|
const stagedFiles: string[] = []; // Files that are staged
|
||||||
|
const untrackedFiles: string[] = []; // Untracked files (?)
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
for (const file of allFiles) {
|
||||||
|
if (!filesToDiscard.has(file.path)) continue;
|
||||||
|
|
||||||
|
if (file.status === '?') {
|
||||||
|
untrackedFiles.push(file.path);
|
||||||
|
} else {
|
||||||
|
// Check if the file has staged changes (first character of status)
|
||||||
|
const indexStatus = statusLines
|
||||||
|
.find((l) => l.substring(3).trim() === file.path)
|
||||||
|
?.charAt(0);
|
||||||
|
if (indexStatus && indexStatus !== ' ' && indexStatus !== '?') {
|
||||||
|
stagedFiles.push(file.path);
|
||||||
|
}
|
||||||
|
// Check for working tree changes (tracked files)
|
||||||
|
if (file.status === 'M' || file.status === 'D' || file.status === 'A') {
|
||||||
|
trackedModified.push(file.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Unstage selected staged files (using execFile to bypass shell)
|
||||||
|
if (stagedFiles.length > 0) {
|
||||||
|
try {
|
||||||
|
await execFileAsync('git', ['reset', 'HEAD', '--', ...stagedFiles], {
|
||||||
|
cwd: worktreePath,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const msg = getErrorMessage(error);
|
||||||
|
logError(error, `Failed to unstage files: ${msg}`);
|
||||||
|
warnings.push(`Failed to unstage some files: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Revert selected tracked file changes
|
||||||
|
if (trackedModified.length > 0) {
|
||||||
|
try {
|
||||||
|
await execFileAsync('git', ['checkout', '--', ...trackedModified], {
|
||||||
|
cwd: worktreePath,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const msg = getErrorMessage(error);
|
||||||
|
logError(error, `Failed to revert tracked files: ${msg}`);
|
||||||
|
warnings.push(`Failed to revert some tracked files: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Remove selected untracked files
|
||||||
|
if (untrackedFiles.length > 0) {
|
||||||
|
try {
|
||||||
|
await execFileAsync('git', ['clean', '-fd', '--', ...untrackedFiles], {
|
||||||
|
cwd: worktreePath,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const msg = getErrorMessage(error);
|
||||||
|
logError(error, `Failed to clean untracked files: ${msg}`);
|
||||||
|
warnings.push(`Failed to remove some untracked files: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileCount = files.length;
|
||||||
|
|
||||||
|
// Verify the remaining state
|
||||||
|
const { stdout: finalStatus } = await execFileAsync('git', ['status', '--porcelain'], {
|
||||||
|
cwd: worktreePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const remainingCount = finalStatus.trim()
|
||||||
|
? finalStatus.trim().split('\n').filter(Boolean).length
|
||||||
|
: 0;
|
||||||
|
const actualDiscarded = allFiles.length - remainingCount;
|
||||||
|
|
||||||
|
let message =
|
||||||
|
actualDiscarded < fileCount
|
||||||
|
? `Discarded ${actualDiscarded} of ${fileCount} selected files, ${remainingCount} files remaining`
|
||||||
|
: `Discarded ${actualDiscarded} ${actualDiscarded === 1 ? 'file' : 'files'}`;
|
||||||
|
|
||||||
if (finalStatus.trim()) {
|
|
||||||
// Some changes couldn't be discarded (possibly ignored files or permission issues)
|
|
||||||
const remainingCount = finalStatus.trim().split('\n').filter(Boolean).length;
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
discarded: true,
|
discarded: true,
|
||||||
filesDiscarded: fileCount - remainingCount,
|
filesDiscarded: actualDiscarded,
|
||||||
filesRemaining: remainingCount,
|
filesRemaining: remainingCount,
|
||||||
branch: branchName,
|
branch: branchName,
|
||||||
message: `Discarded ${fileCount - remainingCount} files, ${remainingCount} files could not be removed`,
|
message,
|
||||||
|
...(warnings.length > 0 && { warnings }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
res.json({
|
// Discard ALL changes (original behavior)
|
||||||
success: true,
|
const fileCount = allFiles.length;
|
||||||
result: {
|
const warnings: string[] = [];
|
||||||
discarded: true,
|
|
||||||
filesDiscarded: fileCount,
|
// 1. Reset any staged changes
|
||||||
filesRemaining: 0,
|
try {
|
||||||
branch: branchName,
|
await execFileAsync('git', ['reset', 'HEAD'], { cwd: worktreePath });
|
||||||
message: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`,
|
} catch (error) {
|
||||||
},
|
const msg = getErrorMessage(error);
|
||||||
|
logError(error, `git reset HEAD failed: ${msg}`);
|
||||||
|
warnings.push(`Failed to unstage changes: ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Discard changes in tracked files
|
||||||
|
try {
|
||||||
|
await execFileAsync('git', ['checkout', '.'], { cwd: worktreePath });
|
||||||
|
} catch (error) {
|
||||||
|
const msg = getErrorMessage(error);
|
||||||
|
logError(error, `git checkout . failed: ${msg}`);
|
||||||
|
warnings.push(`Failed to revert tracked changes: ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Remove untracked files and directories
|
||||||
|
try {
|
||||||
|
await execFileAsync('git', ['clean', '-fd'], { cwd: worktreePath });
|
||||||
|
} catch (error) {
|
||||||
|
const msg = getErrorMessage(error);
|
||||||
|
logError(error, `git clean -fd failed: ${msg}`);
|
||||||
|
warnings.push(`Failed to remove untracked files: ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all changes were discarded
|
||||||
|
const { stdout: finalStatus } = await execFileAsync('git', ['status', '--porcelain'], {
|
||||||
|
cwd: worktreePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (finalStatus.trim()) {
|
||||||
|
const remainingCount = finalStatus.trim().split('\n').filter(Boolean).length;
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
discarded: true,
|
||||||
|
filesDiscarded: fileCount - remainingCount,
|
||||||
|
filesRemaining: remainingCount,
|
||||||
|
branch: branchName,
|
||||||
|
message: `Discarded ${fileCount - remainingCount} files, ${remainingCount} files could not be removed`,
|
||||||
|
...(warnings.length > 0 && { warnings }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
discarded: true,
|
||||||
|
filesDiscarded: fileCount,
|
||||||
|
filesRemaining: 0,
|
||||||
|
branch: branchName,
|
||||||
|
message: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`,
|
||||||
|
...(warnings.length > 0 && { warnings }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Discard changes failed');
|
logError(error, 'Discard changes failed');
|
||||||
|
|||||||
@@ -44,15 +44,30 @@ describe('switch-branch route', () => {
|
|||||||
if (command === 'git rev-parse --abbrev-ref HEAD') {
|
if (command === 'git rev-parse --abbrev-ref HEAD') {
|
||||||
return { stdout: 'main\n', stderr: '' };
|
return { stdout: 'main\n', stderr: '' };
|
||||||
}
|
}
|
||||||
if (command === 'git rev-parse --verify feature/test') {
|
if (command === 'git rev-parse --verify "feature/test"') {
|
||||||
return { stdout: 'abc123\n', stderr: '' };
|
return { stdout: 'abc123\n', stderr: '' };
|
||||||
}
|
}
|
||||||
|
if (command === 'git branch -r --format="%(refname:short)"') {
|
||||||
|
return { stdout: '', stderr: '' };
|
||||||
|
}
|
||||||
if (command === 'git status --porcelain') {
|
if (command === 'git status --porcelain') {
|
||||||
return { stdout: '?? .automaker/\n?? notes.txt\n', stderr: '' };
|
return { stdout: '?? .automaker/\n?? notes.txt\n', stderr: '' };
|
||||||
}
|
}
|
||||||
if (command === 'git checkout "feature/test"') {
|
if (command === 'git checkout "feature/test"') {
|
||||||
return { stdout: '', stderr: '' };
|
return { stdout: '', stderr: '' };
|
||||||
}
|
}
|
||||||
|
if (command === 'git fetch --all --quiet') {
|
||||||
|
return { stdout: '', stderr: '' };
|
||||||
|
}
|
||||||
|
if (command === 'git stash list') {
|
||||||
|
return { stdout: '', stderr: '' };
|
||||||
|
}
|
||||||
|
if (command.startsWith('git stash push')) {
|
||||||
|
return { stdout: '', stderr: '' };
|
||||||
|
}
|
||||||
|
if (command === 'git stash pop') {
|
||||||
|
return { stdout: '', stderr: '' };
|
||||||
|
}
|
||||||
return { stdout: '', stderr: '' };
|
return { stdout: '', stderr: '' };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,12 +80,14 @@ describe('switch-branch route', () => {
|
|||||||
previousBranch: 'main',
|
previousBranch: 'main',
|
||||||
currentBranch: 'feature/test',
|
currentBranch: 'feature/test',
|
||||||
message: "Switched to branch 'feature/test'",
|
message: "Switched to branch 'feature/test'",
|
||||||
|
hasConflicts: false,
|
||||||
|
stashedChanges: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(mockExec).toHaveBeenCalledWith('git checkout "feature/test"', { cwd: '/repo/path' });
|
expect(mockExec).toHaveBeenCalledWith('git checkout "feature/test"', { cwd: '/repo/path' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should block switching when tracked files are modified', async () => {
|
it('should stash changes and switch when tracked files are modified', async () => {
|
||||||
req.body = {
|
req.body = {
|
||||||
worktreePath: '/repo/path',
|
worktreePath: '/repo/path',
|
||||||
branchName: 'feature/test',
|
branchName: 'feature/test',
|
||||||
@@ -80,14 +97,34 @@ describe('switch-branch route', () => {
|
|||||||
if (command === 'git rev-parse --abbrev-ref HEAD') {
|
if (command === 'git rev-parse --abbrev-ref HEAD') {
|
||||||
return { stdout: 'main\n', stderr: '' };
|
return { stdout: 'main\n', stderr: '' };
|
||||||
}
|
}
|
||||||
if (command === 'git rev-parse --verify feature/test') {
|
if (command === 'git rev-parse --verify "feature/test"') {
|
||||||
return { stdout: 'abc123\n', stderr: '' };
|
return { stdout: 'abc123\n', stderr: '' };
|
||||||
}
|
}
|
||||||
if (command === 'git status --porcelain') {
|
if (command === 'git status --porcelain') {
|
||||||
return { stdout: ' M src/index.ts\n?? notes.txt\n', stderr: '' };
|
return { stdout: ' M src/index.ts\n?? notes.txt\n', stderr: '' };
|
||||||
}
|
}
|
||||||
if (command === 'git status --short') {
|
if (command === 'git branch -r --format="%(refname:short)"') {
|
||||||
return { stdout: ' M src/index.ts\n?? notes.txt\n', stderr: '' };
|
return { stdout: '', stderr: '' };
|
||||||
|
}
|
||||||
|
if (command === 'git stash list') {
|
||||||
|
// Return different counts before and after stash to indicate stash was created
|
||||||
|
if (!mockExec._stashCalled) {
|
||||||
|
mockExec._stashCalled = true;
|
||||||
|
return { stdout: '', stderr: '' };
|
||||||
|
}
|
||||||
|
return { stdout: 'stash@{0}: automaker-branch-switch\n', stderr: '' };
|
||||||
|
}
|
||||||
|
if (command.startsWith('git stash push')) {
|
||||||
|
return { stdout: '', stderr: '' };
|
||||||
|
}
|
||||||
|
if (command === 'git checkout "feature/test"') {
|
||||||
|
return { stdout: '', stderr: '' };
|
||||||
|
}
|
||||||
|
if (command === 'git fetch --all --quiet') {
|
||||||
|
return { stdout: '', stderr: '' };
|
||||||
|
}
|
||||||
|
if (command === 'git stash pop') {
|
||||||
|
return { stdout: 'Already applied.\n', stderr: '' };
|
||||||
}
|
}
|
||||||
return { stdout: '', stderr: '' };
|
return { stdout: '', stderr: '' };
|
||||||
});
|
});
|
||||||
@@ -95,12 +132,15 @@ describe('switch-branch route', () => {
|
|||||||
const handler = createSwitchBranchHandler();
|
const handler = createSwitchBranchHandler();
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(400);
|
|
||||||
expect(res.json).toHaveBeenCalledWith({
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
success: false,
|
success: true,
|
||||||
error:
|
result: {
|
||||||
'Cannot switch branches: you have uncommitted changes (M src/index.ts). Please commit your changes first.',
|
previousBranch: 'main',
|
||||||
code: 'UNCOMMITTED_CHANGES',
|
currentBranch: 'feature/test',
|
||||||
|
message: "Switched to branch 'feature/test' (local changes stashed and reapplied)",
|
||||||
|
hasConflicts: false,
|
||||||
|
stashedChanges: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const eslintConfig = defineConfig([
|
|||||||
getComputedStyle: 'readonly',
|
getComputedStyle: 'readonly',
|
||||||
requestAnimationFrame: 'readonly',
|
requestAnimationFrame: 'readonly',
|
||||||
cancelAnimationFrame: 'readonly',
|
cancelAnimationFrame: 'readonly',
|
||||||
|
requestIdleCallback: 'readonly',
|
||||||
alert: 'readonly',
|
alert: 'readonly',
|
||||||
// DOM Element Types
|
// DOM Element Types
|
||||||
HTMLElement: 'readonly',
|
HTMLElement: 'readonly',
|
||||||
@@ -62,6 +63,8 @@ const eslintConfig = defineConfig([
|
|||||||
HTMLHeadingElement: 'readonly',
|
HTMLHeadingElement: 'readonly',
|
||||||
HTMLParagraphElement: 'readonly',
|
HTMLParagraphElement: 'readonly',
|
||||||
HTMLImageElement: 'readonly',
|
HTMLImageElement: 'readonly',
|
||||||
|
HTMLLinkElement: 'readonly',
|
||||||
|
HTMLScriptElement: 'readonly',
|
||||||
Element: 'readonly',
|
Element: 'readonly',
|
||||||
SVGElement: 'readonly',
|
SVGElement: 'readonly',
|
||||||
SVGSVGElement: 'readonly',
|
SVGSVGElement: 'readonly',
|
||||||
@@ -91,6 +94,7 @@ const eslintConfig = defineConfig([
|
|||||||
Response: 'readonly',
|
Response: 'readonly',
|
||||||
RequestInit: 'readonly',
|
RequestInit: 'readonly',
|
||||||
RequestCache: 'readonly',
|
RequestCache: 'readonly',
|
||||||
|
ServiceWorkerRegistration: 'readonly',
|
||||||
// Timers
|
// Timers
|
||||||
setTimeout: 'readonly',
|
setTimeout: 'readonly',
|
||||||
setInterval: 'readonly',
|
setInterval: 'readonly',
|
||||||
@@ -138,6 +142,25 @@ const eslintConfig = defineConfig([
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ['public/sw.js'],
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
// Service Worker globals
|
||||||
|
self: 'readonly',
|
||||||
|
caches: 'readonly',
|
||||||
|
fetch: 'readonly',
|
||||||
|
Headers: 'readonly',
|
||||||
|
Response: 'readonly',
|
||||||
|
URL: 'readonly',
|
||||||
|
setTimeout: 'readonly',
|
||||||
|
console: 'readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
globalIgnores([
|
globalIgnores([
|
||||||
'dist/**',
|
'dist/**',
|
||||||
'dist-electron/**',
|
'dist-electron/**',
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@radix-ui/react-tooltip": "1.2.8",
|
||||||
"@tanstack/react-query": "^5.90.17",
|
"@tanstack/react-query": "^5.90.17",
|
||||||
"@tanstack/react-query-devtools": "^5.91.2",
|
"@tanstack/react-query-devtools": "^5.91.2",
|
||||||
|
"@tanstack/react-query-persist-client": "^5.90.22",
|
||||||
"@tanstack/react-router": "1.141.6",
|
"@tanstack/react-router": "1.141.6",
|
||||||
"@uiw/react-codemirror": "4.25.4",
|
"@uiw/react-codemirror": "4.25.4",
|
||||||
"@xterm/addon-fit": "0.10.0",
|
"@xterm/addon-fit": "0.10.0",
|
||||||
@@ -96,6 +97,7 @@
|
|||||||
"dagre": "0.8.5",
|
"dagre": "0.8.5",
|
||||||
"dotenv": "17.2.3",
|
"dotenv": "17.2.3",
|
||||||
"geist": "1.5.1",
|
"geist": "1.5.1",
|
||||||
|
"idb-keyval": "^6.2.2",
|
||||||
"lucide-react": "0.562.0",
|
"lucide-react": "0.562.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
// Automaker Service Worker - Optimized for mobile PWA loading performance
|
// Automaker Service Worker - Optimized for mobile PWA loading performance
|
||||||
const CACHE_NAME = 'automaker-v3';
|
// 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
|
||||||
|
// builds it becomes e.g. 'automaker-v3-a1b2c3d4' for automatic cache invalidation.
|
||||||
|
const CACHE_NAME = 'automaker-v3'; // replaced at build time → 'automaker-v3-<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';
|
||||||
@@ -17,8 +20,47 @@ const SHELL_ASSETS = [
|
|||||||
'/favicon.ico',
|
'/favicon.ico',
|
||||||
];
|
];
|
||||||
|
|
||||||
// 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.
|
||||||
let mobileMode = false;
|
let mobileMode = false;
|
||||||
|
const MOBILE_MODE_CACHE_KEY = 'automaker-sw-config';
|
||||||
|
const MOBILE_MODE_URL = '/sw-config/mobile-mode';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist mobileMode to Cache Storage so it survives SW restarts.
|
||||||
|
* Service workers on mobile get killed aggressively — without persistence,
|
||||||
|
* mobileMode resets to false and API caching silently stops working.
|
||||||
|
*/
|
||||||
|
async function persistMobileMode(enabled) {
|
||||||
|
try {
|
||||||
|
const cache = await caches.open(MOBILE_MODE_CACHE_KEY);
|
||||||
|
const response = new Response(JSON.stringify({ mobileMode: enabled }), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
await cache.put(MOBILE_MODE_URL, response);
|
||||||
|
} catch (_e) {
|
||||||
|
// Best-effort persistence — SW still works without it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore mobileMode from Cache Storage on SW startup.
|
||||||
|
*/
|
||||||
|
async function restoreMobileMode() {
|
||||||
|
try {
|
||||||
|
const cache = await caches.open(MOBILE_MODE_CACHE_KEY);
|
||||||
|
const response = await cache.match(MOBILE_MODE_URL);
|
||||||
|
if (response) {
|
||||||
|
const data = await response.json();
|
||||||
|
mobileMode = !!data.mobileMode;
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
// Best-effort restore — defaults to false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore mobileMode immediately on SW startup
|
||||||
|
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
|
||||||
@@ -64,12 +106,13 @@ function isApiCacheFresh(response) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clone a response and add a timestamp header for cache freshness tracking
|
* Clone a response and add a timestamp header for cache freshness tracking.
|
||||||
|
* Uses arrayBuffer() instead of blob() to avoid doubling memory for large responses.
|
||||||
*/
|
*/
|
||||||
async function addCacheTimestamp(response) {
|
async function addCacheTimestamp(response) {
|
||||||
const headers = new Headers(response.headers);
|
const headers = new Headers(response.headers);
|
||||||
headers.set('x-sw-cached-at', String(Date.now()));
|
headers.set('x-sw-cached-at', String(Date.now()));
|
||||||
const body = await response.clone().blob();
|
const body = await response.clone().arrayBuffer();
|
||||||
return new Response(body, {
|
return new Response(body, {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
statusText: response.statusText,
|
statusText: response.statusText,
|
||||||
@@ -89,7 +132,7 @@ self.addEventListener('install', (event) => {
|
|||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
// Remove old caches (both regular and immutable)
|
// Remove old caches (both regular and immutable)
|
||||||
const validCaches = new Set([CACHE_NAME, IMMUTABLE_CACHE, API_CACHE]);
|
const validCaches = new Set([CACHE_NAME, IMMUTABLE_CACHE, API_CACHE, MOBILE_MODE_CACHE_KEY]);
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
Promise.all([
|
Promise.all([
|
||||||
// Clean old caches
|
// Clean old caches
|
||||||
@@ -116,7 +159,7 @@ 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>
|
||||||
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)
|
// Font files are immutable (woff2, woff, ttf, otf)
|
||||||
@@ -166,29 +209,36 @@ self.addEventListener('fetch', (event) => {
|
|||||||
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);
|
||||||
|
|
||||||
// Start network fetch in background regardless
|
// Helper: start a network fetch that updates the cache on success.
|
||||||
const fetchPromise = fetch(event.request)
|
// Lazily invoked so we don't fire a network request when the cache
|
||||||
.then(async (networkResponse) => {
|
// is already fresh — saves bandwidth and battery on mobile.
|
||||||
if (networkResponse.ok) {
|
const startNetworkFetch = () =>
|
||||||
// Store with timestamp for freshness checking
|
fetch(event.request)
|
||||||
const timestampedResponse = await addCacheTimestamp(networkResponse);
|
.then(async (networkResponse) => {
|
||||||
cache.put(event.request, timestampedResponse);
|
if (networkResponse.ok) {
|
||||||
}
|
// Store with timestamp for freshness checking
|
||||||
return networkResponse;
|
const timestampedResponse = await addCacheTimestamp(networkResponse);
|
||||||
})
|
cache.put(event.request, timestampedResponse);
|
||||||
.catch((err) => {
|
}
|
||||||
// Network failed - if we have cache, that's fine (returned below)
|
return networkResponse;
|
||||||
// If no cache, propagate the error
|
})
|
||||||
if (cachedResponse) return null;
|
.catch((err) => {
|
||||||
throw err;
|
// Network failed - if we have cache, that's fine (returned below)
|
||||||
});
|
// If no cache, propagate the error
|
||||||
|
if (cachedResponse) return null;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
// If we have a fresh-enough cached response, return it immediately
|
// If we have a fresh-enough cached response, return it immediately
|
||||||
|
// without firing a background fetch — React Query's own refetching
|
||||||
|
// will request fresh data when its stale time expires.
|
||||||
if (cachedResponse && isApiCacheFresh(cachedResponse)) {
|
if (cachedResponse && isApiCacheFresh(cachedResponse)) {
|
||||||
// Return cached data instantly - network update happens in background
|
|
||||||
return cachedResponse;
|
return cachedResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// From here the cache is either stale or missing — start the network fetch.
|
||||||
|
const fetchPromise = startNetworkFetch();
|
||||||
|
|
||||||
// If we have a stale cached response but network is slow, race them:
|
// If we have a stale cached response but network is slow, race them:
|
||||||
// Return whichever resolves first (cached immediately vs network)
|
// Return whichever resolves first (cached immediately vs network)
|
||||||
if (cachedResponse) {
|
if (cachedResponse) {
|
||||||
@@ -283,7 +333,7 @@ self.addEventListener('fetch', (event) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
// Offline: serve the cached app shell
|
// Offline: serve the cached app shell
|
||||||
const cached = await caches.match('/');
|
const cached = await caches.match('/');
|
||||||
return (
|
return (
|
||||||
@@ -344,8 +394,10 @@ self.addEventListener('message', (event) => {
|
|||||||
// 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.
|
||||||
|
// Persisted to Cache Storage so it survives SW restarts on mobile.
|
||||||
if (event.data?.type === 'SET_MOBILE_MODE') {
|
if (event.data?.type === 'SET_MOBILE_MODE') {
|
||||||
mobileMode = !!event.data.enabled;
|
mobileMode = !!event.data.enabled;
|
||||||
|
persistMobileMode(mobileMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warm the immutable cache with critical assets the app will need.
|
// Warm the immutable cache with critical assets the app will need.
|
||||||
|
|||||||
@@ -28,8 +28,13 @@ export default function App() {
|
|||||||
if (savedPreference === 'true') {
|
if (savedPreference === 'true') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Only show splash once per session
|
// Only show splash once per browser session.
|
||||||
if (sessionStorage.getItem('automaker-splash-shown')) {
|
// Uses localStorage (not sessionStorage) so tab restores after discard
|
||||||
|
// don't replay the splash — sessionStorage is cleared when a tab is discarded.
|
||||||
|
// The flag is written on splash complete and cleared when the tab is fully closed
|
||||||
|
// (via the 'pagehide' + persisted=false event, which fires on true tab close but
|
||||||
|
// not on discard/background). This gives "once per actual session" semantics.
|
||||||
|
if (localStorage.getItem('automaker-splash-shown-session')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -103,10 +108,25 @@ export default function App() {
|
|||||||
useMobileOnlineManager();
|
useMobileOnlineManager();
|
||||||
|
|
||||||
const handleSplashComplete = useCallback(() => {
|
const handleSplashComplete = useCallback(() => {
|
||||||
sessionStorage.setItem('automaker-splash-shown', 'true');
|
// Mark splash as shown for this session (survives tab discard/restore)
|
||||||
|
localStorage.setItem('automaker-splash-shown-session', 'true');
|
||||||
setShowSplash(false);
|
setShowSplash(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Clear the splash-shown flag when the tab is truly closed (not just discarded).
|
||||||
|
// `pagehide` with persisted=false fires on real navigation/close but NOT on discard,
|
||||||
|
// so discarded tabs that are restored skip the splash while true re-opens show it.
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePageHide = (e: PageTransitionEvent) => {
|
||||||
|
if (!e.persisted) {
|
||||||
|
// Tab is being closed or navigating away (not going into bfcache)
|
||||||
|
localStorage.removeItem('automaker-splash-shown-session');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('pagehide', handlePageHide);
|
||||||
|
return () => window.removeEventListener('pagehide', handlePageHide);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export function BoardHeader({
|
|||||||
}, [isRefreshingBoard, onRefreshBoard]);
|
}, [isRefreshingBoard, onRefreshBoard]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-5 p-4 border-b border-border bg-glass backdrop-blur-md">
|
<div className="flex items-center justify-between gap-5 px-4 py-2 sm:p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<BoardSearchBar
|
<BoardSearchBar
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ChevronDown, ChevronRight, Plus } from 'lucide-react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||||
|
import { useAppStore, formatShortcut } from '@/store/app-store';
|
||||||
import type { Feature } from '@/store/app-store';
|
import type { Feature } from '@/store/app-store';
|
||||||
import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types';
|
import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types';
|
||||||
import { ListHeader } from './list-header';
|
import { ListHeader } from './list-header';
|
||||||
@@ -134,7 +135,7 @@ const EmptyState = memo(function EmptyState({ onAddFeature }: { onAddFeature?: (
|
|||||||
>
|
>
|
||||||
<p className="text-sm mb-4">No features to display</p>
|
<p className="text-sm mb-4">No features to display</p>
|
||||||
{onAddFeature && (
|
{onAddFeature && (
|
||||||
<Button variant="outline" size="sm" onClick={onAddFeature}>
|
<Button variant="default" size="sm" onClick={onAddFeature}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Add Feature
|
Add Feature
|
||||||
</Button>
|
</Button>
|
||||||
@@ -197,6 +198,10 @@ export const ListView = memo(function ListView({
|
|||||||
// Track collapsed state for each status group
|
// Track collapsed state for each status group
|
||||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Get the keyboard shortcut for adding features
|
||||||
|
const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts);
|
||||||
|
const addFeatureShortcut = keyboardShortcuts.addFeature || 'N';
|
||||||
|
|
||||||
// Generate status groups from columnFeaturesMap
|
// Generate status groups from columnFeaturesMap
|
||||||
const statusGroups = useMemo<StatusGroup[]>(() => {
|
const statusGroups = useMemo<StatusGroup[]>(() => {
|
||||||
const columns = getColumnsWithPipeline(pipelineConfig);
|
const columns = getColumnsWithPipeline(pipelineConfig);
|
||||||
@@ -439,18 +444,21 @@ export const ListView = memo(function ListView({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer with Add Feature button */}
|
{/* Footer with Add Feature button, styled like board view */}
|
||||||
{onAddFeature && (
|
{onAddFeature && (
|
||||||
<div className="border-t border-border px-4 py-3">
|
<div className="border-t border-border px-4 py-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onAddFeature}
|
onClick={onAddFeature}
|
||||||
className="w-full sm:w-auto"
|
className="w-full h-9 text-sm"
|
||||||
data-testid="list-view-add-feature"
|
data-testid="list-view-add-feature"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Add Feature
|
Add Feature
|
||||||
|
<span className="ml-auto pl-2 text-[10px] font-mono opacity-70 bg-black/20 px-1.5 py-0.5 rounded">
|
||||||
|
{formatShortcut(addFeatureShortcut, true)}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,596 @@
|
|||||||
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Undo2,
|
||||||
|
FilePlus,
|
||||||
|
FileX,
|
||||||
|
FilePen,
|
||||||
|
FileText,
|
||||||
|
File,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
AlertTriangle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { FileStatus } from '@/types/electron';
|
||||||
|
|
||||||
|
interface WorktreeInfo {
|
||||||
|
path: string;
|
||||||
|
branch: string;
|
||||||
|
isMain: boolean;
|
||||||
|
hasChanges?: boolean;
|
||||||
|
changedFilesCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscardWorktreeChangesDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
worktree: WorktreeInfo | null;
|
||||||
|
onDiscarded: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedDiffHunk {
|
||||||
|
header: string;
|
||||||
|
lines: {
|
||||||
|
type: 'context' | 'addition' | 'deletion' | 'header';
|
||||||
|
content: string;
|
||||||
|
lineNumber?: { old?: number; new?: number };
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedFileDiff {
|
||||||
|
filePath: string;
|
||||||
|
hunks: ParsedDiffHunk[];
|
||||||
|
isNew?: boolean;
|
||||||
|
isDeleted?: boolean;
|
||||||
|
isRenamed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFileIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'A':
|
||||||
|
case '?':
|
||||||
|
return <FilePlus className="w-3.5 h-3.5 text-green-500 flex-shrink-0" />;
|
||||||
|
case 'D':
|
||||||
|
return <FileX className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />;
|
||||||
|
case 'M':
|
||||||
|
case 'U':
|
||||||
|
return <FilePen className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />;
|
||||||
|
case 'R':
|
||||||
|
case 'C':
|
||||||
|
return <File className="w-3.5 h-3.5 text-blue-500 flex-shrink-0" />;
|
||||||
|
default:
|
||||||
|
return <FileText className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'A':
|
||||||
|
return 'Added';
|
||||||
|
case '?':
|
||||||
|
return 'Untracked';
|
||||||
|
case 'D':
|
||||||
|
return 'Deleted';
|
||||||
|
case 'M':
|
||||||
|
return 'Modified';
|
||||||
|
case 'U':
|
||||||
|
return 'Updated';
|
||||||
|
case 'R':
|
||||||
|
return 'Renamed';
|
||||||
|
case 'C':
|
||||||
|
return 'Copied';
|
||||||
|
default:
|
||||||
|
return 'Changed';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadgeColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'A':
|
||||||
|
case '?':
|
||||||
|
return 'bg-green-500/20 text-green-400 border-green-500/30';
|
||||||
|
case 'D':
|
||||||
|
return 'bg-red-500/20 text-red-400 border-red-500/30';
|
||||||
|
case 'M':
|
||||||
|
case 'U':
|
||||||
|
return 'bg-amber-500/20 text-amber-400 border-amber-500/30';
|
||||||
|
case 'R':
|
||||||
|
case 'C':
|
||||||
|
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
|
||||||
|
default:
|
||||||
|
return 'bg-muted text-muted-foreground border-border';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse unified diff format into structured data
|
||||||
|
*/
|
||||||
|
function parseDiff(diffText: string): ParsedFileDiff[] {
|
||||||
|
if (!diffText) return [];
|
||||||
|
|
||||||
|
const files: ParsedFileDiff[] = [];
|
||||||
|
const lines = diffText.split('\n');
|
||||||
|
let currentFile: ParsedFileDiff | null = null;
|
||||||
|
let currentHunk: ParsedDiffHunk | null = null;
|
||||||
|
let oldLineNum = 0;
|
||||||
|
let newLineNum = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
|
||||||
|
if (line.startsWith('diff --git')) {
|
||||||
|
if (currentFile) {
|
||||||
|
if (currentHunk) currentFile.hunks.push(currentHunk);
|
||||||
|
files.push(currentFile);
|
||||||
|
}
|
||||||
|
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
|
||||||
|
currentFile = {
|
||||||
|
filePath: match ? match[2] : 'unknown',
|
||||||
|
hunks: [],
|
||||||
|
};
|
||||||
|
currentHunk = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('new file mode')) {
|
||||||
|
if (currentFile) currentFile.isNew = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('deleted file mode')) {
|
||||||
|
if (currentFile) currentFile.isDeleted = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('rename from') || line.startsWith('rename to')) {
|
||||||
|
if (currentFile) currentFile.isRenamed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('@@')) {
|
||||||
|
if (currentHunk && currentFile) currentFile.hunks.push(currentHunk);
|
||||||
|
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||||
|
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
|
||||||
|
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
|
||||||
|
currentHunk = {
|
||||||
|
header: line,
|
||||||
|
lines: [{ type: 'header', content: line }],
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentHunk) {
|
||||||
|
if (line.startsWith('+')) {
|
||||||
|
currentHunk.lines.push({
|
||||||
|
type: 'addition',
|
||||||
|
content: line.substring(1),
|
||||||
|
lineNumber: { new: newLineNum },
|
||||||
|
});
|
||||||
|
newLineNum++;
|
||||||
|
} else if (line.startsWith('-')) {
|
||||||
|
currentHunk.lines.push({
|
||||||
|
type: 'deletion',
|
||||||
|
content: line.substring(1),
|
||||||
|
lineNumber: { old: oldLineNum },
|
||||||
|
});
|
||||||
|
oldLineNum++;
|
||||||
|
} else if (line.startsWith(' ') || line === '') {
|
||||||
|
currentHunk.lines.push({
|
||||||
|
type: 'context',
|
||||||
|
content: line.substring(1) || '',
|
||||||
|
lineNumber: { old: oldLineNum, new: newLineNum },
|
||||||
|
});
|
||||||
|
oldLineNum++;
|
||||||
|
newLineNum++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentFile) {
|
||||||
|
if (currentHunk) currentFile.hunks.push(currentHunk);
|
||||||
|
files.push(currentFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffLine({
|
||||||
|
type,
|
||||||
|
content,
|
||||||
|
lineNumber,
|
||||||
|
}: {
|
||||||
|
type: 'context' | 'addition' | 'deletion' | 'header';
|
||||||
|
content: string;
|
||||||
|
lineNumber?: { old?: number; new?: number };
|
||||||
|
}) {
|
||||||
|
const bgClass = {
|
||||||
|
context: 'bg-transparent',
|
||||||
|
addition: 'bg-green-500/10',
|
||||||
|
deletion: 'bg-red-500/10',
|
||||||
|
header: 'bg-blue-500/10',
|
||||||
|
};
|
||||||
|
|
||||||
|
const textClass = {
|
||||||
|
context: 'text-foreground-secondary',
|
||||||
|
addition: 'text-green-400',
|
||||||
|
deletion: 'text-red-400',
|
||||||
|
header: 'text-blue-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const prefix = {
|
||||||
|
context: ' ',
|
||||||
|
addition: '+',
|
||||||
|
deletion: '-',
|
||||||
|
header: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type === 'header') {
|
||||||
|
return (
|
||||||
|
<div className={cn('px-2 py-1 font-mono text-xs', bgClass[type], textClass[type])}>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex font-mono text-xs', bgClass[type])}>
|
||||||
|
<span className="w-10 flex-shrink-0 text-right pr-1.5 text-muted-foreground select-none border-r border-border-glass text-[10px]">
|
||||||
|
{lineNumber?.old ?? ''}
|
||||||
|
</span>
|
||||||
|
<span className="w-10 flex-shrink-0 text-right pr-1.5 text-muted-foreground select-none border-r border-border-glass text-[10px]">
|
||||||
|
{lineNumber?.new ?? ''}
|
||||||
|
</span>
|
||||||
|
<span className={cn('w-4 flex-shrink-0 text-center select-none', textClass[type])}>
|
||||||
|
{prefix[type]}
|
||||||
|
</span>
|
||||||
|
<span className={cn('flex-1 px-1.5 whitespace-pre-wrap break-all', textClass[type])}>
|
||||||
|
{content || '\u00A0'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiscardWorktreeChangesDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
worktree,
|
||||||
|
onDiscarded,
|
||||||
|
}: DiscardWorktreeChangesDialogProps) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// File selection state
|
||||||
|
const [files, setFiles] = useState<FileStatus[]>([]);
|
||||||
|
const [diffContent, setDiffContent] = useState('');
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||||
|
const [expandedFile, setExpandedFile] = useState<string | null>(null);
|
||||||
|
const [isLoadingDiffs, setIsLoadingDiffs] = useState(false);
|
||||||
|
|
||||||
|
// Parse diffs
|
||||||
|
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
|
||||||
|
|
||||||
|
// Create a map of file path to parsed diff for quick lookup
|
||||||
|
const diffsByFile = useMemo(() => {
|
||||||
|
const map = new Map<string, ParsedFileDiff>();
|
||||||
|
for (const diff of parsedDiffs) {
|
||||||
|
map.set(diff.filePath, diff);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [parsedDiffs]);
|
||||||
|
|
||||||
|
// Load diffs when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && worktree) {
|
||||||
|
setIsLoadingDiffs(true);
|
||||||
|
setFiles([]);
|
||||||
|
setDiffContent('');
|
||||||
|
setSelectedFiles(new Set());
|
||||||
|
setExpandedFile(null);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const loadDiffs = async () => {
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (api?.git?.getDiffs) {
|
||||||
|
const result = await api.git.getDiffs(worktree.path);
|
||||||
|
if (result.success) {
|
||||||
|
const fileList = result.files ?? [];
|
||||||
|
setFiles(fileList);
|
||||||
|
setDiffContent(result.diff ?? '');
|
||||||
|
// Select all files by default
|
||||||
|
setSelectedFiles(new Set(fileList.map((f) => f.path)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to load diffs for discard dialog:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingDiffs(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDiffs();
|
||||||
|
}
|
||||||
|
}, [open, worktree]);
|
||||||
|
|
||||||
|
const handleToggleFile = useCallback((filePath: string) => {
|
||||||
|
setSelectedFiles((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(filePath)) {
|
||||||
|
next.delete(filePath);
|
||||||
|
} else {
|
||||||
|
next.add(filePath);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleAll = useCallback(() => {
|
||||||
|
setSelectedFiles((prev) => {
|
||||||
|
if (prev.size === files.length) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
return new Set(files.map((f) => f.path));
|
||||||
|
});
|
||||||
|
}, [files]);
|
||||||
|
|
||||||
|
const handleFileClick = useCallback((filePath: string) => {
|
||||||
|
setExpandedFile((prev) => (prev === filePath ? null : filePath));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDiscard = async () => {
|
||||||
|
if (!worktree || selectedFiles.size === 0) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
|
||||||
|
// Pass selected files if not all files are selected
|
||||||
|
const filesToDiscard =
|
||||||
|
selectedFiles.size === files.length ? undefined : Array.from(selectedFiles);
|
||||||
|
|
||||||
|
const result = await api.worktree.discardChanges(worktree.path, filesToDiscard);
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
if (result.result.discarded) {
|
||||||
|
const fileCount = filesToDiscard ? filesToDiscard.length : result.result.filesDiscarded;
|
||||||
|
toast.success('Changes discarded', {
|
||||||
|
description: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'} in ${worktree.branch}`,
|
||||||
|
});
|
||||||
|
onDiscarded();
|
||||||
|
onOpenChange(false);
|
||||||
|
} else {
|
||||||
|
toast.info('No changes to discard', {
|
||||||
|
description: result.result.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(result.error || 'Failed to discard changes');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to discard changes');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!worktree) return null;
|
||||||
|
|
||||||
|
const allSelected = selectedFiles.size === files.length && files.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[700px] max-h-[85vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Undo2 className="w-5 h-5 text-destructive" />
|
||||||
|
Discard Changes
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Select which changes to discard in the{' '}
|
||||||
|
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> worktree.
|
||||||
|
This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 py-2 min-h-0 flex-1 overflow-hidden">
|
||||||
|
{/* File Selection */}
|
||||||
|
<div className="flex flex-col min-h-0">
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<Label className="text-sm font-medium flex items-center gap-2">
|
||||||
|
Files to discard
|
||||||
|
{isLoadingDiffs ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground font-normal">
|
||||||
|
({selectedFiles.size}/{files.length} selected)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
{files.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleToggleAll}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{allSelected ? 'Deselect all' : 'Select all'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoadingDiffs ? (
|
||||||
|
<div className="flex items-center justify-center py-6 text-muted-foreground border border-border rounded-lg">
|
||||||
|
<Spinner size="sm" className="mr-2" />
|
||||||
|
<span className="text-sm">Loading changes...</span>
|
||||||
|
</div>
|
||||||
|
) : files.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-6 text-muted-foreground border border-border rounded-lg">
|
||||||
|
<span className="text-sm">No changes detected</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border border-border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto scrollbar-visible">
|
||||||
|
{files.map((file) => {
|
||||||
|
const isChecked = selectedFiles.has(file.path);
|
||||||
|
const isExpanded = expandedFile === file.path;
|
||||||
|
const fileDiff = diffsByFile.get(file.path);
|
||||||
|
const additions = fileDiff
|
||||||
|
? fileDiff.hunks.reduce(
|
||||||
|
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'addition').length,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
const deletions = fileDiff
|
||||||
|
? fileDiff.hunks.reduce(
|
||||||
|
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'deletion').length,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={file.path} className="border-b border-border last:border-b-0">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-3 py-1.5 hover:bg-accent/50 transition-colors group',
|
||||||
|
isExpanded && 'bg-accent/30'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Checkbox */}
|
||||||
|
<Checkbox
|
||||||
|
checked={isChecked}
|
||||||
|
onCheckedChange={() => handleToggleFile(file.path)}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Clickable file row to show diff */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleFileClick(file.path)}
|
||||||
|
className="flex items-center gap-2 flex-1 min-w-0 text-left"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
{getFileIcon(file.status)}
|
||||||
|
<span className="text-xs font-mono truncate flex-1 text-foreground">
|
||||||
|
{file.path}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0',
|
||||||
|
getStatusBadgeColor(file.status)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getStatusLabel(file.status)}
|
||||||
|
</span>
|
||||||
|
{additions > 0 && (
|
||||||
|
<span className="text-[10px] text-green-400 flex-shrink-0">
|
||||||
|
+{additions}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{deletions > 0 && (
|
||||||
|
<span className="text-[10px] text-red-400 flex-shrink-0">
|
||||||
|
-{deletions}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded diff view */}
|
||||||
|
{isExpanded && fileDiff && (
|
||||||
|
<div className="bg-background border-t border-border max-h-[200px] overflow-y-auto scrollbar-visible">
|
||||||
|
{fileDiff.hunks.map((hunk, hunkIndex) => (
|
||||||
|
<div
|
||||||
|
key={hunkIndex}
|
||||||
|
className="border-b border-border-glass last:border-b-0"
|
||||||
|
>
|
||||||
|
{hunk.lines.map((line, lineIndex) => (
|
||||||
|
<DiffLine
|
||||||
|
key={lineIndex}
|
||||||
|
type={line.type}
|
||||||
|
content={line.content}
|
||||||
|
lineNumber={line.lineNumber}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isExpanded && !fileDiff && (
|
||||||
|
<div className="px-4 py-3 text-xs text-muted-foreground bg-background border-t border-border">
|
||||||
|
{file.status === '?' ? (
|
||||||
|
<span>New file - diff preview not available</span>
|
||||||
|
) : file.status === 'D' ? (
|
||||||
|
<span>File deleted</span>
|
||||||
|
) : (
|
||||||
|
<span>Diff content not available</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning message */}
|
||||||
|
<div className="flex items-start gap-2 p-3 rounded-lg bg-destructive/10 border border-destructive/20">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-destructive flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
This will permanently discard the selected changes. Staged changes will be unstaged,
|
||||||
|
modifications to tracked files will be reverted, and untracked files will be deleted.
|
||||||
|
This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDiscard}
|
||||||
|
disabled={isLoading || selectedFiles.size === 0}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Spinner size="sm" className="mr-2" />
|
||||||
|
Discarding...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Undo2 className="w-4 h-4 mr-2" />
|
||||||
|
Discard
|
||||||
|
{selectedFiles.size > 0 && selectedFiles.size < files.length
|
||||||
|
? ` (${selectedFiles.size} file${selectedFiles.size > 1 ? 's' : ''})`
|
||||||
|
: selectedFiles.size > 0
|
||||||
|
? ` All (${selectedFiles.size} file${selectedFiles.size > 1 ? 's' : ''})`
|
||||||
|
: ''}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ export { CompletedFeaturesModal } from './completed-features-modal';
|
|||||||
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
|
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
|
||||||
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
|
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
|
||||||
export { DependencyLinkDialog, type DependencyLinkType } from './dependency-link-dialog';
|
export { DependencyLinkDialog, type DependencyLinkType } from './dependency-link-dialog';
|
||||||
|
export { DiscardWorktreeChangesDialog } from './discard-worktree-changes-dialog';
|
||||||
export { EditFeatureDialog } from './edit-feature-dialog';
|
export { EditFeatureDialog } from './edit-feature-dialog';
|
||||||
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
|
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
|
||||||
export { MergeWorktreeDialog, type MergeConflictInfo } from './merge-worktree-dialog';
|
export { MergeWorktreeDialog, type MergeConflictInfo } from './merge-worktree-dialog';
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import type { ReasoningEffort } from '@automaker/types';
|
import type { ReasoningEffort } from '@automaker/types';
|
||||||
import { FeatureImagePath as DescriptionImagePath } from '@/components/ui/description-image-dropzone';
|
import { FeatureImagePath as DescriptionImagePath } from '@/components/ui/description-image-dropzone';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { isConnectionError, handleServerOffline } from '@/lib/http-api-client';
|
import { isConnectionError, handleServerOffline, getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useAutoMode } from '@/hooks/use-auto-mode';
|
import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||||
import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations';
|
import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations';
|
||||||
@@ -903,17 +903,40 @@ export function useBoardActions({
|
|||||||
|
|
||||||
const handleUnarchiveFeature = useCallback(
|
const handleUnarchiveFeature = useCallback(
|
||||||
(feature: Feature) => {
|
(feature: Feature) => {
|
||||||
const updates = {
|
// Determine the branch to restore to:
|
||||||
|
// - If the feature had a branch assigned, keep it (preserves worktree context)
|
||||||
|
// - If no branch was assigned, it will show on the primary worktree
|
||||||
|
const featureBranch = feature.branchName;
|
||||||
|
|
||||||
|
// Check if the feature will be visible on the current worktree view
|
||||||
|
const willBeVisibleOnCurrentView = !featureBranch
|
||||||
|
? !currentWorktreeBranch ||
|
||||||
|
(projectPath ? isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch) : true)
|
||||||
|
: featureBranch === currentWorktreeBranch;
|
||||||
|
|
||||||
|
const updates: Partial<Feature> = {
|
||||||
status: 'verified' as const,
|
status: 'verified' as const,
|
||||||
};
|
};
|
||||||
updateFeature(feature.id, updates);
|
updateFeature(feature.id, updates);
|
||||||
persistFeatureUpdate(feature.id, updates);
|
persistFeatureUpdate(feature.id, updates);
|
||||||
|
|
||||||
toast.success('Feature restored', {
|
if (willBeVisibleOnCurrentView) {
|
||||||
description: `Moved back to verified: ${truncateDescription(feature.description)}`,
|
toast.success('Feature restored', {
|
||||||
});
|
description: `Moved back to verified: ${truncateDescription(feature.description)}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.success('Feature restored', {
|
||||||
|
description: `Moved back to verified on branch "${featureBranch}": ${truncateDescription(feature.description)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[updateFeature, persistFeatureUpdate]
|
[
|
||||||
|
updateFeature,
|
||||||
|
persistFeatureUpdate,
|
||||||
|
currentWorktreeBranch,
|
||||||
|
projectPath,
|
||||||
|
isPrimaryWorktreeBranch,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleViewOutput = useCallback(
|
const handleViewOutput = useCallback(
|
||||||
@@ -1073,28 +1096,53 @@ export function useBoardActions({
|
|||||||
|
|
||||||
const handleArchiveAllVerified = useCallback(async () => {
|
const handleArchiveAllVerified = useCallback(async () => {
|
||||||
const verifiedFeatures = features.filter((f) => f.status === 'verified');
|
const verifiedFeatures = features.filter((f) => f.status === 'verified');
|
||||||
|
if (verifiedFeatures.length === 0) return;
|
||||||
|
|
||||||
|
// Optimistically update all features in the UI immediately
|
||||||
for (const feature of verifiedFeatures) {
|
for (const feature of verifiedFeatures) {
|
||||||
const isRunning = runningAutoTasks.includes(feature.id);
|
updateFeature(feature.id, { status: 'completed' as const });
|
||||||
if (isRunning) {
|
}
|
||||||
try {
|
|
||||||
await autoMode.stopFeature(feature.id);
|
// Stop any running features in parallel (non-blocking for the UI)
|
||||||
} catch (error) {
|
const runningVerified = verifiedFeatures.filter((f) => runningAutoTasks.includes(f.id));
|
||||||
logger.error('Error stopping feature before archive:', error);
|
if (runningVerified.length > 0) {
|
||||||
|
await Promise.allSettled(
|
||||||
|
runningVerified.map((feature) =>
|
||||||
|
autoMode.stopFeature(feature.id).catch((error) => {
|
||||||
|
logger.error('Error stopping feature before archive:', error);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use bulk update API for a single server request instead of N individual calls
|
||||||
|
try {
|
||||||
|
if (currentProject) {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
const featureIds = verifiedFeatures.map((f) => f.id);
|
||||||
|
const result = await api.features.bulkUpdate(currentProject.path, featureIds, {
|
||||||
|
status: 'completed' as const,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Refresh features from server to sync React Query cache
|
||||||
|
loadFeatures();
|
||||||
|
} else {
|
||||||
|
logger.error('Bulk archive failed:', result);
|
||||||
|
// Reload features to sync state with server
|
||||||
|
loadFeatures();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Archive the feature by setting status to completed
|
} catch (error) {
|
||||||
const updates = {
|
logger.error('Failed to bulk archive features:', error);
|
||||||
status: 'completed' as const,
|
// Reload features to sync state with server on error
|
||||||
};
|
loadFeatures();
|
||||||
updateFeature(feature.id, updates);
|
|
||||||
persistFeatureUpdate(feature.id, updates);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success('All verified features archived', {
|
toast.success('All verified features archived', {
|
||||||
description: `Archived ${verifiedFeatures.length} feature(s).`,
|
description: `Archived ${verifiedFeatures.length} feature(s).`,
|
||||||
});
|
});
|
||||||
}, [features, runningAutoTasks, autoMode, updateFeature, persistFeatureUpdate]);
|
}, [features, runningAutoTasks, autoMode, updateFeature, currentProject, loadFeatures]);
|
||||||
|
|
||||||
const handleDuplicateFeature = useCallback(
|
const handleDuplicateFeature = useCallback(
|
||||||
async (feature: Feature, asChild: boolean = false) => {
|
async (feature: Feature, asChild: boolean = false) => {
|
||||||
|
|||||||
@@ -28,10 +28,29 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
|||||||
) => {
|
) => {
|
||||||
if (!currentProject) return;
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
// Capture previous cache snapshot for rollback on error
|
||||||
|
const previousFeatures = queryClient.getQueryData<Feature[]>(
|
||||||
|
queryKeys.features.all(currentProject.path)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Optimistically update React Query cache for immediate board refresh
|
||||||
|
// This ensures status changes (e.g., restoring archived features) are reflected immediately
|
||||||
|
queryClient.setQueryData<Feature[]>(
|
||||||
|
queryKeys.features.all(currentProject.path),
|
||||||
|
(existing) => {
|
||||||
|
if (!existing) return existing;
|
||||||
|
return existing.map((f) => (f.id === featureId ? { ...f, ...updates } : f));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api.features) {
|
if (!api.features) {
|
||||||
logger.error('Features API not available');
|
logger.error('Features API not available');
|
||||||
|
// Rollback optimistic update since we can't persist
|
||||||
|
if (previousFeatures) {
|
||||||
|
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +70,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
|||||||
if (result.success && result.feature) {
|
if (result.success && result.feature) {
|
||||||
const updatedFeature = result.feature as Feature;
|
const updatedFeature = result.feature as Feature;
|
||||||
updateFeature(updatedFeature.id, updatedFeature as Partial<Feature>);
|
updateFeature(updatedFeature.id, updatedFeature as Partial<Feature>);
|
||||||
|
// Update cache with server-confirmed feature before invalidating
|
||||||
queryClient.setQueryData<Feature[]>(
|
queryClient.setQueryData<Feature[]>(
|
||||||
queryKeys.features.all(currentProject.path),
|
queryKeys.features.all(currentProject.path),
|
||||||
(features) => {
|
(features) => {
|
||||||
@@ -66,9 +86,23 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
|||||||
});
|
});
|
||||||
} else if (!result.success) {
|
} else if (!result.success) {
|
||||||
logger.error('API features.update failed', result);
|
logger.error('API features.update failed', result);
|
||||||
|
// Rollback optimistic update on failure
|
||||||
|
if (previousFeatures) {
|
||||||
|
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(currentProject.path),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to persist feature update:', error);
|
logger.error('Failed to persist feature update:', error);
|
||||||
|
// Rollback optimistic update on error
|
||||||
|
if (previousFeatures) {
|
||||||
|
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(currentProject.path),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentProject, updateFeature, queryClient]
|
[currentProject, updateFeature, queryClient]
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ export function KanbanBoard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 overflow-x-auto px-5 pt-4 pb-4 relative',
|
'flex-1 overflow-x-auto px-5 pt-2 sm:pt-4 pb-1 sm:pb-4 relative',
|
||||||
'transition-opacity duration-200',
|
'transition-opacity duration-200',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,12 +14,7 @@ import type {
|
|||||||
TestRunnerOutputEvent,
|
TestRunnerOutputEvent,
|
||||||
TestRunnerCompletedEvent,
|
TestRunnerCompletedEvent,
|
||||||
} from '@/types/electron';
|
} from '@/types/electron';
|
||||||
import type {
|
import type { WorktreePanelProps, WorktreeInfo, TestSessionInfo } from './types';
|
||||||
WorktreePanelProps,
|
|
||||||
WorktreeInfo,
|
|
||||||
TestSessionInfo,
|
|
||||||
BranchSwitchConflictInfo,
|
|
||||||
} from './types';
|
|
||||||
import {
|
import {
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
useDevServers,
|
useDevServers,
|
||||||
@@ -36,10 +31,13 @@ import {
|
|||||||
WorktreeDropdown,
|
WorktreeDropdown,
|
||||||
} from './components';
|
} from './components';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
|
import {
|
||||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
ViewWorktreeChangesDialog,
|
||||||
|
PushToRemoteDialog,
|
||||||
|
MergeWorktreeDialog,
|
||||||
|
DiscardWorktreeChangesDialog,
|
||||||
|
} from '../dialogs';
|
||||||
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
|
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
|
||||||
import { Undo2 } from 'lucide-react';
|
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
|
||||||
/** Threshold for switching from tabs to dropdown layout (number of worktrees) */
|
/** Threshold for switching from tabs to dropdown layout (number of worktrees) */
|
||||||
@@ -471,30 +469,9 @@ export function WorktreePanel({
|
|||||||
setDiscardChangesDialogOpen(true);
|
setDiscardChangesDialogOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleConfirmDiscardChanges = useCallback(async () => {
|
const handleDiscardCompleted = useCallback(() => {
|
||||||
if (!discardChangesWorktree) return;
|
fetchWorktrees({ silent: true });
|
||||||
|
}, [fetchWorktrees]);
|
||||||
try {
|
|
||||||
const api = getHttpApiClient();
|
|
||||||
const result = await api.worktree.discardChanges(discardChangesWorktree.path);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
toast.success('Changes discarded', {
|
|
||||||
description: `Discarded changes in ${discardChangesWorktree.branch}`,
|
|
||||||
});
|
|
||||||
// Refresh worktrees to update the changes status
|
|
||||||
fetchWorktrees({ silent: true });
|
|
||||||
} else {
|
|
||||||
toast.error('Failed to discard changes', {
|
|
||||||
description: result.error || 'Unknown error',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to discard changes', {
|
|
||||||
description: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [discardChangesWorktree, fetchWorktrees]);
|
|
||||||
|
|
||||||
// Handle opening the log panel for a specific worktree
|
// Handle opening the log panel for a specific worktree
|
||||||
const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => {
|
const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => {
|
||||||
@@ -679,17 +656,12 @@ export function WorktreePanel({
|
|||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Discard Changes Confirmation Dialog */}
|
{/* Discard Changes Dialog */}
|
||||||
<ConfirmDialog
|
<DiscardWorktreeChangesDialog
|
||||||
open={discardChangesDialogOpen}
|
open={discardChangesDialogOpen}
|
||||||
onOpenChange={setDiscardChangesDialogOpen}
|
onOpenChange={setDiscardChangesDialogOpen}
|
||||||
onConfirm={handleConfirmDiscardChanges}
|
worktree={discardChangesWorktree}
|
||||||
title="Discard Changes"
|
onDiscarded={handleDiscardCompleted}
|
||||||
description={`Are you sure you want to discard all changes in "${discardChangesWorktree?.branch}"? This will reset staged changes, discard modifications to tracked files, and remove untracked files. This action cannot be undone.`}
|
|
||||||
icon={Undo2}
|
|
||||||
iconClassName="text-destructive"
|
|
||||||
confirmText="Discard Changes"
|
|
||||||
confirmVariant="destructive"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dev Server Logs Panel */}
|
{/* Dev Server Logs Panel */}
|
||||||
@@ -1015,17 +987,12 @@ export function WorktreePanel({
|
|||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Discard Changes Confirmation Dialog */}
|
{/* Discard Changes Dialog */}
|
||||||
<ConfirmDialog
|
<DiscardWorktreeChangesDialog
|
||||||
open={discardChangesDialogOpen}
|
open={discardChangesDialogOpen}
|
||||||
onOpenChange={setDiscardChangesDialogOpen}
|
onOpenChange={setDiscardChangesDialogOpen}
|
||||||
onConfirm={handleConfirmDiscardChanges}
|
worktree={discardChangesWorktree}
|
||||||
title="Discard Changes"
|
onDiscarded={handleDiscardCompleted}
|
||||||
description={`Are you sure you want to discard all changes in "${discardChangesWorktree?.branch}"? This will reset staged changes, discard modifications to tracked files, and remove untracked files. This action cannot be undone.`}
|
|
||||||
icon={Undo2}
|
|
||||||
iconClassName="text-destructive"
|
|
||||||
confirmText="Discard Changes"
|
|
||||||
confirmVariant="destructive"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dev Server Logs Panel */}
|
{/* Dev Server Logs Panel */}
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ const SPECIAL_KEYS = {
|
|||||||
const CTRL_KEYS = {
|
const CTRL_KEYS = {
|
||||||
'Ctrl+C': '\x03', // Interrupt / SIGINT
|
'Ctrl+C': '\x03', // Interrupt / SIGINT
|
||||||
'Ctrl+Z': '\x1a', // Suspend / SIGTSTP
|
'Ctrl+Z': '\x1a', // Suspend / SIGTSTP
|
||||||
'Ctrl+D': '\x04', // EOF
|
|
||||||
'Ctrl+L': '\x0c', // Clear screen
|
|
||||||
'Ctrl+A': '\x01', // Move to beginning of line
|
'Ctrl+A': '\x01', // Move to beginning of line
|
||||||
'Ctrl+B': '\x02', // Move cursor back (tmux prefix)
|
'Ctrl+B': '\x02', // Move cursor back (tmux prefix)
|
||||||
} as const;
|
} as const;
|
||||||
@@ -34,7 +32,7 @@ const ARROW_KEYS = {
|
|||||||
left: '\x1b[D',
|
left: '\x1b[D',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
interface MobileTerminalControlsProps {
|
interface MobileTerminalShortcutsProps {
|
||||||
/** Callback to send input data to the terminal WebSocket */
|
/** Callback to send input data to the terminal WebSocket */
|
||||||
onSendInput: (data: string) => void;
|
onSendInput: (data: string) => void;
|
||||||
/** Whether the terminal is connected and ready */
|
/** Whether the terminal is connected and ready */
|
||||||
@@ -42,14 +40,17 @@ interface MobileTerminalControlsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mobile quick controls bar for terminal interaction on touch devices.
|
* Mobile shortcuts bar for terminal interaction on touch devices.
|
||||||
* Provides special keys (Escape, Tab, Ctrl+C, etc.) and arrow keys that are
|
* Provides special keys (Escape, Tab, Ctrl+C, etc.) and arrow keys that are
|
||||||
* typically unavailable on mobile virtual keyboards.
|
* typically unavailable on mobile virtual keyboards.
|
||||||
*
|
*
|
||||||
* Anchored at the top of the terminal panel, above the terminal content.
|
* Anchored at the top of the terminal panel, above the terminal content.
|
||||||
* Can be collapsed to a minimal toggle to maximize terminal space.
|
* Can be collapsed to a minimal toggle to maximize terminal space.
|
||||||
*/
|
*/
|
||||||
export function MobileTerminalControls({ onSendInput, isConnected }: MobileTerminalControlsProps) {
|
export function MobileTerminalShortcuts({
|
||||||
|
onSendInput,
|
||||||
|
isConnected,
|
||||||
|
}: MobileTerminalShortcutsProps) {
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
|
||||||
// Track repeat interval for arrow key long-press
|
// Track repeat interval for arrow key long-press
|
||||||
@@ -108,10 +109,10 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
|
|||||||
<button
|
<button
|
||||||
className="flex items-center gap-1 px-4 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors touch-manipulation"
|
className="flex items-center gap-1 px-4 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors touch-manipulation"
|
||||||
onClick={() => setIsCollapsed(false)}
|
onClick={() => setIsCollapsed(false)}
|
||||||
title="Show quick controls"
|
title="Show shortcuts"
|
||||||
>
|
>
|
||||||
<ChevronDown className="h-3.5 w-3.5" />
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
<span>Controls</span>
|
<span>Shortcuts</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -123,7 +124,7 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
|
|||||||
<button
|
<button
|
||||||
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shrink-0 touch-manipulation"
|
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shrink-0 touch-manipulation"
|
||||||
onClick={() => setIsCollapsed(true)}
|
onClick={() => setIsCollapsed(true)}
|
||||||
title="Hide quick controls"
|
title="Hide shortcuts"
|
||||||
>
|
>
|
||||||
<ChevronUp className="h-4 w-4" />
|
<ChevronUp className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -132,12 +133,12 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
|
|||||||
<div className="w-px h-6 bg-border shrink-0" />
|
<div className="w-px h-6 bg-border shrink-0" />
|
||||||
|
|
||||||
{/* Special keys */}
|
{/* Special keys */}
|
||||||
<ControlButton
|
<ShortcutButton
|
||||||
label="Esc"
|
label="Esc"
|
||||||
onPress={() => sendKey(SPECIAL_KEYS.escape)}
|
onPress={() => sendKey(SPECIAL_KEYS.escape)}
|
||||||
disabled={!isConnected}
|
disabled={!isConnected}
|
||||||
/>
|
/>
|
||||||
<ControlButton
|
<ShortcutButton
|
||||||
label="Tab"
|
label="Tab"
|
||||||
onPress={() => sendKey(SPECIAL_KEYS.tab)}
|
onPress={() => sendKey(SPECIAL_KEYS.tab)}
|
||||||
disabled={!isConnected}
|
disabled={!isConnected}
|
||||||
@@ -147,31 +148,19 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
|
|||||||
<div className="w-px h-6 bg-border shrink-0" />
|
<div className="w-px h-6 bg-border shrink-0" />
|
||||||
|
|
||||||
{/* Common Ctrl shortcuts */}
|
{/* Common Ctrl shortcuts */}
|
||||||
<ControlButton
|
<ShortcutButton
|
||||||
label="^C"
|
label="^C"
|
||||||
title="Ctrl+C (Interrupt)"
|
title="Ctrl+C (Interrupt)"
|
||||||
onPress={() => sendKey(CTRL_KEYS['Ctrl+C'])}
|
onPress={() => sendKey(CTRL_KEYS['Ctrl+C'])}
|
||||||
disabled={!isConnected}
|
disabled={!isConnected}
|
||||||
/>
|
/>
|
||||||
<ControlButton
|
<ShortcutButton
|
||||||
label="^Z"
|
label="^Z"
|
||||||
title="Ctrl+Z (Suspend)"
|
title="Ctrl+Z (Suspend)"
|
||||||
onPress={() => sendKey(CTRL_KEYS['Ctrl+Z'])}
|
onPress={() => sendKey(CTRL_KEYS['Ctrl+Z'])}
|
||||||
disabled={!isConnected}
|
disabled={!isConnected}
|
||||||
/>
|
/>
|
||||||
<ControlButton
|
<ShortcutButton
|
||||||
label="^D"
|
|
||||||
title="Ctrl+D (EOF)"
|
|
||||||
onPress={() => sendKey(CTRL_KEYS['Ctrl+D'])}
|
|
||||||
disabled={!isConnected}
|
|
||||||
/>
|
|
||||||
<ControlButton
|
|
||||||
label="^L"
|
|
||||||
title="Ctrl+L (Clear)"
|
|
||||||
onPress={() => sendKey(CTRL_KEYS['Ctrl+L'])}
|
|
||||||
disabled={!isConnected}
|
|
||||||
/>
|
|
||||||
<ControlButton
|
|
||||||
label="^B"
|
label="^B"
|
||||||
title="Ctrl+B (Back/tmux prefix)"
|
title="Ctrl+B (Back/tmux prefix)"
|
||||||
onPress={() => sendKey(CTRL_KEYS['Ctrl+B'])}
|
onPress={() => sendKey(CTRL_KEYS['Ctrl+B'])}
|
||||||
@@ -181,26 +170,6 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
|
|||||||
{/* Separator */}
|
{/* Separator */}
|
||||||
<div className="w-px h-6 bg-border shrink-0" />
|
<div className="w-px h-6 bg-border shrink-0" />
|
||||||
|
|
||||||
{/* Navigation keys */}
|
|
||||||
<ControlButton
|
|
||||||
label="Del"
|
|
||||||
onPress={() => sendKey(SPECIAL_KEYS.delete)}
|
|
||||||
disabled={!isConnected}
|
|
||||||
/>
|
|
||||||
<ControlButton
|
|
||||||
label="Home"
|
|
||||||
onPress={() => sendKey(SPECIAL_KEYS.home)}
|
|
||||||
disabled={!isConnected}
|
|
||||||
/>
|
|
||||||
<ControlButton
|
|
||||||
label="End"
|
|
||||||
onPress={() => sendKey(SPECIAL_KEYS.end)}
|
|
||||||
disabled={!isConnected}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Separator */}
|
|
||||||
<div className="w-px h-6 bg-border shrink-0" />
|
|
||||||
|
|
||||||
{/* Arrow keys with long-press repeat */}
|
{/* Arrow keys with long-press repeat */}
|
||||||
<ArrowButton
|
<ArrowButton
|
||||||
direction="left"
|
direction="left"
|
||||||
@@ -226,14 +195,34 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
|
|||||||
onRelease={handleArrowRelease}
|
onRelease={handleArrowRelease}
|
||||||
disabled={!isConnected}
|
disabled={!isConnected}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<div className="w-px h-6 bg-border shrink-0" />
|
||||||
|
|
||||||
|
{/* Navigation keys */}
|
||||||
|
<ShortcutButton
|
||||||
|
label="Del"
|
||||||
|
onPress={() => sendKey(SPECIAL_KEYS.delete)}
|
||||||
|
disabled={!isConnected}
|
||||||
|
/>
|
||||||
|
<ShortcutButton
|
||||||
|
label="Home"
|
||||||
|
onPress={() => sendKey(SPECIAL_KEYS.home)}
|
||||||
|
disabled={!isConnected}
|
||||||
|
/>
|
||||||
|
<ShortcutButton
|
||||||
|
label="End"
|
||||||
|
onPress={() => sendKey(SPECIAL_KEYS.end)}
|
||||||
|
disabled={!isConnected}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Individual control button for special keys and shortcuts.
|
* Individual shortcut button for special keys.
|
||||||
*/
|
*/
|
||||||
function ControlButton({
|
function ShortcutButton({
|
||||||
label,
|
label,
|
||||||
title,
|
title,
|
||||||
onPress,
|
onPress,
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export type StickyModifier = 'ctrl' | 'alt' | null;
|
||||||
|
|
||||||
|
interface StickyModifierKeysProps {
|
||||||
|
/** Currently active sticky modifier (null = none) */
|
||||||
|
activeModifier: StickyModifier;
|
||||||
|
/** Callback when a modifier is toggled */
|
||||||
|
onModifierChange: (modifier: StickyModifier) => void;
|
||||||
|
/** Whether the terminal is connected */
|
||||||
|
isConnected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sticky modifier keys (Ctrl, Alt) for the terminal toolbar.
|
||||||
|
*
|
||||||
|
* "Sticky" means: tap a modifier to activate it, then the next key pressed
|
||||||
|
* in the terminal will be sent with that modifier applied. After the modified
|
||||||
|
* key is sent, the sticky modifier automatically deactivates.
|
||||||
|
*
|
||||||
|
* - Ctrl: Sends the control code (character code & 0x1f)
|
||||||
|
* - Alt: Sends escape prefix (\x1b) before the character
|
||||||
|
*
|
||||||
|
* Tapping an already-active modifier deactivates it (toggle behavior).
|
||||||
|
*/
|
||||||
|
export function StickyModifierKeys({
|
||||||
|
activeModifier,
|
||||||
|
onModifierChange,
|
||||||
|
isConnected,
|
||||||
|
}: StickyModifierKeysProps) {
|
||||||
|
const toggleCtrl = useCallback(() => {
|
||||||
|
onModifierChange(activeModifier === 'ctrl' ? null : 'ctrl');
|
||||||
|
}, [activeModifier, onModifierChange]);
|
||||||
|
|
||||||
|
const toggleAlt = useCallback(() => {
|
||||||
|
onModifierChange(activeModifier === 'alt' ? null : 'alt');
|
||||||
|
}, [activeModifier, onModifierChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<ModifierButton
|
||||||
|
label="Ctrl"
|
||||||
|
isActive={activeModifier === 'ctrl'}
|
||||||
|
onPress={toggleCtrl}
|
||||||
|
disabled={!isConnected}
|
||||||
|
title="Sticky Ctrl – tap to activate, then press a key (e.g. Ctrl+C)"
|
||||||
|
/>
|
||||||
|
<ModifierButton
|
||||||
|
label="Alt"
|
||||||
|
isActive={activeModifier === 'alt'}
|
||||||
|
onPress={toggleAlt}
|
||||||
|
disabled={!isConnected}
|
||||||
|
title="Sticky Alt – tap to activate, then press a key (e.g. Alt+D)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual modifier toggle button with active state styling.
|
||||||
|
*/
|
||||||
|
function ModifierButton({
|
||||||
|
label,
|
||||||
|
isActive,
|
||||||
|
onPress,
|
||||||
|
disabled = false,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
isActive: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1 rounded-md text-xs font-medium shrink-0 select-none transition-all min-w-[36px] min-h-[28px] flex items-center justify-center',
|
||||||
|
'touch-manipulation border',
|
||||||
|
isActive
|
||||||
|
? 'bg-brand-500 text-white border-brand-500 shadow-sm shadow-brand-500/25'
|
||||||
|
: 'bg-muted/80 text-foreground hover:bg-accent border-transparent',
|
||||||
|
disabled && 'opacity-40 pointer-events-none'
|
||||||
|
)}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault(); // Prevent focus stealing from terminal
|
||||||
|
onPress();
|
||||||
|
}}
|
||||||
|
title={title}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
role="switch"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a sticky modifier to raw terminal input data.
|
||||||
|
*
|
||||||
|
* For Ctrl: converts printable ASCII characters to their control-code equivalent.
|
||||||
|
* e.g. 'c' → \x03 (Ctrl+C), 'a' → \x01 (Ctrl+A)
|
||||||
|
*
|
||||||
|
* For Alt: prepends the escape character (\x1b) before the data.
|
||||||
|
* e.g. 'd' → \x1bd (Alt+D)
|
||||||
|
*
|
||||||
|
* Returns null if the modifier cannot be applied (non-ASCII, etc.)
|
||||||
|
*/
|
||||||
|
export function applyStickyModifier(data: string, modifier: StickyModifier): string | null {
|
||||||
|
if (!modifier || !data) return null;
|
||||||
|
|
||||||
|
if (modifier === 'ctrl') {
|
||||||
|
// Only apply Ctrl to single printable ASCII characters (a-z, A-Z, and some specials)
|
||||||
|
if (data.length === 1) {
|
||||||
|
const code = data.charCodeAt(0);
|
||||||
|
|
||||||
|
// Letters a-z or A-Z: Ctrl sends code & 0x1f
|
||||||
|
if ((code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a)) {
|
||||||
|
return String.fromCharCode(code & 0x1f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special Ctrl combinations
|
||||||
|
// Ctrl+[ = Escape (0x1b)
|
||||||
|
if (code === 0x5b) return '\x1b';
|
||||||
|
// Ctrl+\ = 0x1c
|
||||||
|
if (code === 0x5c) return '\x1c';
|
||||||
|
// Ctrl+] = 0x1d
|
||||||
|
if (code === 0x5d) return '\x1d';
|
||||||
|
// Ctrl+^ = 0x1e
|
||||||
|
if (code === 0x5e) return '\x1e';
|
||||||
|
// Ctrl+_ = 0x1f
|
||||||
|
if (code === 0x5f) return '\x1f';
|
||||||
|
// Ctrl+Space or Ctrl+@ = 0x00 (NUL)
|
||||||
|
if (code === 0x20 || code === 0x40) return '\x00';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modifier === 'alt') {
|
||||||
|
// Alt sends ESC prefix followed by the character
|
||||||
|
return '\x1b' + data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -53,7 +53,12 @@ import { getElectronAPI } from '@/lib/electron';
|
|||||||
import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client';
|
import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client';
|
||||||
import { useIsMobile } from '@/hooks/use-media-query';
|
import { useIsMobile } from '@/hooks/use-media-query';
|
||||||
import { useVirtualKeyboardResize } from '@/hooks/use-virtual-keyboard-resize';
|
import { useVirtualKeyboardResize } from '@/hooks/use-virtual-keyboard-resize';
|
||||||
import { MobileTerminalControls } from './mobile-terminal-controls';
|
import { MobileTerminalShortcuts } from './mobile-terminal-shortcuts';
|
||||||
|
import {
|
||||||
|
StickyModifierKeys,
|
||||||
|
applyStickyModifier,
|
||||||
|
type StickyModifier,
|
||||||
|
} from './sticky-modifier-keys';
|
||||||
|
|
||||||
const logger = createLogger('Terminal');
|
const logger = createLogger('Terminal');
|
||||||
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
|
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
|
||||||
@@ -158,6 +163,10 @@ export function TerminalPanel({
|
|||||||
const showSearchRef = useRef(false);
|
const showSearchRef = useRef(false);
|
||||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||||
|
|
||||||
|
// Sticky modifier key state (Ctrl or Alt) for the terminal toolbar
|
||||||
|
const [stickyModifier, setStickyModifier] = useState<StickyModifier>(null);
|
||||||
|
const stickyModifierRef = useRef<StickyModifier>(null);
|
||||||
|
|
||||||
const [connectionStatus, setConnectionStatus] = useState<
|
const [connectionStatus, setConnectionStatus] = useState<
|
||||||
'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'auth_failed'
|
'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'auth_failed'
|
||||||
>('connecting');
|
>('connecting');
|
||||||
@@ -166,7 +175,7 @@ export function TerminalPanel({
|
|||||||
const INITIAL_RECONNECT_DELAY = 1000;
|
const INITIAL_RECONNECT_DELAY = 1000;
|
||||||
const [processExitCode, setProcessExitCode] = useState<number | null>(null);
|
const [processExitCode, setProcessExitCode] = useState<number | null>(null);
|
||||||
|
|
||||||
// Detect mobile viewport for quick controls
|
// Detect mobile viewport for shortcuts bar
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// Track virtual keyboard height on mobile to prevent overlap
|
// Track virtual keyboard height on mobile to prevent overlap
|
||||||
@@ -354,7 +363,13 @@ export function TerminalPanel({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Send raw input to terminal via WebSocket (used by mobile quick controls)
|
// Handle sticky modifier toggle and keep ref in sync
|
||||||
|
const handleStickyModifierChange = useCallback((modifier: StickyModifier) => {
|
||||||
|
setStickyModifier(modifier);
|
||||||
|
stickyModifierRef.current = modifier;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Send raw input to terminal via WebSocket (used by mobile shortcuts bar)
|
||||||
const sendTerminalInput = useCallback((data: string) => {
|
const sendTerminalInput = useCallback((data: string) => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
wsRef.current.send(JSON.stringify({ type: 'input', data }));
|
wsRef.current.send(JSON.stringify({ type: 'input', data }));
|
||||||
@@ -1207,10 +1222,24 @@ export function TerminalPanel({
|
|||||||
|
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
// Handle terminal input
|
// Handle terminal input - apply sticky modifier if active
|
||||||
const dataHandler = terminal.onData((data) => {
|
const dataHandler = terminal.onData((data) => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
wsRef.current.send(JSON.stringify({ type: 'input', data }));
|
const modifier = stickyModifierRef.current;
|
||||||
|
if (modifier) {
|
||||||
|
const modified = applyStickyModifier(data, modifier);
|
||||||
|
if (modified !== null) {
|
||||||
|
wsRef.current.send(JSON.stringify({ type: 'input', data: modified }));
|
||||||
|
} else {
|
||||||
|
// Could not apply modifier (e.g. non-ASCII input), send as-is
|
||||||
|
wsRef.current.send(JSON.stringify({ type: 'input', data }));
|
||||||
|
}
|
||||||
|
// Clear sticky modifier after one key press (one-shot behavior)
|
||||||
|
stickyModifierRef.current = null;
|
||||||
|
setStickyModifier(null);
|
||||||
|
} else {
|
||||||
|
wsRef.current.send(JSON.stringify({ type: 'input', data }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2037,6 +2066,15 @@ export function TerminalPanel({
|
|||||||
|
|
||||||
<div className="w-px h-3 mx-0.5 bg-border" />
|
<div className="w-px h-3 mx-0.5 bg-border" />
|
||||||
|
|
||||||
|
{/* Sticky modifier keys (Ctrl, Alt) */}
|
||||||
|
<StickyModifierKeys
|
||||||
|
activeModifier={stickyModifier}
|
||||||
|
onModifierChange={handleStickyModifierChange}
|
||||||
|
isConnected={connectionStatus === 'connected'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="w-px h-3 mx-0.5 bg-border" />
|
||||||
|
|
||||||
{/* Split/close buttons */}
|
{/* Split/close buttons */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -2157,9 +2195,9 @@ export function TerminalPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile quick controls - special keys and arrow keys for touch devices */}
|
{/* Mobile shortcuts bar - special keys and arrow keys for touch devices */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<MobileTerminalControls
|
<MobileTerminalShortcuts
|
||||||
onSendInput={sendTerminalInput}
|
onSendInput={sendTerminalInput}
|
||||||
isConnected={connectionStatus === 'connected'}
|
isConnected={connectionStatus === 'connected'}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { focusManager, onlineManager } from '@tanstack/react-query';
|
import { focusManager, onlineManager } from '@tanstack/react-query';
|
||||||
import { isMobileDevice } from '@/lib/mobile-detect';
|
import { isMobileDevice } from '@/lib/mobile-detect';
|
||||||
|
import { queryClient } from '@/lib/query-client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Grace period (ms) after the app becomes visible before allowing refetches.
|
* Grace period (ms) after the app becomes visible before allowing refetches.
|
||||||
@@ -108,9 +109,19 @@ export function useMobileOnlineManager(): void {
|
|||||||
// App was backgrounded for a long time.
|
// App was backgrounded for a long time.
|
||||||
// Briefly mark as offline to prevent all queries from refetching at once,
|
// Briefly mark as offline to prevent all queries from refetching at once,
|
||||||
// then restore online status after a delay so queries refetch gradually.
|
// then restore online status after a delay so queries refetch gradually.
|
||||||
|
//
|
||||||
|
// IMPORTANT: When online is restored, invalidate all stale queries.
|
||||||
|
// This fixes a race condition where WebSocket reconnects immediately
|
||||||
|
// and fires invalidations during the offline window — those invalidations
|
||||||
|
// are silently dropped by React Query because it thinks we're offline.
|
||||||
|
// By invalidating stale queries after going online, we catch any updates
|
||||||
|
// that were missed during the offline grace period.
|
||||||
onlineManager.setOnline(false);
|
onlineManager.setOnline(false);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onlineManager.setOnline(true);
|
onlineManager.setOnline(true);
|
||||||
|
// Re-invalidate all stale queries to catch any WebSocket events
|
||||||
|
// that were dropped during the offline grace period
|
||||||
|
queryClient.invalidateQueries({ stale: true });
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2572,8 +2572,8 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
discardChanges: async (worktreePath: string) => {
|
discardChanges: async (worktreePath: string, files?: string[]) => {
|
||||||
console.log('[Mock] Discarding changes:', { worktreePath });
|
console.log('[Mock] Discarding changes:', { worktreePath, files });
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
|
|||||||
@@ -692,6 +692,10 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
private eventCallbacks: Map<EventType, Set<EventCallback>> = new Map();
|
private eventCallbacks: Map<EventType, Set<EventCallback>> = new Map();
|
||||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||||
private isConnecting = false;
|
private isConnecting = false;
|
||||||
|
/** Consecutive reconnect failure count for exponential backoff */
|
||||||
|
private reconnectAttempts = 0;
|
||||||
|
/** Visibility change handler reference for cleanup */
|
||||||
|
private visibilityHandler: (() => void) | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.serverUrl = getServerUrl();
|
this.serverUrl = getServerUrl();
|
||||||
@@ -709,6 +713,27 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
this.connectWebSocket();
|
this.connectWebSocket();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OPTIMIZATION: Reconnect WebSocket immediately when tab becomes visible
|
||||||
|
// This eliminates the reconnection delay after tab discard/background
|
||||||
|
this.visibilityHandler = () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
// If WebSocket is disconnected, reconnect immediately
|
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
logger.info('Tab became visible - attempting immediate WebSocket reconnect');
|
||||||
|
// Clear any pending reconnect timer
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
this.reconnectAttempts = 0; // Reset backoff on visibility change
|
||||||
|
this.connectWebSocket();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('visibilitychange', this.visibilityHandler);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -832,6 +857,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
logger.info('WebSocket connected');
|
logger.info('WebSocket connected');
|
||||||
this.isConnecting = false;
|
this.isConnecting = false;
|
||||||
|
this.reconnectAttempts = 0; // Reset backoff on successful connection
|
||||||
if (this.reconnectTimer) {
|
if (this.reconnectTimer) {
|
||||||
clearTimeout(this.reconnectTimer);
|
clearTimeout(this.reconnectTimer);
|
||||||
this.reconnectTimer = null;
|
this.reconnectTimer = null;
|
||||||
@@ -863,12 +889,27 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
logger.info('WebSocket disconnected');
|
logger.info('WebSocket disconnected');
|
||||||
this.isConnecting = false;
|
this.isConnecting = false;
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
// Attempt to reconnect after 5 seconds
|
|
||||||
|
// OPTIMIZATION: Exponential backoff instead of fixed 5-second delay
|
||||||
|
// First attempt: immediate (0ms), then 500ms → 1s → 2s → 5s max
|
||||||
if (!this.reconnectTimer) {
|
if (!this.reconnectTimer) {
|
||||||
this.reconnectTimer = setTimeout(() => {
|
const backoffDelays = [0, 500, 1000, 2000, 5000];
|
||||||
this.reconnectTimer = null;
|
const delayMs =
|
||||||
|
backoffDelays[Math.min(this.reconnectAttempts, backoffDelays.length - 1)] ?? 5000;
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
|
||||||
|
if (delayMs === 0) {
|
||||||
|
// Immediate reconnect on first attempt
|
||||||
this.connectWebSocket();
|
this.connectWebSocket();
|
||||||
}, 5000);
|
} else {
|
||||||
|
logger.info(
|
||||||
|
`WebSocket reconnecting in ${delayMs}ms (attempt ${this.reconnectAttempts})`
|
||||||
|
);
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
this.connectWebSocket();
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2147,8 +2188,8 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
this.httpDelete('/api/worktree/init-script', { projectPath }),
|
this.httpDelete('/api/worktree/init-script', { projectPath }),
|
||||||
runInitScript: (projectPath: string, worktreePath: string, branch: string) =>
|
runInitScript: (projectPath: string, worktreePath: string, branch: string) =>
|
||||||
this.post('/api/worktree/run-init-script', { projectPath, worktreePath, branch }),
|
this.post('/api/worktree/run-init-script', { projectPath, worktreePath, branch }),
|
||||||
discardChanges: (worktreePath: string) =>
|
discardChanges: (worktreePath: string, files?: string[]) =>
|
||||||
this.post('/api/worktree/discard-changes', { worktreePath }),
|
this.post('/api/worktree/discard-changes', { worktreePath, files }),
|
||||||
onInitScriptEvent: (
|
onInitScriptEvent: (
|
||||||
callback: (event: {
|
callback: (event: {
|
||||||
type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed';
|
type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed';
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* blank screens, reloads, and battery drain on flaky mobile connections.
|
* blank screens, reloads, and battery drain on flaky mobile connections.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { QueryClient } from '@tanstack/react-query';
|
import { QueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { isConnectionError, handleServerOffline } from './http-api-client';
|
import { isConnectionError, handleServerOffline } from './http-api-client';
|
||||||
@@ -63,10 +63,10 @@ export const STALE_TIMES = {
|
|||||||
* and component unmounts, preventing blank screens on re-mount.
|
* and component unmounts, preventing blank screens on re-mount.
|
||||||
*/
|
*/
|
||||||
export const GC_TIMES = {
|
export const GC_TIMES = {
|
||||||
/** Default garbage collection time */
|
/** Default garbage collection time - must exceed persist maxAge for cache to survive tab discard */
|
||||||
DEFAULT: isMobileDevice ? 15 * 60 * 1000 : 5 * 60 * 1000, // 15 min on mobile, 5 min desktop
|
DEFAULT: isMobileDevice ? 15 * 60 * 1000 : 10 * 60 * 1000, // 15 min on mobile, 10 min desktop
|
||||||
/** Extended for expensive queries */
|
/** Extended for expensive queries */
|
||||||
EXTENDED: isMobileDevice ? 30 * 60 * 1000 : 10 * 60 * 1000, // 30 min on mobile, 10 min desktop
|
EXTENDED: isMobileDevice ? 30 * 60 * 1000 : 15 * 60 * 1000, // 30 min on mobile, 15 min desktop
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -143,13 +143,14 @@ export const queryClient = new QueryClient({
|
|||||||
// invalidation handles real-time updates; polling handles the rest.
|
// invalidation handles real-time updates; polling handles the rest.
|
||||||
refetchOnWindowFocus: !isMobileDevice,
|
refetchOnWindowFocus: !isMobileDevice,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
// On mobile, only refetch on mount if data is stale (not always).
|
// On mobile, only refetch on mount if data is stale (true = refetch only when stale).
|
||||||
|
// On desktop, always refetch on mount for freshest data ('always' = refetch even if fresh).
|
||||||
// This prevents unnecessary network requests when navigating between
|
// This prevents unnecessary network requests when navigating between
|
||||||
// routes, which was causing blank screen flickers on mobile.
|
// routes, which was causing blank screen flickers on mobile.
|
||||||
refetchOnMount: isMobileDevice ? true : true,
|
refetchOnMount: isMobileDevice ? true : 'always',
|
||||||
// Keep previous data visible while refetching to prevent blank flashes.
|
// Keep previous data visible while refetching to prevent blank flashes.
|
||||||
// This is especially important on mobile where network is slower.
|
// This is especially important on mobile where network is slower.
|
||||||
placeholderData: isMobileDevice ? (previousData: unknown) => previousData : undefined,
|
placeholderData: isMobileDevice ? keepPreviousData : undefined,
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
onError: handleMutationError,
|
onError: handleMutationError,
|
||||||
|
|||||||
133
apps/ui/src/lib/query-persist.ts
Normal file
133
apps/ui/src/lib/query-persist.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* React Query Cache Persistence
|
||||||
|
*
|
||||||
|
* Persists the React Query cache to IndexedDB so that after a tab discard
|
||||||
|
* or page reload, the user sees cached data instantly while fresh data
|
||||||
|
* loads in the background.
|
||||||
|
*
|
||||||
|
* Uses @tanstack/react-query-persist-client with idb-keyval for IndexedDB storage.
|
||||||
|
* Cached data is treated as stale on restore and silently refetched.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { get, set, del } from 'idb-keyval';
|
||||||
|
import type { PersistedClient, Persister } from '@tanstack/react-query-persist-client';
|
||||||
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
|
||||||
|
const logger = createLogger('QueryPersist');
|
||||||
|
|
||||||
|
const IDB_KEY = 'automaker-react-query-cache';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum age of persisted cache before it's discarded (24 hours).
|
||||||
|
* After this time, the cache is considered too old and will be removed.
|
||||||
|
*/
|
||||||
|
export const PERSIST_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throttle time for persisting cache to IndexedDB.
|
||||||
|
* Prevents excessive writes during rapid query updates.
|
||||||
|
*/
|
||||||
|
export const PERSIST_THROTTLE_MS = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query key prefixes that should NOT be persisted.
|
||||||
|
* Auth-related and volatile data should always be fetched fresh.
|
||||||
|
*/
|
||||||
|
const EXCLUDED_QUERY_KEY_PREFIXES = ['auth', 'health', 'wsToken', 'sandbox'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a query key should be excluded from persistence
|
||||||
|
*/
|
||||||
|
function shouldExcludeQuery(queryKey: readonly unknown[]): boolean {
|
||||||
|
if (queryKey.length === 0) return false;
|
||||||
|
const firstKey = String(queryKey[0]);
|
||||||
|
return EXCLUDED_QUERY_KEY_PREFIXES.some((prefix) => firstKey.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether there is a recent enough React Query cache in IndexedDB
|
||||||
|
* to consider the app "warm" (i.e., safe to skip blocking on the server
|
||||||
|
* health check and show the UI immediately).
|
||||||
|
*
|
||||||
|
* Returns true only if:
|
||||||
|
* 1. The cache exists and is recent (within maxAgeMs)
|
||||||
|
* 2. The cache buster matches the current build hash
|
||||||
|
*
|
||||||
|
* If the buster doesn't match, PersistQueryClientProvider will wipe the
|
||||||
|
* cache on restore — so we must NOT skip the server wait in that case,
|
||||||
|
* otherwise the board renders with empty queries and no data.
|
||||||
|
*
|
||||||
|
* This is a read-only probe — it does not restore the cache (that is
|
||||||
|
* handled by PersistQueryClientProvider automatically).
|
||||||
|
*/
|
||||||
|
export async function hasWarmIDBCache(
|
||||||
|
currentBuster: string,
|
||||||
|
maxAgeMs = PERSIST_MAX_AGE_MS
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const client = await get<PersistedClient>(IDB_KEY);
|
||||||
|
if (!client) return false;
|
||||||
|
// PersistedClient stores a `timestamp` (ms) when it was last persisted
|
||||||
|
const age = Date.now() - (client.timestamp ?? 0);
|
||||||
|
if (age >= maxAgeMs) return false;
|
||||||
|
// If the buster doesn't match, PersistQueryClientProvider will wipe the cache.
|
||||||
|
// Treat this as a cold start — we need fresh data from the server.
|
||||||
|
if (currentBuster && client.buster !== currentBuster) return false;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an IndexedDB-based persister for React Query.
|
||||||
|
*
|
||||||
|
* This persister:
|
||||||
|
* - Stores the full query cache in IndexedDB under a single key
|
||||||
|
* - Filters out auth/health queries that shouldn't be persisted
|
||||||
|
* - Handles errors gracefully (cache persistence is best-effort)
|
||||||
|
*/
|
||||||
|
export function createIDBPersister(): Persister {
|
||||||
|
return {
|
||||||
|
persistClient: async (client: PersistedClient) => {
|
||||||
|
try {
|
||||||
|
// Filter out excluded queries before persisting
|
||||||
|
const filteredClient: PersistedClient = {
|
||||||
|
...client,
|
||||||
|
clientState: {
|
||||||
|
...client.clientState,
|
||||||
|
queries: client.clientState.queries.filter(
|
||||||
|
(query) => !shouldExcludeQuery(query.queryKey)
|
||||||
|
),
|
||||||
|
// Don't persist mutations (they should be re-triggered, not replayed)
|
||||||
|
mutations: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await set(IDB_KEY, filteredClient);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to persist query cache to IndexedDB:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
restoreClient: async () => {
|
||||||
|
try {
|
||||||
|
const client = await get<PersistedClient>(IDB_KEY);
|
||||||
|
if (client) {
|
||||||
|
logger.info('Restored React Query cache from IndexedDB');
|
||||||
|
}
|
||||||
|
return client ?? undefined;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to restore query cache from IndexedDB:', error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeClient: async () => {
|
||||||
|
try {
|
||||||
|
await del(IDB_KEY);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to remove query cache from IndexedDB:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router';
|
import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router';
|
||||||
import { useEffect, useState, useCallback, useDeferredValue, useRef } from 'react';
|
import { useEffect, useState, useCallback, useDeferredValue, useRef } from 'react';
|
||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { Sidebar } from '@/components/layout/sidebar';
|
import { Sidebar } from '@/components/layout/sidebar';
|
||||||
@@ -26,10 +26,17 @@ import {
|
|||||||
} from '@/lib/http-api-client';
|
} from '@/lib/http-api-client';
|
||||||
import {
|
import {
|
||||||
hydrateStoreFromSettings,
|
hydrateStoreFromSettings,
|
||||||
|
parseLocalStorageSettings,
|
||||||
signalMigrationComplete,
|
signalMigrationComplete,
|
||||||
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 {
|
||||||
|
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';
|
||||||
@@ -38,6 +45,8 @@ import { LoadingState } from '@/components/ui/loading-state';
|
|||||||
import { useProjectSettingsLoader } from '@/hooks/use-project-settings-loader';
|
import { useProjectSettingsLoader } from '@/hooks/use-project-settings-loader';
|
||||||
import { useIsCompact } from '@/hooks/use-media-query';
|
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 { syncUICache, restoreFromUICache } from '@/store/ui-cache-store';
|
||||||
|
|
||||||
const logger = createLogger('RootLayout');
|
const logger = createLogger('RootLayout');
|
||||||
const IS_DEV = import.meta.env.DEV;
|
const IS_DEV = import.meta.env.DEV;
|
||||||
@@ -49,6 +58,28 @@ const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
|
|||||||
const AUTO_OPEN_HISTORY_INDEX = 0;
|
const AUTO_OPEN_HISTORY_INDEX = 0;
|
||||||
const SINGLE_PROJECT_COUNT = 1;
|
const SINGLE_PROJECT_COUNT = 1;
|
||||||
const DEFAULT_LAST_OPENED_TIME_MS = 0;
|
const DEFAULT_LAST_OPENED_TIME_MS = 0;
|
||||||
|
|
||||||
|
// IndexedDB persister for React Query cache (survives tab discard)
|
||||||
|
const idbPersister = createIDBPersister();
|
||||||
|
|
||||||
|
/** Options for PersistQueryClientProvider */
|
||||||
|
const persistOptions = {
|
||||||
|
persister: idbPersister,
|
||||||
|
maxAge: PERSIST_MAX_AGE_MS,
|
||||||
|
// Throttle IndexedDB writes to prevent excessive I/O on every query state change.
|
||||||
|
// Without this, every query update triggers an IndexedDB write — especially costly on mobile.
|
||||||
|
throttleTime: PERSIST_THROTTLE_MS,
|
||||||
|
// Build hash injected by Vite — same hash used by swCacheBuster for the SW CACHE_NAME.
|
||||||
|
// When the app is rebuilt, this changes and both the IDB query cache and SW cache
|
||||||
|
// are invalidated together, preventing stale data from surviving a deployment.
|
||||||
|
// In dev mode this is a stable hash of the package version so the cache persists
|
||||||
|
// across hot reloads.
|
||||||
|
buster: typeof __APP_BUILD_HASH__ !== 'undefined' ? __APP_BUILD_HASH__ : '',
|
||||||
|
dehydrateOptions: {
|
||||||
|
shouldDehydrateQuery: (query: { state: { status: string } }) =>
|
||||||
|
query.state.status === 'success',
|
||||||
|
},
|
||||||
|
};
|
||||||
const AUTO_OPEN_STATUS = {
|
const AUTO_OPEN_STATUS = {
|
||||||
idle: 'idle',
|
idle: 'idle',
|
||||||
opening: 'opening',
|
opening: 'opening',
|
||||||
@@ -265,6 +296,21 @@ function RootLayoutContent() {
|
|||||||
setIsMounted(true);
|
setIsMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Sync critical UI state to the persistent UI cache store
|
||||||
|
// This keeps the cache up-to-date so tab discard recovery is instant
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = useAppStore.subscribe((state) => {
|
||||||
|
syncUICache({
|
||||||
|
currentProject: state.currentProject,
|
||||||
|
sidebarOpen: state.sidebarOpen,
|
||||||
|
sidebarStyle: state.sidebarStyle,
|
||||||
|
worktreePanelCollapsed: state.worktreePanelCollapsed,
|
||||||
|
collapsedNavSections: state.collapsedNavSections,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Check sandbox environment only after user is authenticated, setup is complete, and settings are loaded
|
// Check sandbox environment only after user is authenticated, setup is complete, and settings are loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip if already decided
|
// Skip if already decided
|
||||||
@@ -391,6 +437,11 @@ function RootLayoutContent() {
|
|||||||
// Initialize authentication
|
// Initialize authentication
|
||||||
// - Electron mode: Uses API key from IPC (header-based auth)
|
// - Electron mode: Uses API key from IPC (header-based auth)
|
||||||
// - Web mode: Uses HTTP-only session cookie
|
// - Web mode: Uses HTTP-only session cookie
|
||||||
|
//
|
||||||
|
// Optimizations applied:
|
||||||
|
// 1. Instant hydration from localStorage settings cache (optimistic)
|
||||||
|
// 2. Parallelized server checks: verifySession + fetchSettings fire together
|
||||||
|
// 3. Server settings reconcile in background after optimistic render
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Prevent concurrent auth checks
|
// Prevent concurrent auth checks
|
||||||
if (authCheckRunning.current) {
|
if (authCheckRunning.current) {
|
||||||
@@ -401,40 +452,171 @@ function RootLayoutContent() {
|
|||||||
authCheckRunning.current = true;
|
authCheckRunning.current = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// OPTIMIZATION: Restore UI layout from the UI cache store immediately.
|
||||||
|
// This gives instant visual continuity (sidebar state, nav sections, etc.)
|
||||||
|
// before server settings arrive. Will be reconciled by hydrateStoreFromSettings().
|
||||||
|
restoreFromUICache((state) => useAppStore.setState(state));
|
||||||
|
|
||||||
|
// OPTIMIZATION: Immediately hydrate from localStorage settings cache
|
||||||
|
// This gives the user an instant UI while server data loads in the background
|
||||||
|
const cachedSettings = parseLocalStorageSettings();
|
||||||
|
let optimisticallyHydrated = false;
|
||||||
|
if (cachedSettings && cachedSettings.projects && cachedSettings.projects.length > 0) {
|
||||||
|
logger.info('[FAST_HYDRATE] Optimistically hydrating from localStorage cache');
|
||||||
|
hydrateStoreFromSettings(cachedSettings as GlobalSettings);
|
||||||
|
optimisticallyHydrated = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize API key for Electron mode
|
// Initialize API key for Electron mode
|
||||||
await initApiKey();
|
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
|
||||||
|
// making auth/settings requests. But on a tab restore or page reload, the
|
||||||
|
// server is almost certainly already running — waiting up to ~12s for health
|
||||||
|
// check retries just shows a blank loading screen when the user has data cached.
|
||||||
|
//
|
||||||
|
// When BOTH of these are true:
|
||||||
|
// 1. localStorage settings cache has valid project data (optimisticallyHydrated)
|
||||||
|
// 2. IndexedDB React Query cache exists and is recent (< 24h old)
|
||||||
|
//
|
||||||
|
// ...we mark auth as complete immediately with the cached data, then verify
|
||||||
|
// the session in the background. If the session turns out to be invalid, the
|
||||||
|
// 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();
|
||||||
|
useAuthStore.getState().setAuthState({
|
||||||
|
isAuthenticated: true,
|
||||||
|
authChecked: true,
|
||||||
|
settingsLoaded: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify session + fetch fresh settings in the background.
|
||||||
|
// The UI is already rendered; this reconciles any stale data.
|
||||||
|
void (async () => {
|
||||||
|
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 [sessionValid, settingsResult] = await Promise.all([
|
||||||
|
verifySession().catch(() => false),
|
||||||
|
api.settings.getGlobal().catch(() => ({ success: false, settings: null }) as const),
|
||||||
|
]);
|
||||||
|
if (!sessionValid) {
|
||||||
|
// Session expired while user was away — log them out
|
||||||
|
logger.warn('[FAST_HYDRATE] Background verify: session invalid, logging out');
|
||||||
|
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (settingsResult.success && settingsResult.settings) {
|
||||||
|
const { settings: finalSettings } = await performSettingsMigration(
|
||||||
|
settingsResult.settings as unknown as Parameters<
|
||||||
|
typeof performSettingsMigration
|
||||||
|
>[0]
|
||||||
|
);
|
||||||
|
hydrateStoreFromSettings(finalSettings);
|
||||||
|
logger.info('[FAST_HYDRATE] Background reconcile complete');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
'[FAST_HYDRATE] Background verify failed (server may be restarting):',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return; // Auth is done — foreground initAuth exits here
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cold start path: server not yet confirmed running, wait for it
|
||||||
const serverReady = await waitForServerReady();
|
const serverReady = await waitForServerReady();
|
||||||
if (!serverReady) {
|
if (!serverReady) {
|
||||||
handleServerOffline();
|
handleServerOffline();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Verify session (Single Request, ALL modes)
|
// OPTIMIZATION: Fire verifySession and fetchSettings in parallel
|
||||||
let isValid = false;
|
// instead of waiting for session verification before fetching settings
|
||||||
try {
|
const api = getHttpApiClient();
|
||||||
isValid = await verifySession();
|
const [sessionValid, settingsResult] = await Promise.all([
|
||||||
} catch (error) {
|
verifySession().catch((error) => {
|
||||||
logger.warn('Session verification failed (likely network/server issue):', error);
|
logger.warn('Session verification failed (likely network/server issue):', error);
|
||||||
isValid = false;
|
return false;
|
||||||
}
|
}),
|
||||||
|
api.settings.getGlobal().catch((error) => {
|
||||||
|
logger.warn('Settings fetch failed during parallel init:', error);
|
||||||
|
return { success: false, settings: null } as const;
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
if (isValid) {
|
if (sessionValid) {
|
||||||
// 2. Load settings (and hydrate stores) before marking auth as checked.
|
// Settings were fetched in parallel - use them directly
|
||||||
// This prevents useSettingsSync from pushing default/empty state to the server
|
if (settingsResult.success && settingsResult.settings) {
|
||||||
// when the backend is still starting up or temporarily unavailable.
|
const { settings: finalSettings, migrated } = await performSettingsMigration(
|
||||||
const api = getHttpApiClient();
|
settingsResult.settings as unknown as Parameters<typeof performSettingsMigration>[0]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (migrated) {
|
||||||
|
logger.info('Settings migration from localStorage completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hydrate store with the final settings (reconcile with optimistic data)
|
||||||
|
hydrateStoreFromSettings(finalSettings);
|
||||||
|
|
||||||
|
// CRITICAL: Wait for React to render the hydrated state before
|
||||||
|
// signaling completion. Zustand updates are synchronous, but React
|
||||||
|
// hasn't necessarily re-rendered yet. This prevents race conditions
|
||||||
|
// where useSettingsSync reads state before the UI has updated.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
// Signal that settings hydration is complete FIRST.
|
||||||
|
signalMigrationComplete();
|
||||||
|
|
||||||
|
// Now mark auth as checked AND settings as loaded.
|
||||||
|
useAuthStore.getState().setAuthState({
|
||||||
|
isAuthenticated: true,
|
||||||
|
authChecked: true,
|
||||||
|
settingsLoaded: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings weren't available in parallel response - retry with backoff
|
||||||
try {
|
try {
|
||||||
const maxAttempts = 8;
|
const maxAttempts = 6;
|
||||||
const baseDelayMs = 250;
|
const baseDelayMs = 250;
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = settingsResult;
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
const delayMs = Math.min(1500, baseDelayMs * attempt);
|
||||||
|
logger.warn(
|
||||||
|
`Settings not ready (attempt ${attempt}/${maxAttempts}); retrying in ${delayMs}ms...`,
|
||||||
|
lastError
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const settingsResult = await api.settings.getGlobal();
|
const retryResult = await api.settings.getGlobal();
|
||||||
if (settingsResult.success && settingsResult.settings) {
|
if (retryResult.success && retryResult.settings) {
|
||||||
const { settings: finalSettings, migrated } = await performSettingsMigration(
|
const { settings: finalSettings, migrated } = await performSettingsMigration(
|
||||||
settingsResult.settings as unknown as Parameters<
|
retryResult.settings as unknown as Parameters<
|
||||||
typeof performSettingsMigration
|
typeof performSettingsMigration
|
||||||
>[0]
|
>[0]
|
||||||
);
|
);
|
||||||
@@ -443,25 +625,10 @@ function RootLayoutContent() {
|
|||||||
logger.info('Settings migration from localStorage completed');
|
logger.info('Settings migration from localStorage completed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hydrate store with the final settings (merged if migration occurred)
|
|
||||||
hydrateStoreFromSettings(finalSettings);
|
hydrateStoreFromSettings(finalSettings);
|
||||||
|
|
||||||
// CRITICAL: Wait for React to render the hydrated state before
|
|
||||||
// signaling completion. Zustand updates are synchronous, but React
|
|
||||||
// hasn't necessarily re-rendered yet. This prevents race conditions
|
|
||||||
// where useSettingsSync reads state before the UI has updated.
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
// Signal that settings hydration is complete FIRST.
|
|
||||||
// This ensures useSettingsSync's waitForMigrationComplete() will resolve
|
|
||||||
// immediately when it starts after auth state change, preventing it from
|
|
||||||
// syncing default empty state to the server.
|
|
||||||
signalMigrationComplete();
|
signalMigrationComplete();
|
||||||
|
|
||||||
// Now mark auth as checked AND settings as loaded.
|
|
||||||
// The settingsLoaded flag ensures useSettingsSync won't start syncing
|
|
||||||
// until settings have been properly hydrated, even if authChecked was
|
|
||||||
// set earlier by login-view.
|
|
||||||
useAuthStore.getState().setAuthState({
|
useAuthStore.getState().setAuthState({
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
authChecked: true,
|
authChecked: true,
|
||||||
@@ -471,24 +638,29 @@ function RootLayoutContent() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastError = settingsResult;
|
lastError = retryResult;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const delayMs = Math.min(1500, baseDelayMs * attempt);
|
|
||||||
logger.warn(
|
|
||||||
`Settings not ready (attempt ${attempt}/${maxAttempts}); retrying in ${delayMs}ms...`,
|
|
||||||
lastError
|
|
||||||
);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw lastError ?? new Error('Failed to load settings');
|
throw lastError ?? new Error('Failed to load settings');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to fetch settings after valid session:', error);
|
logger.error('Failed to fetch settings after valid session:', error);
|
||||||
|
|
||||||
|
// If optimistically hydrated, allow the user to continue with cached data
|
||||||
|
if (optimisticallyHydrated) {
|
||||||
|
logger.info('[FAST_HYDRATE] Using optimistic cache as fallback (server unavailable)');
|
||||||
|
signalMigrationComplete();
|
||||||
|
useAuthStore.getState().setAuthState({
|
||||||
|
isAuthenticated: true,
|
||||||
|
authChecked: true,
|
||||||
|
settingsLoaded: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If we can't load settings, we must NOT start syncing defaults to the server.
|
// If we can't load settings, we must NOT start syncing defaults to the server.
|
||||||
// Treat as not authenticated for now (backend likely unavailable) and unblock sync hook.
|
|
||||||
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||||
signalMigrationComplete();
|
signalMigrationComplete();
|
||||||
if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
|
if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
|
||||||
@@ -892,14 +1064,14 @@ function RootLayout() {
|
|||||||
const shouldShowDevtools = IS_DEV && showQueryDevtools && !isCompact;
|
const shouldShowDevtools = IS_DEV && showQueryDevtools && !isCompact;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<PersistQueryClientProvider client={queryClient} persistOptions={persistOptions}>
|
||||||
<FileBrowserProvider>
|
<FileBrowserProvider>
|
||||||
<RootLayoutContent />
|
<RootLayoutContent />
|
||||||
</FileBrowserProvider>
|
</FileBrowserProvider>
|
||||||
{shouldShowDevtools && (
|
{shouldShowDevtools && (
|
||||||
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-right" />
|
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-right" />
|
||||||
)}
|
)}
|
||||||
</QueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
6
apps/ui/src/routes/board.lazy.tsx
Normal file
6
apps/ui/src/routes/board.lazy.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createLazyFileRoute } from '@tanstack/react-router';
|
||||||
|
import { BoardView } from '@/components/views/board-view';
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute('/board')({
|
||||||
|
component: BoardView,
|
||||||
|
});
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { BoardView } from '@/components/views/board-view';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/board')({
|
// Component is lazy-loaded via board.lazy.tsx for code splitting.
|
||||||
component: BoardView,
|
// Board is the most-visited landing route, but lazy loading still benefits
|
||||||
});
|
// initial load because the board component and its dependencies are only
|
||||||
|
// downloaded when the user actually navigates to /board (vs being bundled
|
||||||
|
// into the entry chunk). TanStack Router's autoCodeSplitting handles the
|
||||||
|
// dynamic import automatically when a .lazy.tsx file exists.
|
||||||
|
export const Route = createFileRoute('/board')({});
|
||||||
|
|||||||
6
apps/ui/src/routes/graph.lazy.tsx
Normal file
6
apps/ui/src/routes/graph.lazy.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createLazyFileRoute } from '@tanstack/react-router';
|
||||||
|
import { GraphViewPage } from '@/components/views/graph-view-page';
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute('/graph')({
|
||||||
|
component: GraphViewPage,
|
||||||
|
});
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { GraphViewPage } from '@/components/views/graph-view-page';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/graph')({
|
// Component is lazy-loaded via graph.lazy.tsx for code splitting
|
||||||
component: GraphViewPage,
|
export const Route = createFileRoute('/graph')({});
|
||||||
});
|
|
||||||
|
|||||||
6
apps/ui/src/routes/spec.lazy.tsx
Normal file
6
apps/ui/src/routes/spec.lazy.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createLazyFileRoute } from '@tanstack/react-router';
|
||||||
|
import { SpecView } from '@/components/views/spec-view';
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute('/spec')({
|
||||||
|
component: SpecView,
|
||||||
|
});
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { SpecView } from '@/components/views/spec-view';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/spec')({
|
// Component is lazy-loaded via spec.lazy.tsx for code splitting
|
||||||
component: SpecView,
|
export const Route = createFileRoute('/spec')({});
|
||||||
});
|
|
||||||
|
|||||||
11
apps/ui/src/routes/terminal.lazy.tsx
Normal file
11
apps/ui/src/routes/terminal.lazy.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { createLazyFileRoute, useSearch } from '@tanstack/react-router';
|
||||||
|
import { TerminalView } from '@/components/views/terminal-view';
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute('/terminal')({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const { cwd, branch, mode, nonce } = useSearch({ from: '/terminal' });
|
||||||
|
return <TerminalView initialCwd={cwd} initialBranch={branch} initialMode={mode} nonce={nonce} />;
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { TerminalView } from '@/components/views/terminal-view';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const terminalSearchSchema = z.object({
|
const terminalSearchSchema = z.object({
|
||||||
@@ -9,12 +8,7 @@ const terminalSearchSchema = z.object({
|
|||||||
nonce: z.coerce.number().optional(),
|
nonce: z.coerce.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Component is lazy-loaded via terminal.lazy.tsx for code splitting
|
||||||
export const Route = createFileRoute('/terminal')({
|
export const Route = createFileRoute('/terminal')({
|
||||||
validateSearch: terminalSearchSchema,
|
validateSearch: terminalSearchSchema,
|
||||||
component: RouteComponent,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
|
||||||
const { cwd, branch, mode, nonce } = Route.useSearch();
|
|
||||||
return <TerminalView initialCwd={cwd} initialBranch={branch} initialMode={mode} nonce={nonce} />;
|
|
||||||
}
|
|
||||||
|
|||||||
123
apps/ui/src/store/ui-cache-store.ts
Normal file
123
apps/ui/src/store/ui-cache-store.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* UI Cache Store - Persisted UI State for Instant Restore
|
||||||
|
*
|
||||||
|
* This lightweight Zustand store persists critical UI state to localStorage
|
||||||
|
* so that after a tab discard, the user sees their previous UI configuration
|
||||||
|
* instantly without waiting for the server.
|
||||||
|
*
|
||||||
|
* This is NOT a replacement for the app-store or the API-first settings sync.
|
||||||
|
* It's a fast cache layer that provides instant visual continuity during:
|
||||||
|
* - Tab discard recovery
|
||||||
|
* - Page reloads
|
||||||
|
* - App restarts
|
||||||
|
*
|
||||||
|
* The app-store remains the source of truth. This cache is reconciled
|
||||||
|
* when server settings are loaded (hydrateStoreFromSettings overwrites everything).
|
||||||
|
*
|
||||||
|
* Only stores UI-visual state that affects what the user sees immediately:
|
||||||
|
* - Selected project ID (to restore board context)
|
||||||
|
* - Sidebar state (open/closed, style)
|
||||||
|
* - View preferences (board view mode, collapsed sections)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
interface UICacheState {
|
||||||
|
/** ID of the currently selected project */
|
||||||
|
cachedProjectId: string | null;
|
||||||
|
/** Whether sidebar is open */
|
||||||
|
cachedSidebarOpen: boolean;
|
||||||
|
/** Sidebar style (unified or discord) */
|
||||||
|
cachedSidebarStyle: 'unified' | 'discord';
|
||||||
|
/** Whether worktree panel is collapsed */
|
||||||
|
cachedWorktreePanelCollapsed: boolean;
|
||||||
|
/** Collapsed nav sections */
|
||||||
|
cachedCollapsedNavSections: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UICacheActions {
|
||||||
|
/** Update the cached UI state from the main app store */
|
||||||
|
updateFromAppStore: (state: Partial<UICacheState>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORE_NAME = 'automaker-ui-cache';
|
||||||
|
|
||||||
|
export const useUICacheStore = create<UICacheState & UICacheActions>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
cachedProjectId: null,
|
||||||
|
cachedSidebarOpen: true,
|
||||||
|
cachedSidebarStyle: 'unified',
|
||||||
|
cachedWorktreePanelCollapsed: false,
|
||||||
|
cachedCollapsedNavSections: {},
|
||||||
|
|
||||||
|
updateFromAppStore: (state) => set(state),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: STORE_NAME,
|
||||||
|
version: 1,
|
||||||
|
partialize: (state) => ({
|
||||||
|
cachedProjectId: state.cachedProjectId,
|
||||||
|
cachedSidebarOpen: state.cachedSidebarOpen,
|
||||||
|
cachedSidebarStyle: state.cachedSidebarStyle,
|
||||||
|
cachedWorktreePanelCollapsed: state.cachedWorktreePanelCollapsed,
|
||||||
|
cachedCollapsedNavSections: state.cachedCollapsedNavSections,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync critical UI state from the main app store to the UI cache.
|
||||||
|
* Call this whenever the app store changes to keep the cache up to date.
|
||||||
|
*
|
||||||
|
* This is intentionally a function (not a hook) so it can be called
|
||||||
|
* from store subscriptions without React.
|
||||||
|
*/
|
||||||
|
export function syncUICache(appState: {
|
||||||
|
currentProject?: { id: string } | null;
|
||||||
|
sidebarOpen?: boolean;
|
||||||
|
sidebarStyle?: 'unified' | 'discord';
|
||||||
|
worktreePanelCollapsed?: boolean;
|
||||||
|
collapsedNavSections?: Record<string, boolean>;
|
||||||
|
}): void {
|
||||||
|
useUICacheStore.getState().updateFromAppStore({
|
||||||
|
cachedProjectId: appState.currentProject?.id ?? null,
|
||||||
|
cachedSidebarOpen: appState.sidebarOpen ?? true,
|
||||||
|
cachedSidebarStyle: appState.sidebarStyle ?? 'unified',
|
||||||
|
cachedWorktreePanelCollapsed: appState.worktreePanelCollapsed ?? false,
|
||||||
|
cachedCollapsedNavSections: appState.collapsedNavSections ?? {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore cached UI state into the main app store.
|
||||||
|
* Call this early during initialization — before server settings arrive —
|
||||||
|
* so the user sees their previous UI layout instantly on tab discard recovery
|
||||||
|
* or page reload, instead of a flash of default state.
|
||||||
|
*
|
||||||
|
* This is reconciled later when hydrateStoreFromSettings() overwrites
|
||||||
|
* the app store with authoritative server data.
|
||||||
|
*
|
||||||
|
* @param appStoreSetState - The setState function from the app store (avoids circular import)
|
||||||
|
*/
|
||||||
|
export function restoreFromUICache(
|
||||||
|
appStoreSetState: (state: Record<string, unknown>) => void
|
||||||
|
): boolean {
|
||||||
|
const cache = useUICacheStore.getState();
|
||||||
|
|
||||||
|
// Only restore if we have meaningful cached data (not just defaults)
|
||||||
|
if (cache.cachedProjectId === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
appStoreSetState({
|
||||||
|
sidebarOpen: cache.cachedSidebarOpen,
|
||||||
|
sidebarStyle: cache.cachedSidebarStyle,
|
||||||
|
worktreePanelCollapsed: cache.cachedWorktreePanelCollapsed,
|
||||||
|
collapsedNavSections: cache.cachedCollapsedNavSections,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
7
apps/ui/src/types/electron.d.ts
vendored
7
apps/ui/src/types/electron.d.ts
vendored
@@ -1304,8 +1304,11 @@ export interface WorktreeAPI {
|
|||||||
}) => void
|
}) => void
|
||||||
) => () => void;
|
) => () => void;
|
||||||
|
|
||||||
// Discard changes for a worktree
|
// Discard changes for a worktree (optionally only specific files)
|
||||||
discardChanges: (worktreePath: string) => Promise<{
|
discardChanges: (
|
||||||
|
worktreePath: string,
|
||||||
|
files?: string[]
|
||||||
|
) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
result?: {
|
result?: {
|
||||||
discarded: boolean;
|
discarded: boolean;
|
||||||
|
|||||||
1
apps/ui/src/vite-env.d.ts
vendored
1
apps/ui/src/vite-env.d.ts
vendored
@@ -12,3 +12,4 @@ interface ImportMeta {
|
|||||||
|
|
||||||
// Global constants defined in vite.config.mts
|
// Global constants defined in vite.config.mts
|
||||||
declare const __APP_VERSION__: string;
|
declare const __APP_VERSION__: string;
|
||||||
|
declare const __APP_BUILD_HASH__: string;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import { defineConfig, type Plugin } from 'vite';
|
import { defineConfig, type Plugin } from 'vite';
|
||||||
@@ -13,6 +14,67 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const packageJson = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'));
|
const packageJson = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'));
|
||||||
const appVersion = packageJson.version;
|
const appVersion = packageJson.version;
|
||||||
|
|
||||||
|
// Generate a build hash for cache busting.
|
||||||
|
// Uses git commit hash when available (deterministic across CI builds),
|
||||||
|
// falls back to version + timestamp for non-git environments.
|
||||||
|
// This ensures users get fresh SW caches after each deployment.
|
||||||
|
function getBuildHash(): string {
|
||||||
|
// Try git commit hash first (deterministic, same across CI workers)
|
||||||
|
try {
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
const gitHash = execSync('git rev-parse --short=8 HEAD', { encoding: 'utf-8' }).trim();
|
||||||
|
if (gitHash) return gitHash;
|
||||||
|
} catch {
|
||||||
|
// Not a git repo or git not available — fall back
|
||||||
|
}
|
||||||
|
// Fallback: version + timestamp (unique per build)
|
||||||
|
return crypto.createHash('md5').update(`${appVersion}-${Date.now()}`).digest('hex').slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildHash = getBuildHash();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vite plugin to inject the build hash into sw.js for cache busting.
|
||||||
|
*
|
||||||
|
* Problem: CACHE_NAME = 'automaker-v3' is hardcoded in the service worker.
|
||||||
|
* After a deployment, users may continue getting stale HTML from the SW cache
|
||||||
|
* if someone forgets to manually bump the version.
|
||||||
|
*
|
||||||
|
* Solution: Replace the hardcoded version with a build-time hash so the
|
||||||
|
* SW cache is automatically invalidated on each deployment.
|
||||||
|
*/
|
||||||
|
function swCacheBuster(): Plugin {
|
||||||
|
const CACHE_NAME_PATTERN = /const CACHE_NAME = 'automaker-v3';/;
|
||||||
|
return {
|
||||||
|
name: 'sw-cache-buster',
|
||||||
|
// In build mode: copy sw.js to output with hash injected
|
||||||
|
// In dev mode: no transformation needed (sw.js is served from public/)
|
||||||
|
apply: 'build',
|
||||||
|
closeBundle() {
|
||||||
|
const swPath = path.resolve(__dirname, 'dist', 'sw.js');
|
||||||
|
if (!fs.existsSync(swPath)) {
|
||||||
|
console.warn('[sw-cache-buster] sw.js not found in dist/ — skipping cache bust');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const swContent = fs.readFileSync(swPath, 'utf-8');
|
||||||
|
if (!CACHE_NAME_PATTERN.test(swContent)) {
|
||||||
|
console.error(
|
||||||
|
'[sw-cache-buster] Could not find CACHE_NAME declaration in sw.js. ' +
|
||||||
|
'The service worker cache will NOT be busted on this deploy! ' +
|
||||||
|
"Check that public/sw.js still contains: const CACHE_NAME = 'automaker-v3';"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated = swContent.replace(
|
||||||
|
CACHE_NAME_PATTERN,
|
||||||
|
`const CACHE_NAME = 'automaker-v3-${buildHash}';`
|
||||||
|
);
|
||||||
|
fs.writeFileSync(swPath, updated, 'utf-8');
|
||||||
|
console.log(`[sw-cache-buster] Injected build hash: automaker-v3-${buildHash}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vite plugin to optimize the HTML output for mobile PWA loading speed.
|
* Vite plugin to optimize the HTML output for mobile PWA loading speed.
|
||||||
*
|
*
|
||||||
@@ -121,6 +183,8 @@ export default defineConfig(({ command }) => {
|
|||||||
// Mobile PWA optimization: demote route-specific vendor chunks from
|
// Mobile PWA optimization: demote route-specific vendor chunks from
|
||||||
// modulepreload (blocks FCP) to prefetch (background download)
|
// modulepreload (blocks FCP) to prefetch (background download)
|
||||||
mobilePreloadOptimizer(),
|
mobilePreloadOptimizer(),
|
||||||
|
// Inject build hash into sw.js CACHE_NAME for automatic cache busting
|
||||||
|
swCacheBuster(),
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
@@ -215,6 +279,9 @@ export default defineConfig(({ command }) => {
|
|||||||
},
|
},
|
||||||
define: {
|
define: {
|
||||||
__APP_VERSION__: JSON.stringify(appVersion),
|
__APP_VERSION__: JSON.stringify(appVersion),
|
||||||
|
// Build hash injected for IDB cache busting — matches what swCacheBuster injects
|
||||||
|
// into the SW CACHE_NAME. When the build changes, both caches are invalidated together.
|
||||||
|
__APP_BUILD_HASH__: JSON.stringify(buildHash),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
56
package-lock.json
generated
56
package-lock.json
generated
@@ -146,6 +146,7 @@
|
|||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@radix-ui/react-tooltip": "1.2.8",
|
||||||
"@tanstack/react-query": "^5.90.17",
|
"@tanstack/react-query": "^5.90.17",
|
||||||
"@tanstack/react-query-devtools": "^5.91.2",
|
"@tanstack/react-query-devtools": "^5.91.2",
|
||||||
|
"@tanstack/react-query-persist-client": "^5.90.22",
|
||||||
"@tanstack/react-router": "1.141.6",
|
"@tanstack/react-router": "1.141.6",
|
||||||
"@uiw/react-codemirror": "4.25.4",
|
"@uiw/react-codemirror": "4.25.4",
|
||||||
"@xterm/addon-fit": "0.10.0",
|
"@xterm/addon-fit": "0.10.0",
|
||||||
@@ -160,6 +161,7 @@
|
|||||||
"dagre": "0.8.5",
|
"dagre": "0.8.5",
|
||||||
"dotenv": "17.2.3",
|
"dotenv": "17.2.3",
|
||||||
"geist": "1.5.1",
|
"geist": "1.5.1",
|
||||||
|
"idb-keyval": "^6.2.2",
|
||||||
"lucide-react": "0.562.0",
|
"lucide-react": "0.562.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
@@ -1551,7 +1553,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@electron/node-gyp": {
|
"node_modules/@electron/node-gyp": {
|
||||||
"version": "10.2.0-electron.1",
|
"version": "10.2.0-electron.1",
|
||||||
"resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
|
"resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
|
||||||
"integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==",
|
"integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -5741,9 +5743,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.90.19",
|
"version": "5.90.20",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.19.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
||||||
"integrity": "sha512-GLW5sjPVIvH491VV1ufddnfldyVB+teCnpPIvweEfkpRx7CfUmUGhoh9cdcUKBh/KwVxk22aNEDxeTsvmyB/WA==",
|
"integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -5760,13 +5762,26 @@
|
|||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/query-persist-client-core": {
|
||||||
"version": "5.90.19",
|
"version": "5.91.19",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.19.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.91.19.tgz",
|
||||||
"integrity": "sha512-qTZRZ4QyTzQc+M0IzrbKHxSeISUmRB3RPGmao5bT+sI6ayxSRhn0FXEnT5Hg3as8SBFcRosrXXRFB+yAcxVxJQ==",
|
"integrity": "sha512-whrASqbVq8261Ue+/ZzpHsrLDYVfRaENs4HTdLuYKxawkGWzdMfV7BmOdWl8ZF0mEBEbrQR8V6oE3R635JF2Fw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.90.19"
|
"@tanstack/query-core": "5.90.20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/react-query": {
|
||||||
|
"version": "5.90.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
|
||||||
|
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-core": "5.90.20"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -5793,6 +5808,23 @@
|
|||||||
"react": "^18 || ^19"
|
"react": "^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-query-persist-client": {
|
||||||
|
"version": "5.90.22",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.90.22.tgz",
|
||||||
|
"integrity": "sha512-BrD3Y/SsrSIDl+t/gpYvjvGHXd7m7oF+GIqktKE8LmTgt7bS1lYHd/CLkVxMPixTU53gHHVFfPNGmY7Hv4L/7g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-persist-client-core": "5.91.19"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tanstack/react-query": "^5.90.20",
|
||||||
|
"react": "^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tanstack/react-router": {
|
"node_modules/@tanstack/react-router": {
|
||||||
"version": "1.141.6",
|
"version": "1.141.6",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz",
|
||||||
@@ -10898,6 +10930,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/idb-keyval": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user