diff --git a/apps/server/src/routes/fs/index.ts b/apps/server/src/routes/fs/index.ts index 9991c346..e8805102 100644 --- a/apps/server/src/routes/fs/index.ts +++ b/apps/server/src/routes/fs/index.ts @@ -20,6 +20,9 @@ import { createImageHandler } from './routes/image.js'; import { createSaveBoardBackgroundHandler } from './routes/save-board-background.js'; import { createDeleteBoardBackgroundHandler } from './routes/delete-board-background.js'; import { createBrowseProjectFilesHandler } from './routes/browse-project-files.js'; +import { createCopyHandler } from './routes/copy.js'; +import { createMoveHandler } from './routes/move.js'; +import { createDownloadHandler } from './routes/download.js'; export function createFsRoutes(_events: EventEmitter): Router { const router = Router(); @@ -39,6 +42,9 @@ export function createFsRoutes(_events: EventEmitter): Router { router.post('/save-board-background', createSaveBoardBackgroundHandler()); router.post('/delete-board-background', createDeleteBoardBackgroundHandler()); router.post('/browse-project-files', createBrowseProjectFilesHandler()); + router.post('/copy', createCopyHandler()); + router.post('/move', createMoveHandler()); + router.post('/download', createDownloadHandler()); return router; } diff --git a/apps/server/src/routes/fs/routes/copy.ts b/apps/server/src/routes/fs/routes/copy.ts new file mode 100644 index 00000000..c52a546e --- /dev/null +++ b/apps/server/src/routes/fs/routes/copy.ts @@ -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 { + 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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/fs/routes/download.ts b/apps/server/src/routes/fs/routes/download.ts new file mode 100644 index 00000000..3ac44078 --- /dev/null +++ b/apps/server/src/routes/fs/routes/download.ts @@ -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 { + 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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/fs/routes/move.ts b/apps/server/src/routes/fs/routes/move.ts new file mode 100644 index 00000000..8979db55 --- /dev/null +++ b/apps/server/src/routes/fs/routes/move.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/git/index.ts b/apps/server/src/routes/git/index.ts index e6bf5a0c..600eb87b 100644 --- a/apps/server/src/routes/git/index.ts +++ b/apps/server/src/routes/git/index.ts @@ -7,6 +7,8 @@ import { validatePathParams } from '../../middleware/validate-paths.js'; import { createDiffsHandler } from './routes/diffs.js'; import { createFileDiffHandler } from './routes/file-diff.js'; import { createStageFilesHandler } from './routes/stage-files.js'; +import { createDetailsHandler } from './routes/details.js'; +import { createEnhancedStatusHandler } from './routes/enhanced-status.js'; export function createGitRoutes(): Router { const router = Router(); @@ -18,6 +20,8 @@ export function createGitRoutes(): Router { validatePathParams('projectPath', 'files[]'), createStageFilesHandler() ); + router.post('/details', validatePathParams('projectPath', 'filePath?'), createDetailsHandler()); + router.post('/enhanced-status', validatePathParams('projectPath'), createEnhancedStatusHandler()); return router; } diff --git a/apps/server/src/routes/git/routes/details.ts b/apps/server/src/routes/git/routes/details.ts new file mode 100644 index 00000000..0861b89e --- /dev/null +++ b/apps/server/src/routes/git/routes/details.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/git/routes/enhanced-status.ts b/apps/server/src/routes/git/routes/enhanced-status.ts new file mode 100644 index 00000000..4d7d2e3d --- /dev/null +++ b/apps/server/src/routes/git/routes/enhanced-status.ts @@ -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 => { + 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 = {}; + 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 = {}; + 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) }); + } + }; +} diff --git a/apps/ui/package.json b/apps/ui/package.json index 8b05d4e1..b93fd7c6 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -42,10 +42,24 @@ "@automaker/dependency-resolver": "1.0.0", "@automaker/spec-parser": "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/language": "^6.12.1", "@codemirror/legacy-modes": "^6.5.2", + "@codemirror/search": "^6.6.0", + "@codemirror/state": "^6.5.4", "@codemirror/theme-one-dark": "6.1.3", + "@codemirror/view": "^6.39.15", "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "10.0.0", "@dnd-kit/utilities": "3.2.2", diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts index 90d59db9..542dfb88 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts @@ -2,6 +2,7 @@ import { useMemo, useState, useEffect } from 'react'; import type { NavigateOptions } from '@tanstack/react-router'; import { FileText, + Folder, LayoutGrid, Bot, BookOpen, @@ -142,7 +143,7 @@ export function useNavigation({ return true; }); - // Build project items - Terminal is conditionally included + // Build project items - Terminal and File Editor are conditionally included const projectItems: NavItem[] = [ { id: 'board', @@ -156,6 +157,11 @@ export function useNavigation({ icon: Network, shortcut: shortcuts.graph, }, + { + id: 'file-editor', + label: 'File Editor', + icon: Folder, + }, { id: 'agent', label: 'Agent Runner', diff --git a/apps/ui/src/components/views/file-editor-view/components/code-editor.tsx b/apps/ui/src/components/views/file-editor-view/components/code-editor.tsx new file mode 100644 index 00000000..1967c2a1 --- /dev/null +++ b/apps/ui/src/components/views/file-editor-view/components/code-editor.tsx @@ -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(function CodeEditor( + { + value, + onChange, + filePath, + readOnly = false, + tabSize = 2, + wordWrap = true, + fontSize = 13, + fontFamily, + onCursorChange, + onSave, + className, + scrollCursorIntoView = false, + }, + ref +) { + const editorRef = useRef(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 ( +
+ +
+ ); +}); diff --git a/apps/ui/src/components/views/file-editor-view/components/editor-settings-form.tsx b/apps/ui/src/components/views/file-editor-view/components/editor-settings-form.tsx new file mode 100644 index 00000000..ec330cdf --- /dev/null +++ b/apps/ui/src/components/views/file-editor-view/components/editor-settings-form.tsx @@ -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 */} +
+
+ + {editorFontSize}px +
+
+ setEditorFontSize(value)} + className="flex-1" + /> + +
+
+ + {/* Font Family */} +
+ + +
+ + {/* Auto Save toggle */} +
+ + +
+ + ); +} diff --git a/apps/ui/src/components/views/file-editor-view/components/editor-tabs.tsx b/apps/ui/src/components/views/file-editor-view/components/editor-tabs.tsx new file mode 100644 index 00000000..08dcb2b2 --- /dev/null +++ b/apps/ui/src/components/views/file-editor-view/components/editor-tabs.tsx @@ -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 ( +
+ {tabs.map((tab) => { + const isActive = tab.id === activeTabId; + const fileColor = getFileColor(tab.fileName); + + return ( +
onTabSelect(tab.id)} + title={tab.filePath} + > + {/* Dirty indicator */} + {tab.isDirty ? ( + + ) : ( + + )} + + {/* File name */} + {tab.fileName} + + {/* Close button */} + +
+ ); + })} + + {/* Tab actions dropdown (close all, etc.) */} +
+ + + + + + + + Close All + + + +
+
+ ); +} diff --git a/apps/ui/src/components/views/file-editor-view/components/file-tree.tsx b/apps/ui/src/components/views/file-editor-view/components/file-tree.tsx new file mode 100644 index 00000000..954c5824 --- /dev/null +++ b/apps/ui/src/components/views/file-editor-view/components/file-tree.tsx @@ -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; + onCreateFolder: (parentPath: string, name: string) => Promise; + onDeleteItem: (path: string, isDirectory: boolean) => Promise; + onRenameItem: (oldPath: string, newName: string) => Promise; + onCopyPath: (path: string) => void; + onRefresh: () => void; + onToggleFolder: (path: string) => void; + activeFilePath: string | null; + onCopyItem?: (sourcePath: string, destinationPath: string) => Promise; + onMoveItem?: (sourcePath: string, destinationPath: string) => Promise; + onDownloadItem?: (filePath: string) => Promise; + onDragDropMove?: (sourcePaths: string[], targetFolderPath: string) => Promise; + 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(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 ( + 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(null); + + useEffect(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, []); + + return ( +
+
+
+

{action} To...

+

+ Enter the destination path for the {action.toLowerCase()} operation +

+
+
+ 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" + /> +
+
+ + +
+
+
+ ); +} + +/** 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; + onCreateFolder: (parentPath: string, name: string) => Promise; + onDeleteItem: (path: string, isDirectory: boolean) => Promise; + onRenameItem: (oldPath: string, newName: string) => Promise; + onCopyPath: (path: string) => void; + onToggleFolder: (path: string) => void; + activeFilePath: string | null; + gitStatusMap: Map; + showHiddenFiles: boolean; + onCopyItem?: (sourcePath: string, destinationPath: string) => Promise; + onMoveItem?: (sourcePath: string, destinationPath: string) => Promise; + onDownloadItem?: (filePath: string) => Promise; + onDragDropMove?: (sourcePaths: string[], targetFolderPath: string) => Promise; + 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 ( +
+ {/* Destination picker dialogs */} + {showCopyPicker && onCopyItem && ( + { + setShowCopyPicker(false); + await onCopyItem(node.path, destPath); + }} + onCancel={() => setShowCopyPicker(false)} + /> + )} + {showMovePicker && onMoveItem && ( + { + setShowMovePicker(false); + await onMoveItem(node.path, destPath); + }} + onCancel={() => setShowMovePicker(false)} + /> + )} + + {isRenaming ? ( +
+ { + await onRenameItem(node.path, newName); + setIsRenaming(false); + }} + onCancel={() => setIsRenaming(false)} + /> +
+ ) : ( +
+ {/* Drag handle indicator (visible on hover) */} + + + {/* Expand/collapse chevron */} + {node.isDirectory ? ( + isExpanded ? ( + + ) : ( + + ) + ) : ( + + )} + + {/* Icon */} + {node.isDirectory ? ( + isExpanded ? ( + + ) : ( + + ) + ) : isConflicted ? ( + + ) : ( + + )} + + {/* Name */} + {node.name} + + {/* Diff stats (lines added/removed) shown inline */} + {!node.isDirectory && (linesAdded > 0 || linesRemoved > 0) && ( + + {linesAdded > 0 && ( + + + {linesAdded} + + )} + {linesRemoved > 0 && ( + + + {linesRemoved} + + )} + + )} + + {/* Git status indicator - two-tone badge for staged+unstaged */} + {gitStatus && ( + + {isStaged && isUnstaged ? ( + // Two-tone badge: staged (green) + unstaged (yellow) + <> + + + + ) : isConflicted ? ( + + ) : ( + + )} + + )} + + {/* Actions dropdown menu (three-dot button) */} + + + + + + {/* Folder-specific: New File / New Folder */} + {node.isDirectory && ( + <> + { + e.stopPropagation(); + if (!isExpanded) onToggleFolder(node.path); + setIsCreatingFile(true); + }} + className="gap-2" + > + + New File + + { + e.stopPropagation(); + if (!isExpanded) onToggleFolder(node.path); + setIsCreatingFolder(true); + }} + className="gap-2" + > + + New Folder + + + + )} + + {/* Copy operations */} + { + e.stopPropagation(); + onCopyPath(node.path); + }} + className="gap-2" + > + + Copy Path + + { + e.stopPropagation(); + handleCopyName(); + }} + className="gap-2" + > + + Copy Name + + + + + {/* Copy To... */} + {onCopyItem && ( + { + e.stopPropagation(); + setShowCopyPicker(true); + }} + className="gap-2" + > + + Copy To... + + )} + + {/* Move To... */} + {onMoveItem && ( + { + e.stopPropagation(); + setShowMovePicker(true); + }} + className="gap-2" + > + + Move To... + + )} + + {/* Download */} + {onDownloadItem && ( + { + e.stopPropagation(); + onDownloadItem(node.path); + }} + className="gap-2" + > + + Download{node.isDirectory ? ' as ZIP' : ''} + + )} + + + + {/* Rename */} + { + e.stopPropagation(); + setIsRenaming(true); + }} + className="gap-2" + > + + Rename + + + {/* Delete */} + { + e.stopPropagation(); + handleDelete(); + }} + className="gap-2 text-destructive focus:text-destructive focus:bg-destructive/10" + > + + Delete + + + +
+ )} + + {/* Children (expanded folder) */} + {node.isDirectory && isExpanded && node.children && ( +
+ {/* Inline create file input */} + {isCreatingFile && ( +
+ { + await onCreateFile(node.path, name); + setIsCreatingFile(false); + }} + onCancel={() => setIsCreatingFile(false)} + /> +
+ )} + {/* Inline create folder input */} + {isCreatingFolder && ( +
+ { + await onCreateFolder(node.path, name); + setIsCreatingFolder(false); + }} + onCancel={() => setIsCreatingFolder(false)} + /> +
+ )} + {(showHiddenFiles + ? node.children + : node.children.filter((child) => !child.name.startsWith('.')) + ).map((child) => ( + + ))} +
+ )} +
+ ); +} + +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 ( +
+ {/* Tree toolbar */} +
+
+ + Explorer + + {gitBranch && ( + + {gitBranch} + + )} +
+
+ + + + +
+
+ + {/* Tree content */} +
+ {/* Root-level inline creators */} + {isCreatingFile && ( +
+ { + await onCreateFile('', name); + setIsCreatingFile(false); + }} + onCancel={() => setIsCreatingFile(false)} + /> +
+ )} + {isCreatingFolder && ( +
+ { + await onCreateFolder('', name); + setIsCreatingFolder(false); + }} + onCancel={() => setIsCreatingFolder(false)} + /> +
+ )} + + {filteredTree.length === 0 ? ( +
+

No files found

+
+ ) : ( + filteredTree.map((node) => ( + + )) + )} +
+
+ ); +} diff --git a/apps/ui/src/components/views/file-editor-view/components/git-detail-panel.tsx b/apps/ui/src/components/views/file-editor-view/components/git-detail-panel.tsx new file mode 100644 index 00000000..32adf9c6 --- /dev/null +++ b/apps/ui/src/components/views/file-editor-view/components/git-detail-panel.tsx @@ -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 ( +
+ {/* Collapsed summary bar */} + + + {/* Expanded details */} + {isExpanded && ( +
+ {/* Last commit info */} + {details.lastCommitHash && ( + <> +
+ +
+
{commitHashShort}
+ {details.lastCommitMessage && ( +
+ {details.lastCommitMessage} +
+ )} +
+
+ + {details.lastCommitAuthor && ( +
+ + {details.lastCommitAuthor} +
+ )} + + {timeAgo && ( +
+ + {timeAgo} +
+ )} + + )} + + {/* Conflict warning with action */} + {details.isConflicted && ( +
+ + This file has merge conflicts + {onOpenFile && ( + + )} +
+ )} +
+ )} +
+ ); +} + +/** 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 ''; + } +} diff --git a/apps/ui/src/components/views/file-editor-view/components/markdown-preview.tsx b/apps/ui/src/components/views/file-editor-view/components/markdown-preview.tsx new file mode 100644 index 00000000..f41933e3 --- /dev/null +++ b/apps/ui/src/components/views/file-editor-view/components/markdown-preview.tsx @@ -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 ( +
+ + + +
+ ); +} + +/** Rendered markdown preview panel */ +export function MarkdownPreviewPanel({ + content, + className, +}: { + content: string; + className?: string; +}) { + const scrollRef = useRef(null); + + return ( +
+
+ {content || '*No content to preview*'} +
+
+ ); +} + +/** 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); +} diff --git a/apps/ui/src/components/views/file-editor-view/components/worktree-directory-dropdown.tsx b/apps/ui/src/components/views/file-editor-view/components/worktree-directory-dropdown.tsx new file mode 100644 index 00000000..e12b302d --- /dev/null +++ b/apps/ui/src/components/views/file-editor-view/components/worktree-directory-dropdown.tsx @@ -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 ( + + + + + + + Working Directory + + + + {/* Main directory */} + {mainWorktree && ( + handleSelectWorktree(null, mainWorktree.branch)} + className="gap-2" + > + +
+ {mainWorktree.branch} + Main directory +
+ {selectedIsMain && } +
+ )} + + {/* Worktree directories */} + {otherWorktrees.length > 0 && ( + <> + + + Worktrees + + {otherWorktrees.map((wt) => { + const isSelected = + currentWorktreePath !== null && pathsEqual(wt.path, currentWorktreePath); + return ( + handleSelectWorktree(wt.path, wt.branch)} + className="gap-2" + > + +
+ {wt.branch} + {wt.hasChanges && ( + + {wt.changedFilesCount ?? ''} change{wt.changedFilesCount !== 1 ? 's' : ''} + + )} +
+ {isSelected && } +
+ ); + })} + + )} +
+
+ ); +} diff --git a/apps/ui/src/components/views/file-editor-view/file-editor-view.tsx b/apps/ui/src/components/views/file-editor-view/file-editor-view.tsx new file mode 100644 index 00000000..56805417 --- /dev/null +++ b/apps/ui/src/components/views/file-editor-view/file-editor-view.tsx @@ -0,0 +1,1492 @@ +import { useEffect, useCallback, useRef, useState, useMemo } from 'react'; +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import { + FileCode2, + Save, + FileWarning, + Binary, + Circle, + PanelLeftOpen, + Search, + Undo2, + Redo2, + Settings, +} from 'lucide-react'; +import { createLogger } from '@automaker/utils/logger'; +import { useAppStore } from '@/store/app-store'; +import { getElectronAPI } from '@/lib/electron'; +import { useIsMobile } from '@/hooks/use-media-query'; +import { useVirtualKeyboardResize } from '@/hooks/use-virtual-keyboard-resize'; +import { Button } from '@/components/ui/button'; +import { + HeaderActionsPanel, + HeaderActionsPanelTrigger, +} from '@/components/ui/header-actions-panel'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; + +import { toast } from 'sonner'; +import { + useFileEditorStore, + type FileTreeNode, + type EnhancedGitFileStatus, +} from './use-file-editor-store'; +import { FileTree } from './components/file-tree'; +import { CodeEditor, getLanguageName, type CodeEditorHandle } from './components/code-editor'; +import { EditorTabs } from './components/editor-tabs'; +import { EditorSettingsForm } from './components/editor-settings-form'; +import { + MarkdownPreviewPanel, + MarkdownViewToolbar, + isMarkdownFile, +} from './components/markdown-preview'; +import { WorktreeDirectoryDropdown } from './components/worktree-directory-dropdown'; +import { GitDetailPanel } from './components/git-detail-panel'; + +const logger = createLogger('FileEditorView'); + +// Files with these extensions are considered binary +const BINARY_EXTENSIONS = new Set([ + 'png', + 'jpg', + 'jpeg', + 'gif', + 'bmp', + 'ico', + 'svg', + 'webp', + 'avif', + 'mp3', + 'mp4', + 'wav', + 'ogg', + 'webm', + 'avi', + 'mov', + 'flac', + 'zip', + 'tar', + 'gz', + 'bz2', + 'xz', + '7z', + 'rar', + 'pdf', + 'doc', + 'docx', + 'xls', + 'xlsx', + 'ppt', + 'pptx', + 'exe', + 'dll', + 'so', + 'dylib', + 'bin', + 'dat', + 'woff', + 'woff2', + 'ttf', + 'otf', + 'eot', + 'sqlite', + 'db', +]); + +function isBinaryFile(filePath: string): boolean { + // Extract the filename from the full path first, then get the extension. + // Using split('/').pop() ensures we don't confuse dots in directory names + // with the file extension. Files without an extension (no dot after the + // last slash) correctly return '' here. + const fileName = filePath.split('/').pop() || ''; + const dotIndex = fileName.lastIndexOf('.'); + // No dot found, or dot is at index 0 (dotfile like ".gitignore") → no extension + if (dotIndex <= 0) return false; + const ext = fileName.slice(dotIndex + 1).toLowerCase(); + return BINARY_EXTENSIONS.has(ext); +} + +interface FileEditorViewProps { + initialPath?: string; +} + +export function FileEditorView({ initialPath }: FileEditorViewProps) { + const { currentProject } = useAppStore(); + const currentWorktree = useAppStore((s) => + currentProject?.path ? (s.currentWorktreeByProject[currentProject.path] ?? null) : null + ); + // Read persisted editor font settings from app store + const editorFontSize = useAppStore((s) => s.editorFontSize); + const editorFontFamily = useAppStore((s) => s.editorFontFamily); + const setEditorFontSize = useAppStore((s) => s.setEditorFontSize); + const setEditorFontFamily = useAppStore((s) => s.setEditorFontFamily); + // Auto-save settings + const editorAutoSave = useAppStore((s) => s.editorAutoSave); + const editorAutoSaveDelay = useAppStore((s) => s.editorAutoSaveDelay); + const setEditorAutoSave = useAppStore((s) => s.setEditorAutoSave); + const store = useFileEditorStore(); + const isMobile = useIsMobile(); + const loadedProjectRef = useRef(null); + const refreshTimerRef = useRef | null>(null); + const editorRef = useRef(null); + const autoSaveTimerRef = useRef | null>(null); + const [showActionsPanel, setShowActionsPanel] = useState(false); + + // Derive the effective working path from the current worktree selection. + // When a worktree is selected (path is non-null), use the worktree path; + // otherwise fall back to the main project path. + const effectivePath = useMemo(() => { + if (!currentProject?.path) return null; + return currentWorktree?.path ?? currentProject.path; + }, [currentProject?.path, currentWorktree?.path]); + + // Track virtual keyboard height on mobile to prevent content from being hidden + const { keyboardHeight, isKeyboardOpen } = useVirtualKeyboardResize(); + + const { + tabs, + activeTabId, + markdownViewMode, + mobileBrowserVisible, + tabSize, + wordWrap, + maxFileSize, + setFileTree, + openTab, + closeTab, + closeAllTabs, + setActiveTab, + markTabSaved, + setMarkdownViewMode, + setMobileBrowserVisible, + setGitStatusMap, + setExpandedFolders, + setEnhancedGitStatusMap, + setGitBranch, + setActiveFileGitDetails, + activeFileGitDetails, + gitBranch, + enhancedGitStatusMap, + } = store; + + const activeTab = tabs.find((t) => t.id === activeTabId) || null; + + // ─── Load File Tree ────────────────────────────────────────── + const loadTree = useCallback( + async (basePath?: string, options?: { preserveExpanded?: boolean }) => { + const treePath = basePath || effectivePath; + if (!treePath) return; + + // Snapshot expanded folders before loading so we can restore them after + // (loadTree resets expandedFolders by default on initial load, but + // refreshes triggered by file/folder operations should preserve state) + const expandedSnapshot = options?.preserveExpanded + ? new Set(useFileEditorStore.getState().expandedFolders) + : null; + + try { + const api = getElectronAPI(); + + // Recursive tree builder + const buildTree = async (dirPath: string, depth: number = 0): Promise => { + const result = await api.readdir(dirPath); + if (!result.success || !result.entries) return []; + + const nodes: FileTreeNode[] = result.entries + .sort((a, b) => { + if (a.isDirectory && !b.isDirectory) return -1; + if (!a.isDirectory && b.isDirectory) return 1; + return a.name.localeCompare(b.name); + }) + .map((entry) => ({ + name: entry.name, + path: `${dirPath}/${entry.name}`, + isDirectory: entry.isDirectory, + })); + + // Load first level of children for directories (lazy after that) + if (depth < 1) { + for (const node of nodes) { + if (node.isDirectory) { + node.children = await buildTree(node.path, depth + 1); + } + } + } + + return nodes; + }; + + const tree = await buildTree(treePath); + setFileTree(tree); + + if (expandedSnapshot !== null) { + // Restore previously expanded folders after refresh + setExpandedFolders(expandedSnapshot); + } else { + // Folders are collapsed by default — do not auto-expand any directories + setExpandedFolders(new Set()); + } + } catch (error) { + logger.error('Failed to load file tree:', error); + } + }, + [effectivePath, setFileTree, setExpandedFolders] + ); + + // ─── Load Git Status ───────────────────────────────────────── + const loadGitStatus = useCallback(async () => { + if (!effectivePath) return; + + try { + const api = getElectronAPI(); + if (!api.git) return; + + // Load basic diffs (backwards-compatible) + const result = await api.git.getDiffs(effectivePath); + if (result.success && result.files) { + const statusMap = new Map(); + for (const file of result.files) { + const fullPath = `${effectivePath}/${file.path}`; + // Determine status - prefer workTree, fallback to index + let status = file.workTreeStatus || file.indexStatus || file.status; + if (status === ' ') status = file.indexStatus || ''; + if (status) { + statusMap.set(fullPath, status); + } + } + setGitStatusMap(statusMap); + } + + // Also load enhanced status (with diff stats and staged/unstaged info) + try { + const enhancedResult = await api.git.getEnhancedStatus(effectivePath); + if (enhancedResult.success) { + if (enhancedResult.branch) { + setGitBranch(enhancedResult.branch); + } + if (enhancedResult.files) { + const enhancedMap = new Map(); + for (const file of enhancedResult.files) { + const fullPath = `${effectivePath}/${file.path}`; + enhancedMap.set(fullPath, { + indexStatus: file.indexStatus, + workTreeStatus: file.workTreeStatus, + isConflicted: file.isConflicted, + isStaged: file.isStaged, + isUnstaged: file.isUnstaged, + linesAdded: file.linesAdded, + linesRemoved: file.linesRemoved, + statusLabel: file.statusLabel, + }); + } + setEnhancedGitStatusMap(enhancedMap); + } + } + } catch { + // Enhanced status not available - that's okay + } + } catch (error) { + // Git might not be available - that's okay + logger.debug('Git status not available:', error); + } + }, [effectivePath, setGitStatusMap, setEnhancedGitStatusMap, setGitBranch]); + + // ─── Load subdirectory children lazily ─────────────────────── + const loadSubdirectory = useCallback(async (dirPath: string): Promise => { + try { + const api = getElectronAPI(); + const result = await api.readdir(dirPath); + if (!result.success || !result.entries) return []; + + const nodes: FileTreeNode[] = result.entries + .sort((a, b) => { + if (a.isDirectory && !b.isDirectory) return -1; + if (!a.isDirectory && b.isDirectory) return 1; + return a.name.localeCompare(b.name); + }) + .map((entry) => ({ + name: entry.name, + path: `${dirPath}/${entry.name}`, + isDirectory: entry.isDirectory, + })); + + // Pre-load first level of children for subdirectories so they can be expanded next + for (const node of nodes) { + if (node.isDirectory) { + try { + const subResult = await api.readdir(node.path); + if (subResult.success && subResult.entries) { + node.children = subResult.entries + .sort((a, b) => { + if (a.isDirectory && !b.isDirectory) return -1; + if (!a.isDirectory && b.isDirectory) return 1; + return a.name.localeCompare(b.name); + }) + .map((entry) => ({ + name: entry.name, + path: `${node.path}/${entry.name}`, + isDirectory: entry.isDirectory, + })); + } + } catch { + // Failed to pre-load children, they'll be loaded on expand + } + } + } + + return nodes; + } catch (error) { + logger.error('Failed to load subdirectory:', error); + return []; + } + }, []); + + // ─── Handle File Select ────────────────────────────────────── + const handleFileSelect = useCallback( + async (filePath: string) => { + // Check if already open + const existing = tabs.find((t) => t.filePath === filePath); + if (existing) { + setActiveTab(existing.id); + return; + } + + const fileName = filePath.split('/').pop() || 'untitled'; + + // Check if binary + if (isBinaryFile(filePath)) { + openTab({ + filePath, + fileName, + content: '', + originalContent: '', + isDirty: false, + scrollTop: 0, + cursorLine: 1, + cursorCol: 1, + isBinary: true, + isTooLarge: false, + fileSize: 0, + }); + return; + } + + try { + const api = getElectronAPI(); + + // Check file size first + const statResult = await api.stat(filePath); + const fileSize = statResult.success && statResult.stats ? statResult.stats.size : 0; + + if (fileSize > maxFileSize) { + openTab({ + filePath, + fileName, + content: '', + originalContent: '', + isDirty: false, + scrollTop: 0, + cursorLine: 1, + cursorCol: 1, + isBinary: false, + isTooLarge: true, + fileSize, + }); + return; + } + + // Read file content + const result = await api.readFile(filePath); + if (result.success && result.content !== undefined) { + // Check if content looks binary (contains null bytes) + if (result.content.includes('\0')) { + openTab({ + filePath, + fileName, + content: '', + originalContent: '', + isDirty: false, + scrollTop: 0, + cursorLine: 1, + cursorCol: 1, + isBinary: true, + isTooLarge: false, + fileSize, + }); + return; + } + + openTab({ + filePath, + fileName, + content: result.content, + originalContent: result.content, + isDirty: false, + scrollTop: 0, + cursorLine: 1, + cursorCol: 1, + isBinary: false, + isTooLarge: false, + fileSize, + }); + } + } catch (error) { + logger.error('Failed to open file:', error); + } + }, + [tabs, setActiveTab, openTab, maxFileSize] + ); + + // ─── Mobile-aware file select ──────────────────────────────── + const handleMobileFileSelect = useCallback( + async (filePath: string) => { + await handleFileSelect(filePath); + if (isMobile) { + setMobileBrowserVisible(false); + } + }, + [handleFileSelect, isMobile, setMobileBrowserVisible] + ); + + // ─── Handle Save ───────────────────────────────────────────── + const handleSave = useCallback(async () => { + if (!activeTab || !activeTab.isDirty) return; + + try { + const api = getElectronAPI(); + const result = await api.writeFile(activeTab.filePath, activeTab.content); + + if (result.success) { + markTabSaved(activeTab.id, activeTab.content); + // Refresh git status after save + loadGitStatus(); + } else { + logger.error('Failed to save file:', result.error); + } + } catch (error) { + logger.error('Failed to save file:', error); + } + }, [activeTab, markTabSaved, loadGitStatus]); + + // ─── Auto Save: save a specific tab by ID ─────────────────── + const saveTabById = useCallback( + async (tabId: string) => { + const { tabs: currentTabs } = useFileEditorStore.getState(); + const tab = currentTabs.find((t) => t.id === tabId); + if (!tab || !tab.isDirty) return; + + try { + const api = getElectronAPI(); + const result = await api.writeFile(tab.filePath, tab.content); + + if (result.success) { + markTabSaved(tab.id, tab.content); + loadGitStatus(); + } else { + logger.error('Auto-save failed:', result.error); + } + } catch (error) { + logger.error('Auto-save failed:', error); + } + }, + [markTabSaved, loadGitStatus] + ); + + // ─── Auto Save: on tab switch ────────────────────────────── + const prevActiveTabIdRef = useRef(null); + + useEffect(() => { + if (!editorAutoSave) { + prevActiveTabIdRef.current = activeTabId; + return; + } + + const prevTabId = prevActiveTabIdRef.current; + prevActiveTabIdRef.current = activeTabId; + + // When switching away from a dirty tab, auto-save it + if (prevTabId && prevTabId !== activeTabId) { + saveTabById(prevTabId); + } + }, [activeTabId, editorAutoSave, saveTabById]); + + // ─── Auto Save: after timeout on content change ──────────── + useEffect(() => { + if (!editorAutoSave || !activeTab || !activeTab.isDirty) { + // Clear any pending auto-save timer + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + autoSaveTimerRef.current = null; + } + return; + } + + // Debounce: set a timer to save after the configured delay + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + } + + autoSaveTimerRef.current = setTimeout(() => { + handleSave(); + autoSaveTimerRef.current = null; + }, editorAutoSaveDelay); + + return () => { + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + autoSaveTimerRef.current = null; + } + }; + }, [editorAutoSave, editorAutoSaveDelay, activeTab?.isDirty, activeTab?.content, handleSave]); + + // ─── Handle Search ────────────────────────────────────────── + const handleSearch = useCallback(() => { + if (editorRef.current) { + editorRef.current.openSearch(); + } + }, []); + + // ─── Handle Undo ─────────────────────────────────────────── + const handleUndo = useCallback(() => { + if (editorRef.current) { + editorRef.current.undo(); + } + }, []); + + // ─── Handle Redo ─────────────────────────────────────────── + const handleRedo = useCallback(() => { + if (editorRef.current) { + editorRef.current.redo(); + } + }, []); + + // ─── File Operations ───────────────────────────────────────── + const handleCreateFile = useCallback( + async (parentPath: string, name: string) => { + if (!effectivePath) return; + const fullPath = parentPath ? `${parentPath}/${name}` : `${effectivePath}/${name}`; + + try { + const api = getElectronAPI(); + await api.writeFile(fullPath, ''); + + // If the new file starts with a dot, auto-enable hidden files visibility + // so the created file doesn't "disappear" from the tree + if (name.startsWith('.')) { + const { showHiddenFiles } = useFileEditorStore.getState(); + if (!showHiddenFiles) { + store.setShowHiddenFiles(true); + } + } + + // Preserve expanded folders so the parent directory stays open after refresh + await loadTree(undefined, { preserveExpanded: true }); + // Open the newly created file (use mobile-aware select on mobile) + if (isMobile) { + handleMobileFileSelect(fullPath); + } else { + handleFileSelect(fullPath); + } + } catch (error) { + logger.error('Failed to create file:', error); + } + }, + [effectivePath, loadTree, handleFileSelect, handleMobileFileSelect, isMobile, store] + ); + + const handleCreateFolder = useCallback( + async (parentPath: string, name: string) => { + if (!effectivePath) return; + const fullPath = parentPath ? `${parentPath}/${name}` : `${effectivePath}/${name}`; + + try { + const api = getElectronAPI(); + await api.mkdir(fullPath); + + // If the new folder starts with a dot, auto-enable hidden files visibility + // so the created folder doesn't "disappear" from the tree + if (name.startsWith('.')) { + const { showHiddenFiles } = useFileEditorStore.getState(); + if (!showHiddenFiles) { + store.setShowHiddenFiles(true); + } + } + + // Preserve expanded folders so the parent directory stays open after refresh + await loadTree(undefined, { preserveExpanded: true }); + } catch (error) { + logger.error('Failed to create folder:', error); + } + }, + [effectivePath, loadTree, store] + ); + + const handleDeleteItem = useCallback( + async (path: string, _isDirectory: boolean) => { + try { + const api = getElectronAPI(); + // Use trashItem if available (safer), fallback to deleteFile + if (api.trashItem) { + await api.trashItem(path); + } else { + await api.deleteFile(path); + } + + // Close tab if the deleted file is open + const tab = tabs.find((t) => t.filePath === path); + if (tab) { + closeTab(tab.id); + } + + // Preserve expanded folders so siblings of the deleted item remain visible + await loadTree(undefined, { preserveExpanded: true }); + loadGitStatus(); + } catch (error) { + logger.error('Failed to delete item:', error); + } + }, + [tabs, closeTab, loadTree, loadGitStatus] + ); + + const handleRenameItem = useCallback( + async (oldPath: string, newName: string) => { + const parentPath = oldPath.substring(0, oldPath.lastIndexOf('/')); + const newPath = `${parentPath}/${newName}`; + + try { + const api = getElectronAPI(); + + // Use the moveItem API for an atomic rename (works for both files and directories) + const result = await api.moveItem?.(oldPath, newPath); + + if (result?.success) { + // Update the open tab if it was renamed + const tab = tabs.find((t) => t.filePath === oldPath); + if (tab) { + closeTab(tab.id); + if (isMobile) { + handleMobileFileSelect(newPath); + } else { + handleFileSelect(newPath); + } + } + + // If the new name starts with a dot, auto-enable hidden files visibility + // so the renamed file doesn't "disappear" from the tree + if (newName.startsWith('.')) { + const { showHiddenFiles } = useFileEditorStore.getState(); + if (!showHiddenFiles) { + store.setShowHiddenFiles(true); + } + } + + await loadTree(undefined, { preserveExpanded: true }); + loadGitStatus(); + } else { + toast.error('Rename failed', { description: result?.error }); + } + } catch (error) { + logger.error('Failed to rename item:', error); + } + }, + [ + tabs, + closeTab, + handleFileSelect, + handleMobileFileSelect, + isMobile, + loadTree, + loadGitStatus, + store, + ] + ); + + // ─── Handle Copy Item ──────────────────────────────────────── + const handleCopyItem = useCallback( + async (sourcePath: string, destinationPath: string) => { + try { + const api = getElectronAPI(); + if (!api.copyItem) { + toast.error('Copy not supported'); + return; + } + + // First try without overwrite + const result = await api.copyItem(sourcePath, destinationPath); + if (!result.success && result.exists) { + // Ask for confirmation to overwrite + const confirmed = window.confirm( + `"${destinationPath.split('/').pop()}" already exists at the destination. Do you want to replace it?` + ); + if (confirmed) { + const retryResult = await api.copyItem(sourcePath, destinationPath, true); + if (retryResult.success) { + toast.success('Copied successfully'); + await loadTree(undefined, { preserveExpanded: true }); + loadGitStatus(); + } else { + toast.error('Copy failed', { description: retryResult.error }); + } + } + } else if (result.success) { + toast.success('Copied successfully'); + await loadTree(undefined, { preserveExpanded: true }); + loadGitStatus(); + } else { + toast.error('Copy failed', { description: result.error }); + } + } catch (error) { + logger.error('Failed to copy item:', error); + toast.error('Copy failed', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, + [loadTree, loadGitStatus] + ); + + // ─── Handle Move Item ────────────────────────────────────── + const handleMoveItem = useCallback( + async (sourcePath: string, destinationPath: string) => { + try { + const api = getElectronAPI(); + if (!api.moveItem) { + toast.error('Move not supported'); + return; + } + + // First try without overwrite + const result = await api.moveItem(sourcePath, destinationPath); + if (!result.success && result.exists) { + // Ask for confirmation to overwrite + const confirmed = window.confirm( + `"${destinationPath.split('/').pop()}" already exists at the destination. Do you want to replace it?` + ); + if (confirmed) { + const retryResult = await api.moveItem(sourcePath, destinationPath, true); + if (retryResult.success) { + toast.success('Moved successfully'); + // Update open tabs that point to moved files + const tab = tabs.find((t) => t.filePath === sourcePath); + if (tab) { + closeTab(tab.id); + handleFileSelect(destinationPath); + } + await loadTree(undefined, { preserveExpanded: true }); + loadGitStatus(); + } else { + toast.error('Move failed', { description: retryResult.error }); + } + } + } else if (result.success) { + toast.success('Moved successfully'); + // Update open tabs that point to moved files + const tab = tabs.find((t) => t.filePath === sourcePath); + if (tab) { + closeTab(tab.id); + handleFileSelect(destinationPath); + } + await loadTree(undefined, { preserveExpanded: true }); + loadGitStatus(); + } else { + toast.error('Move failed', { description: result.error }); + } + } catch (error) { + logger.error('Failed to move item:', error); + toast.error('Move failed', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, + [tabs, closeTab, handleFileSelect, loadTree, loadGitStatus] + ); + + // ─── Handle Download Item ────────────────────────────────── + const handleDownloadItem = useCallback(async (filePath: string) => { + try { + const api = getElectronAPI(); + if (!api.downloadItem) { + toast.error('Download not supported'); + return; + } + toast.info('Starting download...'); + await api.downloadItem(filePath); + toast.success('Download complete'); + } catch (error) { + logger.error('Failed to download item:', error); + toast.error('Download failed', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, []); + + // ─── Handle Drag and Drop Move ───────────────────────────── + const handleDragDropMove = useCallback( + async (sourcePaths: string[], targetFolderPath: string) => { + for (const sourcePath of sourcePaths) { + const fileName = sourcePath.split('/').pop() || ''; + const destinationPath = `${targetFolderPath}/${fileName}`; + + // Prevent moving to the same location + const sourceDir = sourcePath.substring(0, sourcePath.lastIndexOf('/')); + if (sourceDir === targetFolderPath) continue; + + await handleMoveItem(sourcePath, destinationPath); + } + }, + [handleMoveItem] + ); + + // ─── Load git details for active file ────────────────────── + const loadFileGitDetails = useCallback( + async (filePath: string) => { + if (!effectivePath) return; + try { + const api = getElectronAPI(); + if (!api.git?.getDetails) return; + + // Get relative path + const relativePath = filePath.startsWith(effectivePath) + ? filePath.substring(effectivePath.length + 1) + : filePath; + + const result = await api.git.getDetails(effectivePath, relativePath); + if (result.success && result.details) { + setActiveFileGitDetails(result.details); + } else { + setActiveFileGitDetails(null); + } + } catch { + setActiveFileGitDetails(null); + } + }, + [effectivePath, setActiveFileGitDetails] + ); + + // Load git details when active tab changes + useEffect(() => { + if (activeTab && !activeTab.isBinary) { + loadFileGitDetails(activeTab.filePath); + } else { + setActiveFileGitDetails(null); + } + }, [activeTab?.filePath, activeTab?.isBinary, loadFileGitDetails, setActiveFileGitDetails]); + + // ─── Handle Cursor Change ──────────────────────────────────── + // Stable callback to avoid recreating CodeMirror extensions on every render. + // Accessing activeTabId from the store directly prevents this callback from + // changing every time the active tab switches (which would trigger an infinite + // update loop: cursor change → extension rebuild → view update → cursor change). + const handleCursorChange = useCallback((line: number, col: number) => { + const { activeTabId: currentActiveTabId } = useFileEditorStore.getState(); + if (currentActiveTabId) { + useFileEditorStore.getState().updateTabCursor(currentActiveTabId, line, col); + } + }, []); + + // ─── Handle Editor Content Change ──────────────────────────── + // Stable callback to avoid recreating CodeMirror extensions on every render. + // Reading activeTabId from getState() keeps the reference identity stable. + const handleEditorChange = useCallback((val: string) => { + const { activeTabId: currentActiveTabId } = useFileEditorStore.getState(); + if (currentActiveTabId) { + useFileEditorStore.getState().updateTabContent(currentActiveTabId, val); + } + }, []); + + // ─── Handle Copy Path ──────────────────────────────────────── + const handleCopyPath = useCallback(async (path: string) => { + try { + await navigator.clipboard.writeText(path); + } catch (error) { + logger.error('Failed to copy path to clipboard:', error); + } + }, []); + + // ─── Handle folder expand (lazy load children) ─────────────── + const handleToggleFolder = useCallback( + async (path: string) => { + const { expandedFolders, fileTree } = useFileEditorStore.getState(); + + if (!expandedFolders.has(path)) { + // Loading children for newly expanded folder + const findNode = (nodes: FileTreeNode[]): FileTreeNode | null => { + for (const n of nodes) { + if (n.path === path) return n; + if (n.children) { + const found = findNode(n.children); + if (found) return found; + } + } + return null; + }; + + const node = findNode(fileTree); + if (node && !node.children) { + const children = await loadSubdirectory(path); + // Update the tree with loaded children + const updateChildren = (nodes: FileTreeNode[]): FileTreeNode[] => { + return nodes.map((n) => { + if (n.path === path) return { ...n, children }; + if (n.children) return { ...n, children: updateChildren(n.children) }; + return n; + }); + }; + setFileTree(updateChildren(fileTree)); + } + } + + // Access toggleFolder via getState() to avoid capturing a new store reference + // on every render, which would make this useCallback's dependency unstable. + useFileEditorStore.getState().toggleFolder(path); + }, + [loadSubdirectory, setFileTree] + ); + + // ─── Initial Load ──────────────────────────────────────────── + // Reload the file tree and git status when the effective working directory changes + // (either from switching projects or switching worktrees) + useEffect(() => { + if (!effectivePath) return; + if (loadedProjectRef.current === effectivePath) return; + + loadedProjectRef.current = effectivePath; + loadTree(); + loadGitStatus(); + + // Set up periodic refresh for git status (every 10 seconds) + refreshTimerRef.current = setInterval(() => { + loadGitStatus(); + }, 10000); + + return () => { + if (refreshTimerRef.current) { + clearInterval(refreshTimerRef.current); + } + }; + }, [effectivePath, loadTree, loadGitStatus]); + + // Open initial path if provided + useEffect(() => { + if (initialPath) { + if (isMobile) { + handleMobileFileSelect(initialPath); + } else { + handleFileSelect(initialPath); + } + } + }, [initialPath, handleFileSelect, handleMobileFileSelect, isMobile]); + + // ─── Handle Tab Close with Dirty Check ─────────────────────── + const handleTabClose = useCallback( + (tabId: string) => { + const tab = tabs.find((t) => t.id === tabId); + if (tab?.isDirty) { + const shouldClose = window.confirm( + `"${tab.fileName}" has unsaved changes. Are you sure you want to close it?` + ); + if (!shouldClose) return; + } + closeTab(tabId); + }, + [tabs, closeTab] + ); + + // ─── Handle Close All Tabs with Dirty Check ────────────────── + const handleCloseAll = useCallback(() => { + const dirtyTabs = tabs.filter((t) => t.isDirty); + if (dirtyTabs.length > 0) { + const fileList = dirtyTabs.map((t) => ` • ${t.fileName}`).join('\n'); + const shouldClose = window.confirm( + `${dirtyTabs.length} file${dirtyTabs.length > 1 ? 's have' : ' has'} unsaved changes:\n${fileList}\n\nAre you sure you want to close all tabs?` + ); + if (!shouldClose) return; + } + closeAllTabs(); + }, [tabs, closeAllTabs]); + + // ─── Rendering ─────────────────────────────────────────────── + if (!currentProject) { + return ( +
+

