Feature: File Editor (#789)

* feat: Add file management feature

* feat: Add auto-save functionality to file editor

* fix: Replace HardDriveDownload icon with Save icon for consistency

* fix: Prevent recursive copy/move and improve shell injection prevention

* refactor: Extract editor settings form into separate component
This commit is contained in:
gsxdsm
2026-02-20 16:06:44 -08:00
committed by GitHub
parent 0a5540c9a2
commit 0e020f7e4a
36 changed files with 5513 additions and 11 deletions

View File

@@ -20,6 +20,9 @@ import { createImageHandler } from './routes/image.js';
import { createSaveBoardBackgroundHandler } from './routes/save-board-background.js'; import { createSaveBoardBackgroundHandler } from './routes/save-board-background.js';
import { createDeleteBoardBackgroundHandler } from './routes/delete-board-background.js'; import { createDeleteBoardBackgroundHandler } from './routes/delete-board-background.js';
import { createBrowseProjectFilesHandler } from './routes/browse-project-files.js'; import { createBrowseProjectFilesHandler } from './routes/browse-project-files.js';
import { createCopyHandler } from './routes/copy.js';
import { createMoveHandler } from './routes/move.js';
import { createDownloadHandler } from './routes/download.js';
export function createFsRoutes(_events: EventEmitter): Router { export function createFsRoutes(_events: EventEmitter): Router {
const router = Router(); const router = Router();
@@ -39,6 +42,9 @@ export function createFsRoutes(_events: EventEmitter): Router {
router.post('/save-board-background', createSaveBoardBackgroundHandler()); router.post('/save-board-background', createSaveBoardBackgroundHandler());
router.post('/delete-board-background', createDeleteBoardBackgroundHandler()); router.post('/delete-board-background', createDeleteBoardBackgroundHandler());
router.post('/browse-project-files', createBrowseProjectFilesHandler()); router.post('/browse-project-files', createBrowseProjectFilesHandler());
router.post('/copy', createCopyHandler());
router.post('/move', createMoveHandler());
router.post('/download', createDownloadHandler());
return router; return router;
} }

View File

@@ -0,0 +1,99 @@
/**
* POST /copy endpoint - Copy file or directory to a new location
*/
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { PathNotAllowedError } from '@automaker/platform';
import { mkdirSafe } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
/**
* Recursively copy a directory and its contents
*/
async function copyDirectoryRecursive(src: string, dest: string): Promise<void> {
await mkdirSafe(dest);
const entries = await secureFs.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDirectoryRecursive(srcPath, destPath);
} else {
await secureFs.copyFile(srcPath, destPath);
}
}
}
export function createCopyHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { sourcePath, destinationPath, overwrite } = req.body as {
sourcePath: string;
destinationPath: string;
overwrite?: boolean;
};
if (!sourcePath || !destinationPath) {
res
.status(400)
.json({ success: false, error: 'sourcePath and destinationPath are required' });
return;
}
// Prevent copying a folder into itself or its own descendant (infinite recursion)
const resolvedSrc = path.resolve(sourcePath);
const resolvedDest = path.resolve(destinationPath);
if (resolvedDest === resolvedSrc || resolvedDest.startsWith(resolvedSrc + path.sep)) {
res.status(400).json({
success: false,
error: 'Cannot copy a folder into itself or one of its own descendants',
});
return;
}
// Check if destination already exists
try {
await secureFs.stat(destinationPath);
// Destination exists
if (!overwrite) {
res.status(409).json({
success: false,
error: 'Destination already exists',
exists: true,
});
return;
}
// If overwrite is true, remove the existing destination first to avoid merging
await secureFs.rm(destinationPath, { recursive: true });
} catch {
// Destination doesn't exist - good to proceed
}
// Ensure parent directory exists
await mkdirSafe(path.dirname(path.resolve(destinationPath)));
// Check if source is a directory
const stats = await secureFs.stat(sourcePath);
if (stats.isDirectory()) {
await copyDirectoryRecursive(sourcePath, destinationPath);
} else {
await secureFs.copyFile(sourcePath, destinationPath);
}
res.json({ success: true });
} catch (error) {
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, 'Copy file failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,142 @@
/**
* POST /download endpoint - Download a file, or GET /download for streaming
* For folders, creates a zip archive on the fly
*/
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
import { createReadStream } from 'fs';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { tmpdir } from 'os';
const execFileAsync = promisify(execFile);
/**
* Get total size of a directory recursively
*/
async function getDirectorySize(dirPath: string): Promise<number> {
let totalSize = 0;
const entries = await secureFs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
totalSize += await getDirectorySize(entryPath);
} else {
const stats = await secureFs.stat(entryPath);
totalSize += Number(stats.size);
}
}
return totalSize;
}
export function createDownloadHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { filePath } = req.body as { filePath: string };
if (!filePath) {
res.status(400).json({ success: false, error: 'filePath is required' });
return;
}
const stats = await secureFs.stat(filePath);
const fileName = path.basename(filePath);
if (stats.isDirectory()) {
// For directories, create a zip archive
const dirSize = await getDirectorySize(filePath);
const MAX_DIR_SIZE = 100 * 1024 * 1024; // 100MB limit
if (dirSize > MAX_DIR_SIZE) {
res.status(413).json({
success: false,
error: `Directory is too large to download (${(dirSize / (1024 * 1024)).toFixed(1)}MB). Maximum size is ${MAX_DIR_SIZE / (1024 * 1024)}MB.`,
size: dirSize,
});
return;
}
// Create a temporary zip file
const zipFileName = `${fileName}.zip`;
const tmpZipPath = path.join(tmpdir(), `automaker-download-${Date.now()}-${zipFileName}`);
try {
// Use system zip command (available on macOS and Linux)
// Use execFile to avoid shell injection via user-provided paths
await execFileAsync('zip', ['-r', tmpZipPath, fileName], {
cwd: path.dirname(filePath),
maxBuffer: 50 * 1024 * 1024,
});
const zipStats = await secureFs.stat(tmpZipPath);
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${zipFileName}"`);
res.setHeader('Content-Length', zipStats.size.toString());
res.setHeader('X-Directory-Size', dirSize.toString());
const stream = createReadStream(tmpZipPath);
stream.pipe(res);
stream.on('end', async () => {
// Cleanup temp file
try {
await secureFs.rm(tmpZipPath);
} catch {
// Ignore cleanup errors
}
});
stream.on('error', async (err) => {
logError(err, 'Download stream error');
try {
await secureFs.rm(tmpZipPath);
} catch {
// Ignore cleanup errors
}
if (!res.headersSent) {
res.status(500).json({ success: false, error: 'Stream error during download' });
}
});
} catch (zipError) {
// Cleanup on zip failure
try {
await secureFs.rm(tmpZipPath);
} catch {
// Ignore
}
throw zipError;
}
} else {
// For individual files, stream directly
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
res.setHeader('Content-Length', stats.size.toString());
const stream = createReadStream(filePath);
stream.pipe(res);
stream.on('error', (err) => {
logError(err, 'Download stream error');
if (!res.headersSent) {
res.status(500).json({ success: false, error: 'Stream error during download' });
}
});
}
} catch (error) {
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, 'Download failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,79 @@
/**
* POST /move endpoint - Move (rename) file or directory to a new location
*/
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { PathNotAllowedError } from '@automaker/platform';
import { mkdirSafe } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
export function createMoveHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { sourcePath, destinationPath, overwrite } = req.body as {
sourcePath: string;
destinationPath: string;
overwrite?: boolean;
};
if (!sourcePath || !destinationPath) {
res
.status(400)
.json({ success: false, error: 'sourcePath and destinationPath are required' });
return;
}
// Prevent moving to same location or into its own descendant
const resolvedSrc = path.resolve(sourcePath);
const resolvedDest = path.resolve(destinationPath);
if (resolvedDest === resolvedSrc) {
// No-op: source and destination are the same
res.json({ success: true });
return;
}
if (resolvedDest.startsWith(resolvedSrc + path.sep)) {
res.status(400).json({
success: false,
error: 'Cannot move a folder into one of its own descendants',
});
return;
}
// Check if destination already exists
try {
await secureFs.stat(destinationPath);
// Destination exists
if (!overwrite) {
res.status(409).json({
success: false,
error: 'Destination already exists',
exists: true,
});
return;
}
// If overwrite is true, remove the existing destination first
await secureFs.rm(destinationPath, { recursive: true });
} catch {
// Destination doesn't exist - good to proceed
}
// Ensure parent directory exists
await mkdirSafe(path.dirname(path.resolve(destinationPath)));
// Use rename for the move operation
await secureFs.rename(sourcePath, destinationPath);
res.json({ success: true });
} catch (error) {
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, 'Move file failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -7,6 +7,8 @@ import { validatePathParams } from '../../middleware/validate-paths.js';
import { createDiffsHandler } from './routes/diffs.js'; import { createDiffsHandler } from './routes/diffs.js';
import { createFileDiffHandler } from './routes/file-diff.js'; import { createFileDiffHandler } from './routes/file-diff.js';
import { createStageFilesHandler } from './routes/stage-files.js'; import { createStageFilesHandler } from './routes/stage-files.js';
import { createDetailsHandler } from './routes/details.js';
import { createEnhancedStatusHandler } from './routes/enhanced-status.js';
export function createGitRoutes(): Router { export function createGitRoutes(): Router {
const router = Router(); const router = Router();
@@ -18,6 +20,8 @@ export function createGitRoutes(): Router {
validatePathParams('projectPath', 'files[]'), validatePathParams('projectPath', 'files[]'),
createStageFilesHandler() createStageFilesHandler()
); );
router.post('/details', validatePathParams('projectPath', 'filePath?'), createDetailsHandler());
router.post('/enhanced-status', validatePathParams('projectPath'), createEnhancedStatusHandler());
return router; return router;
} }

View File

@@ -0,0 +1,248 @@
/**
* POST /details endpoint - Get detailed git info for a file or project
* Returns branch, last commit info, diff stats, and conflict status
*/
import type { Request, Response } from 'express';
import { exec, execFile } from 'child_process';
import { promisify } from 'util';
import * as secureFs from '../../../lib/secure-fs.js';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
interface GitFileDetails {
branch: string;
lastCommitHash: string;
lastCommitMessage: string;
lastCommitAuthor: string;
lastCommitTimestamp: string;
linesAdded: number;
linesRemoved: number;
isConflicted: boolean;
isStaged: boolean;
isUnstaged: boolean;
statusLabel: string;
}
export function createDetailsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, filePath } = req.body as {
projectPath: string;
filePath?: string;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath required' });
return;
}
try {
// Get current branch
const { stdout: branchRaw } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: projectPath,
});
const branch = branchRaw.trim();
if (!filePath) {
// Project-level details - just return branch info
res.json({
success: true,
details: { branch },
});
return;
}
// Get last commit info for this file
let lastCommitHash = '';
let lastCommitMessage = '';
let lastCommitAuthor = '';
let lastCommitTimestamp = '';
try {
const { stdout: logOutput } = await execFileAsync(
'git',
['log', '-1', '--format=%H|%s|%an|%aI', '--', filePath],
{ cwd: projectPath }
);
if (logOutput.trim()) {
const parts = logOutput.trim().split('|');
lastCommitHash = parts[0] || '';
lastCommitMessage = parts[1] || '';
lastCommitAuthor = parts[2] || '';
lastCommitTimestamp = parts[3] || '';
}
} catch {
// File may not have any commits yet
}
// Get diff stats (lines added/removed)
let linesAdded = 0;
let linesRemoved = 0;
try {
// Check if file is untracked first
const { stdout: statusLine } = await execFileAsync(
'git',
['status', '--porcelain', '--', filePath],
{ cwd: projectPath }
);
if (statusLine.trim().startsWith('??')) {
// Untracked file - count all lines as added using Node.js instead of shell
try {
const fileContent = (await secureFs.readFile(filePath, 'utf-8')).toString();
const lines = fileContent.split('\n');
// Don't count trailing empty line from final newline
linesAdded =
lines.length > 0 && lines[lines.length - 1] === ''
? lines.length - 1
: lines.length;
} catch {
// Ignore
}
} else {
const { stdout: diffStatRaw } = await execFileAsync(
'git',
['diff', '--numstat', 'HEAD', '--', filePath],
{ cwd: projectPath }
);
if (diffStatRaw.trim()) {
const parts = diffStatRaw.trim().split('\t');
linesAdded = parseInt(parts[0], 10) || 0;
linesRemoved = parseInt(parts[1], 10) || 0;
}
// Also check staged diff stats
const { stdout: stagedDiffStatRaw } = await execFileAsync(
'git',
['diff', '--numstat', '--cached', '--', filePath],
{ cwd: projectPath }
);
if (stagedDiffStatRaw.trim()) {
const parts = stagedDiffStatRaw.trim().split('\t');
linesAdded += parseInt(parts[0], 10) || 0;
linesRemoved += parseInt(parts[1], 10) || 0;
}
}
} catch {
// Diff might not be available
}
// Get conflict and staging status
let isConflicted = false;
let isStaged = false;
let isUnstaged = false;
let statusLabel = '';
try {
const { stdout: statusOutput } = await execFileAsync(
'git',
['status', '--porcelain', '--', filePath],
{ cwd: projectPath }
);
if (statusOutput.trim()) {
const indexStatus = statusOutput[0];
const workTreeStatus = statusOutput[1];
// Check for conflicts (both modified, unmerged states)
if (
indexStatus === 'U' ||
workTreeStatus === 'U' ||
(indexStatus === 'A' && workTreeStatus === 'A') ||
(indexStatus === 'D' && workTreeStatus === 'D')
) {
isConflicted = true;
statusLabel = 'Conflicted';
} else {
// Staged changes (index has a status)
if (indexStatus !== ' ' && indexStatus !== '?') {
isStaged = true;
}
// Unstaged changes (work tree has a status)
if (workTreeStatus !== ' ' && workTreeStatus !== '?') {
isUnstaged = true;
}
// Build status label
if (isStaged && isUnstaged) {
statusLabel = 'Staged + Modified';
} else if (isStaged) {
statusLabel = 'Staged';
} else {
const statusChar = workTreeStatus !== ' ' ? workTreeStatus : indexStatus;
switch (statusChar) {
case 'M':
statusLabel = 'Modified';
break;
case 'A':
statusLabel = 'Added';
break;
case 'D':
statusLabel = 'Deleted';
break;
case 'R':
statusLabel = 'Renamed';
break;
case 'C':
statusLabel = 'Copied';
break;
case '?':
statusLabel = 'Untracked';
break;
default:
statusLabel = statusChar || '';
}
}
}
}
} catch {
// Status might not be available
}
const details: GitFileDetails = {
branch,
lastCommitHash,
lastCommitMessage,
lastCommitAuthor,
lastCommitTimestamp,
linesAdded,
linesRemoved,
isConflicted,
isStaged,
isUnstaged,
statusLabel,
};
res.json({ success: true, details });
} catch (innerError) {
logError(innerError, 'Git details failed');
res.json({
success: true,
details: {
branch: '',
lastCommitHash: '',
lastCommitMessage: '',
lastCommitAuthor: '',
lastCommitTimestamp: '',
linesAdded: 0,
linesRemoved: 0,
isConflicted: false,
isStaged: false,
isUnstaged: false,
statusLabel: '',
},
});
}
} catch (error) {
logError(error, 'Get git details failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,176 @@
/**
* POST /enhanced-status endpoint - Get enhanced git status with diff stats per file
* Returns per-file status with lines added/removed and staged/unstaged differentiation
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
interface EnhancedFileStatus {
path: string;
indexStatus: string;
workTreeStatus: string;
isConflicted: boolean;
isStaged: boolean;
isUnstaged: boolean;
linesAdded: number;
linesRemoved: number;
statusLabel: string;
}
function getStatusLabel(indexStatus: string, workTreeStatus: string): string {
// Check for conflicts
if (
indexStatus === 'U' ||
workTreeStatus === 'U' ||
(indexStatus === 'A' && workTreeStatus === 'A') ||
(indexStatus === 'D' && workTreeStatus === 'D')
) {
return 'Conflicted';
}
const hasStaged = indexStatus !== ' ' && indexStatus !== '?';
const hasUnstaged = workTreeStatus !== ' ' && workTreeStatus !== '?';
if (hasStaged && hasUnstaged) return 'Staged + Modified';
if (hasStaged) return 'Staged';
const statusChar = workTreeStatus !== ' ' ? workTreeStatus : indexStatus;
switch (statusChar) {
case 'M':
return 'Modified';
case 'A':
return 'Added';
case 'D':
return 'Deleted';
case 'R':
return 'Renamed';
case 'C':
return 'Copied';
case '?':
return 'Untracked';
default:
return statusChar || '';
}
}
export function createEnhancedStatusHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath required' });
return;
}
try {
// Get current branch
const { stdout: branchRaw } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: projectPath,
});
const branch = branchRaw.trim();
// Get porcelain status for all files
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
cwd: projectPath,
});
// Get diff numstat for working tree changes
let workTreeStats: Record<string, { added: number; removed: number }> = {};
try {
const { stdout: numstatRaw } = await execAsync('git diff --numstat', {
cwd: projectPath,
maxBuffer: 10 * 1024 * 1024,
});
for (const line of numstatRaw.trim().split('\n').filter(Boolean)) {
const parts = line.split('\t');
if (parts.length >= 3) {
const added = parseInt(parts[0], 10) || 0;
const removed = parseInt(parts[1], 10) || 0;
workTreeStats[parts[2]] = { added, removed };
}
}
} catch {
// Ignore
}
// Get diff numstat for staged changes
let stagedStats: Record<string, { added: number; removed: number }> = {};
try {
const { stdout: stagedNumstatRaw } = await execAsync('git diff --numstat --cached', {
cwd: projectPath,
maxBuffer: 10 * 1024 * 1024,
});
for (const line of stagedNumstatRaw.trim().split('\n').filter(Boolean)) {
const parts = line.split('\t');
if (parts.length >= 3) {
const added = parseInt(parts[0], 10) || 0;
const removed = parseInt(parts[1], 10) || 0;
stagedStats[parts[2]] = { added, removed };
}
}
} catch {
// Ignore
}
// Parse status and build enhanced file list
const files: EnhancedFileStatus[] = [];
for (const line of statusOutput.split('\n').filter(Boolean)) {
if (line.length < 4) continue;
const indexStatus = line[0];
const workTreeStatus = line[1];
const filePath = line.substring(3).trim();
// Handle renamed files (format: "R old -> new")
const actualPath = filePath.includes(' -> ')
? filePath.split(' -> ')[1].trim()
: filePath;
const isConflicted =
indexStatus === 'U' ||
workTreeStatus === 'U' ||
(indexStatus === 'A' && workTreeStatus === 'A') ||
(indexStatus === 'D' && workTreeStatus === 'D');
const isStaged = indexStatus !== ' ' && indexStatus !== '?';
const isUnstaged = workTreeStatus !== ' ' && workTreeStatus !== '?';
// Combine diff stats from both working tree and staged
const wtStats = workTreeStats[actualPath] || { added: 0, removed: 0 };
const stStats = stagedStats[actualPath] || { added: 0, removed: 0 };
files.push({
path: actualPath,
indexStatus,
workTreeStatus,
isConflicted,
isStaged,
isUnstaged,
linesAdded: wtStats.added + stStats.added,
linesRemoved: wtStats.removed + stStats.removed,
statusLabel: getStatusLabel(indexStatus, workTreeStatus),
});
}
res.json({
success: true,
branch,
files,
});
} catch (innerError) {
logError(innerError, 'Git enhanced status failed');
res.json({ success: true, branch: '', files: [] });
}
} catch (error) {
logError(error, 'Get enhanced status failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -42,10 +42,24 @@
"@automaker/dependency-resolver": "1.0.0", "@automaker/dependency-resolver": "1.0.0",
"@automaker/spec-parser": "1.0.0", "@automaker/spec-parser": "1.0.0",
"@automaker/types": "1.0.0", "@automaker/types": "1.0.0",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-xml": "6.1.0", "@codemirror/lang-xml": "6.1.0",
"@codemirror/language": "^6.12.1", "@codemirror/language": "^6.12.1",
"@codemirror/legacy-modes": "^6.5.2", "@codemirror/legacy-modes": "^6.5.2",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "6.1.3", "@codemirror/theme-one-dark": "6.1.3",
"@codemirror/view": "^6.39.15",
"@dnd-kit/core": "6.3.1", "@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0", "@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2", "@dnd-kit/utilities": "3.2.2",

View File

@@ -2,6 +2,7 @@ import { useMemo, useState, useEffect } from 'react';
import type { NavigateOptions } from '@tanstack/react-router'; import type { NavigateOptions } from '@tanstack/react-router';
import { import {
FileText, FileText,
Folder,
LayoutGrid, LayoutGrid,
Bot, Bot,
BookOpen, BookOpen,
@@ -142,7 +143,7 @@ export function useNavigation({
return true; return true;
}); });
// Build project items - Terminal is conditionally included // Build project items - Terminal and File Editor are conditionally included
const projectItems: NavItem[] = [ const projectItems: NavItem[] = [
{ {
id: 'board', id: 'board',
@@ -156,6 +157,11 @@ export function useNavigation({
icon: Network, icon: Network,
shortcut: shortcuts.graph, shortcut: shortcuts.graph,
}, },
{
id: 'file-editor',
label: 'File Editor',
icon: Folder,
},
{ {
id: 'agent', id: 'agent',
label: 'Agent Runner', label: 'Agent Runner',

View File

@@ -0,0 +1,603 @@
import { useMemo, useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { EditorView, keymap } from '@codemirror/view';
import { Extension } from '@codemirror/state';
import { undo as cmUndo, redo as cmRedo } from '@codemirror/commands';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { tags as t } from '@lezer/highlight';
import { search, openSearchPanel } from '@codemirror/search';
// Language imports
import { javascript } from '@codemirror/lang-javascript';
import { html } from '@codemirror/lang-html';
import { css } from '@codemirror/lang-css';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { python } from '@codemirror/lang-python';
import { java } from '@codemirror/lang-java';
import { rust } from '@codemirror/lang-rust';
import { cpp } from '@codemirror/lang-cpp';
import { sql } from '@codemirror/lang-sql';
import { php } from '@codemirror/lang-php';
import { xml } from '@codemirror/lang-xml';
import { StreamLanguage } from '@codemirror/language';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
import { toml } from '@codemirror/legacy-modes/mode/toml';
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
import { go } from '@codemirror/legacy-modes/mode/go';
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
import { swift } from '@codemirror/legacy-modes/mode/swift';
import { cn } from '@/lib/utils';
import { useIsMobile } from '@/hooks/use-media-query';
import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
/** Default monospace font stack used when no custom font is set */
const DEFAULT_EDITOR_FONT =
'var(--font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace)';
/** Get the actual CSS font family value for the editor */
function getEditorFontFamily(fontValue: string | undefined): string {
if (!fontValue || fontValue === DEFAULT_FONT_VALUE) {
return DEFAULT_EDITOR_FONT;
}
return fontValue;
}
/** Handle exposed by CodeEditor for external control */
export interface CodeEditorHandle {
/** Opens the CodeMirror search panel */
openSearch: () => void;
/** Focuses the editor */
focus: () => void;
/** Undoes the last edit */
undo: () => void;
/** Redoes the last undone edit */
redo: () => void;
}
interface CodeEditorProps {
value: string;
onChange: (value: string) => void;
filePath: string;
readOnly?: boolean;
tabSize?: number;
wordWrap?: boolean;
fontSize?: number;
/** CSS font-family value for the editor. Use 'default' or undefined for the theme default mono font. */
fontFamily?: string;
onCursorChange?: (line: number, col: number) => void;
onSave?: () => void;
className?: string;
/** When true, scrolls the cursor into view (e.g. after virtual keyboard opens) */
scrollCursorIntoView?: boolean;
}
/** Detect language extension based on file extension */
function getLanguageExtension(filePath: string): Extension | null {
const name = filePath.split('/').pop()?.toLowerCase() || '';
const dotIndex = name.lastIndexOf('.');
// Files without an extension (no dot, or dotfile with dot at position 0)
const ext = dotIndex > 0 ? name.slice(dotIndex + 1) : '';
// Handle files by name first
switch (name) {
case 'dockerfile':
case 'dockerfile.dev':
case 'dockerfile.prod':
return StreamLanguage.define(dockerFile);
case 'makefile':
case 'gnumakefile':
return StreamLanguage.define(shell);
case '.gitignore':
case '.dockerignore':
case '.npmignore':
case '.eslintignore':
return StreamLanguage.define(shell); // close enough for ignore files
case '.env':
case '.env.local':
case '.env.development':
case '.env.production':
return StreamLanguage.define(shell);
}
switch (ext) {
// JavaScript/TypeScript
case 'js':
case 'mjs':
case 'cjs':
return javascript();
case 'jsx':
return javascript({ jsx: true });
case 'ts':
case 'mts':
case 'cts':
return javascript({ typescript: true });
case 'tsx':
return javascript({ jsx: true, typescript: true });
// Web
case 'html':
case 'htm':
case 'svelte':
case 'vue':
return html();
case 'css':
case 'scss':
case 'less':
return css();
case 'json':
case 'jsonc':
case 'json5':
return json();
case 'xml':
case 'svg':
case 'xsl':
case 'xslt':
case 'plist':
return xml();
// Markdown
case 'md':
case 'mdx':
case 'markdown':
return markdown();
// Python
case 'py':
case 'pyx':
case 'pyi':
return python();
// Java/Kotlin
case 'java':
case 'kt':
case 'kts':
return java();
// Systems
case 'rs':
return rust();
case 'c':
case 'h':
return cpp();
case 'cpp':
case 'cc':
case 'cxx':
case 'hpp':
case 'hxx':
return cpp();
case 'go':
return StreamLanguage.define(go);
case 'swift':
return StreamLanguage.define(swift);
// Scripting
case 'rb':
case 'erb':
return StreamLanguage.define(ruby);
case 'php':
return php();
case 'sh':
case 'bash':
case 'zsh':
case 'fish':
return StreamLanguage.define(shell);
// Data
case 'sql':
case 'mysql':
case 'pgsql':
return sql();
case 'yaml':
case 'yml':
return StreamLanguage.define(yaml);
case 'toml':
return StreamLanguage.define(toml);
default:
return null; // Plain text fallback
}
}
/** Get a human-readable language name */
export function getLanguageName(filePath: string): string {
const name = filePath.split('/').pop()?.toLowerCase() || '';
const dotIndex = name.lastIndexOf('.');
// Files without an extension (no dot, or dotfile with dot at position 0)
const ext = dotIndex > 0 ? name.slice(dotIndex + 1) : '';
if (name === 'dockerfile' || name.startsWith('dockerfile.')) return 'Dockerfile';
if (name === 'makefile' || name === 'gnumakefile') return 'Makefile';
if (name.startsWith('.env')) return 'Environment';
if (name.startsWith('.git') || name.startsWith('.npm') || name.startsWith('.docker'))
return 'Config';
switch (ext) {
case 'js':
case 'mjs':
case 'cjs':
return 'JavaScript';
case 'jsx':
return 'JSX';
case 'ts':
case 'mts':
case 'cts':
return 'TypeScript';
case 'tsx':
return 'TSX';
case 'html':
case 'htm':
return 'HTML';
case 'svelte':
return 'Svelte';
case 'vue':
return 'Vue';
case 'css':
return 'CSS';
case 'scss':
return 'SCSS';
case 'less':
return 'Less';
case 'json':
case 'jsonc':
case 'json5':
return 'JSON';
case 'xml':
case 'svg':
return 'XML';
case 'md':
case 'mdx':
case 'markdown':
return 'Markdown';
case 'py':
case 'pyx':
case 'pyi':
return 'Python';
case 'java':
return 'Java';
case 'kt':
case 'kts':
return 'Kotlin';
case 'rs':
return 'Rust';
case 'c':
case 'h':
return 'C';
case 'cpp':
case 'cc':
case 'cxx':
case 'hpp':
return 'C++';
case 'go':
return 'Go';
case 'swift':
return 'Swift';
case 'rb':
case 'erb':
return 'Ruby';
case 'php':
return 'PHP';
case 'sh':
case 'bash':
case 'zsh':
return 'Shell';
case 'sql':
return 'SQL';
case 'yaml':
case 'yml':
return 'YAML';
case 'toml':
return 'TOML';
default:
return 'Plain Text';
}
}
// Syntax highlighting using CSS variables for theme compatibility
const syntaxColors = HighlightStyle.define([
{ tag: t.keyword, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
{ tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
{ tag: t.number, color: 'var(--chart-3, oklch(0.7 0.15 150))' },
{ tag: t.bool, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
{ tag: t.null, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
{ tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' },
{ tag: t.propertyName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
{ tag: t.variableName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
{ tag: t.function(t.variableName), color: 'var(--primary)' },
{ tag: t.typeName, color: 'var(--chart-5, oklch(0.65 0.2 30))' },
{ tag: t.className, color: 'var(--chart-5, oklch(0.65 0.2 30))' },
{ tag: t.definition(t.variableName), color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
{ tag: t.operator, color: 'var(--muted-foreground)' },
{ tag: t.bracket, color: 'var(--muted-foreground)' },
{ tag: t.punctuation, color: 'var(--muted-foreground)' },
{ tag: t.attributeName, color: 'var(--chart-5, oklch(0.65 0.2 30))' },
{ tag: t.attributeValue, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
{ tag: t.tagName, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
{ tag: t.heading, color: 'var(--foreground)', fontWeight: 'bold' },
{ tag: t.emphasis, fontStyle: 'italic' },
{ tag: t.strong, fontWeight: 'bold' },
{ tag: t.link, color: 'var(--primary)', textDecoration: 'underline' },
{ tag: t.content, color: 'var(--foreground)' },
{ tag: t.regexp, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
{ tag: t.meta, color: 'var(--muted-foreground)' },
]);
export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function CodeEditor(
{
value,
onChange,
filePath,
readOnly = false,
tabSize = 2,
wordWrap = true,
fontSize = 13,
fontFamily,
onCursorChange,
onSave,
className,
scrollCursorIntoView = false,
},
ref
) {
const editorRef = useRef<ReactCodeMirrorRef>(null);
const isMobile = useIsMobile();
// Stable refs for callbacks to avoid frequent extension rebuilds
const onSaveRef = useRef(onSave);
const onCursorChangeRef = useRef(onCursorChange);
useEffect(() => {
onSaveRef.current = onSave;
}, [onSave]);
useEffect(() => {
onCursorChangeRef.current = onCursorChange;
}, [onCursorChange]);
// Expose imperative methods to parent components
useImperativeHandle(
ref,
() => ({
openSearch: () => {
if (editorRef.current?.view) {
editorRef.current.view.focus();
openSearchPanel(editorRef.current.view);
}
},
focus: () => {
if (editorRef.current?.view) {
editorRef.current.view.focus();
}
},
undo: () => {
if (editorRef.current?.view) {
editorRef.current.view.focus();
cmUndo(editorRef.current.view);
}
},
redo: () => {
if (editorRef.current?.view) {
editorRef.current.view.focus();
cmRedo(editorRef.current.view);
}
},
}),
[]
);
// When the virtual keyboard opens on mobile, the container shrinks but the
// cursor may be below the new fold. Dispatch a scrollIntoView effect so
// CodeMirror re-centres the viewport around the caret.
useEffect(() => {
if (scrollCursorIntoView && editorRef.current?.view) {
const view = editorRef.current.view;
// Request CodeMirror to scroll the current selection into view
view.dispatch({
effects: EditorView.scrollIntoView(view.state.selection.main.head, { y: 'center' }),
});
}
}, [scrollCursorIntoView]);
// Resolve the effective font family CSS value
const resolvedFontFamily = useMemo(() => getEditorFontFamily(fontFamily), [fontFamily]);
// Build editor theme dynamically based on fontSize, fontFamily, and screen size
const editorTheme = useMemo(
() =>
EditorView.theme({
'&': {
height: '100%',
fontSize: `${fontSize}px`,
fontFamily: resolvedFontFamily,
backgroundColor: 'transparent',
color: 'var(--foreground)',
},
'.cm-scroller': {
overflow: 'auto',
fontFamily: resolvedFontFamily,
},
'.cm-content': {
padding: '0.5rem 0',
minHeight: '100%',
caretColor: 'var(--primary)',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--primary)',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
},
'.cm-activeLine': {
backgroundColor: 'var(--accent)',
opacity: '0.3',
},
'.cm-line': {
padding: '0 0.5rem',
},
'&.cm-focused': {
outline: 'none',
},
'.cm-gutters': {
backgroundColor: 'transparent',
color: 'var(--muted-foreground)',
border: 'none',
borderRight: '1px solid var(--border)',
paddingRight: '0.25rem',
},
'.cm-lineNumbers .cm-gutterElement': {
minWidth: isMobile ? '1.75rem' : '3rem',
textAlign: 'right',
paddingRight: isMobile ? '0.25rem' : '0.5rem',
fontSize: `${fontSize - 1}px`,
},
'.cm-foldGutter .cm-gutterElement': {
padding: '0 0.25rem',
},
'.cm-placeholder': {
color: 'var(--muted-foreground)',
fontStyle: 'italic',
},
// Search panel styling
'.cm-panels': {
backgroundColor: 'var(--card)',
borderBottom: '1px solid var(--border)',
},
'.cm-panels-top': {
borderBottom: '1px solid var(--border)',
},
'.cm-search': {
backgroundColor: 'var(--card)',
padding: '0.5rem 0.75rem',
gap: '0.375rem',
fontSize: `${fontSize - 1}px`,
},
'.cm-search input, .cm-search select': {
backgroundColor: 'var(--background)',
color: 'var(--foreground)',
border: '1px solid var(--border)',
borderRadius: '0.375rem',
padding: '0.25rem 0.5rem',
outline: 'none',
fontSize: `${fontSize - 1}px`,
fontFamily:
'var(--font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace)',
},
'.cm-search input:focus': {
borderColor: 'var(--primary)',
boxShadow: '0 0 0 1px var(--primary)',
},
'.cm-search button': {
backgroundColor: 'var(--muted)',
color: 'var(--foreground)',
border: '1px solid var(--border)',
borderRadius: '0.375rem',
padding: '0.25rem 0.625rem',
cursor: 'pointer',
fontSize: `${fontSize - 1}px`,
transition: 'background-color 0.15s ease',
},
'.cm-search button:hover': {
backgroundColor: 'var(--accent)',
},
'.cm-search button[name="close"]': {
backgroundColor: 'transparent',
border: 'none',
padding: '0.25rem',
borderRadius: '0.25rem',
color: 'var(--muted-foreground)',
},
'.cm-search button[name="close"]:hover': {
backgroundColor: 'var(--accent)',
color: 'var(--foreground)',
},
'.cm-search label': {
color: 'var(--muted-foreground)',
fontSize: `${fontSize - 1}px`,
},
'.cm-search .cm-textfield': {
minWidth: '10rem',
},
'.cm-searchMatch': {
backgroundColor: 'oklch(0.7 0.2 90 / 0.3)',
borderRadius: '1px',
},
'.cm-searchMatch-selected': {
backgroundColor: 'oklch(0.6 0.25 265 / 0.4)',
},
}),
[fontSize, resolvedFontFamily, isMobile]
);
// Build extensions list
// Uses refs for onSave/onCursorChange to avoid frequent extension rebuilds
// when parent passes inline arrow functions
const extensions = useMemo(() => {
const exts: Extension[] = [
syntaxHighlighting(syntaxColors),
editorTheme,
search(),
EditorView.updateListener.of((update) => {
if (update.selectionSet && onCursorChangeRef.current) {
const pos = update.state.selection.main.head;
const line = update.state.doc.lineAt(pos);
onCursorChangeRef.current(line.number, pos - line.from + 1);
}
}),
];
// Add save keybinding (always register, check ref at call time)
exts.push(
keymap.of([
{
key: 'Mod-s',
run: () => {
onSaveRef.current?.();
return true;
},
},
])
);
// Add word wrap
if (wordWrap) {
exts.push(EditorView.lineWrapping);
}
// Add tab size
exts.push(EditorView.editorAttributes.of({ style: `tab-size: ${tabSize}` }));
// Add language extension
const langExt = getLanguageExtension(filePath);
if (langExt) {
exts.push(langExt);
}
return exts;
}, [filePath, wordWrap, tabSize, editorTheme]);
return (
<div className={cn('h-full w-full', className)}>
<CodeMirror
ref={editorRef}
value={value}
onChange={onChange}
extensions={extensions}
theme="none"
height="100%"
readOnly={readOnly}
className="h-full [&_.cm-editor]:h-full"
basicSetup={{
lineNumbers: true,
foldGutter: true,
highlightActiveLine: true,
highlightSelectionMatches: true,
autocompletion: false,
bracketMatching: true,
indentOnInput: true,
closeBrackets: true,
tabSize,
}}
/>
</div>
);
});

View File

@@ -0,0 +1,95 @@
import { RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { UI_MONO_FONT_OPTIONS, DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
interface EditorSettingsFormProps {
editorFontSize: number;
setEditorFontSize: (value: number) => void;
editorFontFamily: string | null | undefined;
setEditorFontFamily: (value: string) => void;
editorAutoSave: boolean;
setEditorAutoSave: (value: boolean) => void;
}
export function EditorSettingsForm({
editorFontSize,
setEditorFontSize,
editorFontFamily,
setEditorFontFamily,
editorAutoSave,
setEditorAutoSave,
}: EditorSettingsFormProps) {
return (
<>
{/* Font Size */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium">Font Size</Label>
<span className="text-xs text-muted-foreground">{editorFontSize}px</span>
</div>
<div className="flex items-center gap-2">
<Slider
value={[editorFontSize]}
min={8}
max={32}
step={1}
onValueChange={([value]) => setEditorFontSize(value)}
className="flex-1"
/>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => setEditorFontSize(13)}
disabled={editorFontSize === 13}
title="Reset to default"
>
<RotateCcw className="h-3 w-3" />
</Button>
</div>
</div>
{/* Font Family */}
<div className="space-y-2">
<Label className="text-xs font-medium">Font Family</Label>
<Select
value={editorFontFamily || DEFAULT_FONT_VALUE}
onValueChange={(value) => setEditorFontFamily(value)}
>
<SelectTrigger className="w-full h-8 text-xs">
<SelectValue placeholder="Default (Geist Mono)" />
</SelectTrigger>
<SelectContent>
{UI_MONO_FONT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<span
style={{
fontFamily: option.value === DEFAULT_FONT_VALUE ? undefined : option.value,
}}
>
{option.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Auto Save toggle */}
<div className="flex items-center justify-between">
<Label className="text-xs font-medium">Auto Save</Label>
<Switch checked={editorAutoSave} onCheckedChange={setEditorAutoSave} />
</div>
</>
);
}

View File

@@ -0,0 +1,152 @@
import { X, Circle, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { EditorTab } from '../use-file-editor-store';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface EditorTabsProps {
tabs: EditorTab[];
activeTabId: string | null;
onTabSelect: (tabId: string) => void;
onTabClose: (tabId: string) => void;
onCloseAll: () => void;
}
/** Get a file icon color based on extension */
function getFileColor(fileName: string): string {
const dotIndex = fileName.lastIndexOf('.');
// Files without an extension (no dot, or dotfile with dot at position 0)
const ext = dotIndex > 0 ? fileName.slice(dotIndex + 1).toLowerCase() : '';
switch (ext) {
case 'ts':
case 'tsx':
return 'text-blue-400';
case 'js':
case 'jsx':
case 'mjs':
return 'text-yellow-400';
case 'css':
case 'scss':
case 'less':
return 'text-purple-400';
case 'html':
case 'htm':
return 'text-orange-400';
case 'json':
return 'text-yellow-300';
case 'md':
case 'mdx':
return 'text-gray-300';
case 'py':
return 'text-green-400';
case 'rs':
return 'text-orange-500';
case 'go':
return 'text-cyan-400';
case 'rb':
return 'text-red-400';
case 'java':
case 'kt':
return 'text-red-500';
case 'sql':
return 'text-blue-300';
case 'yaml':
case 'yml':
return 'text-pink-400';
case 'toml':
return 'text-gray-400';
case 'sh':
case 'bash':
case 'zsh':
return 'text-green-300';
default:
return 'text-muted-foreground';
}
}
export function EditorTabs({
tabs,
activeTabId,
onTabSelect,
onTabClose,
onCloseAll,
}: EditorTabsProps) {
if (tabs.length === 0) return null;
return (
<div
className="flex items-center border-b border-border bg-muted/30 overflow-x-auto"
data-testid="editor-tabs"
>
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
const fileColor = getFileColor(tab.fileName);
return (
<div
key={tab.id}
className={cn(
'group flex items-center gap-1.5 px-3 py-1.5 cursor-pointer border-r border-border min-w-0 max-w-[200px] text-sm transition-colors',
isActive
? 'bg-background text-foreground border-b-2 border-b-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
)}
onClick={() => onTabSelect(tab.id)}
title={tab.filePath}
>
{/* Dirty indicator */}
{tab.isDirty ? (
<Circle className="w-2 h-2 shrink-0 fill-current text-primary" />
) : (
<span className={cn('w-2 h-2 rounded-full shrink-0', fileColor)} />
)}
{/* File name */}
<span className="truncate">{tab.fileName}</span>
{/* Close button */}
<button
onClick={(e) => {
e.stopPropagation();
onTabClose(tab.id);
}}
className={cn(
'p-0.5 rounded shrink-0 transition-colors',
'opacity-0 group-hover:opacity-100',
isActive && 'opacity-60',
'hover:bg-accent'
)}
title="Close"
>
<X className="w-3 h-3" />
</button>
</div>
);
})}
{/* Tab actions dropdown (close all, etc.) */}
<div className="ml-auto shrink-0 flex items-center px-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
title="Tab actions"
>
<MoreHorizontal className="w-4 h-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={onCloseAll} className="gap-2 cursor-pointer">
<X className="w-4 h-4" />
<span>Close All</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}

View File

@@ -0,0 +1,927 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import {
File,
Folder,
FolderOpen,
FolderPlus,
FilePlus,
ChevronRight,
ChevronDown,
Trash2,
Pencil,
Eye,
EyeOff,
RefreshCw,
MoreVertical,
Copy,
ClipboardCopy,
FolderInput,
FolderOutput,
Download,
Plus,
Minus,
AlertTriangle,
GripVertical,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useFileEditorStore, type FileTreeNode } from '../use-file-editor-store';
interface FileTreeProps {
onFileSelect: (path: string) => void;
onCreateFile: (parentPath: string, name: string) => Promise<void>;
onCreateFolder: (parentPath: string, name: string) => Promise<void>;
onDeleteItem: (path: string, isDirectory: boolean) => Promise<void>;
onRenameItem: (oldPath: string, newName: string) => Promise<void>;
onCopyPath: (path: string) => void;
onRefresh: () => void;
onToggleFolder: (path: string) => void;
activeFilePath: string | null;
onCopyItem?: (sourcePath: string, destinationPath: string) => Promise<void>;
onMoveItem?: (sourcePath: string, destinationPath: string) => Promise<void>;
onDownloadItem?: (filePath: string) => Promise<void>;
onDragDropMove?: (sourcePaths: string[], targetFolderPath: string) => Promise<void>;
effectivePath?: string;
}
/** Get a color class for git status */
function getGitStatusColor(status: string | undefined): string {
if (!status) return '';
switch (status) {
case 'M':
return 'text-yellow-500'; // modified
case 'A':
return 'text-green-500'; // added/staged
case 'D':
return 'text-red-500'; // deleted
case '?':
return 'text-gray-400'; // untracked
case '!':
return 'text-gray-600'; // ignored
case 'S':
return 'text-blue-500'; // staged
case 'R':
return 'text-purple-500'; // renamed
case 'C':
return 'text-cyan-500'; // copied
case 'U':
return 'text-orange-500'; // conflicted
default:
return 'text-muted-foreground';
}
}
/** Get a status label for git status */
function getGitStatusLabel(status: string | undefined): string {
if (!status) return '';
switch (status) {
case 'M':
return 'Modified';
case 'A':
return 'Added';
case 'D':
return 'Deleted';
case '?':
return 'Untracked';
case '!':
return 'Ignored';
case 'S':
return 'Staged';
case 'R':
return 'Renamed';
case 'C':
return 'Copied';
case 'U':
return 'Conflicted';
default:
return status;
}
}
/** Inline input for creating/renaming items */
function InlineInput({
defaultValue,
onSubmit,
onCancel,
placeholder,
}: {
defaultValue?: string;
onSubmit: (value: string) => void;
onCancel: () => void;
placeholder?: string;
}) {
const [value, setValue] = useState(defaultValue || '');
const inputRef = useRef<HTMLInputElement>(null);
// Guard against double-submission: pressing Enter triggers onKeyDown AND may
// immediately trigger onBlur (e.g. when the component unmounts after submit).
const submittedRef = useRef(false);
useEffect(() => {
inputRef.current?.focus();
if (defaultValue) {
// Select name without extension for rename
const dotIndex = defaultValue.lastIndexOf('.');
if (dotIndex > 0) {
inputRef.current?.setSelectionRange(0, dotIndex);
} else {
inputRef.current?.select();
}
}
}, [defaultValue]);
return (
<input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && value.trim()) {
if (submittedRef.current) return;
submittedRef.current = true;
onSubmit(value.trim());
} else if (e.key === 'Escape') {
onCancel();
}
}}
onBlur={() => {
// Prevent duplicate submission if onKeyDown already triggered onSubmit
if (submittedRef.current) return;
if (value.trim()) {
submittedRef.current = true;
onSubmit(value.trim());
} else {
onCancel();
}
}}
placeholder={placeholder}
className="text-sm bg-muted border border-border rounded px-1 py-0.5 w-full outline-none focus:border-primary"
/>
);
}
/** Destination path picker dialog for copy/move operations */
function DestinationPicker({
onSubmit,
onCancel,
defaultPath,
action,
}: {
onSubmit: (path: string) => void;
onCancel: () => void;
defaultPath: string;
action: 'Copy' | 'Move';
}) {
const [path, setPath] = useState(defaultPath);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-background border border-border rounded-lg shadow-lg w-full max-w-md">
<div className="px-4 py-3 border-b border-border">
<h3 className="text-sm font-medium">{action} To...</h3>
<p className="text-xs text-muted-foreground mt-0.5">
Enter the destination path for the {action.toLowerCase()} operation
</p>
</div>
<div className="px-4 py-3">
<input
ref={inputRef}
value={path}
onChange={(e) => setPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && path.trim()) {
onSubmit(path.trim());
} else if (e.key === 'Escape') {
onCancel();
}
}}
placeholder="Enter destination path..."
className="w-full text-sm bg-muted border border-border rounded px-3 py-2 outline-none focus:border-primary font-mono"
/>
</div>
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-border">
<button
onClick={onCancel}
className="px-3 py-1.5 text-sm rounded hover:bg-muted transition-colors"
>
Cancel
</button>
<button
onClick={() => path.trim() && onSubmit(path.trim())}
disabled={!path.trim()}
className="px-3 py-1.5 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{action}
</button>
</div>
</div>
</div>
);
}
/** Single tree node renderer */
function TreeNode({
node,
depth,
onFileSelect,
onCreateFile,
onCreateFolder,
onDeleteItem,
onRenameItem,
onCopyPath,
onToggleFolder,
activeFilePath,
gitStatusMap,
showHiddenFiles,
onCopyItem,
onMoveItem,
onDownloadItem,
onDragDropMove,
effectivePath,
}: {
node: FileTreeNode;
depth: number;
onFileSelect: (path: string) => void;
onCreateFile: (parentPath: string, name: string) => Promise<void>;
onCreateFolder: (parentPath: string, name: string) => Promise<void>;
onDeleteItem: (path: string, isDirectory: boolean) => Promise<void>;
onRenameItem: (oldPath: string, newName: string) => Promise<void>;
onCopyPath: (path: string) => void;
onToggleFolder: (path: string) => void;
activeFilePath: string | null;
gitStatusMap: Map<string, string>;
showHiddenFiles: boolean;
onCopyItem?: (sourcePath: string, destinationPath: string) => Promise<void>;
onMoveItem?: (sourcePath: string, destinationPath: string) => Promise<void>;
onDownloadItem?: (filePath: string) => Promise<void>;
onDragDropMove?: (sourcePaths: string[], targetFolderPath: string) => Promise<void>;
effectivePath?: string;
}) {
const {
expandedFolders,
enhancedGitStatusMap,
dragState,
setDragState,
selectedPaths,
toggleSelectedPath,
} = useFileEditorStore();
const [isCreatingFile, setIsCreatingFile] = useState(false);
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [showCopyPicker, setShowCopyPicker] = useState(false);
const [showMovePicker, setShowMovePicker] = useState(false);
const isExpanded = expandedFolders.has(node.path);
const isActive = activeFilePath === node.path;
const gitStatus = node.gitStatus || gitStatusMap.get(node.path);
const statusColor = getGitStatusColor(gitStatus);
const statusLabel = getGitStatusLabel(gitStatus);
// Enhanced git status info
const enhancedStatus = enhancedGitStatusMap.get(node.path);
const isConflicted = enhancedStatus?.isConflicted || gitStatus === 'U';
const isStaged = enhancedStatus?.isStaged || false;
const isUnstaged = enhancedStatus?.isUnstaged || false;
const linesAdded = enhancedStatus?.linesAdded || 0;
const linesRemoved = enhancedStatus?.linesRemoved || 0;
const enhancedLabel = enhancedStatus?.statusLabel || statusLabel;
// Drag state
const isDragging = dragState.draggedPaths.includes(node.path);
const isDropTarget = dragState.dropTargetPath === node.path && node.isDirectory;
const isSelected = selectedPaths.has(node.path);
const handleClick = (e: React.MouseEvent) => {
// Multi-select with Ctrl/Cmd
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
toggleSelectedPath(node.path);
return;
}
if (node.isDirectory) {
onToggleFolder(node.path);
} else {
onFileSelect(node.path);
}
};
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setMenuOpen(true);
};
const handleDelete = async () => {
const itemType = node.isDirectory ? 'folder' : 'file';
const confirmed = window.confirm(
`Are you sure you want to delete "${node.name}"? This ${itemType} will be moved to trash.`
);
if (confirmed) {
await onDeleteItem(node.path, node.isDirectory);
}
};
const handleCopyName = async () => {
try {
await navigator.clipboard.writeText(node.name);
} catch {
// Fallback: silently fail
}
};
// Drag handlers
const handleDragStart = (e: React.DragEvent) => {
e.stopPropagation();
const paths = isSelected && selectedPaths.size > 1 ? Array.from(selectedPaths) : [node.path];
setDragState({ draggedPaths: paths, dropTargetPath: null });
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', JSON.stringify(paths));
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!node.isDirectory) return;
// Prevent dropping into self or descendant
const dragged = dragState.draggedPaths;
const isDescendant = dragged.some((p) => node.path === p || node.path.startsWith(p + '/'));
if (isDescendant) {
e.dataTransfer.dropEffect = 'none';
return;
}
e.dataTransfer.dropEffect = 'move';
setDragState({ ...dragState, dropTargetPath: node.path });
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (dragState.dropTargetPath === node.path) {
setDragState({ ...dragState, dropTargetPath: null });
}
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragState({ draggedPaths: [], dropTargetPath: null });
if (!node.isDirectory || !onDragDropMove) return;
try {
const data = e.dataTransfer.getData('text/plain');
const paths: string[] = JSON.parse(data);
// Validate: don't drop into self or descendant
const isDescendant = paths.some((p) => node.path === p || node.path.startsWith(p + '/'));
if (isDescendant) return;
await onDragDropMove(paths, node.path);
} catch {
// Invalid drag data
}
};
const handleDragEnd = () => {
setDragState({ draggedPaths: [], dropTargetPath: null });
};
// Build tooltip with enhanced info
let tooltip = node.name;
if (enhancedLabel) tooltip += ` (${enhancedLabel})`;
if (linesAdded > 0 || linesRemoved > 0) {
tooltip += ` +${linesAdded} -${linesRemoved}`;
}
return (
<div key={node.path}>
{/* Destination picker dialogs */}
{showCopyPicker && onCopyItem && (
<DestinationPicker
action="Copy"
defaultPath={node.path}
onSubmit={async (destPath) => {
setShowCopyPicker(false);
await onCopyItem(node.path, destPath);
}}
onCancel={() => setShowCopyPicker(false)}
/>
)}
{showMovePicker && onMoveItem && (
<DestinationPicker
action="Move"
defaultPath={node.path}
onSubmit={async (destPath) => {
setShowMovePicker(false);
await onMoveItem(node.path, destPath);
}}
onCancel={() => setShowMovePicker(false)}
/>
)}
{isRenaming ? (
<div style={{ paddingLeft: `${depth * 16 + 8}px` }} className="py-0.5 px-2">
<InlineInput
defaultValue={node.name}
onSubmit={async (newName) => {
await onRenameItem(node.path, newName);
setIsRenaming(false);
}}
onCancel={() => setIsRenaming(false)}
/>
</div>
) : (
<div
className={cn(
'group flex items-center gap-1.5 py-0.5 px-2 rounded cursor-pointer text-sm hover:bg-muted/50 relative transition-colors',
isActive && 'bg-primary/15 text-primary',
statusColor && !isActive && statusColor,
isConflicted && 'border-l-2 border-orange-500',
isDragging && 'opacity-40',
isDropTarget && 'bg-primary/20 ring-1 ring-primary/50',
isSelected && !isActive && 'bg-muted/70'
)}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={handleClick}
onContextMenu={handleContextMenu}
draggable
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
data-testid={`file-tree-item-${node.name}`}
title={tooltip}
>
{/* Drag handle indicator (visible on hover) */}
<GripVertical className="w-3 h-3 text-muted-foreground/30 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity hidden md:block" />
{/* Expand/collapse chevron */}
{node.isDirectory ? (
isExpanded ? (
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
)
) : (
<span className="w-3.5 shrink-0" />
)}
{/* Icon */}
{node.isDirectory ? (
isExpanded ? (
<FolderOpen className="w-4 h-4 text-primary shrink-0" />
) : (
<Folder className="w-4 h-4 text-primary shrink-0" />
)
) : isConflicted ? (
<AlertTriangle className="w-4 h-4 text-orange-500 shrink-0" />
) : (
<File className="w-4 h-4 text-muted-foreground shrink-0" />
)}
{/* Name */}
<span className="truncate flex-1">{node.name}</span>
{/* Diff stats (lines added/removed) shown inline */}
{!node.isDirectory && (linesAdded > 0 || linesRemoved > 0) && (
<span className="flex items-center gap-1 text-[10px] shrink-0 opacity-70">
{linesAdded > 0 && (
<span className="flex items-center text-green-600">
<Plus className="w-2.5 h-2.5" />
{linesAdded}
</span>
)}
{linesRemoved > 0 && (
<span className="flex items-center text-red-500">
<Minus className="w-2.5 h-2.5" />
{linesRemoved}
</span>
)}
</span>
)}
{/* Git status indicator - two-tone badge for staged+unstaged */}
{gitStatus && (
<span className="flex items-center gap-0 shrink-0">
{isStaged && isUnstaged ? (
// Two-tone badge: staged (green) + unstaged (yellow)
<>
<span
className="w-1.5 h-1.5 rounded-l-full bg-green-500"
title="Staged changes"
/>
<span
className="w-1.5 h-1.5 rounded-r-full bg-yellow-500"
title="Unstaged changes"
/>
</>
) : isConflicted ? (
<span
className="w-1.5 h-1.5 rounded-full bg-orange-500 animate-pulse"
title="Conflicted"
/>
) : (
<span
className={cn('w-1.5 h-1.5 rounded-full shrink-0', {
'bg-yellow-500': gitStatus === 'M',
'bg-green-500': gitStatus === 'A' || gitStatus === 'S',
'bg-red-500': gitStatus === 'D',
'bg-gray-400': gitStatus === '?',
'bg-gray-600': gitStatus === '!',
'bg-purple-500': gitStatus === 'R',
'bg-cyan-500': gitStatus === 'C',
'bg-orange-500': gitStatus === 'U',
})}
title={enhancedLabel || statusLabel}
/>
)}
</span>
)}
{/* Actions dropdown menu (three-dot button) */}
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation();
}}
className={cn(
'p-0.5 rounded shrink-0 hover:bg-accent transition-opacity',
// On mobile (max-md): always visible for touch access
// On desktop (md+): show on hover, focus, or when menu is open
'max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100',
menuOpen && 'opacity-100'
)}
data-testid={`file-tree-menu-${node.name}`}
aria-label={`Actions for ${node.name}`}
>
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="right" className="w-48">
{/* Folder-specific: New File / New Folder */}
{node.isDirectory && (
<>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
if (!isExpanded) onToggleFolder(node.path);
setIsCreatingFile(true);
}}
className="gap-2"
>
<FilePlus className="w-4 h-4" />
<span>New File</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
if (!isExpanded) onToggleFolder(node.path);
setIsCreatingFolder(true);
}}
className="gap-2"
>
<FolderPlus className="w-4 h-4" />
<span>New Folder</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{/* Copy operations */}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onCopyPath(node.path);
}}
className="gap-2"
>
<ClipboardCopy className="w-4 h-4" />
<span>Copy Path</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleCopyName();
}}
className="gap-2"
>
<Copy className="w-4 h-4" />
<span>Copy Name</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Copy To... */}
{onCopyItem && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setShowCopyPicker(true);
}}
className="gap-2"
>
<FolderInput className="w-4 h-4" />
<span>Copy To...</span>
</DropdownMenuItem>
)}
{/* Move To... */}
{onMoveItem && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setShowMovePicker(true);
}}
className="gap-2"
>
<FolderOutput className="w-4 h-4" />
<span>Move To...</span>
</DropdownMenuItem>
)}
{/* Download */}
{onDownloadItem && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDownloadItem(node.path);
}}
className="gap-2"
>
<Download className="w-4 h-4" />
<span>Download{node.isDirectory ? ' as ZIP' : ''}</span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{/* Rename */}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setIsRenaming(true);
}}
className="gap-2"
>
<Pencil className="w-4 h-4" />
<span>Rename</span>
</DropdownMenuItem>
{/* Delete */}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleDelete();
}}
className="gap-2 text-destructive focus:text-destructive focus:bg-destructive/10"
>
<Trash2 className="w-4 h-4" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{/* Children (expanded folder) */}
{node.isDirectory && isExpanded && node.children && (
<div>
{/* Inline create file input */}
{isCreatingFile && (
<div style={{ paddingLeft: `${(depth + 1) * 16 + 8}px` }} className="py-0.5 px-2">
<InlineInput
placeholder="filename.ext"
onSubmit={async (name) => {
await onCreateFile(node.path, name);
setIsCreatingFile(false);
}}
onCancel={() => setIsCreatingFile(false)}
/>
</div>
)}
{/* Inline create folder input */}
{isCreatingFolder && (
<div style={{ paddingLeft: `${(depth + 1) * 16 + 8}px` }} className="py-0.5 px-2">
<InlineInput
placeholder="folder-name"
onSubmit={async (name) => {
await onCreateFolder(node.path, name);
setIsCreatingFolder(false);
}}
onCancel={() => setIsCreatingFolder(false)}
/>
</div>
)}
{(showHiddenFiles
? node.children
: node.children.filter((child) => !child.name.startsWith('.'))
).map((child) => (
<TreeNode
key={child.path}
node={child}
depth={depth + 1}
onFileSelect={onFileSelect}
onCreateFile={onCreateFile}
onCreateFolder={onCreateFolder}
onDeleteItem={onDeleteItem}
onRenameItem={onRenameItem}
onCopyPath={onCopyPath}
onToggleFolder={onToggleFolder}
activeFilePath={activeFilePath}
gitStatusMap={gitStatusMap}
showHiddenFiles={showHiddenFiles}
onCopyItem={onCopyItem}
onMoveItem={onMoveItem}
onDownloadItem={onDownloadItem}
onDragDropMove={onDragDropMove}
effectivePath={effectivePath}
/>
))}
</div>
)}
</div>
);
}
export function FileTree({
onFileSelect,
onCreateFile,
onCreateFolder,
onDeleteItem,
onRenameItem,
onCopyPath,
onRefresh,
onToggleFolder,
activeFilePath,
onCopyItem,
onMoveItem,
onDownloadItem,
onDragDropMove,
effectivePath,
}: FileTreeProps) {
const { fileTree, showHiddenFiles, setShowHiddenFiles, gitStatusMap, setDragState, gitBranch } =
useFileEditorStore();
const [isCreatingFile, setIsCreatingFile] = useState(false);
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
// Filter hidden files if needed
const filteredTree = showHiddenFiles
? fileTree
: fileTree.filter((node) => !node.name.startsWith('.'));
// Handle drop on root area
const handleRootDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
if (effectivePath) {
e.dataTransfer.dropEffect = 'move';
setDragState({ draggedPaths: [], dropTargetPath: effectivePath });
}
},
[effectivePath, setDragState]
);
const handleRootDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
setDragState({ draggedPaths: [], dropTargetPath: null });
if (!effectivePath || !onDragDropMove) return;
try {
const data = e.dataTransfer.getData('text/plain');
const paths: string[] = JSON.parse(data);
await onDragDropMove(paths, effectivePath);
} catch {
// Invalid drag data
}
},
[effectivePath, onDragDropMove, setDragState]
);
return (
<div className="flex flex-col h-full" data-testid="file-tree">
{/* Tree toolbar */}
<div className="flex items-center justify-between px-2 py-1.5 border-b border-border">
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Explorer
</span>
{gitBranch && (
<span className="text-[10px] text-primary font-medium px-1 py-0.5 bg-primary/10 rounded">
{gitBranch}
</span>
)}
</div>
<div className="flex items-center gap-0.5">
<button
onClick={() => setIsCreatingFile(true)}
className="p-1 hover:bg-accent rounded"
title="New file"
>
<FilePlus className="w-3.5 h-3.5 text-muted-foreground" />
</button>
<button
onClick={() => setIsCreatingFolder(true)}
className="p-1 hover:bg-accent rounded"
title="New folder"
>
<FolderPlus className="w-3.5 h-3.5 text-muted-foreground" />
</button>
<button
onClick={() => setShowHiddenFiles(!showHiddenFiles)}
className="p-1 hover:bg-accent rounded"
title={showHiddenFiles ? 'Hide dotfiles' : 'Show dotfiles'}
>
{showHiddenFiles ? (
<Eye className="w-3.5 h-3.5 text-muted-foreground" />
) : (
<EyeOff className="w-3.5 h-3.5 text-muted-foreground" />
)}
</button>
<button onClick={onRefresh} className="p-1 hover:bg-accent rounded" title="Refresh">
<RefreshCw className="w-3.5 h-3.5 text-muted-foreground" />
</button>
</div>
</div>
{/* Tree content */}
<div
className="flex-1 overflow-y-auto py-1"
onDragOver={handleRootDragOver}
onDrop={handleRootDrop}
>
{/* Root-level inline creators */}
{isCreatingFile && (
<div className="py-0.5 px-2" style={{ paddingLeft: '8px' }}>
<InlineInput
placeholder="filename.ext"
onSubmit={async (name) => {
await onCreateFile('', name);
setIsCreatingFile(false);
}}
onCancel={() => setIsCreatingFile(false)}
/>
</div>
)}
{isCreatingFolder && (
<div className="py-0.5 px-2" style={{ paddingLeft: '8px' }}>
<InlineInput
placeholder="folder-name"
onSubmit={async (name) => {
await onCreateFolder('', name);
setIsCreatingFolder(false);
}}
onCancel={() => setIsCreatingFolder(false)}
/>
</div>
)}
{filteredTree.length === 0 ? (
<div className="flex items-center justify-center py-8">
<p className="text-xs text-muted-foreground">No files found</p>
</div>
) : (
filteredTree.map((node) => (
<TreeNode
key={node.path}
node={node}
depth={0}
onFileSelect={onFileSelect}
onCreateFile={onCreateFile}
onCreateFolder={onCreateFolder}
onDeleteItem={onDeleteItem}
onRenameItem={onRenameItem}
onCopyPath={onCopyPath}
onToggleFolder={onToggleFolder}
activeFilePath={activeFilePath}
gitStatusMap={gitStatusMap}
showHiddenFiles={showHiddenFiles}
onCopyItem={onCopyItem}
onMoveItem={onMoveItem}
onDownloadItem={onDownloadItem}
onDragDropMove={onDragDropMove}
effectivePath={effectivePath}
/>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,187 @@
import { useState } from 'react';
import {
GitBranch,
GitCommit,
User,
Clock,
Plus,
Minus,
AlertTriangle,
ChevronDown,
ChevronUp,
FileEdit,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { GitFileDetailsInfo } from '../use-file-editor-store';
interface GitDetailPanelProps {
details: GitFileDetailsInfo;
filePath: string;
onOpenFile?: (path: string) => void;
}
export function GitDetailPanel({ details, filePath, onOpenFile }: GitDetailPanelProps) {
const [isExpanded, setIsExpanded] = useState(false);
// Don't show anything if there's no meaningful data
if (!details.branch && !details.lastCommitHash && !details.statusLabel) {
return null;
}
const hasChanges = details.linesAdded > 0 || details.linesRemoved > 0;
const commitHashShort = details.lastCommitHash ? details.lastCommitHash.substring(0, 7) : '';
const timeAgo = details.lastCommitTimestamp ? formatTimeAgo(details.lastCommitTimestamp) : '';
return (
<div className="border-t border-border bg-muted/20">
{/* Collapsed summary bar */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-3 py-1 text-xs text-muted-foreground hover:bg-muted/40 transition-colors"
>
<div className="flex items-center gap-3">
{/* Branch */}
{details.branch && (
<span className="flex items-center gap-1">
<GitBranch className="w-3 h-3" />
<span className="text-primary font-medium">{details.branch}</span>
</span>
)}
{/* Status label with visual treatment */}
{details.statusLabel && (
<span
className={cn('px-1.5 py-0.5 rounded text-[10px] font-medium uppercase', {
'bg-yellow-500/15 text-yellow-600': details.statusLabel === 'Modified',
'bg-green-500/15 text-green-600':
details.statusLabel === 'Added' || details.statusLabel === 'Staged',
'bg-red-500/15 text-red-600': details.statusLabel === 'Deleted',
'bg-purple-500/15 text-purple-600': details.statusLabel === 'Renamed',
'bg-gray-500/15 text-gray-500': details.statusLabel === 'Untracked',
'bg-orange-500/15 text-orange-600':
details.statusLabel === 'Conflicted' || details.isConflicted,
'bg-blue-500/15 text-blue-600': details.statusLabel === 'Staged + Modified',
})}
>
{details.isConflicted && <AlertTriangle className="w-3 h-3 inline mr-0.5" />}
{details.statusLabel}
</span>
)}
{/* Staged/unstaged two-tone badge */}
{details.isStaged && details.isUnstaged && (
<span className="flex items-center gap-0">
<span className="w-2 h-2 rounded-l bg-green-500" title="Staged changes" />
<span className="w-2 h-2 rounded-r bg-yellow-500" title="Unstaged changes" />
</span>
)}
{details.isStaged && !details.isUnstaged && (
<span className="w-2 h-2 rounded bg-green-500" title="Staged" />
)}
{!details.isStaged && details.isUnstaged && (
<span className="w-2 h-2 rounded bg-yellow-500" title="Unstaged" />
)}
{/* Diff stats */}
{hasChanges && (
<span className="flex items-center gap-1.5">
<span className="flex items-center gap-0.5 text-green-600">
<Plus className="w-3 h-3" />
{details.linesAdded}
</span>
<span className="flex items-center gap-0.5 text-red-500">
<Minus className="w-3 h-3" />
{details.linesRemoved}
</span>
</span>
)}
</div>
<div className="flex items-center gap-2">
{commitHashShort && (
<span className="flex items-center gap-1 text-muted-foreground/70">
<GitCommit className="w-3 h-3" />
{commitHashShort}
</span>
)}
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</div>
</button>
{/* Expanded details */}
{isExpanded && (
<div className="px-3 py-2 border-t border-border/50 space-y-1.5 text-xs text-muted-foreground">
{/* Last commit info */}
{details.lastCommitHash && (
<>
<div className="flex items-start gap-2">
<GitCommit className="w-3.5 h-3.5 mt-0.5 shrink-0" />
<div className="min-w-0">
<div className="font-mono text-foreground/80">{commitHashShort}</div>
{details.lastCommitMessage && (
<div className="text-muted-foreground truncate">
{details.lastCommitMessage}
</div>
)}
</div>
</div>
{details.lastCommitAuthor && (
<div className="flex items-center gap-2">
<User className="w-3.5 h-3.5 shrink-0" />
<span>{details.lastCommitAuthor}</span>
</div>
)}
{timeAgo && (
<div className="flex items-center gap-2">
<Clock className="w-3.5 h-3.5 shrink-0" />
<span>{timeAgo}</span>
</div>
)}
</>
)}
{/* Conflict warning with action */}
{details.isConflicted && (
<div className="flex items-center gap-2 p-2 rounded bg-orange-500/10 border border-orange-500/20 text-orange-600">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span className="flex-1 font-medium">This file has merge conflicts</span>
{onOpenFile && (
<button
onClick={() => onOpenFile(filePath)}
className="flex items-center gap-1 px-2 py-0.5 rounded bg-orange-500/20 hover:bg-orange-500/30 text-orange-700 text-[10px] font-medium transition-colors"
>
<FileEdit className="w-3 h-3" />
Resolve
</button>
)}
</div>
)}
</div>
)}
</div>
);
}
/** Format an ISO timestamp as a human-readable relative time */
function formatTimeAgo(isoTimestamp: string): string {
try {
const date = new Date(isoTimestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins} min${diffMins > 1 ? 's' : ''} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
if (diffDays < 30) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
return date.toLocaleDateString();
} catch {
return '';
}
}

View File

@@ -0,0 +1,89 @@
import { useRef } from 'react';
import { Columns2, Eye, Code2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Markdown } from '@/components/ui/markdown';
import type { MarkdownViewMode } from '../use-file-editor-store';
/** Toolbar for switching between editor/preview/split modes */
export function MarkdownViewToolbar({
viewMode,
onViewModeChange,
}: {
viewMode: MarkdownViewMode;
onViewModeChange: (mode: MarkdownViewMode) => void;
}) {
return (
<div className="flex items-center gap-0.5 bg-muted/50 rounded-md p-0.5">
<button
onClick={() => onViewModeChange('editor')}
className={cn(
'flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors',
viewMode === 'editor'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
title="Editor only"
>
<Code2 className="w-3 h-3" />
<span>Edit</span>
</button>
<button
onClick={() => onViewModeChange('split')}
className={cn(
'flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors',
viewMode === 'split'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
title="Split view"
>
<Columns2 className="w-3 h-3" />
<span>Split</span>
</button>
<button
onClick={() => onViewModeChange('preview')}
className={cn(
'flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors',
viewMode === 'preview'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
title="Preview only"
>
<Eye className="w-3 h-3" />
<span>Preview</span>
</button>
</div>
);
}
/** Rendered markdown preview panel */
export function MarkdownPreviewPanel({
content,
className,
}: {
content: string;
className?: string;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
return (
<div
ref={scrollRef}
className={cn('h-full overflow-y-auto bg-background/50 p-6', className)}
data-testid="markdown-preview"
>
<div className="max-w-3xl mx-auto">
<Markdown>{content || '*No content to preview*'}</Markdown>
</div>
</div>
);
}
/** Check if a file is a markdown file */
export function isMarkdownFile(filePath: string): boolean {
const fileName = filePath.split('/').pop() || '';
const dotIndex = fileName.lastIndexOf('.');
const ext = dotIndex > 0 ? fileName.slice(dotIndex + 1).toLowerCase() : '';
return ['md', 'mdx', 'markdown'].includes(ext);
}

View File

@@ -0,0 +1,151 @@
/**
* WorktreeDirectoryDropdown
*
* A dropdown for the file editor header that allows the user to select which
* worktree directory to work from (or the main project directory).
*
* Reads the current worktree selection from the app store so that when a user
* is on a worktree in the board view and then navigates to the file editor,
* it defaults to that worktree directory.
*/
import { useMemo } from 'react';
import { GitBranch, ChevronDown, Check, FolderRoot } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAppStore } from '@/store/app-store';
import { useWorktrees } from '@/hooks/queries';
import { pathsEqual } from '@/lib/utils';
interface WorktreeDirectoryDropdownProps {
projectPath: string;
}
// Stable empty array to avoid creating a new reference every render when there are no worktrees.
// Zustand compares selector results by reference; returning `[]` inline (e.g. via `?? []`) creates
// a new array on every call, causing `forceStoreRerender` to trigger an infinite update loop.
const EMPTY_WORKTREES: never[] = [];
export function WorktreeDirectoryDropdown({ projectPath }: WorktreeDirectoryDropdownProps) {
// Select primitive/stable values directly from the store to prevent infinite re-renders.
// Computed selectors that return new arrays/objects on every call (e.g. via `?? []`)
// are compared by reference, causing Zustand to force re-renders on every store update.
const currentWorktree = useAppStore((s) => s.currentWorktreeByProject[projectPath] ?? null);
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
const worktreesInStore = useAppStore((s) => s.worktreesByProject[projectPath] ?? EMPTY_WORKTREES);
const useWorktreesEnabled = useAppStore((s) => {
const projectOverride = s.useWorktreesByProject[projectPath];
return projectOverride !== undefined ? projectOverride : s.useWorktrees;
});
// Fetch worktrees from query
const { data } = useWorktrees(projectPath);
const worktrees = useMemo(() => data?.worktrees ?? [], [data?.worktrees]);
// Also consider store worktrees as fallback
const effectiveWorktrees = worktrees.length > 0 ? worktrees : worktreesInStore;
// Don't render if worktrees are not enabled or only the main branch exists
if (!useWorktreesEnabled || effectiveWorktrees.length <= 1) {
return null;
}
const currentWorktreePath = currentWorktree?.path ?? null;
const currentBranch = currentWorktree?.branch ?? 'main';
// Find main worktree
const mainWorktree = effectiveWorktrees.find((w) => w.isMain);
const otherWorktrees = effectiveWorktrees.filter((w) => !w.isMain);
// Determine display name for the selected worktree
const selectedIsMain = currentWorktreePath === null;
const selectedBranchName = selectedIsMain ? (mainWorktree?.branch ?? 'main') : currentBranch;
// Truncate long branch names for the trigger button
const maxTriggerLength = 20;
const displayName =
selectedBranchName.length > maxTriggerLength
? `${selectedBranchName.slice(0, maxTriggerLength)}...`
: selectedBranchName;
const handleSelectWorktree = (worktreePath: string | null, branch: string) => {
setCurrentWorktree(projectPath, worktreePath, branch);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-1.5 max-w-[200px] text-xs"
title={`Working directory: ${selectedBranchName}`}
>
<GitBranch className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{displayName}</span>
<ChevronDown className="w-3 h-3 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[240px]">
<DropdownMenuLabel className="text-xs text-muted-foreground">
Working Directory
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* Main directory */}
{mainWorktree && (
<DropdownMenuItem
onClick={() => handleSelectWorktree(null, mainWorktree.branch)}
className="gap-2"
>
<FolderRoot className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<span className="truncate block text-sm">{mainWorktree.branch}</span>
<span className="text-xs text-muted-foreground">Main directory</span>
</div>
{selectedIsMain && <Check className="w-3.5 h-3.5 shrink-0 text-primary" />}
</DropdownMenuItem>
)}
{/* Worktree directories */}
{otherWorktrees.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
Worktrees
</DropdownMenuLabel>
{otherWorktrees.map((wt) => {
const isSelected =
currentWorktreePath !== null && pathsEqual(wt.path, currentWorktreePath);
return (
<DropdownMenuItem
key={wt.path}
onClick={() => handleSelectWorktree(wt.path, wt.branch)}
className="gap-2"
>
<GitBranch className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<span className="truncate block text-sm">{wt.branch}</span>
{wt.hasChanges && (
<span className="text-xs text-amber-500">
{wt.changedFilesCount ?? ''} change{wt.changedFilesCount !== 1 ? 's' : ''}
</span>
)}
</div>
{isSelected && <Check className="w-3.5 h-3.5 shrink-0 text-primary" />}
</DropdownMenuItem>
);
})}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export { FileEditorView } from './file-editor-view';

View File

@@ -0,0 +1,371 @@
import { create } from 'zustand';
import { persist, type StorageValue } from 'zustand/middleware';
export interface FileTreeNode {
name: string;
path: string;
isDirectory: boolean;
children?: FileTreeNode[];
/** Git status indicator: M=modified, A=added, D=deleted, ?=untracked, !=ignored, S=staged */
gitStatus?: string;
}
export interface EditorTab {
id: string;
filePath: string;
fileName: string;
content: string;
originalContent: string;
isDirty: boolean;
scrollTop: number;
cursorLine: number;
cursorCol: number;
/** Whether the file is binary (non-editable) */
isBinary: boolean;
/** Whether the file is too large to edit */
isTooLarge: boolean;
/** File size in bytes */
fileSize: number;
}
export type MarkdownViewMode = 'editor' | 'preview' | 'split';
/** Enhanced git status per file, including diff stats and staged/unstaged info */
export interface EnhancedGitFileStatus {
indexStatus: string;
workTreeStatus: string;
isConflicted: boolean;
isStaged: boolean;
isUnstaged: boolean;
linesAdded: number;
linesRemoved: number;
statusLabel: string;
}
/** Git details for a specific file (shown in detail panel) */
export interface GitFileDetailsInfo {
branch: string;
lastCommitHash: string;
lastCommitMessage: string;
lastCommitAuthor: string;
lastCommitTimestamp: string;
linesAdded: number;
linesRemoved: number;
isConflicted: boolean;
isStaged: boolean;
isUnstaged: boolean;
statusLabel: string;
}
/** Items being dragged in the file tree */
export interface DragState {
/** Paths of items currently being dragged */
draggedPaths: string[];
/** Path of the current drop target folder */
dropTargetPath: string | null;
}
interface FileEditorState {
// File tree state
fileTree: FileTreeNode[];
expandedFolders: Set<string>;
showHiddenFiles: boolean;
// Editor tabs
tabs: EditorTab[];
activeTabId: string | null;
// Markdown preview
markdownViewMode: MarkdownViewMode;
// Mobile layout state
/** Whether the file browser is visible on mobile (defaults to true) */
mobileBrowserVisible: boolean;
// Settings
tabSize: number;
wordWrap: boolean;
fontSize: number;
/** Maximum file size in bytes before warning (default 1MB) */
maxFileSize: number;
// Git status map: filePath -> status
gitStatusMap: Map<string, string>;
// Enhanced git status: filePath -> enhanced status info
enhancedGitStatusMap: Map<string, EnhancedGitFileStatus>;
// Current branch name
gitBranch: string;
// Git details for the currently active file (loaded on demand)
activeFileGitDetails: GitFileDetailsInfo | null;
// Drag and drop state
dragState: DragState;
// Selected items for multi-select operations
selectedPaths: Set<string>;
// Actions
setFileTree: (tree: FileTreeNode[]) => void;
toggleFolder: (path: string) => void;
setShowHiddenFiles: (show: boolean) => void;
setExpandedFolders: (folders: Set<string>) => void;
openTab: (tab: Omit<EditorTab, 'id'>) => void;
closeTab: (tabId: string) => void;
closeAllTabs: () => void;
setActiveTab: (tabId: string) => void;
updateTabContent: (tabId: string, content: string) => void;
markTabSaved: (tabId: string, content: string) => void;
updateTabScroll: (tabId: string, scrollTop: number) => void;
updateTabCursor: (tabId: string, line: number, col: number) => void;
setMarkdownViewMode: (mode: MarkdownViewMode) => void;
setMobileBrowserVisible: (visible: boolean) => void;
setTabSize: (size: number) => void;
setWordWrap: (wrap: boolean) => void;
setFontSize: (size: number) => void;
setGitStatusMap: (map: Map<string, string>) => void;
setEnhancedGitStatusMap: (map: Map<string, EnhancedGitFileStatus>) => void;
setGitBranch: (branch: string) => void;
setActiveFileGitDetails: (details: GitFileDetailsInfo | null) => void;
setDragState: (state: DragState) => void;
setSelectedPaths: (paths: Set<string>) => void;
toggleSelectedPath: (path: string) => void;
clearSelectedPaths: () => void;
reset: () => void;
}
const initialState = {
fileTree: [] as FileTreeNode[],
expandedFolders: new Set<string>(),
showHiddenFiles: true,
tabs: [] as EditorTab[],
activeTabId: null as string | null,
markdownViewMode: 'split' as MarkdownViewMode,
mobileBrowserVisible: true,
tabSize: 2,
wordWrap: true,
fontSize: 13,
maxFileSize: 1024 * 1024, // 1MB
gitStatusMap: new Map<string, string>(),
enhancedGitStatusMap: new Map<string, EnhancedGitFileStatus>(),
gitBranch: '',
activeFileGitDetails: null as GitFileDetailsInfo | null,
dragState: { draggedPaths: [], dropTargetPath: null } as DragState,
selectedPaths: new Set<string>(),
};
/** Shape of the persisted subset (Sets are stored as arrays for JSON compatibility) */
interface PersistedFileEditorState {
tabs: EditorTab[];
activeTabId: string | null;
expandedFolders: string[];
markdownViewMode: MarkdownViewMode;
}
const STORE_NAME = 'automaker-file-editor';
export const useFileEditorStore = create<FileEditorState>()(
persist(
(set, get) => ({
...initialState,
setFileTree: (tree) => set({ fileTree: tree }),
toggleFolder: (path) => {
const { expandedFolders } = get();
const next = new Set(expandedFolders);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
set({ expandedFolders: next });
},
setShowHiddenFiles: (show) => set({ showHiddenFiles: show }),
setExpandedFolders: (folders) => set({ expandedFolders: folders }),
openTab: (tabData) => {
const { tabs } = get();
// Check if file is already open
const existing = tabs.find((t) => t.filePath === tabData.filePath);
if (existing) {
set({ activeTabId: existing.id });
return;
}
const id = `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const newTab: EditorTab = { ...tabData, id };
set({
tabs: [...tabs, newTab],
activeTabId: id,
});
},
closeTab: (tabId) => {
const { tabs, activeTabId } = get();
const idx = tabs.findIndex((t) => t.id === tabId);
if (idx === -1) return;
const newTabs = tabs.filter((t) => t.id !== tabId);
let newActiveId = activeTabId;
if (activeTabId === tabId) {
if (newTabs.length === 0) {
newActiveId = null;
} else if (idx >= newTabs.length) {
newActiveId = newTabs[newTabs.length - 1].id;
} else {
newActiveId = newTabs[idx].id;
}
}
set({ tabs: newTabs, activeTabId: newActiveId });
},
closeAllTabs: () => {
set({ tabs: [], activeTabId: null });
},
setActiveTab: (tabId) => set({ activeTabId: tabId }),
updateTabContent: (tabId, content) => {
set({
tabs: get().tabs.map((t) =>
t.id === tabId ? { ...t, content, isDirty: content !== t.originalContent } : t
),
});
},
markTabSaved: (tabId, content) => {
set({
tabs: get().tabs.map((t) =>
t.id === tabId ? { ...t, content, originalContent: content, isDirty: false } : t
),
});
},
updateTabScroll: (tabId, scrollTop) => {
set({
tabs: get().tabs.map((t) => (t.id === tabId ? { ...t, scrollTop } : t)),
});
},
updateTabCursor: (tabId, line, col) => {
set({
tabs: get().tabs.map((t) =>
t.id === tabId ? { ...t, cursorLine: line, cursorCol: col } : t
),
});
},
setMarkdownViewMode: (mode) => set({ markdownViewMode: mode }),
setMobileBrowserVisible: (visible) => set({ mobileBrowserVisible: visible }),
setTabSize: (size) => set({ tabSize: size }),
setWordWrap: (wrap) => set({ wordWrap: wrap }),
setFontSize: (size) => set({ fontSize: size }),
setGitStatusMap: (map) => set({ gitStatusMap: map }),
setEnhancedGitStatusMap: (map) => set({ enhancedGitStatusMap: map }),
setGitBranch: (branch) => set({ gitBranch: branch }),
setActiveFileGitDetails: (details) => set({ activeFileGitDetails: details }),
setDragState: (state) => set({ dragState: state }),
setSelectedPaths: (paths) => set({ selectedPaths: paths }),
toggleSelectedPath: (path) => {
const { selectedPaths } = get();
const next = new Set(selectedPaths);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
set({ selectedPaths: next });
},
clearSelectedPaths: () => set({ selectedPaths: new Set() }),
reset: () => set(initialState),
}),
{
name: STORE_NAME,
version: 1,
// Only persist tab session state, not transient data (git status, file tree, drag state)
partialize: (state) =>
({
tabs: state.tabs,
activeTabId: state.activeTabId,
expandedFolders: state.expandedFolders,
markdownViewMode: state.markdownViewMode,
}) as unknown as FileEditorState,
// Custom storage adapter to handle Set<string> serialization
storage: {
getItem: (name: string): StorageValue<FileEditorState> | null => {
const raw = localStorage.getItem(name);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as StorageValue<PersistedFileEditorState>;
if (!parsed?.state) return null;
// Convert arrays back to Sets
return {
...parsed,
state: {
...parsed.state,
expandedFolders: new Set(parsed.state.expandedFolders ?? []),
},
} as unknown as StorageValue<FileEditorState>;
} catch {
return null;
}
},
setItem: (name: string, value: StorageValue<FileEditorState>): void => {
try {
const state = value.state as unknown as FileEditorState;
// Convert Sets to arrays for JSON serialization
const serializable: StorageValue<PersistedFileEditorState> = {
...value,
state: {
tabs: state.tabs ?? [],
activeTabId: state.activeTabId ?? null,
expandedFolders: Array.from(state.expandedFolders ?? []),
markdownViewMode: state.markdownViewMode ?? 'split',
},
};
localStorage.setItem(name, JSON.stringify(serializable));
} catch {
// localStorage might be full or disabled
}
},
removeItem: (name: string): void => {
try {
localStorage.removeItem(name);
} catch {
// Ignore
}
},
},
migrate: (persistedState: unknown, version: number) => {
const state = persistedState as Record<string, unknown>;
if (version < 1) {
// Initial migration: ensure all fields exist
state.tabs = state.tabs ?? [];
state.activeTabId = state.activeTabId ?? null;
state.expandedFolders = state.expandedFolders ?? new Set<string>();
state.markdownViewMode = state.markdownViewMode ?? 'split';
}
return state as unknown as FileEditorState;
},
}
)
);

View File

@@ -10,6 +10,7 @@ import { SettingsNavigation } from './settings-view/components/settings-navigati
import { ApiKeysSection } from './settings-view/api-keys/api-keys-section'; import { ApiKeysSection } from './settings-view/api-keys/api-keys-section';
import { ModelDefaultsSection } from './settings-view/model-defaults'; import { ModelDefaultsSection } from './settings-view/model-defaults';
import { AppearanceSection } from './settings-view/appearance/appearance-section'; import { AppearanceSection } from './settings-view/appearance/appearance-section';
import { EditorSection } from './settings-view/editor';
import { TerminalSection } from './settings-view/terminal/terminal-section'; import { TerminalSection } from './settings-view/terminal/terminal-section';
import { AudioSection } from './settings-view/audio/audio-section'; import { AudioSection } from './settings-view/audio/audio-section';
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section'; import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
@@ -148,6 +149,8 @@ export function SettingsView() {
onThemeChange={(newTheme) => setTheme(newTheme as typeof theme)} onThemeChange={(newTheme) => setTheme(newTheme as typeof theme)}
/> />
); );
case 'editor':
return <EditorSection />;
case 'terminal': case 'terminal':
return <TerminalSection />; return <TerminalSection />;
case 'keyboard': case 'keyboard':

View File

@@ -16,6 +16,7 @@ import {
GitBranch, GitBranch,
Code2, Code2,
Webhook, Webhook,
FileCode2,
} from 'lucide-react'; } from 'lucide-react';
import { import {
AnthropicIcon, AnthropicIcon,
@@ -69,6 +70,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
label: 'Interface', label: 'Interface',
items: [ items: [
{ id: 'appearance', label: 'Appearance', icon: Palette }, { id: 'appearance', label: 'Appearance', icon: Palette },
{ id: 'editor', label: 'File Editor', icon: FileCode2 },
{ id: 'terminal', label: 'Terminal', icon: SquareTerminal }, { id: 'terminal', label: 'Terminal', icon: SquareTerminal },
{ id: 'keyboard', label: 'Keyboard Shortcuts', icon: Settings2 }, { id: 'keyboard', label: 'Keyboard Shortcuts', icon: Settings2 },
{ id: 'audio', label: 'Audio', icon: Volume2 }, { id: 'audio', label: 'Audio', icon: Volume2 },

View File

@@ -0,0 +1,120 @@
import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { FileCode2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { UI_MONO_FONT_OPTIONS, DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
/**
* Editor font options - reuses UI_MONO_FONT_OPTIONS with editor-specific default label
*
* The 'default' value means "use the default editor font" (Geist Mono / theme default)
*/
const EDITOR_FONT_OPTIONS = UI_MONO_FONT_OPTIONS.map((option) => {
if (option.value === DEFAULT_FONT_VALUE) {
return { value: option.value, label: 'Default (Geist Mono)' };
}
return option;
});
export function EditorSection() {
const {
editorFontSize,
editorFontFamily,
editorAutoSave,
setEditorFontSize,
setEditorFontFamily,
setEditorAutoSave,
} = useAppStore();
return (
<div className="space-y-6">
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 flex items-center justify-center border border-blue-500/20">
<FileCode2 className="w-5 h-5 text-blue-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">File Editor</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Customize the appearance of the built-in file editor.
</p>
</div>
<div className="p-6 space-y-6">
{/* Font Family */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Font Family</Label>
<Select
value={editorFontFamily || DEFAULT_FONT_VALUE}
onValueChange={(value) => {
setEditorFontFamily(value);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Default (Geist Mono)" />
</SelectTrigger>
<SelectContent>
{EDITOR_FONT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<span
style={{
fontFamily: option.value === DEFAULT_FONT_VALUE ? undefined : option.value,
}}
>
{option.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Font Size */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Font Size</Label>
<span className="text-sm text-muted-foreground">{editorFontSize}px</span>
</div>
<Slider
value={[editorFontSize]}
min={8}
max={32}
step={1}
onValueChange={([value]) => setEditorFontSize(value)}
className="flex-1"
/>
</div>
{/* Auto Save */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<Label className="text-foreground font-medium">Auto Save</Label>
<p className="text-xs text-muted-foreground/80 mt-0.5">
Automatically save files after changes or when switching tabs
</p>
</div>
<Switch checked={editorAutoSave} onCheckedChange={setEditorAutoSave} />
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export { EditorSection } from './editor-section';

View File

@@ -14,6 +14,7 @@ export type SettingsViewId =
| 'prompts' | 'prompts'
| 'model-defaults' | 'model-defaults'
| 'appearance' | 'appearance'
| 'editor'
| 'terminal' | 'terminal'
| 'keyboard' | 'keyboard'
| 'audio' | 'audio'

View File

@@ -758,6 +758,11 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
worktreePanelCollapsed: settings.worktreePanelCollapsed ?? false, worktreePanelCollapsed: settings.worktreePanelCollapsed ?? false,
lastProjectDir: settings.lastProjectDir ?? '', lastProjectDir: settings.lastProjectDir ?? '',
recentFolders: settings.recentFolders ?? [], recentFolders: settings.recentFolders ?? [],
// File editor settings
editorFontSize: settings.editorFontSize ?? 13,
editorFontFamily: settings.editorFontFamily ?? 'default',
editorAutoSave: settings.editorAutoSave ?? false,
editorAutoSaveDelay: settings.editorAutoSaveDelay ?? 1000,
// Terminal font (nested in terminalState) // Terminal font (nested in terminalState)
...(settings.terminalFontFamily && { ...(settings.terminalFontFamily && {
terminalState: { terminalState: {
@@ -848,6 +853,10 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
worktreePanelCollapsed: state.worktreePanelCollapsed, worktreePanelCollapsed: state.worktreePanelCollapsed,
lastProjectDir: state.lastProjectDir, lastProjectDir: state.lastProjectDir,
recentFolders: state.recentFolders, recentFolders: state.recentFolders,
editorFontSize: state.editorFontSize,
editorFontFamily: state.editorFontFamily,
editorAutoSave: state.editorAutoSave,
editorAutoSaveDelay: state.editorAutoSaveDelay,
terminalFontFamily: state.terminalState.fontFamily, terminalFontFamily: state.terminalState.fontFamily,
}; };
} }

View File

@@ -85,6 +85,10 @@ const SETTINGS_FIELDS_TO_SYNC = [
'keyboardShortcuts', 'keyboardShortcuts',
'mcpServers', 'mcpServers',
'defaultEditorCommand', 'defaultEditorCommand',
'editorFontSize',
'editorFontFamily',
'editorAutoSave',
'editorAutoSaveDelay',
'defaultTerminalId', 'defaultTerminalId',
'promptCustomization', 'promptCustomization',
'eventHooks', 'eventHooks',
@@ -751,6 +755,10 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
}, },
mcpServers: serverSettings.mcpServers, mcpServers: serverSettings.mcpServers,
defaultEditorCommand: serverSettings.defaultEditorCommand ?? null, defaultEditorCommand: serverSettings.defaultEditorCommand ?? null,
editorFontSize: serverSettings.editorFontSize ?? 13,
editorFontFamily: serverSettings.editorFontFamily ?? 'default',
editorAutoSave: serverSettings.editorAutoSave ?? false,
editorAutoSaveDelay: serverSettings.editorAutoSaveDelay ?? 1000,
defaultTerminalId: serverSettings.defaultTerminalId ?? null, defaultTerminalId: serverSettings.defaultTerminalId ?? null,
promptCustomization: serverSettings.promptCustomization ?? {}, promptCustomization: serverSettings.promptCustomization ?? {},
claudeApiProfiles: serverSettings.claudeApiProfiles ?? [], claudeApiProfiles: serverSettings.claudeApiProfiles ?? [],

View File

@@ -647,6 +647,17 @@ export interface ElectronAPI {
stat: (filePath: string) => Promise<StatResult>; stat: (filePath: string) => Promise<StatResult>;
deleteFile: (filePath: string) => Promise<WriteResult>; deleteFile: (filePath: string) => Promise<WriteResult>;
trashItem?: (filePath: string) => Promise<WriteResult>; trashItem?: (filePath: string) => Promise<WriteResult>;
copyItem?: (
sourcePath: string,
destinationPath: string,
overwrite?: boolean
) => Promise<WriteResult & { exists?: boolean }>;
moveItem?: (
sourcePath: string,
destinationPath: string,
overwrite?: boolean
) => Promise<WriteResult & { exists?: boolean }>;
downloadItem?: (filePath: string) => Promise<void>;
getPath: (name: string) => Promise<string>; getPath: (name: string) => Promise<string>;
openInEditor?: ( openInEditor?: (
filePath: string, filePath: string,
@@ -2856,6 +2867,47 @@ function createMockGitAPI(): GitAPI {
}, },
}; };
}, },
getDetails: async (projectPath: string, filePath?: string) => {
console.log('[Mock] Git details:', { projectPath, filePath });
return {
success: true,
details: {
branch: 'main',
lastCommitHash: 'abc1234567890',
lastCommitMessage: 'Initial commit',
lastCommitAuthor: 'Developer',
lastCommitTimestamp: new Date().toISOString(),
linesAdded: 5,
linesRemoved: 2,
isConflicted: false,
isStaged: false,
isUnstaged: true,
statusLabel: 'Modified',
},
};
},
getEnhancedStatus: async (projectPath: string) => {
console.log('[Mock] Git enhanced status:', { projectPath });
return {
success: true,
branch: 'main',
files: [
{
path: 'src/feature.ts',
indexStatus: ' ',
workTreeStatus: 'M',
isConflicted: false,
isStaged: false,
isUnstaged: true,
linesAdded: 10,
linesRemoved: 3,
statusLabel: 'Modified',
},
],
};
},
}; };
} }

View File

@@ -1251,6 +1251,69 @@ export class HttpApiClient implements ElectronAPI {
return this.deleteFile(filePath); return this.deleteFile(filePath);
} }
async copyItem(
sourcePath: string,
destinationPath: string,
overwrite?: boolean
): Promise<WriteResult & { exists?: boolean }> {
return this.post('/api/fs/copy', { sourcePath, destinationPath, overwrite });
}
async moveItem(
sourcePath: string,
destinationPath: string,
overwrite?: boolean
): Promise<WriteResult & { exists?: boolean }> {
return this.post('/api/fs/move', { sourcePath, destinationPath, overwrite });
}
async downloadItem(filePath: string): Promise<void> {
const serverUrl = getServerUrl();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const apiKey = getApiKey();
if (apiKey) {
headers['X-API-Key'] = apiKey;
}
const token = getSessionToken();
if (token) {
headers['X-Session-Token'] = token;
}
const response = await fetch(`${serverUrl}/api/fs/download`, {
method: 'POST',
headers,
credentials: 'include',
body: JSON.stringify({ filePath }),
});
if (response.status === 401 || response.status === 403) {
handleUnauthorized();
throw new Error('Unauthorized');
}
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Download failed' }));
throw new Error(error.error || `Download failed with status ${response.status}`);
}
// Create download from response blob
const blob = await response.blob();
const contentDisposition = response.headers.get('Content-Disposition');
const fileNameMatch = contentDisposition?.match(/filename="(.+)"/);
const fileName = fileNameMatch ? fileNameMatch[1] : filePath.split('/').pop() || 'download';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async getPath(name: string): Promise<string> { async getPath(name: string): Promise<string> {
// Server provides data directory // Server provides data directory
if (name === 'userData') { if (name === 'userData') {
@@ -2311,6 +2374,10 @@ export class HttpApiClient implements ElectronAPI {
this.post('/api/git/file-diff', { projectPath, filePath }), this.post('/api/git/file-diff', { projectPath, filePath }),
stageFiles: (projectPath: string, files: string[], operation: 'stage' | 'unstage') => stageFiles: (projectPath: string, files: string[], operation: 'stage' | 'unstage') =>
this.post('/api/git/stage-files', { projectPath, files, operation }), this.post('/api/git/stage-files', { projectPath, files, operation }),
getDetails: (projectPath: string, filePath?: string) =>
this.post('/api/git/details', { projectPath, filePath }),
getEnhancedStatus: (projectPath: string) =>
this.post('/api/git/enhanced-status', { projectPath }),
}; };
// Spec Regeneration API // Spec Regeneration API

View File

@@ -0,0 +1,11 @@
import { createLazyFileRoute, useSearch } from '@tanstack/react-router';
import { FileEditorView } from '@/components/views/file-editor-view/file-editor-view';
export const Route = createLazyFileRoute('/file-editor')({
component: RouteComponent,
});
function RouteComponent() {
const { path } = useSearch({ from: '/file-editor' });
return <FileEditorView initialPath={path} />;
}

View File

@@ -0,0 +1,11 @@
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
const fileEditorSearchSchema = z.object({
path: z.string().optional(),
});
// Component is lazy-loaded via file-editor.lazy.tsx for code splitting
export const Route = createFileRoute('/file-editor')({
validateSearch: fileEditorSearchSchema,
});

View File

@@ -343,6 +343,10 @@ const initialState: AppState = {
skipSandboxWarning: false, skipSandboxWarning: false,
mcpServers: [], mcpServers: [],
defaultEditorCommand: null, defaultEditorCommand: null,
editorFontSize: 13,
editorFontFamily: 'default',
editorAutoSave: false,
editorAutoSaveDelay: 1000,
defaultTerminalId: null, defaultTerminalId: null,
enableSkills: true, enableSkills: true,
skillsSources: ['user', 'project'] as Array<'user' | 'project'>, skillsSources: ['user', 'project'] as Array<'user' | 'project'>,
@@ -1389,6 +1393,12 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
// Editor Configuration actions // Editor Configuration actions
setDefaultEditorCommand: (command) => set({ defaultEditorCommand: command }), setDefaultEditorCommand: (command) => set({ defaultEditorCommand: command }),
// File Editor Settings actions
setEditorFontSize: (size) => set({ editorFontSize: size }),
setEditorFontFamily: (fontFamily) => set({ editorFontFamily: fontFamily }),
setEditorAutoSave: (enabled) => set({ editorAutoSave: enabled }),
setEditorAutoSaveDelay: (delay) => set({ editorAutoSaveDelay: delay }),
// Terminal Configuration actions // Terminal Configuration actions
setDefaultTerminalId: (terminalId) => set({ defaultTerminalId: terminalId }), setDefaultTerminalId: (terminalId) => set({ defaultTerminalId: terminalId }),

View File

@@ -237,6 +237,12 @@ export interface AppState {
// Editor Configuration // Editor Configuration
defaultEditorCommand: string | null; // Default editor for "Open In" action defaultEditorCommand: string | null; // Default editor for "Open In" action
// File Editor Settings
editorFontSize: number; // Font size for file editor (default: 13)
editorFontFamily: string; // Font family for file editor (default: 'default' = use theme mono font)
editorAutoSave: boolean; // Enable auto-save for file editor (default: false)
editorAutoSaveDelay: number; // Auto-save delay in milliseconds (default: 1000)
// Terminal Configuration // Terminal Configuration
defaultTerminalId: string | null; // Default external terminal for "Open In Terminal" action (null = integrated) defaultTerminalId: string | null; // Default external terminal for "Open In Terminal" action (null = integrated)
@@ -611,6 +617,12 @@ export interface AppActions {
// Editor Configuration actions // Editor Configuration actions
setDefaultEditorCommand: (command: string | null) => void; setDefaultEditorCommand: (command: string | null) => void;
// File Editor Settings actions
setEditorFontSize: (size: number) => void;
setEditorFontFamily: (fontFamily: string) => void;
setEditorAutoSave: (enabled: boolean) => void;
setEditorAutoSaveDelay: (delay: number) => void;
// Terminal Configuration actions // Terminal Configuration actions
setDefaultTerminalId: (terminalId: string | null) => void; setDefaultTerminalId: (terminalId: string | null) => void;

View File

@@ -944,6 +944,19 @@
animation: accordion-up 0.2s ease-out forwards; animation: accordion-up 0.2s ease-out forwards;
} }
/* ========================================
CODE EDITOR - MOBILE RESPONSIVE STYLES
Reduce line number gutter width on mobile
======================================== */
/* On small screens (mobile), reduce line number gutter width to save horizontal space */
@media (max-width: 640px) {
.cm-lineNumbers .cm-gutterElement {
min-width: 1.75rem !important;
padding-right: 0.25rem !important;
}
}
/* Terminal scrollbar theming */ /* Terminal scrollbar theming */
.xterm-viewport::-webkit-scrollbar { .xterm-viewport::-webkit-scrollbar {
width: 8px; width: 8px;

View File

@@ -9,6 +9,7 @@ import type {
GeminiUsageResponse, GeminiUsageResponse,
} from '@/store/app-store'; } from '@/store/app-store';
import type { ParsedTask, FeatureStatusWithPipeline, MergeStateInfo } from '@automaker/types'; import type { ParsedTask, FeatureStatusWithPipeline, MergeStateInfo } from '@automaker/types';
export type { MergeStateInfo } from '@automaker/types';
export interface ImageAttachment { export interface ImageAttachment {
id?: string; // Optional - may not be present in messages loaded from server id?: string; // Optional - may not be present in messages loaded from server
@@ -642,6 +643,27 @@ export interface ElectronAPI {
error?: string; error?: string;
}>; }>;
// Copy, Move, Download APIs
copyItem?: (
sourcePath: string,
destinationPath: string,
overwrite?: boolean
) => Promise<{
success: boolean;
error?: string;
exists?: boolean;
}>;
moveItem?: (
sourcePath: string,
destinationPath: string,
overwrite?: boolean
) => Promise<{
success: boolean;
error?: string;
exists?: boolean;
}>;
downloadItem?: (filePath: string) => Promise<void>;
// App APIs // App APIs
getPath: (name: string) => Promise<string>; getPath: (name: string) => Promise<string>;
saveImageToTemp: ( saveImageToTemp: (
@@ -1695,6 +1717,45 @@ export interface TestRunnerCompletedEvent {
timestamp: string; timestamp: string;
} }
export interface GitFileDetails {
branch: string;
lastCommitHash: string;
lastCommitMessage: string;
lastCommitAuthor: string;
lastCommitTimestamp: string;
linesAdded: number;
linesRemoved: number;
isConflicted: boolean;
isStaged: boolean;
isUnstaged: boolean;
statusLabel: string;
}
export interface EnhancedFileStatus {
path: string;
indexStatus: string;
workTreeStatus: string;
isConflicted: boolean;
isStaged: boolean;
isUnstaged: boolean;
linesAdded: number;
linesRemoved: number;
statusLabel: string;
}
export interface EnhancedStatusResult {
success: boolean;
branch?: string;
files?: EnhancedFileStatus[];
error?: string;
}
export interface GitDetailsResult {
success: boolean;
details?: GitFileDetails;
error?: string;
}
export interface GitAPI { export interface GitAPI {
// Get diffs for the main project (not a worktree) // Get diffs for the main project (not a worktree)
getDiffs: (projectPath: string) => Promise<FileDiffsResult>; getDiffs: (projectPath: string) => Promise<FileDiffsResult>;
@@ -1715,6 +1776,12 @@ export interface GitAPI {
}; };
error?: string; error?: string;
}>; }>;
// Get detailed git info for a file (branch, last commit, diff stats, conflict status)
getDetails: (projectPath: string, filePath?: string) => Promise<GitDetailsResult>;
// Get enhanced status with per-file diff stats and staged/unstaged differentiation
getEnhancedStatus: (projectPath: string) => Promise<EnhancedStatusResult>;
} }
// Model definition type // Model definition type

View File

@@ -1005,6 +1005,16 @@ export interface GlobalSettings {
/** Terminal font family (undefined = use default Menlo/Monaco) */ /** Terminal font family (undefined = use default Menlo/Monaco) */
terminalFontFamily?: string; terminalFontFamily?: string;
// File Editor Configuration
/** File editor font size in pixels (default: 13) */
editorFontSize?: number;
/** File editor font family CSS value (default: 'default' = use theme mono font) */
editorFontFamily?: string;
/** Enable auto-save for file editor (default: false) */
editorAutoSave?: boolean;
/** Auto-save delay in milliseconds (default: 1000) */
editorAutoSaveDelay?: number;
// Terminal Configuration // Terminal Configuration
/** How to open terminals from "Open in Terminal" worktree action */ /** How to open terminals from "Open in Terminal" worktree action */
openTerminalMode?: 'newTab' | 'split'; openTerminalMode?: 'newTab' | 'split';

283
package-lock.json generated
View File

@@ -106,10 +106,24 @@
"@automaker/dependency-resolver": "1.0.0", "@automaker/dependency-resolver": "1.0.0",
"@automaker/spec-parser": "1.0.0", "@automaker/spec-parser": "1.0.0",
"@automaker/types": "1.0.0", "@automaker/types": "1.0.0",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-xml": "6.1.0", "@codemirror/lang-xml": "6.1.0",
"@codemirror/language": "^6.12.1", "@codemirror/language": "^6.12.1",
"@codemirror/legacy-modes": "^6.5.2", "@codemirror/legacy-modes": "^6.5.2",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "6.1.3", "@codemirror/theme-one-dark": "6.1.3",
"@codemirror/view": "^6.39.15",
"@dnd-kit/core": "6.3.1", "@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0", "@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2", "@dnd-kit/utilities": "3.2.2",
@@ -1262,6 +1276,146 @@
"@lezer/common": "^1.1.0" "@lezer/common": "^1.1.0"
} }
}, },
"node_modules/@codemirror/lang-cpp": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz",
"integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/cpp": "^1.0.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-java": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz",
"integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/java": "^1.0.0"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
"integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
"integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/lang-php": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz",
"integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/php": "^1.0.0"
}
},
"node_modules/@codemirror/lang-python": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
"integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.3.2",
"@codemirror/language": "^6.8.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/python": "^1.1.4"
}
},
"node_modules/@codemirror/lang-rust": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz",
"integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/rust": "^1.0.0"
}
},
"node_modules/@codemirror/lang-sql": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz",
"integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@codemirror/lang-xml": { "node_modules/@codemirror/lang-xml": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz",
@@ -1311,20 +1465,20 @@
} }
}, },
"node_modules/@codemirror/search": { "node_modules/@codemirror/search": {
"version": "6.5.11", "version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz", "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",
"integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==", "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/state": "^6.0.0", "@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0", "@codemirror/view": "^6.37.0",
"crelt": "^1.0.5" "crelt": "^1.0.5"
} }
}, },
"node_modules/@codemirror/state": { "node_modules/@codemirror/state": {
"version": "6.5.2", "version": "6.5.4",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz",
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@marijn/find-cluster-break": "^1.0.0" "@marijn/find-cluster-break": "^1.0.0"
@@ -1343,9 +1497,9 @@
} }
}, },
"node_modules/@codemirror/view": { "node_modules/@codemirror/view": {
"version": "6.39.4", "version": "6.39.15",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz", "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.15.tgz",
"integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==", "integrity": "sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/state": "^6.5.0", "@codemirror/state": "^6.5.0",
@@ -3711,6 +3865,28 @@
"integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==", "integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@lezer/cpp": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.5.tgz",
"integrity": "sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/css": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.1.tgz",
"integrity": "sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": { "node_modules/@lezer/highlight": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
@@ -3720,6 +3896,50 @@
"@lezer/common": "^1.3.0" "@lezer/common": "^1.3.0"
} }
}, },
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/java": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz",
"integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": { "node_modules/@lezer/lr": {
"version": "1.4.5", "version": "1.4.5",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.5.tgz", "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.5.tgz",
@@ -3729,6 +3949,49 @@
"@lezer/common": "^1.0.0" "@lezer/common": "^1.0.0"
} }
}, },
"node_modules/@lezer/markdown": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz",
"integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@lezer/php": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz",
"integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.1.0"
}
},
"node_modules/@lezer/python": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz",
"integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/rust": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz",
"integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/xml": { "node_modules/@lezer/xml": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz",