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 { createDeleteBoardBackgroundHandler } from './routes/delete-board-background.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 {
const router = Router();
@@ -39,6 +42,9 @@ export function createFsRoutes(_events: EventEmitter): Router {
router.post('/save-board-background', createSaveBoardBackgroundHandler());
router.post('/delete-board-background', createDeleteBoardBackgroundHandler());
router.post('/browse-project-files', createBrowseProjectFilesHandler());
router.post('/copy', createCopyHandler());
router.post('/move', createMoveHandler());
router.post('/download', createDownloadHandler());
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 { createFileDiffHandler } from './routes/file-diff.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 {
const router = Router();
@@ -18,6 +20,8 @@ export function createGitRoutes(): Router {
validatePathParams('projectPath', 'files[]'),
createStageFilesHandler()
);
router.post('/details', validatePathParams('projectPath', 'filePath?'), createDetailsHandler());
router.post('/enhanced-status', validatePathParams('projectPath'), createEnhancedStatusHandler());
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) });
}
};
}