No project selected

+
+ ); + } + + const isMarkdown = activeTab ? isMarkdownFile(activeTab.filePath) : false; + const showPreview = isMarkdown && markdownViewMode !== 'editor'; + const showEditor = !isMarkdown || markdownViewMode !== 'preview'; + + // ─── Editor Panel Content (shared between mobile and desktop) ── + const renderEditorPanel = () => ( +
+ {/* Tab bar */} + + + {/* Editor content */} + {activeTab ? ( +
+ {/* Binary file notice */} + {activeTab.isBinary && ( +
+ +
+

Binary File

+

+ This file cannot be displayed as text. +

+
+
+ )} + + {/* Too large file notice */} + {activeTab.isTooLarge && ( +
+ +
+

File Too Large

+

+ This file is {(activeTab.fileSize / (1024 * 1024)).toFixed(1)}MB, which exceeds + the {(maxFileSize / (1024 * 1024)).toFixed(0)}MB limit. +

+
+
+ )} + + {/* Normal editable file */} + {!activeTab.isBinary && !activeTab.isTooLarge && ( + <> + {isMarkdown && showEditor && showPreview ? ( + // Markdown split view (stacks vertically on mobile) + + + + + + + + + + ) : isMarkdown && !showEditor ? ( + // Markdown preview only + + ) : ( + // Regular editor (or markdown editor-only mode) + + )} + + )} +
+ ) : ( + // No file open +
+ +
+

+ {isMobile + ? 'Tap a file from the explorer to start editing' + : 'Select a file from the explorer to start editing'} +

+ {!isMobile && ( +

+ Ctrl+S to save · Ctrl+F to search +

+ )} +
+
+ )} + + {/* Git detail panel (shown below editor for active file) */} + {activeTab && !activeTab.isBinary && !activeTab.isTooLarge && activeFileGitDetails && ( + + )} + + {/* Status bar */} + {activeTab && !activeTab.isBinary && !activeTab.isTooLarge && ( +
+
+ {getLanguageName(activeTab.filePath)} + + Ln {activeTab.cursorLine}, Col {activeTab.cursorCol} + + {activeTab.isDirty && ( + + + Modified + + )} +
+
+ {gitBranch && ( + + {gitBranch} + + )} + Spaces: {tabSize} + {!isMobile && {wordWrap ? 'Wrap' : 'No Wrap'}} + UTF-8 +
+
+ )} +
+ ); + + // ─── File Tree Panel (shared between mobile and desktop) ── + const renderFileTree = () => ( + { + loadTree(); + loadGitStatus(); + }} + onToggleFolder={handleToggleFolder} + activeFilePath={activeTab?.filePath || null} + onCopyItem={handleCopyItem} + onMoveItem={handleMoveItem} + onDownloadItem={handleDownloadItem} + onDragDropMove={handleDragDropMove} + effectivePath={effectivePath || ''} + /> + ); + + return ( +
+ {/* Header */} +
+
+ {/* Mobile: show browser toggle button when viewing editor */} + {isMobile && !mobileBrowserVisible && ( + + )} +
+ +
+
+

File Editor

+

+ {currentProject.name} +

+
+
+ +
+ {/* Worktree directory selector */} + {currentProject?.path && } + + {/* Desktop: Markdown view mode toggle */} + {isMarkdown && !(isMobile && mobileBrowserVisible) && ( +
+ +
+ )} + + {/* Desktop: Search button */} + {activeTab && + !activeTab.isBinary && + !activeTab.isTooLarge && + !(isMobile && mobileBrowserVisible) && ( + + )} + + {/* Desktop: Undo / Redo buttons */} + {activeTab && + !activeTab.isBinary && + !activeTab.isTooLarge && + !(isMobile && mobileBrowserVisible) && ( +
+ + +
+ )} + + {/* Desktop: Save button */} + {activeTab && + !activeTab.isBinary && + !activeTab.isTooLarge && + !(isMobile && mobileBrowserVisible) && ( + + )} + + {/* Editor Settings popover */} + + + + + +
+

Editor Settings

+ +
+
+
+ + {/* Mobile: Save button in main toolbar */} + {activeTab && + !activeTab.isBinary && + !activeTab.isTooLarge && + isMobile && + !mobileBrowserVisible && ( + + )} + + {/* Tablet/Mobile: actions panel trigger */} + setShowActionsPanel(!showActionsPanel)} + /> +
+
+ + {/* Actions Panel (tablet/mobile) */} + setShowActionsPanel(false)} + title="Editor Actions" + > + {/* Markdown view mode toggle */} + {isMarkdown && ( +
+ + View Mode + + +
+ )} + + {/* Search button */} + {activeTab && !activeTab.isBinary && !activeTab.isTooLarge && ( + + )} + + {/* Undo / Redo buttons */} + {activeTab && !activeTab.isBinary && !activeTab.isTooLarge && ( +
+ + +
+ )} + + {/* Save button */} + {activeTab && !activeTab.isBinary && !activeTab.isTooLarge && ( + + )} + + {/* File info */} + {activeTab && !activeTab.isBinary && !activeTab.isTooLarge && ( +
+ + File Info + +
{getLanguageName(activeTab.filePath)}
+
+ Ln {activeTab.cursorLine}, Col {activeTab.cursorCol} +
+
+ )} + + {/* Editor Settings */} +
+ + Editor Settings + + +
+
+ + {/* Main content area */} + {isMobile ? ( + // ─── Mobile Layout: full-screen browser or editor ───────── + // When the virtual keyboard is open, reduce container height so the + // editor content scrolls up and the cursor stays visible above the keyboard. +
+ {mobileBrowserVisible ? ( + // Full-screen file browser on mobile +
{renderFileTree()}
+ ) : ( + // Full-screen editor on mobile + renderEditorPanel() + )} +
+ ) : ( + // ─── Desktop Layout: resizable split panels ────────────── + + {/* File Browser Panel */} + + {renderFileTree()} + + + {/* Resize handle */} + + + {/* Editor Panel */} + + {renderEditorPanel()} + + + )} +
+ ); +} diff --git a/apps/ui/src/components/views/file-editor-view/index.ts b/apps/ui/src/components/views/file-editor-view/index.ts new file mode 100644 index 00000000..ba94898c --- /dev/null +++ b/apps/ui/src/components/views/file-editor-view/index.ts @@ -0,0 +1 @@ +export { FileEditorView } from './file-editor-view'; diff --git a/apps/ui/src/components/views/file-editor-view/use-file-editor-store.ts b/apps/ui/src/components/views/file-editor-view/use-file-editor-store.ts new file mode 100644 index 00000000..ef64fccc --- /dev/null +++ b/apps/ui/src/components/views/file-editor-view/use-file-editor-store.ts @@ -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; + 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; + + // Enhanced git status: filePath -> enhanced status info + enhancedGitStatusMap: Map; + + // 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; + + // Actions + setFileTree: (tree: FileTreeNode[]) => void; + toggleFolder: (path: string) => void; + setShowHiddenFiles: (show: boolean) => void; + setExpandedFolders: (folders: Set) => void; + + openTab: (tab: Omit) => 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) => void; + setEnhancedGitStatusMap: (map: Map) => void; + setGitBranch: (branch: string) => void; + setActiveFileGitDetails: (details: GitFileDetailsInfo | null) => void; + + setDragState: (state: DragState) => void; + setSelectedPaths: (paths: Set) => void; + toggleSelectedPath: (path: string) => void; + clearSelectedPaths: () => void; + + reset: () => void; +} + +const initialState = { + fileTree: [] as FileTreeNode[], + expandedFolders: new Set(), + 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(), + enhancedGitStatusMap: new Map(), + gitBranch: '', + activeFileGitDetails: null as GitFileDetailsInfo | null, + dragState: { draggedPaths: [], dropTargetPath: null } as DragState, + selectedPaths: new Set(), +}; + +/** 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()( + 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 serialization + storage: { + getItem: (name: string): StorageValue | null => { + const raw = localStorage.getItem(name); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as StorageValue; + 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; + } catch { + return null; + } + }, + setItem: (name: string, value: StorageValue): void => { + try { + const state = value.state as unknown as FileEditorState; + // Convert Sets to arrays for JSON serialization + const serializable: StorageValue = { + ...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; + if (version < 1) { + // Initial migration: ensure all fields exist + state.tabs = state.tabs ?? []; + state.activeTabId = state.activeTabId ?? null; + state.expandedFolders = state.expandedFolders ?? new Set(); + state.markdownViewMode = state.markdownViewMode ?? 'split'; + } + return state as unknown as FileEditorState; + }, + } + ) +); diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index ff1b6a8c..2a2ce038 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -10,6 +10,7 @@ import { SettingsNavigation } from './settings-view/components/settings-navigati import { ApiKeysSection } from './settings-view/api-keys/api-keys-section'; import { ModelDefaultsSection } from './settings-view/model-defaults'; import { AppearanceSection } from './settings-view/appearance/appearance-section'; +import { EditorSection } from './settings-view/editor'; import { TerminalSection } from './settings-view/terminal/terminal-section'; import { AudioSection } from './settings-view/audio/audio-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)} /> ); + case 'editor': + return ; case 'terminal': return ; case 'keyboard': diff --git a/apps/ui/src/components/views/settings-view/config/navigation.ts b/apps/ui/src/components/views/settings-view/config/navigation.ts index e7647379..f12f8616 100644 --- a/apps/ui/src/components/views/settings-view/config/navigation.ts +++ b/apps/ui/src/components/views/settings-view/config/navigation.ts @@ -16,6 +16,7 @@ import { GitBranch, Code2, Webhook, + FileCode2, } from 'lucide-react'; import { AnthropicIcon, @@ -69,6 +70,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [ label: 'Interface', items: [ { id: 'appearance', label: 'Appearance', icon: Palette }, + { id: 'editor', label: 'File Editor', icon: FileCode2 }, { id: 'terminal', label: 'Terminal', icon: SquareTerminal }, { id: 'keyboard', label: 'Keyboard Shortcuts', icon: Settings2 }, { id: 'audio', label: 'Audio', icon: Volume2 }, diff --git a/apps/ui/src/components/views/settings-view/editor/editor-section.tsx b/apps/ui/src/components/views/settings-view/editor/editor-section.tsx new file mode 100644 index 00000000..7a4738c3 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/editor/editor-section.tsx @@ -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 ( +
+
+
+
+
+ +
+

File Editor

+
+

+ Customize the appearance of the built-in file editor. +

+
+
+ {/* Font Family */} +
+ + +
+ + {/* Font Size */} +
+
+ + {editorFontSize}px +
+ setEditorFontSize(value)} + className="flex-1" + /> +
+ + {/* Auto Save */} +
+
+
+ +

+ Automatically save files after changes or when switching tabs +

+
+ +
+
+
+
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/editor/index.ts b/apps/ui/src/components/views/settings-view/editor/index.ts new file mode 100644 index 00000000..e85f5861 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/editor/index.ts @@ -0,0 +1 @@ +export { EditorSection } from './editor-section'; diff --git a/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts b/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts index e466da5d..f5fe1db4 100644 --- a/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts +++ b/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts @@ -14,6 +14,7 @@ export type SettingsViewId = | 'prompts' | 'model-defaults' | 'appearance' + | 'editor' | 'terminal' | 'keyboard' | 'audio' diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 0db8e605..61e28032 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -758,6 +758,11 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { worktreePanelCollapsed: settings.worktreePanelCollapsed ?? false, lastProjectDir: settings.lastProjectDir ?? '', 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) ...(settings.terminalFontFamily && { terminalState: { @@ -848,6 +853,10 @@ function buildSettingsUpdateFromStore(): Record { worktreePanelCollapsed: state.worktreePanelCollapsed, lastProjectDir: state.lastProjectDir, recentFolders: state.recentFolders, + editorFontSize: state.editorFontSize, + editorFontFamily: state.editorFontFamily, + editorAutoSave: state.editorAutoSave, + editorAutoSaveDelay: state.editorAutoSaveDelay, terminalFontFamily: state.terminalState.fontFamily, }; } diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts index 3bd3ed0f..69ef6be1 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -85,6 +85,10 @@ const SETTINGS_FIELDS_TO_SYNC = [ 'keyboardShortcuts', 'mcpServers', 'defaultEditorCommand', + 'editorFontSize', + 'editorFontFamily', + 'editorAutoSave', + 'editorAutoSaveDelay', 'defaultTerminalId', 'promptCustomization', 'eventHooks', @@ -751,6 +755,10 @@ export async function refreshSettingsFromServer(): Promise { }, mcpServers: serverSettings.mcpServers, defaultEditorCommand: serverSettings.defaultEditorCommand ?? null, + editorFontSize: serverSettings.editorFontSize ?? 13, + editorFontFamily: serverSettings.editorFontFamily ?? 'default', + editorAutoSave: serverSettings.editorAutoSave ?? false, + editorAutoSaveDelay: serverSettings.editorAutoSaveDelay ?? 1000, defaultTerminalId: serverSettings.defaultTerminalId ?? null, promptCustomization: serverSettings.promptCustomization ?? {}, claudeApiProfiles: serverSettings.claudeApiProfiles ?? [], diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 67b78e9e..0abc8cea 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -647,6 +647,17 @@ export interface ElectronAPI { stat: (filePath: string) => Promise; deleteFile: (filePath: string) => Promise; trashItem?: (filePath: string) => Promise; + copyItem?: ( + sourcePath: string, + destinationPath: string, + overwrite?: boolean + ) => Promise; + moveItem?: ( + sourcePath: string, + destinationPath: string, + overwrite?: boolean + ) => Promise; + downloadItem?: (filePath: string) => Promise; getPath: (name: string) => Promise; openInEditor?: ( 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', + }, + ], + }; + }, }; } diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index df9bcffa..e9e39564 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1251,6 +1251,69 @@ export class HttpApiClient implements ElectronAPI { return this.deleteFile(filePath); } + async copyItem( + sourcePath: string, + destinationPath: string, + overwrite?: boolean + ): Promise { + return this.post('/api/fs/copy', { sourcePath, destinationPath, overwrite }); + } + + async moveItem( + sourcePath: string, + destinationPath: string, + overwrite?: boolean + ): Promise { + return this.post('/api/fs/move', { sourcePath, destinationPath, overwrite }); + } + + async downloadItem(filePath: string): Promise { + const serverUrl = getServerUrl(); + const headers: Record = { + '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 { // Server provides data directory if (name === 'userData') { @@ -2311,6 +2374,10 @@ export class HttpApiClient implements ElectronAPI { this.post('/api/git/file-diff', { projectPath, filePath }), stageFiles: (projectPath: string, files: string[], operation: 'stage' | 'unstage') => 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 diff --git a/apps/ui/src/routes/file-editor.lazy.tsx b/apps/ui/src/routes/file-editor.lazy.tsx new file mode 100644 index 00000000..f5552628 --- /dev/null +++ b/apps/ui/src/routes/file-editor.lazy.tsx @@ -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 ; +} diff --git a/apps/ui/src/routes/file-editor.tsx b/apps/ui/src/routes/file-editor.tsx new file mode 100644 index 00000000..d172d71b --- /dev/null +++ b/apps/ui/src/routes/file-editor.tsx @@ -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, +}); diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index b9730ecf..b7a03936 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -343,6 +343,10 @@ const initialState: AppState = { skipSandboxWarning: false, mcpServers: [], defaultEditorCommand: null, + editorFontSize: 13, + editorFontFamily: 'default', + editorAutoSave: false, + editorAutoSaveDelay: 1000, defaultTerminalId: null, enableSkills: true, skillsSources: ['user', 'project'] as Array<'user' | 'project'>, @@ -1389,6 +1393,12 @@ export const useAppStore = create()((set, get) => ({ // Editor Configuration actions 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 setDefaultTerminalId: (terminalId) => set({ defaultTerminalId: terminalId }), diff --git a/apps/ui/src/store/types/state-types.ts b/apps/ui/src/store/types/state-types.ts index ec8fcd2b..c0bdcd2b 100644 --- a/apps/ui/src/store/types/state-types.ts +++ b/apps/ui/src/store/types/state-types.ts @@ -237,6 +237,12 @@ export interface AppState { // Editor Configuration 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 defaultTerminalId: string | null; // Default external terminal for "Open In Terminal" action (null = integrated) @@ -611,6 +617,12 @@ export interface AppActions { // Editor Configuration actions 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 setDefaultTerminalId: (terminalId: string | null) => void; diff --git a/apps/ui/src/styles/global.css b/apps/ui/src/styles/global.css index e5394d74..4a1de822 100644 --- a/apps/ui/src/styles/global.css +++ b/apps/ui/src/styles/global.css @@ -944,6 +944,19 @@ 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 */ .xterm-viewport::-webkit-scrollbar { width: 8px; diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index cb0e0b1b..92b6ec60 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -9,6 +9,7 @@ import type { GeminiUsageResponse, } from '@/store/app-store'; import type { ParsedTask, FeatureStatusWithPipeline, MergeStateInfo } from '@automaker/types'; +export type { MergeStateInfo } from '@automaker/types'; export interface ImageAttachment { id?: string; // Optional - may not be present in messages loaded from server @@ -642,6 +643,27 @@ export interface ElectronAPI { 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; + // App APIs getPath: (name: string) => Promise; saveImageToTemp: ( @@ -1695,6 +1717,45 @@ export interface TestRunnerCompletedEvent { 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 { // Get diffs for the main project (not a worktree) getDiffs: (projectPath: string) => Promise; @@ -1715,6 +1776,12 @@ export interface GitAPI { }; error?: string; }>; + + // Get detailed git info for a file (branch, last commit, diff stats, conflict status) + getDetails: (projectPath: string, filePath?: string) => Promise; + + // Get enhanced status with per-file diff stats and staged/unstaged differentiation + getEnhancedStatus: (projectPath: string) => Promise; } // Model definition type diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 9b076564..90bc53c2 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -1005,6 +1005,16 @@ export interface GlobalSettings { /** Terminal font family (undefined = use default Menlo/Monaco) */ 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 /** How to open terminals from "Open in Terminal" worktree action */ openTerminalMode?: 'newTab' | 'split'; diff --git a/package-lock.json b/package-lock.json index 48dc3c7e..ceb868d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -106,10 +106,24 @@ "@automaker/dependency-resolver": "1.0.0", "@automaker/spec-parser": "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/language": "^6.12.1", "@codemirror/legacy-modes": "^6.5.2", + "@codemirror/search": "^6.6.0", + "@codemirror/state": "^6.5.4", "@codemirror/theme-one-dark": "6.1.3", + "@codemirror/view": "^6.39.15", "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "10.0.0", "@dnd-kit/utilities": "3.2.2", @@ -1262,6 +1276,146 @@ "@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": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", @@ -1311,20 +1465,20 @@ } }, "node_modules/@codemirror/search": { - "version": "6.5.11", - "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz", - "integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz", + "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", + "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "node_modules/@codemirror/state": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", - "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz", + "integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==", "license": "MIT", "dependencies": { "@marijn/find-cluster-break": "^1.0.0" @@ -1343,9 +1497,9 @@ } }, "node_modules/@codemirror/view": { - "version": "6.39.4", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz", - "integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==", + "version": "6.39.15", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.15.tgz", + "integrity": "sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.5.0", @@ -3711,6 +3865,28 @@ "integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==", "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": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", @@ -3720,6 +3896,50 @@ "@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": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.5.tgz", @@ -3729,6 +3949,49 @@ "@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": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz",