mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-17 22:13:08 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
248
apps/server/src/routes/git/routes/details.ts
Normal file
248
apps/server/src/routes/git/routes/details.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* POST /details endpoint - Get detailed git info for a file or project
|
||||
* Returns branch, last commit info, diff stats, and conflict status
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { exec, execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface GitFileDetails {
|
||||
branch: string;
|
||||
lastCommitHash: string;
|
||||
lastCommitMessage: string;
|
||||
lastCommitAuthor: string;
|
||||
lastCommitTimestamp: string;
|
||||
linesAdded: number;
|
||||
linesRemoved: number;
|
||||
isConflicted: boolean;
|
||||
isStaged: boolean;
|
||||
isUnstaged: boolean;
|
||||
statusLabel: string;
|
||||
}
|
||||
|
||||
export function createDetailsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, filePath } = req.body as {
|
||||
projectPath: string;
|
||||
filePath?: string;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current branch
|
||||
const { stdout: branchRaw } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: projectPath,
|
||||
});
|
||||
const branch = branchRaw.trim();
|
||||
|
||||
if (!filePath) {
|
||||
// Project-level details - just return branch info
|
||||
res.json({
|
||||
success: true,
|
||||
details: { branch },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get last commit info for this file
|
||||
let lastCommitHash = '';
|
||||
let lastCommitMessage = '';
|
||||
let lastCommitAuthor = '';
|
||||
let lastCommitTimestamp = '';
|
||||
|
||||
try {
|
||||
const { stdout: logOutput } = await execFileAsync(
|
||||
'git',
|
||||
['log', '-1', '--format=%H|%s|%an|%aI', '--', filePath],
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
if (logOutput.trim()) {
|
||||
const parts = logOutput.trim().split('|');
|
||||
lastCommitHash = parts[0] || '';
|
||||
lastCommitMessage = parts[1] || '';
|
||||
lastCommitAuthor = parts[2] || '';
|
||||
lastCommitTimestamp = parts[3] || '';
|
||||
}
|
||||
} catch {
|
||||
// File may not have any commits yet
|
||||
}
|
||||
|
||||
// Get diff stats (lines added/removed)
|
||||
let linesAdded = 0;
|
||||
let linesRemoved = 0;
|
||||
|
||||
try {
|
||||
// Check if file is untracked first
|
||||
const { stdout: statusLine } = await execFileAsync(
|
||||
'git',
|
||||
['status', '--porcelain', '--', filePath],
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
if (statusLine.trim().startsWith('??')) {
|
||||
// Untracked file - count all lines as added using Node.js instead of shell
|
||||
try {
|
||||
const fileContent = (await secureFs.readFile(filePath, 'utf-8')).toString();
|
||||
const lines = fileContent.split('\n');
|
||||
// Don't count trailing empty line from final newline
|
||||
linesAdded =
|
||||
lines.length > 0 && lines[lines.length - 1] === ''
|
||||
? lines.length - 1
|
||||
: lines.length;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
} else {
|
||||
const { stdout: diffStatRaw } = await execFileAsync(
|
||||
'git',
|
||||
['diff', '--numstat', 'HEAD', '--', filePath],
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
if (diffStatRaw.trim()) {
|
||||
const parts = diffStatRaw.trim().split('\t');
|
||||
linesAdded = parseInt(parts[0], 10) || 0;
|
||||
linesRemoved = parseInt(parts[1], 10) || 0;
|
||||
}
|
||||
|
||||
// Also check staged diff stats
|
||||
const { stdout: stagedDiffStatRaw } = await execFileAsync(
|
||||
'git',
|
||||
['diff', '--numstat', '--cached', '--', filePath],
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
if (stagedDiffStatRaw.trim()) {
|
||||
const parts = stagedDiffStatRaw.trim().split('\t');
|
||||
linesAdded += parseInt(parts[0], 10) || 0;
|
||||
linesRemoved += parseInt(parts[1], 10) || 0;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Diff might not be available
|
||||
}
|
||||
|
||||
// Get conflict and staging status
|
||||
let isConflicted = false;
|
||||
let isStaged = false;
|
||||
let isUnstaged = false;
|
||||
let statusLabel = '';
|
||||
|
||||
try {
|
||||
const { stdout: statusOutput } = await execFileAsync(
|
||||
'git',
|
||||
['status', '--porcelain', '--', filePath],
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
if (statusOutput.trim()) {
|
||||
const indexStatus = statusOutput[0];
|
||||
const workTreeStatus = statusOutput[1];
|
||||
|
||||
// Check for conflicts (both modified, unmerged states)
|
||||
if (
|
||||
indexStatus === 'U' ||
|
||||
workTreeStatus === 'U' ||
|
||||
(indexStatus === 'A' && workTreeStatus === 'A') ||
|
||||
(indexStatus === 'D' && workTreeStatus === 'D')
|
||||
) {
|
||||
isConflicted = true;
|
||||
statusLabel = 'Conflicted';
|
||||
} else {
|
||||
// Staged changes (index has a status)
|
||||
if (indexStatus !== ' ' && indexStatus !== '?') {
|
||||
isStaged = true;
|
||||
}
|
||||
// Unstaged changes (work tree has a status)
|
||||
if (workTreeStatus !== ' ' && workTreeStatus !== '?') {
|
||||
isUnstaged = true;
|
||||
}
|
||||
|
||||
// Build status label
|
||||
if (isStaged && isUnstaged) {
|
||||
statusLabel = 'Staged + Modified';
|
||||
} else if (isStaged) {
|
||||
statusLabel = 'Staged';
|
||||
} else {
|
||||
const statusChar = workTreeStatus !== ' ' ? workTreeStatus : indexStatus;
|
||||
switch (statusChar) {
|
||||
case 'M':
|
||||
statusLabel = 'Modified';
|
||||
break;
|
||||
case 'A':
|
||||
statusLabel = 'Added';
|
||||
break;
|
||||
case 'D':
|
||||
statusLabel = 'Deleted';
|
||||
break;
|
||||
case 'R':
|
||||
statusLabel = 'Renamed';
|
||||
break;
|
||||
case 'C':
|
||||
statusLabel = 'Copied';
|
||||
break;
|
||||
case '?':
|
||||
statusLabel = 'Untracked';
|
||||
break;
|
||||
default:
|
||||
statusLabel = statusChar || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Status might not be available
|
||||
}
|
||||
|
||||
const details: GitFileDetails = {
|
||||
branch,
|
||||
lastCommitHash,
|
||||
lastCommitMessage,
|
||||
lastCommitAuthor,
|
||||
lastCommitTimestamp,
|
||||
linesAdded,
|
||||
linesRemoved,
|
||||
isConflicted,
|
||||
isStaged,
|
||||
isUnstaged,
|
||||
statusLabel,
|
||||
};
|
||||
|
||||
res.json({ success: true, details });
|
||||
} catch (innerError) {
|
||||
logError(innerError, 'Git details failed');
|
||||
res.json({
|
||||
success: true,
|
||||
details: {
|
||||
branch: '',
|
||||
lastCommitHash: '',
|
||||
lastCommitMessage: '',
|
||||
lastCommitAuthor: '',
|
||||
lastCommitTimestamp: '',
|
||||
linesAdded: 0,
|
||||
linesRemoved: 0,
|
||||
isConflicted: false,
|
||||
isStaged: false,
|
||||
isUnstaged: false,
|
||||
statusLabel: '',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Get git details failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
176
apps/server/src/routes/git/routes/enhanced-status.ts
Normal file
176
apps/server/src/routes/git/routes/enhanced-status.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* POST /enhanced-status endpoint - Get enhanced git status with diff stats per file
|
||||
* Returns per-file status with lines added/removed and staged/unstaged differentiation
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
interface EnhancedFileStatus {
|
||||
path: string;
|
||||
indexStatus: string;
|
||||
workTreeStatus: string;
|
||||
isConflicted: boolean;
|
||||
isStaged: boolean;
|
||||
isUnstaged: boolean;
|
||||
linesAdded: number;
|
||||
linesRemoved: number;
|
||||
statusLabel: string;
|
||||
}
|
||||
|
||||
function getStatusLabel(indexStatus: string, workTreeStatus: string): string {
|
||||
// Check for conflicts
|
||||
if (
|
||||
indexStatus === 'U' ||
|
||||
workTreeStatus === 'U' ||
|
||||
(indexStatus === 'A' && workTreeStatus === 'A') ||
|
||||
(indexStatus === 'D' && workTreeStatus === 'D')
|
||||
) {
|
||||
return 'Conflicted';
|
||||
}
|
||||
|
||||
const hasStaged = indexStatus !== ' ' && indexStatus !== '?';
|
||||
const hasUnstaged = workTreeStatus !== ' ' && workTreeStatus !== '?';
|
||||
|
||||
if (hasStaged && hasUnstaged) return 'Staged + Modified';
|
||||
if (hasStaged) return 'Staged';
|
||||
|
||||
const statusChar = workTreeStatus !== ' ' ? workTreeStatus : indexStatus;
|
||||
switch (statusChar) {
|
||||
case 'M':
|
||||
return 'Modified';
|
||||
case 'A':
|
||||
return 'Added';
|
||||
case 'D':
|
||||
return 'Deleted';
|
||||
case 'R':
|
||||
return 'Renamed';
|
||||
case 'C':
|
||||
return 'Copied';
|
||||
case '?':
|
||||
return 'Untracked';
|
||||
default:
|
||||
return statusChar || '';
|
||||
}
|
||||
}
|
||||
|
||||
export function createEnhancedStatusHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current branch
|
||||
const { stdout: branchRaw } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: projectPath,
|
||||
});
|
||||
const branch = branchRaw.trim();
|
||||
|
||||
// Get porcelain status for all files
|
||||
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
|
||||
cwd: projectPath,
|
||||
});
|
||||
|
||||
// Get diff numstat for working tree changes
|
||||
let workTreeStats: Record<string, { added: number; removed: number }> = {};
|
||||
try {
|
||||
const { stdout: numstatRaw } = await execAsync('git diff --numstat', {
|
||||
cwd: projectPath,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
for (const line of numstatRaw.trim().split('\n').filter(Boolean)) {
|
||||
const parts = line.split('\t');
|
||||
if (parts.length >= 3) {
|
||||
const added = parseInt(parts[0], 10) || 0;
|
||||
const removed = parseInt(parts[1], 10) || 0;
|
||||
workTreeStats[parts[2]] = { added, removed };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// Get diff numstat for staged changes
|
||||
let stagedStats: Record<string, { added: number; removed: number }> = {};
|
||||
try {
|
||||
const { stdout: stagedNumstatRaw } = await execAsync('git diff --numstat --cached', {
|
||||
cwd: projectPath,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
for (const line of stagedNumstatRaw.trim().split('\n').filter(Boolean)) {
|
||||
const parts = line.split('\t');
|
||||
if (parts.length >= 3) {
|
||||
const added = parseInt(parts[0], 10) || 0;
|
||||
const removed = parseInt(parts[1], 10) || 0;
|
||||
stagedStats[parts[2]] = { added, removed };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// Parse status and build enhanced file list
|
||||
const files: EnhancedFileStatus[] = [];
|
||||
|
||||
for (const line of statusOutput.split('\n').filter(Boolean)) {
|
||||
if (line.length < 4) continue;
|
||||
|
||||
const indexStatus = line[0];
|
||||
const workTreeStatus = line[1];
|
||||
const filePath = line.substring(3).trim();
|
||||
|
||||
// Handle renamed files (format: "R old -> new")
|
||||
const actualPath = filePath.includes(' -> ')
|
||||
? filePath.split(' -> ')[1].trim()
|
||||
: filePath;
|
||||
|
||||
const isConflicted =
|
||||
indexStatus === 'U' ||
|
||||
workTreeStatus === 'U' ||
|
||||
(indexStatus === 'A' && workTreeStatus === 'A') ||
|
||||
(indexStatus === 'D' && workTreeStatus === 'D');
|
||||
|
||||
const isStaged = indexStatus !== ' ' && indexStatus !== '?';
|
||||
const isUnstaged = workTreeStatus !== ' ' && workTreeStatus !== '?';
|
||||
|
||||
// Combine diff stats from both working tree and staged
|
||||
const wtStats = workTreeStats[actualPath] || { added: 0, removed: 0 };
|
||||
const stStats = stagedStats[actualPath] || { added: 0, removed: 0 };
|
||||
|
||||
files.push({
|
||||
path: actualPath,
|
||||
indexStatus,
|
||||
workTreeStatus,
|
||||
isConflicted,
|
||||
isStaged,
|
||||
isUnstaged,
|
||||
linesAdded: wtStats.added + stStats.added,
|
||||
linesRemoved: wtStats.removed + stStats.removed,
|
||||
statusLabel: getStatusLabel(indexStatus, workTreeStatus),
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
branch,
|
||||
files,
|
||||
});
|
||||
} catch (innerError) {
|
||||
logError(innerError, 'Git enhanced status failed');
|
||||
res.json({ success: true, branch: '', files: [] });
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Get enhanced status failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user