mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
99
apps/server/src/routes/fs/routes/copy.ts
Normal file
99
apps/server/src/routes/fs/routes/copy.ts
Normal 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
142
apps/server/src/routes/fs/routes/download.ts
Normal file
142
apps/server/src/routes/fs/routes/download.ts
Normal 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
79
apps/server/src/routes/fs/routes/move.ts
Normal file
79
apps/server/src/routes/fs/routes/move.ts
Normal 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1492
apps/ui/src/components/views/file-editor-view/file-editor-view.tsx
Normal file
1492
apps/ui/src/components/views/file-editor-view/file-editor-view.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1
apps/ui/src/components/views/file-editor-view/index.ts
Normal file
1
apps/ui/src/components/views/file-editor-view/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { FileEditorView } from './file-editor-view';
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -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':
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { EditorSection } from './editor-section';
|
||||||
@@ -14,6 +14,7 @@ export type SettingsViewId =
|
|||||||
| 'prompts'
|
| 'prompts'
|
||||||
| 'model-defaults'
|
| 'model-defaults'
|
||||||
| 'appearance'
|
| 'appearance'
|
||||||
|
| 'editor'
|
||||||
| 'terminal'
|
| 'terminal'
|
||||||
| 'keyboard'
|
| 'keyboard'
|
||||||
| 'audio'
|
| 'audio'
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? [],
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
11
apps/ui/src/routes/file-editor.lazy.tsx
Normal file
11
apps/ui/src/routes/file-editor.lazy.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
11
apps/ui/src/routes/file-editor.tsx
Normal file
11
apps/ui/src/routes/file-editor.tsx
Normal 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,
|
||||||
|
});
|
||||||
@@ -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 }),
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
67
apps/ui/src/types/electron.d.ts
vendored
67
apps/ui/src/types/electron.d.ts
vendored
@@ -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
|
||||||
|
|||||||
@@ -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
283
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user