diff --git a/package.json b/package.json index 2529b301..ce303835 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "@codemirror/language": "^6.12.1", "@codemirror/language-data": "^6.5.2", "@codemirror/merge": "^6.12.0", + "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.5.4", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.39.15", @@ -92,6 +93,7 @@ "@fastify/static": "^9.0.0", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", @@ -102,6 +104,7 @@ "@tanstack/react-virtual": "^3.10.8", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", + "chokidar": "^4.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.0.4", @@ -111,6 +114,7 @@ "fastify": "^5.7.4", "highlight.js": "^11.11.1", "idb-keyval": "^6.2.2", + "isbinaryfile": "^6.0.0", "lucide-react": "^0.562.0", "mdast-util-to-hast": "^13.2.1", "node-diff3": "^3.2.0", @@ -121,6 +125,7 @@ "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", + "simple-git": "^3.32.3", "ssh-config": "^5.0.4", "ssh2": "^1.17.0", "tailwind-merge": "^3.5.0", @@ -249,6 +254,7 @@ "packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501", "pnpm": { "onlyBuiltDependencies": [ + "electron", "node-pty" ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ecedb1d..f6dff701 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: '@codemirror/merge': specifier: ^6.12.0 version: 6.12.0 + '@codemirror/search': + specifier: ^6.6.0 + version: 6.6.0 '@codemirror/state': specifier: ^6.5.4 version: 6.5.4 @@ -101,6 +104,9 @@ importers: '@radix-ui/react-collapsible': specifier: ^1.1.12 version: 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-context-menu': + specifier: ^2.2.16 + version: 2.2.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -131,6 +137,9 @@ importers: '@xterm/xterm': specifier: ^6.0.0 version: 6.0.0 + chokidar: + specifier: ^4.0.3 + version: 4.0.3 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -158,6 +167,9 @@ importers: idb-keyval: specifier: ^6.2.2 version: 6.2.2 + isbinaryfile: + specifier: ^6.0.0 + version: 6.0.0 lucide-react: specifier: ^0.562.0 version: 0.562.0(react@18.3.1) @@ -188,6 +200,9 @@ importers: remark-parse: specifier: ^11.0.0 version: 11.0.0 + simple-git: + specifier: ^3.32.3 + version: 3.32.3 ssh-config: specifier: ^5.0.4 version: 5.0.4 @@ -531,6 +546,9 @@ packages: '@codemirror/merge@6.12.0': resolution: {integrity: sha512-o+36bbapcEHf4Ux75pZ4CKjMBUd14parA0uozvWVlacaT+uxaA3DDefEvWYjngsKU+qsrDe/HOOfsw0Q72pLjA==} + '@codemirror/search@6.6.0': + resolution: {integrity: sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==} + '@codemirror/state@6.5.4': resolution: {integrity: sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==} @@ -1061,6 +1079,12 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@kwsites/file-exists@1.1.1': + resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} + + '@kwsites/promise-deferred@1.1.1': + resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} + '@lezer/common@1.5.1': resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==} @@ -1340,6 +1364,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-context-menu@2.2.16': + resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-context@1.1.2': resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} peerDependencies: @@ -1428,6 +1465,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popover@1.1.15': resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} peerDependencies: @@ -2547,6 +2597,10 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + chownr@2.0.0: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} @@ -3734,6 +3788,10 @@ packages: resolution: {integrity: sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==} engines: {node: '>= 18.0.0'} + isbinaryfile@6.0.0: + resolution: {integrity: sha512-2FN2B8MAqKv6d5TaKsLvMrwMcghxwHTpcKy0L5mhNbRqjNqo2++SpCqN6eG1lCC1GmTQgvrYJYXv2+Chvyevag==} + engines: {node: '>= 24.0.0'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -4752,6 +4810,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -4989,6 +5051,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-git@3.32.3: + resolution: {integrity: sha512-56a5oxFdWlsGygOXHWrG+xjj5w9ZIt2uQbzqiIGdR/6i5iococ7WQ/bNPzWxCJdEUGUCmyMH0t9zMpRJTaKxmw==} + simple-update-notifier@2.0.0: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} @@ -6046,6 +6111,12 @@ snapshots: '@lezer/highlight': 1.2.3 style-mod: 4.1.3 + '@codemirror/search@6.6.0': + dependencies: + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.15 + crelt: 1.0.6 + '@codemirror/state@6.5.4': dependencies: '@marijn/find-cluster-break': 1.0.2 @@ -6552,6 +6623,14 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@kwsites/file-exists@1.1.1': + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@kwsites/promise-deferred@1.1.1': {} + '@lezer/common@1.5.1': {} '@lezer/cpp@1.1.5': @@ -6846,6 +6925,20 @@ snapshots: optionalDependencies: '@types/react': 18.3.27 + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + '@radix-ui/react-context@1.1.2(@types/react@18.3.27)(react@18.3.1)': dependencies: react: 18.3.1 @@ -6926,6 +7019,32 @@ snapshots: '@types/react': 18.3.27 '@types/react-dom': 18.3.7(@types/react@18.3.27) + '@radix-ui/react-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.27)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -8202,6 +8321,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + chownr@2.0.0: {} chownr@3.0.0: {} @@ -9689,6 +9812,8 @@ snapshots: isbinaryfile@5.0.7: {} + isbinaryfile@6.0.0: {} + isexe@2.0.0: {} isexe@3.1.5: {} @@ -10931,6 +11056,8 @@ snapshots: dependencies: picomatch: 2.3.1 + readdirp@4.1.2: {} + real-require@0.2.0: {} refa@0.12.1: @@ -11238,6 +11365,14 @@ snapshots: signal-exit@4.1.0: {} + simple-git@3.32.3: + dependencies: + '@kwsites/file-exists': 1.1.1 + '@kwsites/promise-deferred': 1.1.1 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + simple-update-notifier@2.0.0: dependencies: semver: 7.7.3 diff --git a/src/main/index.ts b/src/main/index.ts index 4547d15c..2786f360 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -32,6 +32,7 @@ import { app, BrowserWindow } from 'electron'; import { existsSync } from 'fs'; import { join } from 'path'; +import { cleanupEditorState, setEditorMainWindow } from './ipc/editor'; import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers'; import { showTeamNativeNotification } from './ipc/teams'; import { HttpServer } from './services/infrastructure/HttpServer'; @@ -562,6 +563,9 @@ function shutdownServices(): void { teamChangeCleanup = null; } + // Clean up editor state (watcher, git service) + cleanupEditorState(); + // Dispose all contexts (including local) if (contextRegistry) { contextRegistry.dispose(); @@ -731,6 +735,8 @@ function createWindow(): void { if (ptyTerminalService) { ptyTerminalService.setMainWindow(null); } + setEditorMainWindow(null); + cleanupEditorState(); }); // Handle renderer process crashes (render-process-gone replaces deprecated 'crashed' event) @@ -752,6 +758,7 @@ function createWindow(): void { if (ptyTerminalService) { ptyTerminalService.setMainWindow(mainWindow); } + setEditorMainWindow(mainWindow); logger.info('Main window created'); } diff --git a/src/main/ipc/editor.ts b/src/main/ipc/editor.ts new file mode 100644 index 00000000..6fbf4eeb --- /dev/null +++ b/src/main/ipc/editor.ts @@ -0,0 +1,363 @@ +/** + * Editor IPC handlers. + * + * Module-level state: `activeProjectRoot` stores the validated project path. + * Renderer cannot override it — it's set only via `editor:open` with full validation (SEC-5). + */ + +import { getClaudeBasePath } from '@main/utils/pathDecoder'; +import { isPathWithinRoot } from '@main/utils/pathValidation'; +import { + EDITOR_CHANGE, + EDITOR_CLOSE, + EDITOR_CREATE_DIR, + EDITOR_CREATE_FILE, + EDITOR_DELETE_FILE, + EDITOR_GIT_STATUS, + EDITOR_MOVE_FILE, + EDITOR_OPEN, + EDITOR_READ_DIR, + EDITOR_READ_FILE, + EDITOR_SEARCH_IN_FILES, + EDITOR_WATCH_DIR, + EDITOR_WRITE_FILE, + // eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design +} from '@preload/constants/ipcChannels'; +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import { + checkFileConflict, + createSearchAbortController, + EditorFileWatcher, + FileSearchService, + GitStatusService, + ProjectFileService, +} from '../services/editor'; + +import { createIpcWrapper } from './ipcWrapper'; + +import type { + CreateDirResponse, + CreateFileResponse, + DeleteFileResponse, + GitStatusResult, + MoveFileResponse, + ReadDirResult, + ReadFileResult, + SearchInFilesOptions, + SearchInFilesResult, + WriteFileResponse, +} from '@shared/types/editor'; +import type { IpcResult } from '@shared/types/ipc'; +import type { BrowserWindow, IpcMain, IpcMainInvokeEvent } from 'electron'; + +// ============================================================================= +// Module-level state (SEC-5) +// ============================================================================= + +let activeProjectRoot: string | null = null; + +let mainWindowRef: BrowserWindow | null = null; + +let activeSearchController: AbortController | null = null; + +const projectFileService = new ProjectFileService(); +const fileSearchService = new FileSearchService(); +const gitStatusService = new GitStatusService(); +const editorFileWatcher = new EditorFileWatcher(); +const wrapHandler = createIpcWrapper('IPC:editor'); +const log = createLogger('IPC:editor'); + +// ============================================================================= +// Handlers +// ============================================================================= + +/** + * Initialize editor with validated project path (SEC-15). + */ +async function handleEditorOpen( + _event: IpcMainInvokeEvent, + projectPath: string +): Promise> { + return wrapHandler('open', async () => { + // Validate projectPath before trusting it + if (!projectPath || typeof projectPath !== 'string') { + throw new Error('Invalid project path'); + } + + if (!path.isAbsolute(projectPath)) { + throw new Error('Project path must be absolute'); + } + + const normalized = path.resolve(path.normalize(projectPath)); + + // Block filesystem root + if (normalized === '/' || /^[A-Z]:\\$/i.test(normalized)) { + throw new Error('Cannot open filesystem root as project'); + } + + // Block ~/.claude directory itself + const claudeDir = getClaudeBasePath(); + if (isPathWithinRoot(normalized, claudeDir)) { + throw new Error('Cannot open Claude data directory as project'); + } + + // Verify it's an existing directory + const stat = await fs.stat(normalized); + if (!stat.isDirectory()) { + throw new Error('Project path is not a directory'); + } + + // Stop any previous watcher/git before switching projects + editorFileWatcher.stop(); + gitStatusService.destroy(); + + activeProjectRoot = normalized; + gitStatusService.init(normalized); + log.info('Editor opened:', normalized); + }); +} + +/** + * Cleanup editor state. + */ +async function handleEditorClose(): Promise> { + return wrapHandler('close', async () => { + editorFileWatcher.stop(); + gitStatusService.destroy(); + activeProjectRoot = null; + log.info('Editor closed'); + }); +} + +/** + * Read directory listing (depth=1, lazy). + */ +async function handleEditorReadDir( + _event: IpcMainInvokeEvent, + dirPath: string, + maxEntries?: number +): Promise> { + return wrapHandler('readDir', async () => { + if (!activeProjectRoot) throw new Error('Editor not initialized'); + return projectFileService.readDir(activeProjectRoot, dirPath, maxEntries ?? undefined); + }); +} + +/** + * Read file content with binary detection. + */ +async function handleEditorReadFile( + _event: IpcMainInvokeEvent, + filePath: string +): Promise> { + return wrapHandler('readFile', async () => { + if (!activeProjectRoot) throw new Error('Editor not initialized'); + return projectFileService.readFile(activeProjectRoot, filePath); + }); +} + +/** + * Write file content with atomic write (SEC-9, SEC-12, SEC-14). + * Optional baselineMtimeMs enables conflict detection before writing. + */ +async function handleEditorWriteFile( + _event: IpcMainInvokeEvent, + filePath: string, + content: string, + baselineMtimeMs?: number +): Promise> { + return wrapHandler('writeFile', async () => { + if (!activeProjectRoot) throw new Error('Editor not initialized'); + + // Conflict detection: check if file was modified externally since last read/save + if (baselineMtimeMs !== undefined && baselineMtimeMs > 0) { + const conflict = await checkFileConflict(filePath, baselineMtimeMs); + if (conflict.hasConflict) { + if (conflict.deleted) { + throw new Error('CONFLICT_DELETED: File was deleted externally'); + } + throw new Error('CONFLICT: File was modified externally'); + } + } + + return projectFileService.writeFile(activeProjectRoot, filePath, content); + }); +} + +/** + * Create a new file in the project. + */ +async function handleEditorCreateFile( + _event: IpcMainInvokeEvent, + parentDir: string, + fileName: string +): Promise> { + return wrapHandler('createFile', async () => { + if (!activeProjectRoot) throw new Error('Editor not initialized'); + return projectFileService.createFile(activeProjectRoot, parentDir, fileName); + }); +} + +/** + * Create a new directory in the project. + */ +async function handleEditorCreateDir( + _event: IpcMainInvokeEvent, + parentDir: string, + dirName: string +): Promise> { + return wrapHandler('createDir', async () => { + if (!activeProjectRoot) throw new Error('Editor not initialized'); + return projectFileService.createDir(activeProjectRoot, parentDir, dirName); + }); +} + +/** + * Delete a file or directory (move to Trash). + */ +async function handleEditorDeleteFile( + _event: IpcMainInvokeEvent, + filePath: string +): Promise> { + return wrapHandler('deleteFile', async () => { + if (!activeProjectRoot) throw new Error('Editor not initialized'); + return projectFileService.deleteFile(activeProjectRoot, filePath); + }); +} + +/** + * Move a file or directory to a new location. + */ +async function handleEditorMoveFile( + _event: IpcMainInvokeEvent, + sourcePath: string, + destDir: string +): Promise> { + return wrapHandler('moveFile', async () => { + if (!activeProjectRoot) throw new Error('Editor not initialized'); + return projectFileService.moveFile(activeProjectRoot, sourcePath, destDir); + }); +} + +/** + * Search in files (literal string search, SEC-8 timeout). + */ +async function handleEditorSearchInFiles( + _event: IpcMainInvokeEvent, + options: SearchInFilesOptions +): Promise> { + return wrapHandler('searchInFiles', async () => { + if (!activeProjectRoot) throw new Error('Editor not initialized'); + + // Cancel any in-flight search + if (activeSearchController) { + activeSearchController.abort(); + } + + const controller = createSearchAbortController(); + activeSearchController = controller; + + try { + return await fileSearchService.searchInFiles(activeProjectRoot, options, controller.signal); + } finally { + if (activeSearchController === controller) { + activeSearchController = null; + } + } + }); +} + +/** + * Get git status for current project (cached 5s). + */ +async function handleEditorGitStatus(): Promise> { + return wrapHandler('gitStatus', async () => { + if (!activeProjectRoot) throw new Error('Editor not initialized'); + return gitStatusService.getStatus(); + }); +} + +/** + * Enable/disable file watcher for current project. + */ +async function handleEditorWatchDir( + _event: IpcMainInvokeEvent, + enable: boolean +): Promise> { + return wrapHandler('watchDir', async () => { + if (!activeProjectRoot) throw new Error('Editor not initialized'); + + if (enable) { + editorFileWatcher.start(activeProjectRoot, (event) => { + // Invalidate git cache on file changes + gitStatusService.invalidateCache(); + + // Forward event to renderer + if (mainWindowRef && !mainWindowRef.isDestroyed()) { + mainWindowRef.webContents.send(EDITOR_CHANGE, event); + } + }); + } else { + editorFileWatcher.stop(); + } + }); +} + +// ============================================================================= +// Registration +// ============================================================================= + +export function initializeEditorHandlers(): void { + // No external dependencies needed — service created at module level +} + +/** + * Set main window reference for forwarding watcher events. + * Called from main/index.ts after window creation. + */ +export function setEditorMainWindow(win: BrowserWindow | null): void { + mainWindowRef = win; +} + +export function registerEditorHandlers(ipcMain: IpcMain): void { + ipcMain.handle(EDITOR_OPEN, handleEditorOpen); + ipcMain.handle(EDITOR_CLOSE, handleEditorClose); + ipcMain.handle(EDITOR_READ_DIR, handleEditorReadDir); + ipcMain.handle(EDITOR_READ_FILE, handleEditorReadFile); + ipcMain.handle(EDITOR_WRITE_FILE, handleEditorWriteFile); + ipcMain.handle(EDITOR_CREATE_FILE, handleEditorCreateFile); + ipcMain.handle(EDITOR_CREATE_DIR, handleEditorCreateDir); + ipcMain.handle(EDITOR_DELETE_FILE, handleEditorDeleteFile); + ipcMain.handle(EDITOR_MOVE_FILE, handleEditorMoveFile); + ipcMain.handle(EDITOR_SEARCH_IN_FILES, handleEditorSearchInFiles); + ipcMain.handle(EDITOR_GIT_STATUS, handleEditorGitStatus); + ipcMain.handle(EDITOR_WATCH_DIR, handleEditorWatchDir); +} + +export function removeEditorHandlers(ipcMain: IpcMain): void { + ipcMain.removeHandler(EDITOR_OPEN); + ipcMain.removeHandler(EDITOR_CLOSE); + ipcMain.removeHandler(EDITOR_READ_DIR); + ipcMain.removeHandler(EDITOR_READ_FILE); + ipcMain.removeHandler(EDITOR_WRITE_FILE); + ipcMain.removeHandler(EDITOR_CREATE_FILE); + ipcMain.removeHandler(EDITOR_CREATE_DIR); + ipcMain.removeHandler(EDITOR_DELETE_FILE); + ipcMain.removeHandler(EDITOR_MOVE_FILE); + ipcMain.removeHandler(EDITOR_SEARCH_IN_FILES); + ipcMain.removeHandler(EDITOR_GIT_STATUS); + ipcMain.removeHandler(EDITOR_WATCH_DIR); +} + +/** + * Reset editor state (called from mainWindow.on('closed')). + * Prevents state leak when Cmd+Q on macOS. + */ +export function cleanupEditorState(): void { + editorFileWatcher.stop(); + gitStatusService.destroy(); + activeProjectRoot = null; +} diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 711c6b7d..95df0efb 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -28,6 +28,7 @@ import { registerContextHandlers, removeContextHandlers, } from './context'; +import { initializeEditorHandlers, registerEditorHandlers, removeEditorHandlers } from './editor'; import { initializeHttpServerHandlers, registerHttpServerHandlers, @@ -143,6 +144,8 @@ export function initializeIpcHandlers( if (ptyTerminal) { initializeTerminalHandlers(ptyTerminal); } + initializeEditorHandlers(); + if (changeExtractor) { initializeReviewHandlers({ extractor: changeExtractor, @@ -166,6 +169,7 @@ export function initializeIpcHandlers( registerContextHandlers(ipcMain); registerTeamHandlers(ipcMain); registerReviewHandlers(ipcMain); + registerEditorHandlers(ipcMain); registerWindowHandlers(ipcMain); if (cliInstaller) { registerCliInstallerHandlers(ipcMain); @@ -198,6 +202,7 @@ export function removeIpcHandlers(): void { removeContextHandlers(ipcMain); removeTeamHandlers(ipcMain); removeReviewHandlers(ipcMain); + removeEditorHandlers(ipcMain); removeWindowHandlers(ipcMain); removeCliInstallerHandlers(ipcMain); removeTerminalHandlers(ipcMain); diff --git a/src/main/ipc/ipcWrapper.ts b/src/main/ipc/ipcWrapper.ts new file mode 100644 index 00000000..4d8d6583 --- /dev/null +++ b/src/main/ipc/ipcWrapper.ts @@ -0,0 +1,25 @@ +/** + * Generic IPC handler wrapper — standardizes error handling and logging. + * + * Creates a domain-specific wrapper that catches errors, logs them, + * and returns IpcResult for consistent renderer-side handling. + */ + +import { createLogger } from '@shared/utils/logger'; + +import type { IpcResult } from '@shared/types/ipc'; + +export function createIpcWrapper(logPrefix: string) { + const log = createLogger(logPrefix); + + return async function wrap(operation: string, fn: () => Promise): Promise> { + try { + const data = await fn(); + return { success: true, data }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log.error(`handler error [${operation}]:`, message); + return { success: false, error: message }; + } + }; +} diff --git a/src/main/ipc/review.ts b/src/main/ipc/review.ts index 20c56752..771faafe 100644 --- a/src/main/ipc/review.ts +++ b/src/main/ipc/review.ts @@ -4,6 +4,7 @@ * Паттерн: module-level state + guard + wrapReviewHandler (как teams.ts) */ +import { createIpcWrapper } from '@main/ipc/ipcWrapper'; import { ReviewDecisionStore } from '@main/services/team/ReviewDecisionStore'; import { REVIEW_APPLY_DECISIONS, @@ -22,7 +23,6 @@ import { REVIEW_SAVE_EDITED_FILE, // eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design } from '@preload/constants/ipcChannels'; -import { createLogger } from '@shared/utils/logger'; import type { ChangeExtractorService } from '@main/services/team/ChangeExtractorService'; import type { FileContentResolver } from '@main/services/team/FileContentResolver'; @@ -43,7 +43,7 @@ import type { } from '@shared/types/review'; import type { IpcMain, IpcMainInvokeEvent } from 'electron'; -const logger = createLogger('IPC:review'); +const wrapReviewHandler = createIpcWrapper('IPC:review'); // --- Module-level state --- @@ -128,22 +128,6 @@ export function removeReviewHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(REVIEW_CLEAR_DECISIONS); } -// --- Локальный wrapReviewHandler --- - -async function wrapReviewHandler( - operation: string, - handler: () => Promise -): Promise> { - try { - const data = await handler(); - return { success: true, data }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logger.error(`Review handler error [${operation}]:`, message); - return { success: false, error: message }; - } -} - // --- Phase 1 Handlers --- async function handleGetAgentChanges( diff --git a/src/main/services/editor/EditorFileWatcher.ts b/src/main/services/editor/EditorFileWatcher.ts new file mode 100644 index 00000000..9aaf31b7 --- /dev/null +++ b/src/main/services/editor/EditorFileWatcher.ts @@ -0,0 +1,92 @@ +/** + * File watcher for the project editor using chokidar v4. + * + * Watches project directory for external file changes and emits + * normalized events. chokidar handles platform differences (FSEvents on macOS, + * inotify on Linux), recursive watching, and ENOSPC fallback. + * + * Security: paths emitted in events are validated against project root + * before being sent to renderer (SEC-2). + */ + +import { isPathWithinRoot } from '@main/utils/pathValidation'; +import { createLogger } from '@shared/utils/logger'; +import { watch } from 'chokidar'; + +import type { EditorFileChangeEvent } from '@shared/types/editor'; +import type { FSWatcher } from 'chokidar'; + +const log = createLogger('EditorFileWatcher'); + +// ============================================================================= +// Constants +// ============================================================================= + +/** Directories to ignore (regex for chokidar's `ignored` option) */ +const IGNORED_PATTERN = + /(node_modules|\.git|dist|__pycache__|\.cache|\.next|\.venv|\.tox|vendor|\.DS_Store)/; + +const MAX_DEPTH = 20; + +// ============================================================================= +// Service +// ============================================================================= + +export class EditorFileWatcher { + private watcher: FSWatcher | null = null; + private projectRoot: string | null = null; + + /** + * Start watching a project directory. + * Idempotent: stops any existing watcher first. + */ + start(projectRoot: string, onChange: (event: EditorFileChangeEvent) => void): void { + this.stop(); + this.projectRoot = projectRoot; + + log.info('Starting file watcher for:', projectRoot); + + this.watcher = watch(projectRoot, { + ignored: IGNORED_PATTERN, + ignoreInitial: true, + followSymlinks: false, + depth: MAX_DEPTH, + }); + + const emitSafe = (type: EditorFileChangeEvent['type'], filePath: string): void => { + // SEC-2: validate path is within project root before sending to renderer + if (!isPathWithinRoot(filePath, projectRoot)) { + log.warn('Watcher event outside project root, ignoring:', filePath); + return; + } + onChange({ type, path: filePath }); + }; + + this.watcher.on('change', (p) => emitSafe('change', p)); + this.watcher.on('add', (p) => emitSafe('create', p)); + this.watcher.on('unlink', (p) => emitSafe('delete', p)); + + this.watcher.on('error', (error) => { + log.error('Watcher error:', error); + }); + } + + /** + * Stop watching. Safe to call multiple times. + */ + stop(): void { + if (this.watcher) { + log.info('Stopping file watcher'); + void this.watcher.close(); + this.watcher = null; + } + this.projectRoot = null; + } + + /** + * Whether the watcher is currently active. + */ + isWatching(): boolean { + return this.watcher !== null; + } +} diff --git a/src/main/services/editor/FileSearchService.ts b/src/main/services/editor/FileSearchService.ts new file mode 100644 index 00000000..3982c367 --- /dev/null +++ b/src/main/services/editor/FileSearchService.ts @@ -0,0 +1,232 @@ +/** + * File search service — literal string search across project files. + * + * Security: path containment enforced via isPathWithinRoot. .git/ blocked. + * Performance: max 1000 files, max 1MB/file, 5s timeout via AbortController. + */ + +import { isGitInternalPath, isPathWithinRoot } from '@main/utils/pathValidation'; +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs/promises'; +import { isBinaryFile } from 'isbinaryfile'; +import * as path from 'path'; + +import type { + SearchFileResult, + SearchInFilesOptions, + SearchInFilesResult, + SearchMatch, +} from '@shared/types/editor'; + +// ============================================================================= +// Constants +// ============================================================================= + +const MAX_FILES = 1000; +const MAX_FILE_SIZE = 1024 * 1024; // 1 MB +const DEFAULT_MAX_RESULT_FILES = 100; +const DEFAULT_MAX_MATCHES = 500; +const SEARCH_TIMEOUT_MS = 5000; + +const IGNORED_DIRS = new Set([ + '.git', + 'node_modules', + '.next', + 'dist', + '__pycache__', + '.cache', + '.venv', + '.tox', + 'vendor', + 'build', + 'coverage', + '.turbo', +]); + +const IGNORED_FILES = new Set(['.DS_Store', 'Thumbs.db']); + +const log = createLogger('FileSearchService'); + +// ============================================================================= +// Service +// ============================================================================= + +export class FileSearchService { + /** + * Search for a literal string across project files. + * + * @param projectRoot - Validated project root path + * @param options - Search options (query, caseSensitive, limits) + * @param signal - Optional AbortSignal for cancellation + */ + async searchInFiles( + projectRoot: string, + options: SearchInFilesOptions, + signal?: AbortSignal + ): Promise { + const { query, caseSensitive = false } = options; + const maxFiles = Math.min( + options.maxFiles ?? DEFAULT_MAX_RESULT_FILES, + DEFAULT_MAX_RESULT_FILES + ); + const maxMatches = Math.min(options.maxMatches ?? DEFAULT_MAX_MATCHES, DEFAULT_MAX_MATCHES); + + if (!query || query.length === 0) { + return { results: [], totalMatches: 0, truncated: false }; + } + + const searchQuery = caseSensitive ? query : query.toLowerCase(); + + // Collect all searchable files + const files: string[] = []; + await this.collectFiles(projectRoot, projectRoot, files, signal); + + const results: SearchFileResult[] = []; + let totalMatches = 0; + let truncated = false; + + for (const filePath of files) { + if (signal?.aborted) break; + if (results.length >= maxFiles || totalMatches >= maxMatches) { + truncated = true; + break; + } + + try { + const matches = await this.searchFile(filePath, searchQuery, caseSensitive, signal); + if (matches.length > 0) { + const remaining = maxMatches - totalMatches; + const trimmedMatches = matches.length > remaining ? matches.slice(0, remaining) : matches; + + results.push({ filePath, matches: trimmedMatches }); + totalMatches += trimmedMatches.length; + + if (totalMatches >= maxMatches) { + truncated = true; + } + } + } catch { + // Skip files that can't be read + } + } + + return { results, totalMatches, truncated }; + } + + /** + * Recursively collect all searchable files. + */ + private async collectFiles( + projectRoot: string, + dirPath: string, + files: string[], + signal?: AbortSignal + ): Promise { + if (signal?.aborted || files.length >= MAX_FILES) return; + + let entries; + try { + entries = await fs.readdir(dirPath, { withFileTypes: true }); + } catch { + return; // Permission denied or not a directory + } + + // Sort: files first for early results + const sorted = [...entries].sort((a, b) => { + if (a.isFile() && !b.isFile()) return -1; + if (!a.isFile() && b.isFile()) return 1; + return a.name.localeCompare(b.name); + }); + + for (const entry of sorted) { + if (signal?.aborted || files.length >= MAX_FILES) break; + + const fullPath = path.join(dirPath, entry.name); + + // Security: containment check + if (!isPathWithinRoot(fullPath, projectRoot)) continue; + + // Block .git internal paths + if (isGitInternalPath(fullPath)) continue; + + if (entry.isDirectory()) { + if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + await this.collectFiles(projectRoot, fullPath, files, signal); + } else if (entry.isFile()) { + if (IGNORED_FILES.has(entry.name)) continue; + + // Skip files > 1MB + try { + const stat = await fs.stat(fullPath); + if (stat.size > MAX_FILE_SIZE) continue; + } catch { + continue; + } + + // Skip binary files (quick check via first 512 bytes) + try { + if (await isBinaryFile(fullPath)) continue; + } catch { + continue; + } + + files.push(fullPath); + } + } + } + + /** + * Search a single file for literal string matches. + */ + private async searchFile( + filePath: string, + query: string, + caseSensitive: boolean, + signal?: AbortSignal + ): Promise { + if (signal?.aborted) return []; + + const content = await fs.readFile(filePath, 'utf8'); + const lines = content.split('\n'); + const matches: SearchMatch[] = []; + + for (let i = 0; i < lines.length; i++) { + if (signal?.aborted) break; + + const line = lines[i]; + const searchLine = caseSensitive ? line : line.toLowerCase(); + let startIndex = 0; + + while (true) { + const idx = searchLine.indexOf(query, startIndex); + if (idx === -1) break; + + matches.push({ + line: i + 1, + column: idx, + lineContent: line.trim(), + }); + + startIndex = idx + query.length; + } + } + + return matches; + } +} + +/** + * Create an AbortController with automatic timeout. + */ +export function createSearchAbortController(): AbortController { + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + log.warn('Search timed out after', SEARCH_TIMEOUT_MS, 'ms'); + }, SEARCH_TIMEOUT_MS); + + // Clean up timeout when aborted by other means + controller.signal.addEventListener('abort', () => clearTimeout(timeoutId), { once: true }); + + return controller; +} diff --git a/src/main/services/editor/GitStatusService.ts b/src/main/services/editor/GitStatusService.ts new file mode 100644 index 00000000..7f26278a --- /dev/null +++ b/src/main/services/editor/GitStatusService.ts @@ -0,0 +1,138 @@ +/** + * Git status service for the project editor. + * + * Uses `simple-git` with --no-optional-locks (GIT_OPTIONAL_LOCKS=0) to prevent + * .git/index.lock conflicts during background queries. + * Results are cached for 5 seconds; invalidated on file watcher events. + */ + +import { createLogger } from '@shared/utils/logger'; +import { simpleGit } from 'simple-git'; + +import type { GitFileStatus, GitStatusResult } from '@shared/types/editor'; +import type { SimpleGit, StatusResult } from 'simple-git'; + +const log = createLogger('GitStatusService'); + +// ============================================================================= +// Constants +// ============================================================================= + +const GIT_TIMEOUT_MS = 10_000; +const CACHE_TTL_MS = 5_000; + +// ============================================================================= +// Service +// ============================================================================= + +export class GitStatusService { + private git: SimpleGit | null = null; + private projectRoot: string | null = null; + + // Cache + private cachedResult: GitStatusResult | null = null; + private cacheTimestamp = 0; + + /** + * Initialize service for a project root. + * Creates a simple-git instance with --no-optional-locks and timeout. + */ + init(projectRoot: string): void { + this.projectRoot = projectRoot; + this.git = simpleGit({ + baseDir: projectRoot, + timeout: { block: GIT_TIMEOUT_MS }, + }).env('GIT_OPTIONAL_LOCKS', '0'); + this.invalidateCache(); + } + + /** + * Reset service state. + */ + destroy(): void { + this.git = null; + this.projectRoot = null; + this.cachedResult = null; + this.cacheTimestamp = 0; + } + + /** + * Invalidate cached status (e.g. on file watcher event). + */ + invalidateCache(): void { + this.cachedResult = null; + this.cacheTimestamp = 0; + } + + /** + * Get git status for the current project. + * Returns cached result if within TTL. + */ + async getStatus(): Promise { + if (!this.git || !this.projectRoot) { + return { files: [], isGitRepo: false, branch: null }; + } + + // Return cached if fresh + if (this.cachedResult && Date.now() - this.cacheTimestamp < CACHE_TTL_MS) { + return this.cachedResult; + } + + try { + // Check if it's a git repo first + const isRepo = await this.isGitRepo(); + if (!isRepo) { + const result: GitStatusResult = { files: [], isGitRepo: false, branch: null }; + this.setCacheResult(result); + return result; + } + + const statusResult = await this.git.status(); + const files = mapStatusResult(statusResult); + const branch = statusResult.current ?? null; + + const result: GitStatusResult = { files, isGitRepo: true, branch }; + this.setCacheResult(result); + return result; + } catch (error) { + log.error('Failed to get git status:', error); + // Graceful degradation: return empty non-repo result + return { files: [], isGitRepo: false, branch: null }; + } + } + + private async isGitRepo(): Promise { + if (!this.git) return false; + try { + await this.git.revparse(['--is-inside-work-tree']); + return true; + } catch { + return false; + } + } + + private setCacheResult(result: GitStatusResult): void { + this.cachedResult = result; + this.cacheTimestamp = Date.now(); + } +} + +// ============================================================================= +// Mapping +// ============================================================================= + +/** + * Map simple-git StatusResult to our GitFileStatus[] format. + */ +export function mapStatusResult(result: StatusResult): GitFileStatus[] { + const files: GitFileStatus[] = []; + for (const p of result.modified) files.push({ path: p, status: 'modified' }); + for (const p of result.not_added) files.push({ path: p, status: 'untracked' }); + for (const p of result.staged) files.push({ path: p, status: 'staged' }); + for (const p of result.deleted) files.push({ path: p, status: 'deleted' }); + for (const p of result.conflicted) files.push({ path: p, status: 'conflict' }); + for (const r of result.renamed) { + files.push({ path: r.to, status: 'renamed', renamedFrom: r.from }); + } + return files; +} diff --git a/src/main/services/editor/ProjectFileService.ts b/src/main/services/editor/ProjectFileService.ts new file mode 100644 index 00000000..e2ac49da --- /dev/null +++ b/src/main/services/editor/ProjectFileService.ts @@ -0,0 +1,564 @@ +/** + * Stateless file service for the project editor. + * + * Every method receives `projectRoot` as the first argument. + * Security: path containment, symlink escape detection, device path blocking, + * binary detection, and size limits are enforced on every call. + */ + +import { atomicWriteAsync } from '@main/utils/atomicWrite'; +import { + isDevicePath, + isGitInternalPath, + isPathWithinAllowedDirectories, + isPathWithinRoot, + matchesSensitivePattern, + validateFileName, + validateFilePath, +} from '@main/utils/pathValidation'; +import { createLogger } from '@shared/utils/logger'; +import { shell } from 'electron'; +import * as fs from 'fs/promises'; +import { isBinaryFile } from 'isbinaryfile'; +import * as path from 'path'; + +import type { + CreateDirResponse, + CreateFileResponse, + DeleteFileResponse, + FileTreeEntry, + MoveFileResponse, + ReadDirResult, + ReadFileResult, + WriteFileResponse, +} from '@shared/types/editor'; + +// ============================================================================= +// Constants +// ============================================================================= + +const MAX_FILE_SIZE_FULL = 2 * 1024 * 1024; // 2 MB +const MAX_FILE_SIZE_PREVIEW = 5 * 1024 * 1024; // 5 MB +const MAX_WRITE_SIZE = 2 * 1024 * 1024; // 2 MB +const MAX_DIR_ENTRIES = 500; +const PREVIEW_LINE_COUNT = 100; + +const IGNORED_DIRS = new Set([ + '.git', + 'node_modules', + '.next', + 'dist', + '__pycache__', + '.cache', + '.venv', + '.tox', + 'vendor', +]); + +const IGNORED_FILES = new Set(['.DS_Store', 'Thumbs.db']); + +const log = createLogger('ProjectFileService'); + +// ============================================================================= +// Service +// ============================================================================= + +export class ProjectFileService { + /** + * Read a directory listing (depth=1, lazy loading). + * + * Security: + * - Containment via isPathWithinAllowedDirectories (NOT validateFilePath — sensitive files + * are shown with isSensitive flag, not filtered) + * - Symlinks: realpath + re-check containment, silently skip escapes (SEC-2) + */ + async readDir( + projectRoot: string, + dirPath: string, + maxEntries: number = MAX_DIR_ENTRIES + ): Promise { + const normalizedDir = path.resolve(dirPath); + + // Containment check (allow sensitive files to be listed with flag) + if (!isPathWithinAllowedDirectories(normalizedDir, projectRoot)) { + throw new Error('Directory is outside project root'); + } + + const stat = await fs.lstat(normalizedDir); + if (!stat.isDirectory()) { + throw new Error('Not a directory'); + } + + const dirents = await fs.readdir(normalizedDir, { withFileTypes: true }); + const entries: FileTreeEntry[] = []; + let truncated = false; + + for (const dirent of dirents) { + // Ignore well-known noise + if (dirent.isDirectory() && IGNORED_DIRS.has(dirent.name)) continue; + if (dirent.isFile() && IGNORED_FILES.has(dirent.name)) continue; + + const entryPath = path.join(normalizedDir, dirent.name); + + // Symlink handling: resolve and re-check containment + if (dirent.isSymbolicLink()) { + try { + const realPath = await fs.realpath(entryPath); + if (!isPathWithinAllowedDirectories(realPath, projectRoot)) { + continue; // Silently skip symlinks that escape project root (SEC-2) + } + const realStat = await fs.stat(realPath); + const entry = this.buildEntry( + dirent.name, + entryPath, + realStat.isDirectory() ? 'directory' : 'file', + realStat.isFile() ? realStat.size : undefined + ); + entries.push(entry); + } catch { + // Broken symlink — skip silently + continue; + } + } else if (dirent.isDirectory()) { + entries.push(this.buildEntry(dirent.name, entryPath, 'directory')); + } else if (dirent.isFile()) { + try { + const fileStat = await fs.stat(entryPath); + entries.push(this.buildEntry(dirent.name, entryPath, 'file', fileStat.size)); + } catch { + // Can't stat — include without size + entries.push(this.buildEntry(dirent.name, entryPath, 'file')); + } + } + // Skip other types (block devices, sockets, etc.) + + if (entries.length >= maxEntries) { + truncated = true; + break; + } + } + + // Sort: directories first, then alphabetical + entries.sort((a, b) => { + if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + return { entries, truncated }; + } + + /** + * Read file content with security checks and binary detection. + * + * Security: + * - validateFilePath for traversal + sensitive check (SEC-1) + * - Device path blocking (SEC-4) + * - lstat + isFile check (SEC-4) + * - Size limits (SEC-4) + * - Post-read TOCTOU realpath verify (SEC-3) + */ + async readFile(projectRoot: string, filePath: string): Promise { + // 1. Path validation (traversal, sensitive, symlink) + const validation = validateFilePath(filePath, projectRoot); + if (!validation.valid) { + throw new Error(validation.error); + } + + const normalizedPath = validation.normalizedPath!; + + // 2. Device path block + if (isDevicePath(normalizedPath)) { + throw new Error('Cannot read device files'); + } + + // 3. File type check + const stats = await fs.lstat(normalizedPath); + if (!stats.isFile()) { + throw new Error('Not a regular file'); + } + + // 4. Size check — reject files beyond preview limit + if (stats.size > MAX_FILE_SIZE_PREVIEW) { + throw new Error( + `File too large (${(stats.size / 1024 / 1024).toFixed(1)}MB). Open in external editor.` + ); + } + + // 5. Binary check + const binary = await isBinaryFile(normalizedPath); + if (binary) { + return { + content: '', + size: stats.size, + mtimeMs: stats.mtimeMs, + truncated: false, + encoding: 'binary', + isBinary: true, + }; + } + + // 6. Read content + const raw = await fs.readFile(normalizedPath, 'utf8'); + + // 7. Post-read TOCTOU verify + const realPath = await fs.realpath(normalizedPath); + const postValidation = validateFilePath(realPath, projectRoot); + if (!postValidation.valid) { + throw new Error('Path changed during read (TOCTOU)'); + } + + // 8. Tiered response + const isPreview = stats.size > MAX_FILE_SIZE_FULL; + const content = isPreview ? raw.split('\n').slice(0, PREVIEW_LINE_COUNT).join('\n') : raw; + + return { + content, + size: stats.size, + mtimeMs: stats.mtimeMs, + truncated: isPreview, + encoding: 'utf-8', + isBinary: false, + }; + } + + /** + * Write file content with atomic write and full security checks. + * + * Security: + * - validateFilePath for traversal + sensitive check (SEC-1) + * - Project-only containment — block writes outside projectRoot (SEC-14) + * - Block .git/ internal paths (SEC-12) + * - Device path blocking (SEC-4) + * - Content size limit (2MB) + * - Atomic write via tmp + rename (SEC-9) + */ + async writeFile( + projectRoot: string, + filePath: string, + content: string + ): Promise { + // 1. Path validation + const validation = validateFilePath(filePath, projectRoot); + if (!validation.valid) { + throw new Error(validation.error); + } + + const normalizedPath = validation.normalizedPath!; + + // 2. Project-only containment (SEC-14: block ~/.claude writes) + if (!isPathWithinRoot(normalizedPath, projectRoot)) { + throw new Error('Path is outside project root'); + } + + // 3. Block .git/ internal paths (SEC-12) + if (isGitInternalPath(normalizedPath)) { + throw new Error('Cannot write to .git/ directory'); + } + + // 4. Device path block + if (isDevicePath(normalizedPath)) { + throw new Error('Cannot write to device files'); + } + + // 5. Content size check + const byteLength = Buffer.byteLength(content, 'utf8'); + if (byteLength > MAX_WRITE_SIZE) { + throw new Error( + `Content too large (${(byteLength / 1024 / 1024).toFixed(1)}MB). Maximum is 2MB.` + ); + } + + // 6. Atomic write + await atomicWriteAsync(normalizedPath, content); + + // 7. Get post-write stats + const stats = await fs.stat(normalizedPath); + log.info('File saved:', normalizedPath, `(${stats.size} bytes)`); + + return { + mtimeMs: stats.mtimeMs, + size: stats.size, + }; + } + + /** + * Create a new empty file. + * + * Security: + * - validateFileName for traversal, control chars (SEC-1) + * - validateFilePath for parent containment (SEC-1) + * - isPathWithinRoot for project-only containment (SEC-14) + * - isGitInternalPath to block .git/ writes (SEC-12) + * - Check parent is directory, file does NOT exist + */ + async createFile( + projectRoot: string, + parentDir: string, + fileName: string + ): Promise { + // 1. Validate file name + const nameValidation = validateFileName(fileName); + if (!nameValidation.valid) { + throw new Error(nameValidation.error); + } + + // 2. Validate parent directory path + const parentValidation = validateFilePath(parentDir, projectRoot); + if (!parentValidation.valid) { + throw new Error(parentValidation.error); + } + const normalizedParent = parentValidation.normalizedPath!; + + // 3. Build full path + const fullPath = path.join(normalizedParent, fileName.trim()); + + // 4. Project-only containment (SEC-14) + if (!isPathWithinRoot(fullPath, projectRoot)) { + throw new Error('Path is outside project root'); + } + + // 5. Block .git/ internal paths (SEC-12) + if (isGitInternalPath(fullPath)) { + throw new Error('Cannot create files in .git/ directory'); + } + + // 6. Verify parent is a directory + const parentStat = await fs.lstat(normalizedParent); + if (!parentStat.isDirectory()) { + throw new Error('Parent path is not a directory'); + } + + // 7. Verify file does NOT exist + try { + await fs.access(fullPath); + throw new Error('File already exists'); + } catch (err) { + // Expected: ENOENT means file doesn't exist (good) + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; // Re-throw 'File already exists' or other errors + } + } + + // 8. Create empty file + await fs.writeFile(fullPath, '', 'utf8'); + + // 9. Get stats + const stats = await fs.stat(fullPath); + log.info('File created:', fullPath); + + return { filePath: fullPath, mtimeMs: stats.mtimeMs }; + } + + /** + * Create a new directory. + * + * Same security checks as createFile, but uses fs.mkdir. + */ + async createDir( + projectRoot: string, + parentDir: string, + dirName: string + ): Promise { + // 1. Validate directory name + const nameValidation = validateFileName(dirName); + if (!nameValidation.valid) { + throw new Error(nameValidation.error); + } + + // 2. Validate parent directory path + const parentValidation = validateFilePath(parentDir, projectRoot); + if (!parentValidation.valid) { + throw new Error(parentValidation.error); + } + const normalizedParent = parentValidation.normalizedPath!; + + // 3. Build full path + const fullPath = path.join(normalizedParent, dirName.trim()); + + // 4. Project-only containment (SEC-14) + if (!isPathWithinRoot(fullPath, projectRoot)) { + throw new Error('Path is outside project root'); + } + + // 5. Block .git/ internal paths (SEC-12) + if (isGitInternalPath(fullPath)) { + throw new Error('Cannot create directories in .git/ directory'); + } + + // 6. Verify parent is a directory + const parentStat = await fs.lstat(normalizedParent); + if (!parentStat.isDirectory()) { + throw new Error('Parent path is not a directory'); + } + + // 7. Verify directory does NOT exist + try { + await fs.access(fullPath); + throw new Error('Directory already exists'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } + + // 8. Create directory + await fs.mkdir(fullPath); + log.info('Directory created:', fullPath); + + return { dirPath: fullPath }; + } + + /** + * Delete a file or directory by moving it to the system Trash. + * + * Security: + * - validateFilePath for containment (SEC-1) + * - isPathWithinRoot for project-only containment (SEC-14) + * - isGitInternalPath to block .git/ deletes (SEC-12) + * - Uses shell.trashItem for safe, reversible deletion + */ + async deleteFile(projectRoot: string, filePath: string): Promise { + // 1. Validate file path + const validation = validateFilePath(filePath, projectRoot); + if (!validation.valid) { + throw new Error(validation.error); + } + const normalizedPath = validation.normalizedPath!; + + // 2. Project-only containment (SEC-14) + if (!isPathWithinRoot(normalizedPath, projectRoot)) { + throw new Error('Path is outside project root'); + } + + // 3. Block .git/ internal paths (SEC-12) + if (isGitInternalPath(normalizedPath)) { + throw new Error('Cannot delete files in .git/ directory'); + } + + // 4. Verify path exists + await fs.lstat(normalizedPath); + + // 5. Move to Trash (safe, reversible) + await shell.trashItem(normalizedPath); + log.info('File moved to Trash:', normalizedPath); + + return { deletedPath: normalizedPath }; + } + + /** + * Move a file or directory to a new location within the project. + * + * Security: + * - validateFilePath for traversal + sensitive check (SEC-1) + * - isPathWithinRoot for project-only containment (SEC-14) + * - isGitInternalPath to block .git/ moves (SEC-12) + * - Parent → child move prevention + * - Name collision detection + * - EXDEV cross-device fallback (fs.cp + fs.rm) + */ + async moveFile( + projectRoot: string, + sourcePath: string, + destDir: string + ): Promise { + // 1. Validate source path + const srcValidation = validateFilePath(sourcePath, projectRoot); + if (!srcValidation.valid) { + throw new Error(srcValidation.error); + } + const normalizedSrc = srcValidation.normalizedPath!; + + // 2. Validate dest directory path + const destValidation = validateFilePath(destDir, projectRoot); + if (!destValidation.valid) { + throw new Error(destValidation.error); + } + const normalizedDest = destValidation.normalizedPath!; + + // 3. Project containment (SEC-14) + if (!isPathWithinRoot(normalizedSrc, projectRoot)) { + throw new Error('Source path is outside project root'); + } + if (!isPathWithinRoot(normalizedDest, projectRoot)) { + throw new Error('Destination path is outside project root'); + } + + // 4. Block .git/ paths (SEC-12) + if (isGitInternalPath(normalizedSrc)) { + throw new Error('Cannot move files from .git/ directory'); + } + if (isGitInternalPath(normalizedDest)) { + throw new Error('Cannot move files into .git/ directory'); + } + + // 5. Verify source exists + await fs.lstat(normalizedSrc); + + // 6. Verify destination is a directory + const destStat = await fs.lstat(normalizedDest); + if (!destStat.isDirectory()) { + throw new Error('Destination is not a directory'); + } + + // 7. Build new path + const newPath = path.join(normalizedDest, path.basename(normalizedSrc)); + + // 8. Prevent parent → child move (moving dir into itself) + if (normalizedDest.startsWith(normalizedSrc + '/') || normalizedDest === normalizedSrc) { + throw new Error('Cannot move a directory into itself'); + } + + // 9. Check destination doesn't already exist + try { + await fs.access(newPath); + throw new Error('File already exists at destination'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } + + // 10. Block sensitive destination + if (matchesSensitivePattern(newPath)) { + throw new Error('Cannot move to sensitive file location'); + } + + // 11. Perform rename with EXDEV fallback + try { + await fs.rename(normalizedSrc, newPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EXDEV') { + const stat = await fs.lstat(normalizedSrc); + if (stat.isDirectory()) { + await fs.cp(normalizedSrc, newPath, { recursive: true }); + } else { + await fs.copyFile(normalizedSrc, newPath); + } + await fs.rm(normalizedSrc, { recursive: true, force: true }); + } else { + throw err; + } + } + + log.info('File moved:', normalizedSrc, '→', newPath); + return { newPath }; + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private buildEntry( + name: string, + entryPath: string, + type: 'file' | 'directory', + size?: number + ): FileTreeEntry { + const entry: FileTreeEntry = { name, path: entryPath, type }; + if (size !== undefined) entry.size = size; + if (matchesSensitivePattern(entryPath)) entry.isSensitive = true; + return entry; + } +} + +export { MAX_DIR_ENTRIES, MAX_FILE_SIZE_FULL, MAX_FILE_SIZE_PREVIEW, MAX_WRITE_SIZE }; diff --git a/src/main/services/editor/conflictDetection.ts b/src/main/services/editor/conflictDetection.ts new file mode 100644 index 00000000..45270801 --- /dev/null +++ b/src/main/services/editor/conflictDetection.ts @@ -0,0 +1,52 @@ +/** + * Conflict detection utility for the project editor. + * + * Checks if a file has been modified externally since the last known mtime. + * Used before saving to prevent silently overwriting external changes. + */ + +import * as fs from 'fs/promises'; + +// ============================================================================= +// Types +// ============================================================================= + +export interface ConflictCheckResult { + /** True if the file was modified externally */ + hasConflict: boolean; + /** Current mtime on disk */ + currentMtimeMs: number; + /** True if the file no longer exists on disk */ + deleted: boolean; +} + +// ============================================================================= +// Functions +// ============================================================================= + +/** + * Check if a file has been modified since the given baseline mtime. + * + * @param filePath - Absolute path to the file + * @param baselineMtimeMs - Last known mtime (from readFile result) + * @returns Conflict check result + */ +export async function checkFileConflict( + filePath: string, + baselineMtimeMs: number +): Promise { + try { + const stats = await fs.stat(filePath); + const currentMtimeMs = stats.mtimeMs; + + // Allow 1ms tolerance for filesystem rounding + const hasConflict = Math.abs(currentMtimeMs - baselineMtimeMs) > 1; + + return { hasConflict, currentMtimeMs, deleted: false }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { hasConflict: true, currentMtimeMs: 0, deleted: true }; + } + throw error; + } +} diff --git a/src/main/services/editor/index.ts b/src/main/services/editor/index.ts new file mode 100644 index 00000000..d9b300e4 --- /dev/null +++ b/src/main/services/editor/index.ts @@ -0,0 +1,5 @@ +export { checkFileConflict } from './conflictDetection'; +export { EditorFileWatcher } from './EditorFileWatcher'; +export { createSearchAbortController, FileSearchService } from './FileSearchService'; +export { GitStatusService, mapStatusResult } from './GitStatusService'; +export { ProjectFileService } from './ProjectFileService'; diff --git a/src/main/services/team/atomicWrite.ts b/src/main/services/team/atomicWrite.ts index 500c038c..e15d3056 100644 --- a/src/main/services/team/atomicWrite.ts +++ b/src/main/services/team/atomicWrite.ts @@ -1,39 +1,5 @@ -import { randomUUID } from 'crypto'; -import * as fs from 'fs'; -import * as path from 'path'; - /** - * Async atomic write: write tmp file then rename over target. - * Uses best-effort fsync and EXDEV fallback for safety. + * Re-export from canonical location. + * Kept to avoid breaking existing imports — new code should import from @main/utils/atomicWrite. */ -export async function atomicWriteAsync(targetPath: string, data: string): Promise { - const dir = path.dirname(targetPath); - const tmpPath = path.join(dir, `.tmp.${randomUUID()}`); - - try { - await fs.promises.mkdir(dir, { recursive: true }); - await fs.promises.writeFile(tmpPath, data, 'utf8'); - - try { - const fd = await fs.promises.open(tmpPath, 'r+'); - await fd.sync(); - await fd.close(); - } catch { - // fsync is best-effort. - } - - try { - await fs.promises.rename(tmpPath, targetPath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'EXDEV') { - await fs.promises.copyFile(tmpPath, targetPath); - await fs.promises.unlink(tmpPath).catch(() => undefined); - } else { - throw error; - } - } - } catch (error) { - await fs.promises.unlink(tmpPath).catch(() => undefined); - throw error; - } -} +export { atomicWriteAsync } from '@main/utils/atomicWrite'; diff --git a/src/main/utils/atomicWrite.ts b/src/main/utils/atomicWrite.ts new file mode 100644 index 00000000..500c038c --- /dev/null +++ b/src/main/utils/atomicWrite.ts @@ -0,0 +1,39 @@ +import { randomUUID } from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Async atomic write: write tmp file then rename over target. + * Uses best-effort fsync and EXDEV fallback for safety. + */ +export async function atomicWriteAsync(targetPath: string, data: string): Promise { + const dir = path.dirname(targetPath); + const tmpPath = path.join(dir, `.tmp.${randomUUID()}`); + + try { + await fs.promises.mkdir(dir, { recursive: true }); + await fs.promises.writeFile(tmpPath, data, 'utf8'); + + try { + const fd = await fs.promises.open(tmpPath, 'r+'); + await fd.sync(); + await fd.close(); + } catch { + // fsync is best-effort. + } + + try { + await fs.promises.rename(tmpPath, targetPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EXDEV') { + await fs.promises.copyFile(tmpPath, targetPath); + await fs.promises.unlink(tmpPath).catch(() => undefined); + } else { + throw error; + } + } + } catch (error) { + await fs.promises.unlink(tmpPath).catch(() => undefined); + throw error; + } +} diff --git a/src/main/utils/pathValidation.ts b/src/main/utils/pathValidation.ts index dc9c6d34..0d2c81ec 100644 --- a/src/main/utils/pathValidation.ts +++ b/src/main/utils/pathValidation.ts @@ -67,7 +67,7 @@ function normalizeForCompare(input: string, isWindows: boolean): string { return isWindows ? normalized.toLowerCase() : normalized; } -function isPathWithinRoot(targetPath: string, rootPath: string): boolean { +export function isPathWithinRoot(targetPath: string, rootPath: string): boolean { return targetPath === rootPath || targetPath.startsWith(rootPath + path.sep); } @@ -85,7 +85,7 @@ function resolveRealPathIfExists(inputPath: string): string | null { * @param normalizedPath - The normalized absolute path to check * @returns true if path matches a sensitive pattern */ -function matchesSensitivePattern(normalizedPath: string): boolean { +export function matchesSensitivePattern(normalizedPath: string): boolean { return SENSITIVE_PATTERNS.some((pattern) => pattern.test(normalizedPath)); } @@ -303,3 +303,67 @@ export function validateOpenPath( return { valid: true, normalizedPath }; } + +// ============================================================================= +// Editor-specific validation utilities +// ============================================================================= + +const MAX_FILENAME_LENGTH = 255; + +/** Characters forbidden in file/directory names. */ +// eslint-disable-next-line no-control-regex, sonarjs/no-control-regex -- Intentional: validating filenames against control characters +const INVALID_FILENAME_CHARS = /[\x00-\x1f/\\:*?"<>|]/; + +/** + * Validates a file or directory name for creation. + * Prevents path traversal, control chars, and OS-invalid characters. + */ +export function validateFileName(name: string): PathValidationResult { + if (!name || typeof name !== 'string') { + return { valid: false, error: 'Name is required' }; + } + + const trimmed = name.trim(); + if (trimmed.length === 0) { + return { valid: false, error: 'Name cannot be empty' }; + } + + if (trimmed.length > MAX_FILENAME_LENGTH) { + return { valid: false, error: `Name exceeds ${MAX_FILENAME_LENGTH} characters` }; + } + + if (trimmed === '.' || trimmed === '..') { + return { valid: false, error: 'Invalid name' }; + } + + if (INVALID_FILENAME_CHARS.test(trimmed)) { + return { valid: false, error: 'Name contains invalid characters' }; + } + + return { valid: true }; +} + +/** Blocked device/pseudo-filesystem path prefixes. */ +const DEVICE_PATH_PREFIXES = ['/dev/', '/proc/', '/sys/']; +const WINDOWS_DEVICE_PREFIX = '\\\\.\\'; + +/** + * Returns true if the path points to a device or pseudo-filesystem + * (/dev/, /proc/, /sys/, \\\\.\\). + */ +export function isDevicePath(filePath: string): boolean { + const lower = filePath.toLowerCase(); + if (DEVICE_PATH_PREFIXES.some((prefix) => lower.startsWith(prefix))) { + return true; + } + return filePath.startsWith(WINDOWS_DEVICE_PREFIX); +} + +/** + * Returns true if the path contains a `.git/` segment. + * Used to block writes to git internals. + */ +export function isGitInternalPath(filePath: string): boolean { + const normalized = filePath.replace(/\\/g, '/'); + return normalized.includes('/.git/') || normalized.endsWith('/.git'); +} diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 19042fdc..433b2ee7 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -396,3 +396,46 @@ export const REVIEW_SAVE_DECISIONS = 'review:saveDecisions'; /** Clear review decisions from disk */ export const REVIEW_CLEAR_DECISIONS = 'review:clearDecisions'; + +// ============================================================================= +// Editor Channels +// ============================================================================= + +/** Initialize editor, set activeProjectRoot in module-level state */ +export const EDITOR_OPEN = 'editor:open'; + +/** Cleanup: reset activeProjectRoot, stop watcher */ +export const EDITOR_CLOSE = 'editor:close'; + +/** Recursive directory reading (depth=1, lazy) */ +export const EDITOR_READ_DIR = 'editor:readDir'; + +/** Read file content with binary detection */ +export const EDITOR_READ_FILE = 'editor:readFile'; + +/** Write file content (atomic write) */ +export const EDITOR_WRITE_FILE = 'editor:writeFile'; + +/** Create a new file */ +export const EDITOR_CREATE_FILE = 'editor:createFile'; + +/** Create a new directory */ +export const EDITOR_CREATE_DIR = 'editor:createDir'; + +/** Delete file or directory (move to Trash) */ +export const EDITOR_DELETE_FILE = 'editor:deleteFile'; + +/** Move file or directory to a new location */ +export const EDITOR_MOVE_FILE = 'editor:moveFile'; + +/** Search in files (literal string search) */ +export const EDITOR_SEARCH_IN_FILES = 'editor:searchInFiles'; + +/** Get git status for current project */ +export const EDITOR_GIT_STATUS = 'editor:gitStatus'; + +/** Enable/disable file watcher for current project */ +export const EDITOR_WATCH_DIR = 'editor:watchDir'; + +/** File change event from watcher (main -> renderer) */ +export const EDITOR_CHANGE = 'editor:change'; diff --git a/src/preload/index.ts b/src/preload/index.ts index d3a96086..50072bcb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,6 +10,19 @@ import { CONTEXT_GET_ACTIVE, CONTEXT_LIST, CONTEXT_SWITCH, + EDITOR_CHANGE, + EDITOR_CLOSE, + EDITOR_CREATE_DIR, + EDITOR_CREATE_FILE, + EDITOR_DELETE_FILE, + EDITOR_GIT_STATUS, + EDITOR_MOVE_FILE, + EDITOR_OPEN, + EDITOR_READ_DIR, + EDITOR_READ_FILE, + EDITOR_SEARCH_IN_FILES, + EDITOR_WATCH_DIR, + EDITOR_WRITE_FILE, HTTP_SERVER_GET_STATUS, HTTP_SERVER_START, HTTP_SERVER_STOP, @@ -179,6 +192,19 @@ import type { UpdateKanbanPatch, WslClaudeRootCandidate, } from '@shared/types'; +import type { + CreateDirResponse, + CreateFileResponse, + DeleteFileResponse, + EditorFileChangeEvent, + GitStatusResult, + MoveFileResponse, + ReadDirResult, + ReadFileResult, + SearchInFilesOptions, + SearchInFilesResult, + WriteFileResponse, +} from '@shared/types/editor'; import type { PtySpawnOptions } from '@shared/types/terminal'; // ============================================================================= @@ -920,6 +946,37 @@ const electronAPI: ElectronAPI = { }; }, }, + + // ===== Editor API ===== + editor: { + open: (projectPath: string) => invokeIpcWithResult(EDITOR_OPEN, projectPath), + close: () => invokeIpcWithResult(EDITOR_CLOSE), + readDir: (dirPath: string, maxEntries?: number) => + invokeIpcWithResult(EDITOR_READ_DIR, dirPath, maxEntries), + readFile: (filePath: string) => invokeIpcWithResult(EDITOR_READ_FILE, filePath), + writeFile: (filePath: string, content: string, baselineMtimeMs?: number) => + invokeIpcWithResult(EDITOR_WRITE_FILE, filePath, content, baselineMtimeMs), + createFile: (parentDir: string, fileName: string) => + invokeIpcWithResult(EDITOR_CREATE_FILE, parentDir, fileName), + createDir: (parentDir: string, dirName: string) => + invokeIpcWithResult(EDITOR_CREATE_DIR, parentDir, dirName), + deleteFile: (filePath: string) => + invokeIpcWithResult(EDITOR_DELETE_FILE, filePath), + moveFile: (sourcePath: string, destDir: string) => + invokeIpcWithResult(EDITOR_MOVE_FILE, sourcePath, destDir), + searchInFiles: (options: SearchInFilesOptions) => + invokeIpcWithResult(EDITOR_SEARCH_IN_FILES, options), + gitStatus: () => invokeIpcWithResult(EDITOR_GIT_STATUS), + watchDir: (enable: boolean) => invokeIpcWithResult(EDITOR_WATCH_DIR, enable), + onEditorChange: (callback: (event: EditorFileChangeEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: EditorFileChangeEvent): void => + callback(data); + ipcRenderer.on(EDITOR_CHANGE, listener); + return (): void => { + ipcRenderer.removeListener(EDITOR_CHANGE, listener); + }; + }, + }, }; // Use contextBridge to securely expose the API to the renderer process diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index aaaececb..4f23b9e9 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -63,6 +63,7 @@ import type { WslClaudeRootCandidate, } from '@shared/types'; import type { AgentConfig } from '@shared/types/api'; +import type { EditorAPI } from '@shared/types/editor'; import type { TerminalAPI } from '@shared/types/terminal'; export class HttpAPIClient implements ElectronAPI { @@ -903,4 +904,50 @@ export class HttpAPIClient implements ElectronAPI { onData: (): (() => void) => () => {}, onExit: (): (() => void) => () => {}, }; + + // --------------------------------------------------------------------------- + // Editor (not available in browser mode) + // --------------------------------------------------------------------------- + + editor: EditorAPI = { + open: async () => { + throw new Error('Editor not available in browser mode'); + }, + close: async () => { + throw new Error('Editor not available in browser mode'); + }, + readDir: async () => { + throw new Error('Editor not available in browser mode'); + }, + readFile: async () => { + throw new Error('Editor not available in browser mode'); + }, + writeFile: async () => { + throw new Error('Editor not available in browser mode'); + }, + createFile: async () => { + throw new Error('Editor not available in browser mode'); + }, + createDir: async () => { + throw new Error('Editor not available in browser mode'); + }, + deleteFile: async () => { + throw new Error('Editor not available in browser mode'); + }, + moveFile: async () => { + throw new Error('Editor not available in browser mode'); + }, + searchInFiles: async () => { + throw new Error('Editor not available in browser mode'); + }, + gitStatus: async () => { + throw new Error('Editor not available in browser mode'); + }, + watchDir: async () => { + throw new Error('Editor not available in browser mode'); + }, + onEditorChange: () => { + return () => {}; + }, + }; } diff --git a/src/renderer/components/common/FileTree.tsx b/src/renderer/components/common/FileTree.tsx new file mode 100644 index 00000000..fb7265f4 --- /dev/null +++ b/src/renderer/components/common/FileTree.tsx @@ -0,0 +1,182 @@ +/** + * Generic file tree component with render-props for customization. + * + * Used by EditorFileTree (FileTreeEntry) and ReviewFileTree (FileChangeSummary). + * ARIA: role="tree", role="treeitem", aria-expanded, role="group". + */ + +import React, { useCallback } from 'react'; + +import { ChevronDown, ChevronRight } from 'lucide-react'; + +import type { TreeNode } from '@renderer/utils/fileTreeBuilder'; + +// ============================================================================= +// Types +// ============================================================================= + +interface FileTreeProps { + nodes: TreeNode[]; + activeNodePath: string | null; + onNodeClick: (node: TreeNode) => void; + expandedPaths: Record; + onToggleExpand: (fullPath: string) => void; + renderLeafNode?: (node: TreeNode, isSelected: boolean, depth: number) => React.ReactNode; + renderFolderLabel?: (node: TreeNode, isOpen: boolean, depth: number) => React.ReactNode; + renderNodeIcon?: (node: TreeNode) => React.ReactNode; + /** Optional data attributes placed on each
  • for event delegation (e.g. context menu) */ + getNodeDataAttrs?: (node: TreeNode) => Record; + maxDepth?: number; +} + +const MAX_VISUAL_DEPTH = 12; +const INDENT_PX = 12; + +// ============================================================================= +// Component +// ============================================================================= + +export const FileTree = (props: Readonly>): React.ReactElement => { + const { nodes, maxDepth = MAX_VISUAL_DEPTH } = props; + + return ( +
      + {nodes.map((node) => ( + + ))} +
    + ); +}; + +// ============================================================================= +// TreeItem (recursive) +// ============================================================================= + +interface TreeItemProps extends FileTreeProps { + node: TreeNode; + depth: number; +} + +const TreeItemInner = ({ + node, + depth, + activeNodePath, + onNodeClick, + expandedPaths, + onToggleExpand, + renderLeafNode, + renderFolderLabel, + renderNodeIcon, + getNodeDataAttrs, + maxDepth = MAX_VISUAL_DEPTH, + nodes: _nodes, + ...rest +}: Readonly>): React.ReactElement => { + const visualDepth = Math.min(depth, maxDepth); + const isSelected = activeNodePath === node.fullPath; + const dataAttrs = getNodeDataAttrs?.(node); + + const handleClick = useCallback(() => { + if (node.isFile) { + onNodeClick(node); + } else { + onToggleExpand(node.fullPath); + } + }, [node, onNodeClick, onToggleExpand]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleClick(); + } + }, + [handleClick] + ); + + // Leaf node (file) + if (node.isFile) { + if (renderLeafNode) { + return ( + // eslint-disable-next-line react/jsx-props-no-spreading -- data attributes from getNodeDataAttrs require spreading +
  • + {renderLeafNode(node, isSelected, visualDepth)} +
  • + ); + } + + return ( +
  • + {renderNodeIcon?.(node)} + {node.name} +
  • + ); + } + + // Folder node + const isExpanded = expandedPaths[node.fullPath] === true; + + return ( + // eslint-disable-next-line react/jsx-props-no-spreading -- data attributes from getNodeDataAttrs require spreading +
  • + {renderFolderLabel ? ( + renderFolderLabel(node, isExpanded, visualDepth) + ) : ( +
    = maxDepth ? node.fullPath : undefined} + > + {isExpanded ? ( + + ) : ( + + )} + {renderNodeIcon?.(node)} + {node.name} +
    + )} + {isExpanded && node.children.length > 0 && ( +
      + {node.children.map((child) => ( + + ))} +
    + )} +
  • + ); +}; + +const TreeItem = React.memo(TreeItemInner) as typeof TreeItemInner; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 734af2f6..e6c6bd6d 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { confirm } from '@renderer/components/common/ConfirmDialog'; @@ -58,6 +58,10 @@ import { KanbanBoard } from './kanban/KanbanBoard'; import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover'; import { TrashDialog } from './kanban/TrashDialog'; import { MemberDetailDialog } from './members/MemberDetailDialog'; + +const ProjectEditorOverlay = lazy(() => + import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay })) +); import { MemberList } from './members/MemberList'; import { MessageComposer } from './messages/MessageComposer'; import { MessagesFilterPopover } from './messages/MessagesFilterPopover'; @@ -71,6 +75,7 @@ import type { KanbanFilterState } from './kanban/KanbanFilterPopover'; import type { MessagesFilterState } from './messages/MessagesFilterPopover'; import type { Session } from '@renderer/types/data'; import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; +import type { EditorSelectionAction } from '@shared/types/editor'; interface TeamDetailViewProps { teamName: string; @@ -123,11 +128,26 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false); const [launchDialogOpen, setLaunchDialogOpen] = useState(false); + const [editorOpen, setEditorOpen] = useState(false); + const contentRef = useRef(null); + + // Set inert on background content when editor overlay is open (a11y focus trap) + useEffect(() => { + const el = contentRef.current; + if (!el) return; + if (editorOpen) { + el.setAttribute('inert', ''); + } else { + el.removeAttribute('inert'); + } + }, [editorOpen]); + const [sendDialogOpen, setSendDialogOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [stoppingTeam, setStoppingTeam] = useState(false); const [trashOpen, setTrashOpen] = useState(false); const [sendDialogRecipient, setSendDialogRecipient] = useState(undefined); + const [sendDialogDefaultText, setSendDialogDefaultText] = useState(undefined); const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>( undefined ); @@ -504,6 +524,21 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele }); }; + const handleEditorAction = useCallback( + (action: EditorSelectionAction) => { + if (action.type === 'sendMessage') { + setSendDialogDefaultText(action.formattedContext); + setSendDialogRecipient(undefined); + setReplyQuote(undefined); + setSendDialogOpen(true); + } else if (action.type === 'createTask') { + openCreateTaskDialog('', action.formattedContext); + } + }, + + [] + ); + const handleStopTeam = useCallback(async (): Promise => { setStoppingTeam(true); try { @@ -671,387 +706,787 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele : nameColorSet(data.config.name); return ( -
    -
    - {headerColorSet ? ( -
    - ) : null} + <> +
    -
    -

    {data.config.name}

    -
    -
    - {data.isAlive && ( + {headerColorSet ? ( +
    + ) : null} +
    +
    +

    + {data.config.name} +

    +
    +
    + {data.isAlive && ( + + + + + Stop team + + )} + + + + + Edit team + - Stop team + Delete team - )} - - - - - Edit team - - - - - - Delete team - +
    -
    - {data.config.description && ( -

    - {data.config.description} -

    - )} - {(data.config.projectPath || leadBranch) && ( -
    - {data.config.projectPath && ( - - - - {formatProjectPath(data.config.projectPath)} - - - )} - {leadBranch && ( - - - {leadBranch} - - )} - {data.isAlive && ( - - - Running - - )} - {!data.isAlive && isTeamProvisioning && ( - - - Launching... - - )} -
    - )} - {(() => { - const currentPath = data.config.projectPath; - const history = data.config.projectPathHistory?.filter((p) => p !== currentPath); - if (!history || history.length === 0) return null; - return ( -
    - - - Previous: {history.map((p) => formatProjectPath(p)).join(', ')} - + {data.config.description} +

    + )} + {(data.config.projectPath || leadBranch) && ( +
    + {data.config.projectPath && ( + + + + {formatProjectPath(data.config.projectPath)} + + + + )} + {leadBranch && ( + + + {leadBranch} + + )} + {data.isAlive && ( + + + Running + + )} + {!data.isAlive && isTeamProvisioning && ( + + + Launching... + + )}
    - ); - })()} -
    - - {!data.isAlive && !isTeamProvisioning ? ( -
    - - - Team is offline - - + )} + {(() => { + const currentPath = data.config.projectPath; + const history = data.config.projectPathHistory?.filter((p) => p !== currentPath); + if (!history || history.length === 0) return null; + return ( +
    + + + Previous: {history.map((p) => formatProjectPath(p)).join(', ')} + +
    + ); + })()}
    - ) : null} - + {!data.isAlive && !isTeamProvisioning ? ( +
    + + + Team is offline + + +
    + ) : null} - {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( -
    - Failed to fully load kanban. Displaying safe data. -
    - ) : null} - {reviewActionError ? ( -
    - {reviewActionError} -
    - ) : null} + - } - badge={activeMembers.length} - defaultOpen - action={ - + } + > + { + setSendDialogRecipient(member.name); + setSendDialogDefaultText(undefined); + setReplyQuote(undefined); + setSendDialogOpen(true); }} - > - - Member - - } - > - { - setSendDialogRecipient(member.name); - setReplyQuote(undefined); - setSendDialogOpen(true); - }} - onAssignTask={(member) => { - openCreateTaskDialog('', '', member.name); - }} - onOpenTask={(task) => setSelectedTask(task)} - /> - - - } - defaultOpen={false} - > - setKanbanFilter((prev) => ({ ...prev, sessionId: id }))} - projectPath={data.config.projectPath} - /> - - - } - badge={filteredTasks.length} - defaultOpen - forceOpen={kanbanSearch.trim().length > 0} - action={ - + } + > + + + setKanbanSearch(e.target.value)} + className="h-8 w-full min-w-[140px] max-w-[240px] rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-8 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none" + /> + {kanbanSearch && ( + + + + + Clear search + + )} +
    + } + onRequestReview={(taskId) => { + void requestReview(teamName, taskId); + }} + onApprove={(taskId) => { + void updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); + }} + onRequestChanges={(taskId) => { + setRequestChangesTaskId(taskId); + }} + onMoveBackToDone={(taskId) => { + void (async () => { + await updateKanban(teamName, taskId, { op: 'remove' }); + await updateTaskStatus(teamName, taskId, 'completed'); + })(); + }} + onStartTask={(taskId) => { + void (async () => { + try { + const result = await startTask(teamName, taskId); + if (data?.isAlive) { + const task = data.tasks.find((t) => t.id === taskId); + try { + if (result.notifiedOwner && task?.owner) { + await api.teams.processSend( + teamName, + `Task #${taskId} "${task.subject}" has started. Please begin working on it.` + ); + } else if (!result.notifiedOwner) { + const desc = task?.description?.trim() + ? `\nDescription: ${task.description.trim()}` + : ''; + await api.teams.processSend( + teamName, + `Task #${taskId} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` + ); + } + } catch { + // best-effort + } + } + } catch { + // error via store + } + })(); + }} + onCompleteTask={(taskId) => { + void updateTaskStatus(teamName, taskId, 'completed'); + }} + onCancelTask={(taskId) => { + void (async () => { + try { + const task = data?.tasks.find((t) => t.id === taskId); + await updateTaskStatus(teamName, taskId, 'pending'); + + // Notify assignee directly via inbox — they'll see it immediately + if (task?.owner) { + try { + await api.teams.sendMessage(teamName, { + member: task.owner, + text: `Task #${taskId} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, + summary: `Task #${taskId} cancelled`, + }); + } catch { + // best-effort + } + } + + // Also notify team lead so they can reassign/coordinate + if (data?.isAlive) { + try { + const ownerSuffix = task?.owner + ? ` ${task.owner} has been notified to stop.` + : ''; + await api.teams.processSend( + teamName, + `Task #${taskId} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` + ); + } catch { + // best-effort + } + } + } catch { + // error via store + } + })(); + }} + onColumnOrderChange={(columnId, orderedTaskIds) => { + void updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); + }} + onScrollToTask={(taskId) => { + const el = document.querySelector(`[data-task-id="${taskId}"]`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + el.classList.add('ring-2', 'ring-blue-400/50'); + setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400/50'), 1500); + } + }} + onTaskClick={(task) => setSelectedTask(task)} + onViewChanges={handleViewChanges} + onAddTask={(startImmediately) => openCreateTaskDialog('', '', '', startImmediately)} + onDeleteTask={handleDeleteTask} + deletedTaskCount={deletedTasks.length} + onOpenTrash={() => setTrashOpen(true)} + /> + + + {(data.processes?.length ?? 0) > 0 && ( + } + badge={data.processes.filter((p) => !p.stoppedAt).length} + defaultOpen > - - Task - - } - > - - - setKanbanSearch(e.target.value)} - className="h-8 w-full min-w-[140px] max-w-[240px] rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-8 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none" - /> - {kanbanSearch && ( + + + )} + + } + badge={filteredMessages.length} + secondaryBadge={ + filteredMessages.length > 0 && messagesUnreadCount > 0 ? messagesUnreadCount : undefined + } + headerExtra={ + + + + + Desktop notifications plugin + + } + defaultOpen + action={ +
    + {messagesUnreadCount > 0 && ( - Clear search + Mark all as read )} +
    + + setMessagesSearchQuery(e.target.value)} + onPointerDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none" + /> +
    +
    } - onRequestReview={(taskId) => { - void requestReview(teamName, taskId); - }} - onApprove={(taskId) => { - void updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); - }} - onRequestChanges={(taskId) => { - setRequestChangesTaskId(taskId); - }} - onMoveBackToDone={(taskId) => { - void (async () => { - await updateKanban(teamName, taskId, { op: 'remove' }); - await updateTaskStatus(teamName, taskId, 'completed'); - })(); - }} - onStartTask={(taskId) => { + > + { + const sentAtMs = Date.now(); + setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); + void sendTeamMessage(teamName, { member, text, summary, attachments }).catch(() => { + setPendingRepliesByMember((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + }); + }} + /> + + + { + openCreateTaskDialog(subject, description); + }} + onReplyToMessage={(message) => { + setSendDialogRecipient(message.from); + setSendDialogDefaultText(undefined); + setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); + setSendDialogOpen(true); + }} + onMessageVisible={handleMessageVisible} + onTaskIdClick={(taskId) => { + const task = taskMap.get(taskId); + if (task) setSelectedTask(task); + }} + /> +
    + + setRequestChangesTaskId(null)} + onSubmit={(comment) => { + if (!requestChangesTaskId) { + return; + } void (async () => { try { - const result = await startTask(teamName, taskId); - if (data?.isAlive) { - const task = data.tasks.find((t) => t.id === taskId); - try { - if (result.notifiedOwner && task?.owner) { - await api.teams.processSend( - teamName, - `Task #${taskId} "${task.subject}" has started. Please begin working on it.` - ); - } else if (!result.notifiedOwner) { - const desc = task?.description?.trim() - ? `\nDescription: ${task.description.trim()}` - : ''; - await api.teams.processSend( - teamName, - `Task #${taskId} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` - ); - } - } catch { - // best-effort - } - } + await updateKanban(teamName, requestChangesTaskId, { + op: 'request_changes', + comment, + }); + setRequestChangesTaskId(null); } catch { - // error via store + // error state is handled in the store and shown in the view } })(); }} - onCompleteTask={(taskId) => { - void updateTaskStatus(teamName, taskId, 'completed'); + /> + + setSelectedMember(null)} + onSendMessage={() => { + const name = selectedMember?.name ?? ''; + setSelectedMember(null); + setSendDialogRecipient(name || undefined); + setSendDialogDefaultText(undefined); + setReplyQuote(undefined); + setSendDialogOpen(true); }} - onCancelTask={(taskId) => { + onAssignTask={() => { + const name = selectedMember?.name ?? ''; + setSelectedMember(null); + openCreateTaskDialog('', '', name); + }} + onTaskClick={(task) => { + setSelectedMember(null); + setSelectedTask(task); + }} + onUpdateRole={async (memberName, role) => { + setUpdatingRoleLoading(true); + try { + await updateMemberRole(teamName, memberName, role); + // Optimistically update local selectedMember to reflect new role + setSelectedMember((prev) => { + if (prev?.name !== memberName) return prev; + const normalized = + typeof role === 'string' && role.trim() ? role.trim() : undefined; + return { ...prev, role: normalized }; + }); + } finally { + setUpdatingRoleLoading(false); + } + }} + updatingRole={updatingRoleLoading} + onRemoveMember={() => { + const name = selectedMember?.name; + if (!name) return; + setRemoveMemberConfirm(name); + }} + onViewMemberChanges={(memberName, filePath) => { + setSelectedMember(null); + setReviewDialogState({ + open: true, + mode: 'agent', + memberName, + initialFilePath: filePath, + }); + }} + /> + + + + setEditDialogOpen(false)} + onSaved={() => void selectTeam(teamName)} + /> + + m.name)} + adding={addingMemberLoading} + onClose={() => setAddMemberDialogOpen(false)} + onAdd={(name, role) => { + setAddingMemberLoading(true); void (async () => { try { - const task = data?.tasks.find((t) => t.id === taskId); - await updateTaskStatus(teamName, taskId, 'pending'); - - // Notify assignee directly via inbox — they'll see it immediately - if (task?.owner) { - try { - await api.teams.sendMessage(teamName, { - member: task.owner, - text: `Task #${taskId} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, - summary: `Task #${taskId} cancelled`, - }); - } catch { - // best-effort - } - } - - // Also notify team lead so they can reassign/coordinate - if (data?.isAlive) { - try { - const ownerSuffix = task?.owner - ? ` ${task.owner} has been notified to stop.` - : ''; - await api.teams.processSend( - teamName, - `Task #${taskId} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` - ); - } catch { - // best-effort - } - } + await addMember(teamName, { name, role }); + setAddMemberDialogOpen(false); } catch { - // error via store + // error shown via store + } finally { + setAddingMemberLoading(false); } })(); }} - onColumnOrderChange={(columnId, orderedTaskIds) => { - void updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); + /> + + { + if (!open) setRemoveMemberConfirm(null); }} + > + + + Remove member + + Remove “{removeMemberConfirm}” from the team? Tasks and messages will be + preserved, but this name cannot be reused. + + + + + + + + + + + + + Delete team + + Delete team “{data.config.name}”? This action is irreversible. All team + data and tasks will be deleted. + + + + + + + + + + setLaunchDialogOpen(false)} + onLaunch={async (request) => { + await launchTeam(request); + }} + /> + + { + void (async () => { + const sentAtMs = Date.now(); + setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); + try { + await sendTeamMessage(teamName, { member, text, summary }); + } catch { + setPendingRepliesByMember((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + } + })(); + }} + onClose={() => { + setSendDialogOpen(false); + setReplyQuote(undefined); + setSendDialogDefaultText(undefined); + }} + /> + + setSelectedTask(null)} onScrollToTask={(taskId) => { + setSelectedTask(null); const el = document.querySelector(`[data-task-id="${taskId}"]`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); @@ -1059,421 +1494,48 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400/50'), 1500); } }} - onTaskClick={(task) => setSelectedTask(task)} - onViewChanges={handleViewChanges} - onAddTask={(startImmediately) => openCreateTaskDialog('', '', '', startImmediately)} + onOwnerChange={(taskId, owner) => { + void updateTaskOwner(teamName, taskId, owner); + }} + onViewChanges={handleViewChangesForFile} onDeleteTask={handleDeleteTask} - deletedTaskCount={deletedTasks.length} - onOpenTrash={() => setTrashOpen(true)} /> - - {(data.processes?.length ?? 0) > 0 && ( - } - badge={data.processes.filter((p) => !p.stoppedAt).length} - defaultOpen - > - - + setTrashOpen(false)} + onRestore={(taskId) => { + void restoreTask(teamName, taskId); + }} + /> + + + setReviewDialogState((prev) => ({ + ...prev, + open, + ...(open ? {} : { initialFilePath: undefined }), + })) + } + teamName={teamName} + mode={reviewDialogState.mode} + memberName={reviewDialogState.memberName} + taskId={reviewDialogState.taskId} + initialFilePath={reviewDialogState.initialFilePath} + /> +
    + + {editorOpen && data.config.projectPath && ( + + setEditorOpen(false)} + onEditorAction={handleEditorAction} + /> + )} - - } - badge={filteredMessages.length} - secondaryBadge={ - filteredMessages.length > 0 && messagesUnreadCount > 0 ? messagesUnreadCount : undefined - } - headerExtra={ - - - - - Desktop notifications plugin - - } - defaultOpen - action={ -
    - {messagesUnreadCount > 0 && ( - - - - - Mark all as read - - )} -
    - - setMessagesSearchQuery(e.target.value)} - onPointerDown={(e) => e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none" - /> -
    - -
    - } - > - { - const sentAtMs = Date.now(); - setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); - void sendTeamMessage(teamName, { member, text, summary, attachments }).catch(() => { - setPendingRepliesByMember((prev) => { - if (prev[member] !== sentAtMs) return prev; - const next = { ...prev }; - delete next[member]; - return next; - }); - }); - }} - /> - - - { - openCreateTaskDialog(subject, description); - }} - onReplyToMessage={(message) => { - setSendDialogRecipient(message.from); - setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); - setSendDialogOpen(true); - }} - onMessageVisible={handleMessageVisible} - onTaskIdClick={(taskId) => { - const task = taskMap.get(taskId); - if (task) setSelectedTask(task); - }} - /> -
    - - setRequestChangesTaskId(null)} - onSubmit={(comment) => { - if (!requestChangesTaskId) { - return; - } - void (async () => { - try { - await updateKanban(teamName, requestChangesTaskId, { - op: 'request_changes', - comment, - }); - setRequestChangesTaskId(null); - } catch { - // error state is handled in the store and shown in the view - } - })(); - }} - /> - - setSelectedMember(null)} - onSendMessage={() => { - const name = selectedMember?.name ?? ''; - setSelectedMember(null); - setSendDialogRecipient(name || undefined); - setReplyQuote(undefined); - setSendDialogOpen(true); - }} - onAssignTask={() => { - const name = selectedMember?.name ?? ''; - setSelectedMember(null); - openCreateTaskDialog('', '', name); - }} - onTaskClick={(task) => { - setSelectedMember(null); - setSelectedTask(task); - }} - onUpdateRole={async (memberName, role) => { - setUpdatingRoleLoading(true); - try { - await updateMemberRole(teamName, memberName, role); - // Optimistically update local selectedMember to reflect new role - setSelectedMember((prev) => { - if (prev?.name !== memberName) return prev; - const normalized = typeof role === 'string' && role.trim() ? role.trim() : undefined; - return { ...prev, role: normalized }; - }); - } finally { - setUpdatingRoleLoading(false); - } - }} - updatingRole={updatingRoleLoading} - onRemoveMember={() => { - const name = selectedMember?.name; - if (!name) return; - setRemoveMemberConfirm(name); - }} - onViewMemberChanges={(memberName, filePath) => { - setSelectedMember(null); - setReviewDialogState({ - open: true, - mode: 'agent', - memberName, - initialFilePath: filePath, - }); - }} - /> - - - - setEditDialogOpen(false)} - onSaved={() => void selectTeam(teamName)} - /> - - m.name)} - adding={addingMemberLoading} - onClose={() => setAddMemberDialogOpen(false)} - onAdd={(name, role) => { - setAddingMemberLoading(true); - void (async () => { - try { - await addMember(teamName, { name, role }); - setAddMemberDialogOpen(false); - } catch { - // error shown via store - } finally { - setAddingMemberLoading(false); - } - })(); - }} - /> - - { - if (!open) setRemoveMemberConfirm(null); - }} - > - - - Remove member - - Remove “{removeMemberConfirm}” from the team? Tasks and messages will be - preserved, but this name cannot be reused. - - - - - - - - - - - - - Delete team - - Delete team “{data.config.name}”? This action is irreversible. All team - data and tasks will be deleted. - - - - - - - - - - setLaunchDialogOpen(false)} - onLaunch={async (request) => { - await launchTeam(request); - }} - /> - - { - void (async () => { - const sentAtMs = Date.now(); - setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); - try { - await sendTeamMessage(teamName, { member, text, summary }); - } catch { - setPendingRepliesByMember((prev) => { - if (prev[member] !== sentAtMs) return prev; - const next = { ...prev }; - delete next[member]; - return next; - }); - } - })(); - }} - onClose={() => { - setSendDialogOpen(false); - setReplyQuote(undefined); - }} - /> - - setSelectedTask(null)} - onScrollToTask={(taskId) => { - setSelectedTask(null); - const el = document.querySelector(`[data-task-id="${taskId}"]`); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - el.classList.add('ring-2', 'ring-blue-400/50'); - setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400/50'), 1500); - } - }} - onOwnerChange={(taskId, owner) => { - void updateTaskOwner(teamName, taskId, owner); - }} - onViewChanges={handleViewChangesForFile} - onDeleteTask={handleDeleteTask} - /> - - setTrashOpen(false)} - onRestore={(taskId) => { - void restoreTask(teamName, taskId); - }} - /> - - - setReviewDialogState((prev) => ({ - ...prev, - open, - ...(open ? {} : { initialFilePath: undefined }), - })) - } - teamName={teamName} - mode={reviewDialogState.mode} - memberName={reviewDialogState.memberName} - taskId={reviewDialogState.taskId} - initialFilePath={reviewDialogState.initialFilePath} - /> -
    + ); }; diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 8edb8a9a..4a00b710 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -39,6 +39,8 @@ interface SendMessageDialogProps { open: boolean; members: ResolvedTeamMember[]; defaultRecipient?: string; + /** Pre-filled message text (e.g. from editor selection action) */ + defaultText?: string; quotedMessage?: QuotedMessage; sending: boolean; sendError: string | null; @@ -53,6 +55,7 @@ export const SendMessageDialog = ({ open, members, defaultRecipient, + defaultText, quotedMessage, sending, sendError, @@ -74,6 +77,9 @@ export const SendMessageDialog = ({ setSummary(''); setQuote(quotedMessage); setPrevResult(lastResult); + if (defaultText) { + textDraft.setValue(defaultText); + } } if (open !== prevOpen) { setPrevOpen(open); diff --git a/src/renderer/components/team/editor/CodeMirrorEditor.tsx b/src/renderer/components/team/editor/CodeMirrorEditor.tsx new file mode 100644 index 00000000..d9ea64d0 --- /dev/null +++ b/src/renderer/components/team/editor/CodeMirrorEditor.tsx @@ -0,0 +1,481 @@ +/** + * Editable CodeMirror 6 editor with EditorState pooling. + * + * Single EditorView, Map in useRef. + * Cmd+S keymap, debounced dirty flag, draft autosave to localStorage. + * LRU eviction at >30 cached states. + */ + +import { useCallback, useEffect, useRef } from 'react'; + +import { defaultKeymap, history, historyKeymap, redo, undo } from '@codemirror/commands'; +import { bracketMatching, indentOnInput, syntaxHighlighting } from '@codemirror/language'; +import { search, searchKeymap } from '@codemirror/search'; +import { Compartment, EditorState } from '@codemirror/state'; +import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark'; +import { + EditorView, + highlightActiveLine, + highlightActiveLineGutter, + keymap, + lineNumbers, +} from '@codemirror/view'; +import { useStore } from '@renderer/store'; +import { + getAsyncLanguageDesc, + getSyncLanguageExtension, +} from '@renderer/utils/codemirrorLanguages'; +import { baseEditorTheme } from '@renderer/utils/codemirrorTheme'; +import { editorBridge } from '@renderer/utils/editorBridge'; + +import type { Extension } from '@codemirror/state'; +import type { EditorSelectionInfo } from '@shared/types/editor'; + +// ============================================================================= +// Constants +// ============================================================================= + +const MAX_CACHED_STATES = 30; +const DIRTY_DEBOUNCE_MS = 300; +const AUTOSAVE_DELAY_MS = 30_000; +const MAX_DRAFT_SIZE = 500 * 1024; // 500KB +const MAX_DRAFTS = 10; +const SELECTION_DEBOUNCE_MS = 150; +const MAX_SELECTION_TEXT = 5000; + +/** Compartment for dynamic line wrap toggling */ +const lineWrapCompartment = new Compartment(); + +// ============================================================================= +// Types +// ============================================================================= + +interface CodeMirrorEditorProps { + /** Currently active file path (tab id) */ + filePath: string; + /** Initial content to load if no cached state exists */ + content: string; + /** File name for language detection */ + fileName: string; + /** File modification time (for draft comparison) */ + mtimeMs?: number; + /** Cursor position callback for status bar */ + onCursorChange?: (line: number, col: number) => void; + /** Called when a draft was recovered from localStorage */ + onDraftRecovered?: (filePath: string) => void; + /** Called when text selection changes (for floating action menu) */ + onSelectionChange?: (info: EditorSelectionInfo | null) => void; +} + +// ============================================================================= +// Selection info helper +// ============================================================================= + +function buildSelectionInfo( + view: EditorView, + sel: { from: number; to: number } +): EditorSelectionInfo | null { + const coords = view.coordsAtPos(sel.to); + if (!coords) return null; // selection end is off-screen + + let text = view.state.sliceDoc(sel.from, sel.to); + if (text.length > MAX_SELECTION_TEXT) { + text = text.slice(0, MAX_SELECTION_TEXT) + '…'; + } + + return { + text, + filePath: '', // filled by parent (CodeMirrorEditor has no file context in buildEditableExtensions) + fromLine: view.state.doc.lineAt(sel.from).number, + toLine: view.state.doc.lineAt(sel.to).number, + screenRect: { + top: coords.top, + right: coords.right ?? coords.left, + bottom: coords.bottom, + }, + }; +} + +// ============================================================================= +// Extensions builder +// ============================================================================= + +function buildEditableExtensions( + fileName: string, + onSave: () => void, + onUpdate: () => void, + onCursorMove: (line: number, col: number) => void, + onSelectionEmit: (info: EditorSelectionInfo | null) => void, + onScrollReposition: (info: EditorSelectionInfo | null) => void +): Extension[] { + const syncLang = getSyncLanguageExtension(fileName); + const asyncLang = getAsyncLanguageDesc(fileName); + + const extensions: Extension[] = [ + // Theme + baseEditorTheme, + syntaxHighlighting(oneDarkHighlightStyle), + + // UI + lineNumbers(), + highlightActiveLine(), + highlightActiveLineGutter(), + bracketMatching(), + indentOnInput(), + + // History + history(), + + // Search (Cmd+F) + search(), + + // Save keymap (Cmd+S / Ctrl+S) + keymap.of([ + { + key: 'Mod-s', + run: () => { + onSave(); + return true; + }, + }, + // Undo/Redo already in historyKeymap, but explicitly add for toolbar + { + key: 'Mod-z', + run: (view) => undo(view), + }, + { + key: 'Mod-Shift-z', + run: (view) => redo(view), + }, + ]), + + // Keymaps + keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]), + + // Update listener for dirty flag + cursor position + selection + EditorView.updateListener.of((update) => { + if (update.docChanged) { + onUpdate(); + } + if (update.selectionSet || update.docChanged) { + const pos = update.state.selection.main.head; + const line = update.state.doc.lineAt(pos); + onCursorMove(line.number, pos - line.from + 1); + + // Selection change detection + const sel = update.state.selection.main; + if (sel.empty) { + onSelectionEmit(null); + } else { + onSelectionEmit(buildSelectionInfo(update.view, sel)); + } + } + }), + + // Re-emit selection coords on scroll — immediate (no debounce) to avoid drift + EditorView.domEventHandlers({ + scroll: (_event, view) => { + const sel = view.state.selection.main; + if (sel.empty) return; + onScrollReposition(buildSelectionInfo(view, sel)); + }, + }), + ]; + + if (syncLang) { + extensions.push(syncLang); + } else if (asyncLang) { + extensions.push(asyncLang.support ?? []); + } + + return extensions; +} + +// ============================================================================= +// Draft autosave helpers +// ============================================================================= + +function saveDraft(filePath: string, content: string): void { + try { + if (content.length > MAX_DRAFT_SIZE) return; + + const key = `editor-draft:${filePath}`; + const value = JSON.stringify({ content, timestamp: Date.now() }); + localStorage.setItem(key, value); + + // Enforce max drafts limit + enforceDraftLimit(); + } catch { + // localStorage may be full or unavailable + } +} + +function enforceDraftLimit(): void { + try { + const drafts: { key: string; timestamp: number }[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key?.startsWith('editor-draft:')) continue; + try { + const parsed = JSON.parse(localStorage.getItem(key)!) as { timestamp: number }; + drafts.push({ key, timestamp: parsed.timestamp }); + } catch { + // corrupted draft — remove + localStorage.removeItem(key); + } + } + + if (drafts.length > MAX_DRAFTS) { + drafts.sort((a, b) => a.timestamp - b.timestamp); + const toRemove = drafts.slice(0, drafts.length - MAX_DRAFTS); + for (const d of toRemove) { + localStorage.removeItem(d.key); + } + } + } catch { + // ignore + } +} + +// ============================================================================= +// Component +// ============================================================================= + +export const CodeMirrorEditor = ({ + filePath, + content, + fileName, + mtimeMs, + onCursorChange, + onDraftRecovered, + onSelectionChange, +}: CodeMirrorEditorProps): React.ReactElement => { + const containerRef = useRef(null); + const viewRef = useRef(null); + const stateCacheRef = useRef(new Map()); + const scrollTopCacheRef = useRef(new Map()); + const lruOrderRef = useRef([]); + + // Dirty flag debounce + const dirtyTimerRef = useRef | null>(null); + // Autosave debounce + const autosaveTimerRef = useRef | null>(null); + // Selection debounce + const selectionTimerRef = useRef | null>(null); + + const markFileModified = useStore((s) => s.markFileModified); + const discardChanges = useStore((s) => s.discardChanges); + const saveFile = useStore((s) => s.saveFile); + const lineWrap = useStore((s) => s.editorLineWrap); + + // Stable callbacks via refs to avoid extension recreation + const filePathRef = useRef(filePath); + filePathRef.current = filePath; + + const onCursorChangeRef = useRef(onCursorChange); + onCursorChangeRef.current = onCursorChange; + + const onDraftRecoveredRef = useRef(onDraftRecovered); + onDraftRecoveredRef.current = onDraftRecovered; + + const onSelectionChangeRef = useRef(onSelectionChange); + onSelectionChangeRef.current = onSelectionChange; + + const lineWrapRef = useRef(lineWrap); + lineWrapRef.current = lineWrap; + + const handleSave = useCallback(() => { + void saveFile(filePathRef.current); + }, [saveFile]); + + const handleDocChanged = useCallback(() => { + // Debounced dirty flag + if (dirtyTimerRef.current) clearTimeout(dirtyTimerRef.current); + dirtyTimerRef.current = setTimeout(() => { + markFileModified(filePathRef.current); + }, DIRTY_DEBOUNCE_MS); + + // Debounced autosave + if (autosaveTimerRef.current) clearTimeout(autosaveTimerRef.current); + autosaveTimerRef.current = setTimeout(() => { + const view = viewRef.current; + if (view) { + saveDraft(filePathRef.current, view.state.doc.toString()); + } + }, AUTOSAVE_DELAY_MS); + }, [markFileModified]); + + const handleCursorMove = useCallback((line: number, col: number) => { + onCursorChangeRef.current?.(line, col); + }, []); + + const handleSelectionEmit = useCallback((info: EditorSelectionInfo | null) => { + if (!info) { + // Empty selection — clear immediately + if (selectionTimerRef.current) clearTimeout(selectionTimerRef.current); + onSelectionChangeRef.current?.(null); + return; + } + + // Non-empty selection — debounce to prevent flicker during rapid selection changes + if (selectionTimerRef.current) clearTimeout(selectionTimerRef.current); + selectionTimerRef.current = setTimeout(() => { + // Enrich with filePath (not available inside extension builder) + onSelectionChangeRef.current?.({ ...info, filePath: filePathRef.current }); + }, SELECTION_DEBOUNCE_MS); + }, []); + + // Immediate position update during scroll — no debounce to avoid menu drift + const handleScrollReposition = useCallback((info: EditorSelectionInfo | null) => { + if (selectionTimerRef.current) clearTimeout(selectionTimerRef.current); + if (info) { + onSelectionChangeRef.current?.({ ...info, filePath: filePathRef.current }); + } else { + onSelectionChangeRef.current?.(null); + } + }, []); + + // LRU touch + const touchLru = useCallback( + (fp: string) => { + const order = lruOrderRef.current; + const idx = order.indexOf(fp); + if (idx !== -1) order.splice(idx, 1); + order.push(fp); + + // Evict if too many + while (order.length > MAX_CACHED_STATES) { + const evicted = order.shift()!; + stateCacheRef.current.delete(evicted); + scrollTopCacheRef.current.delete(evicted); + // Clean dirty flag + draft to prevent stale indicators + discardChanges(evicted); + } + }, + [discardChanges] + ); + + // Mount: create EditorView, register bridge + useEffect(() => { + if (!containerRef.current) return; + + const extensions = buildEditableExtensions( + fileName, + handleSave, + handleDocChanged, + handleCursorMove, + handleSelectionEmit, + handleScrollReposition + ); + + // Line wrap (dynamically reconfigurable via Compartment) + extensions.push(lineWrapCompartment.of(lineWrapRef.current ? EditorView.lineWrapping : [])); + + // Check for cached state or draft recovery + let initialState = stateCacheRef.current.get(filePath); + if (!initialState) { + let initialContent = content; + let draftRecovered = false; + + // Draft recovery: compare draft.timestamp with file mtimeMs + try { + const draftJson = localStorage.getItem(`editor-draft:${filePath}`); + if (draftJson) { + const draft = JSON.parse(draftJson) as { content: string; timestamp: number }; + const fileMtime = mtimeMs ?? 0; + + if (fileMtime === 0 || draft.timestamp > fileMtime) { + // Draft is newer than file (or file is new) — recover draft + initialContent = draft.content; + draftRecovered = true; + } else { + // File was modified after draft — draft is stale, delete silently + localStorage.removeItem(`editor-draft:${filePath}`); + } + } + } catch { + // ignore + } + + initialState = EditorState.create({ + doc: initialContent, + extensions, + }); + stateCacheRef.current.set(filePath, initialState); + + // Signal draft recovery after state creation + if (draftRecovered) { + // Mark as modified so dirty indicator shows + markFileModified(filePath); + onDraftRecoveredRef.current?.(filePath); + } + } + + touchLru(filePath); + + const view = new EditorView({ + state: initialState, + parent: containerRef.current, + }); + + // Restore scroll position + const savedScroll = scrollTopCacheRef.current.get(filePath); + if (savedScroll !== undefined) { + view.scrollDOM.scrollTop = savedScroll; + } + + viewRef.current = view; + + // Register with bridge + editorBridge.register(stateCacheRef.current, scrollTopCacheRef.current, view); + + // Report initial cursor position + const pos = view.state.selection.main.head; + const line = view.state.doc.lineAt(pos); + onCursorChangeRef.current?.(line.number, pos - line.from + 1); + + // Capture ref values for cleanup — React hooks exhaustive-deps requires + // refs used in cleanup to be captured in the effect body, not read + // from .current inside the cleanup function. + const scrollTopCache = scrollTopCacheRef.current; + const stateCache = stateCacheRef.current; + const dirtyTimer = dirtyTimerRef; + const autosaveTimer = autosaveTimerRef; + const selectionTimer = selectionTimerRef; + + return () => { + // Save scroll position before destroying + scrollTopCache.set(filePath, view.scrollDOM.scrollTop); + + // Save current state to cache + stateCache.set(filePath, view.state); + + // Clear timers + if (dirtyTimer.current) clearTimeout(dirtyTimer.current); + if (autosaveTimer.current) clearTimeout(autosaveTimer.current); + if (selectionTimer.current) clearTimeout(selectionTimer.current); + + view.destroy(); + viewRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- Intentional: only re-mount when filePath changes (tab switch). Content/fileName changes with the same filePath should use the cached state. + }, [filePath]); + + // Sync line wrap setting dynamically (including cached states on tab switch) + useEffect(() => { + const view = viewRef.current; + if (!view) return; + view.dispatch({ + effects: lineWrapCompartment.reconfigure(lineWrap ? EditorView.lineWrapping : []), + }); + }, [lineWrap]); + + // Cleanup bridge on full unmount + useEffect(() => { + return () => { + editorBridge.unregister(); + }; + }, []); + + return
    ; +}; diff --git a/src/renderer/components/team/editor/EditorBinaryState.tsx b/src/renderer/components/team/editor/EditorBinaryState.tsx new file mode 100644 index 00000000..f67d8ff8 --- /dev/null +++ b/src/renderer/components/team/editor/EditorBinaryState.tsx @@ -0,0 +1,41 @@ +/** + * Placeholder for binary files — shows file info and "Open in System Viewer" button. + */ + +import { FileQuestion } from 'lucide-react'; + +interface EditorBinaryStateProps { + filePath: string; + size: number; +} + +export const EditorBinaryState = ({ + filePath, + size, +}: EditorBinaryStateProps): React.ReactElement => { + const fileName = filePath.split('/').pop() ?? filePath; + const sizeFormatted = + size < 1024 + ? `${size} B` + : size < 1024 * 1024 + ? `${(size / 1024).toFixed(1)} KB` + : `${(size / 1024 / 1024).toFixed(1)} MB`; + + const handleOpenExternal = (): void => { + window.electronAPI.openPath(filePath).catch(console.error); + }; + + return ( +
    + +

    {fileName}

    +

    Binary file ({sizeFormatted})

    + +
    + ); +}; diff --git a/src/renderer/components/team/editor/EditorBreadcrumb.tsx b/src/renderer/components/team/editor/EditorBreadcrumb.tsx new file mode 100644 index 00000000..5b0475bd --- /dev/null +++ b/src/renderer/components/team/editor/EditorBreadcrumb.tsx @@ -0,0 +1,72 @@ +/** + * Breadcrumb navigation for the active file in the editor. + * + * Each segment is clickable — expands and scrolls the folder in the file tree. + */ + +import { useMemo } from 'react'; + +import { useStore } from '@renderer/store'; +import { ChevronRight } from 'lucide-react'; + +import { getFileIcon } from './fileIcons'; + +// ============================================================================= +// Component +// ============================================================================= + +export const EditorBreadcrumb = (): React.ReactElement | null => { + const activeTabId = useStore((s) => s.editorActiveTabId); + const projectPath = useStore((s) => s.editorProjectPath); + const expandDirectory = useStore((s) => s.expandDirectory); + + const segments = useMemo(() => { + if (!activeTabId || !projectPath) return []; + + const relativePath = activeTabId.startsWith(projectPath) + ? activeTabId.slice(projectPath.length + 1) + : activeTabId; + + return relativePath.split('/'); + }, [activeTabId, projectPath]); + + if (segments.length === 0) return null; + + const fileName = segments[segments.length - 1]; + const iconInfo = getFileIcon(fileName); + const Icon = iconInfo.icon; + + const handleSegmentClick = (segmentIndex: number): void => { + if (!projectPath) return; + // Build absolute path up to this segment (it's a directory) + const dirSegments = segments.slice(0, segmentIndex + 1); + const dirPath = `${projectPath}/${dirSegments.join('/')}`; + void expandDirectory(dirPath); + }; + + return ( +
    + {segments.map((segment, idx) => { + const isLast = idx === segments.length - 1; + return ( + + {idx > 0 && } + {isLast ? ( + + + {segment} + + ) : ( + + )} + + ); + })} +
    + ); +}; diff --git a/src/renderer/components/team/editor/EditorContextMenu.tsx b/src/renderer/components/team/editor/EditorContextMenu.tsx new file mode 100644 index 00000000..beb3a65d --- /dev/null +++ b/src/renderer/components/team/editor/EditorContextMenu.tsx @@ -0,0 +1,133 @@ +/** + * Radix-based context menu for the editor file tree. + * + * Wraps children via ContextMenu.Trigger asChild. Uses event delegation + * with `data-editor-path` / `data-editor-type` attributes on tree items + * to determine the right-clicked target. + */ + +import React, { useCallback, useRef, useState } from 'react'; + +import * as ContextMenu from '@radix-ui/react-context-menu'; +import { FilePlus, FolderOpen, FolderPlus, Trash2 } from 'lucide-react'; + +// ============================================================================= +// Types +// ============================================================================= + +interface TargetEntry { + path: string; + isDir: boolean; + isSensitive: boolean; +} + +interface EditorContextMenuProps { + children: React.ReactNode; + onNewFile: (parentDir: string) => void; + onNewFolder: (parentDir: string) => void; + onDelete: (path: string) => void; +} + +// ============================================================================= +// Component +// ============================================================================= + +export const EditorContextMenu = ({ + children, + onNewFile, + onNewFolder, + onDelete, +}: EditorContextMenuProps): React.ReactElement => { + const [target, setTarget] = useState(null); + const triggerRef = useRef(null); + + const handleContextMenu = useCallback((e: React.MouseEvent) => { + // Walk up from target to find the nearest element with data-editor-path + let el = e.target as HTMLElement | null; + while (el && el !== e.currentTarget) { + const path = el.getAttribute('data-editor-path'); + if (path) { + const type = el.getAttribute('data-editor-type'); + const sensitive = el.getAttribute('data-editor-sensitive'); + setTarget({ + path, + isDir: type === 'directory', + isSensitive: sensitive === 'true', + }); + return; + } + el = el.parentElement; + } + // Clicked on empty area — still show menu but with limited options + setTarget(null); + }, []); + + const parentDir = target + ? target.isDir + ? target.path + : target.path.substring(0, target.path.lastIndexOf('/')) + : null; + + return ( + + +
    + {children} +
    +
    + + + + {parentDir && ( + <> + onNewFile(parentDir)} + > + + New File + + + onNewFolder(parentDir)} + > + + New Folder + + + + + )} + + {target && ( + <> + onDelete(target.path)} + > + + Delete + + + + + )} + + {target && ( + { + void window.electronAPI.showInFolder(target.path); + }} + > + + Reveal in Finder + + )} + + +
    + ); +}; diff --git a/src/renderer/components/team/editor/EditorEmptyState.tsx b/src/renderer/components/team/editor/EditorEmptyState.tsx new file mode 100644 index 00000000..0e657b10 --- /dev/null +++ b/src/renderer/components/team/editor/EditorEmptyState.tsx @@ -0,0 +1,35 @@ +/** + * Empty state shown when no file is open in the editor. + * Shows keyboard shortcuts cheatsheet. + */ + +import { shortcutLabel } from '@renderer/utils/platformKeys'; +import { FileCode } from 'lucide-react'; + +const SHORTCUTS = [ + { keys: shortcutLabel('⌘ P', 'Ctrl+P'), label: 'Quick Open' }, + { keys: shortcutLabel('⌘ ⇧ F', 'Ctrl+Shift+F'), label: 'Search in Files' }, + { keys: shortcutLabel('⌘ S', 'Ctrl+S'), label: 'Save' }, + { keys: shortcutLabel('⌘ B', 'Ctrl+B'), label: 'Toggle Sidebar' }, + { keys: shortcutLabel('⌘ G', 'Ctrl+G'), label: 'Go to Line' }, + { keys: 'Esc', label: 'Close Editor' }, +]; + +export const EditorEmptyState = (): React.ReactElement => { + return ( +
    + +

    Select a file from the tree to edit

    +
    + {SHORTCUTS.map((s) => ( +
    + {s.label} + + {s.keys} + +
    + ))} +
    +
    + ); +}; diff --git a/src/renderer/components/team/editor/EditorErrorBoundary.tsx b/src/renderer/components/team/editor/EditorErrorBoundary.tsx new file mode 100644 index 00000000..6f46f3c9 --- /dev/null +++ b/src/renderer/components/team/editor/EditorErrorBoundary.tsx @@ -0,0 +1,58 @@ +/** + * React error boundary wrapping CodeMirrorEditor. + * + * Catches runtime CM6 errors (OOM, bad extension, corrupted EditorState) + * and shows a fallback UI instead of crashing the entire overlay. + */ + +import React from 'react'; + +import { AlertTriangle } from 'lucide-react'; + +interface Props { + filePath: string; + onRetry?: () => void; + children: React.ReactNode; +} + +interface State { + hasError: boolean; + error: string | null; +} + +export class EditorErrorBoundary extends React.Component { + state: State = { hasError: false, error: null }; + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error: error.message }; + } + + componentDidCatch(error: Error, info: React.ErrorInfo): void { + console.error(`[EditorErrorBoundary] ${this.props.filePath}:`, error, info.componentStack); + } + + handleRetry = (): void => { + this.setState({ hasError: false, error: null }); + this.props.onRetry?.(); + }; + + render(): React.ReactElement { + if (this.state.hasError) { + return ( +
    + +

    + Editor crashed: {this.state.error ?? 'Unknown error'} +

    + +
    + ); + } + return <>{this.props.children}; + } +} diff --git a/src/renderer/components/team/editor/EditorErrorState.tsx b/src/renderer/components/team/editor/EditorErrorState.tsx new file mode 100644 index 00000000..54cbff64 --- /dev/null +++ b/src/renderer/components/team/editor/EditorErrorState.tsx @@ -0,0 +1,42 @@ +/** + * Error state for file read failures (EACCES, ENOENT, etc.). + */ + +import { AlertTriangle } from 'lucide-react'; + +interface EditorErrorStateProps { + error: string; + onRetry?: () => void; + onClose?: () => void; +} + +export const EditorErrorState = ({ + error, + onRetry, + onClose, +}: EditorErrorStateProps): React.ReactElement => { + return ( +
    + +

    {error}

    +
    + {onRetry && ( + + )} + {onClose && ( + + )} +
    +
    + ); +}; diff --git a/src/renderer/components/team/editor/EditorFileTree.tsx b/src/renderer/components/team/editor/EditorFileTree.tsx new file mode 100644 index 00000000..6beae5d1 --- /dev/null +++ b/src/renderer/components/team/editor/EditorFileTree.tsx @@ -0,0 +1,617 @@ +/** + * Editor file tree — virtualized with @tanstack/react-virtual. + * + * Renders project files with file-type icons, sensitive-file lock icons, + * directory expand/collapse, context menu, inline file creation, and drag & drop. + */ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { + DndContext, + DragOverlay, + PointerSensor, + useDraggable, + useDroppable, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { useStore } from '@renderer/store'; +import { sortTreeNodes } from '@renderer/utils/fileTreeBuilder'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { ChevronDown, ChevronRight, Folder, FolderOpen, Lock } from 'lucide-react'; + +import { EditorContextMenu } from './EditorContextMenu'; +import { getFileIcon } from './fileIcons'; +import { GitStatusBadge } from './GitStatusBadge'; +import { NewFileDialog } from './NewFileDialog'; + +import type { DragEndEvent, DragOverEvent, DragStartEvent } from '@dnd-kit/core'; +import type { TreeNode } from '@renderer/utils/fileTreeBuilder'; +import type { FileTreeEntry, GitFileStatusType } from '@shared/types/editor'; + +// ============================================================================= +// Types +// ============================================================================= + +interface EditorFileTreeProps { + selectedFilePath: string | null; + onFileSelect: (filePath: string) => void; +} + +interface NewItemState { + parentDir: string; + type: 'file' | 'directory'; +} + +/** Flat item for virtualization */ +interface FlatTreeItem { + node: TreeNode; + depth: number; + isExpanded: boolean; +} + +// ============================================================================= +// Constants +// ============================================================================= + +const ITEM_HEIGHT = 28; +const INDENT_PX = 12; +const MAX_DEPTH = 12; +const AUTO_EXPAND_DELAY_MS = 500; + +// ============================================================================= +// Component +// ============================================================================= + +export const EditorFileTree = ({ + selectedFilePath, + onFileSelect, +}: EditorFileTreeProps): React.ReactElement => { + const fileTree = useStore((s) => s.editorFileTree); + const expandedDirs = useStore((s) => s.editorExpandedDirs); + const expandDirectory = useStore((s) => s.expandDirectory); + const collapseDirectory = useStore((s) => s.collapseDirectory); + const loading = useStore((s) => s.editorFileTreeLoading); + const error = useStore((s) => s.editorFileTreeError); + const createFileInTree = useStore((s) => s.createFileInTree); + const createDirInTree = useStore((s) => s.createDirInTree); + const deleteFileFromTree = useStore((s) => s.deleteFileFromTree); + const moveFileInTree = useStore((s) => s.moveFileInTree); + const openFile = useStore((s) => s.openFile); + const gitFiles = useStore((s) => s.editorGitFiles); + const projectPath = useStore((s) => s.editorProjectPath); + + const [newItemState, setNewItemState] = useState(null); + const [draggedItem, setDraggedItem] = useState(null); + const [dropTargetPath, setDropTargetPath] = useState(null); + const autoExpandTimerRef = useRef | null>(null); + const scrollRef = useRef(null); + + // Cleanup auto-expand timer on unmount + useEffect(() => { + return () => { + if (autoExpandTimerRef.current) clearTimeout(autoExpandTimerRef.current); + }; + }, []); + + // DnD sensors — 5px distance to prevent accidental drags + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); + + // Convert hierarchical FileTreeEntry[] → TreeNode[] (respects entry.type) + const treeNodes = useMemo(() => { + if (!fileTree) return []; + return sortTreeNodes(convertEntriesToNodes(fileTree)); + }, [fileTree]); + + // Flatten tree into visible items list for virtualization + // expandedDirs is keyed by absolute path, and node.fullPath = entry.path (absolute) + const flatItems = useMemo(() => { + const items: FlatTreeItem[] = []; + flattenVisible(treeNodes, expandedDirs, items, 0); + return items; + }, [treeNodes, expandedDirs]); + + // Lookup: fullPath → FlatTreeItem (for drag start) + const flatItemsByPath = useMemo(() => { + const map = new Map(); + for (const item of flatItems) { + map.set(item.node.fullPath, item); + } + return map; + }, [flatItems]); + + // Virtual scrolling — increase overscan during drag for more drop targets + const virtualizer = useVirtualizer({ + count: flatItems.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => ITEM_HEIGHT, + overscan: draggedItem ? 20 : 10, + }); + + // Git status lookup: absolute path → status type + const gitStatusMap = useMemo(() => { + const map = new Map(); + if (!gitFiles.length || !projectPath) return map; + for (const file of gitFiles) { + const absPath = projectPath.endsWith('/') + ? `${projectPath}${file.path}` + : `${projectPath}/${file.path}`; + map.set(absPath, file.status); + } + return map; + }, [gitFiles, projectPath]); + + // Active node path for selection highlight (fullPath = absolute path) + const activeNodePath = selectedFilePath; + + const handleNodeClick = useCallback( + (node: TreeNode) => { + if (!node.data) return; + if (node.data.isSensitive) return; + if (node.isFile) { + onFileSelect(node.data.path); + } else { + // fullPath = absolute path = entry.path + if (expandedDirs[node.fullPath]) { + collapseDirectory(node.fullPath); + } else { + void expandDirectory(node.fullPath); + } + } + }, + [onFileSelect, expandedDirs, expandDirectory, collapseDirectory] + ); + + // Context menu handlers + const handleNewFile = useCallback((parentDir: string) => { + setNewItemState({ parentDir, type: 'file' }); + }, []); + + const handleNewFolder = useCallback((parentDir: string) => { + setNewItemState({ parentDir, type: 'directory' }); + }, []); + + const handleDelete = useCallback( + async (path: string) => { + const fileName = path.split('/').pop() ?? path; + const confirmed = window.confirm(`Move "${fileName}" to Trash?`); + if (!confirmed) return; + await deleteFileFromTree(path); + }, + [deleteFileFromTree] + ); + + const handleNewItemSubmit = useCallback( + async (name: string) => { + if (!newItemState) return; + if (newItemState.type === 'file') { + const filePath = await createFileInTree(newItemState.parentDir, name); + if (filePath) openFile(filePath); + } else { + await createDirInTree(newItemState.parentDir, name); + } + setNewItemState(null); + }, + [newItemState, createFileInTree, createDirInTree, openFile] + ); + + const handleNewItemCancel = useCallback(() => { + setNewItemState(null); + }, []); + + // ─── Drag & Drop handlers ────────────────────────────────────────────────── + + const clearAutoExpandTimer = useCallback(() => { + if (autoExpandTimerRef.current) { + clearTimeout(autoExpandTimerRef.current); + autoExpandTimerRef.current = null; + } + }, []); + + const handleDragStart = useCallback( + (event: DragStartEvent) => { + const id = String(event.active.id); + const item = flatItemsByPath.get(id); + if (item) setDraggedItem(item); + }, + [flatItemsByPath] + ); + + const handleDragOver = useCallback( + (event: DragOverEvent) => { + const { over } = event; + if (!over || !draggedItem) { + setDropTargetPath(null); + clearAutoExpandTimer(); + return; + } + + const overId = String(over.id); + let targetDir: string | null = null; + + if (overId === 'root-drop-zone') { + targetDir = projectPath; + } else if (overId.startsWith('drop:')) { + // Directory drop target + targetDir = overId.slice(5); + } else { + // File — drop into its parent directory + const item = flatItemsByPath.get(overId); + if (item) { + const p = item.node.fullPath; + targetDir = p.substring(0, p.lastIndexOf('/')); + } + } + + if (targetDir !== dropTargetPath) { + setDropTargetPath(targetDir); + clearAutoExpandTimer(); + + // Auto-expand collapsed folders after 500ms hover + if (targetDir && targetDir !== projectPath && !expandedDirs[targetDir]) { + autoExpandTimerRef.current = setTimeout(() => { + void expandDirectory(targetDir); + }, AUTO_EXPAND_DELAY_MS); + } + } + }, + [ + draggedItem, + dropTargetPath, + projectPath, + flatItemsByPath, + expandedDirs, + expandDirectory, + clearAutoExpandTimer, + ] + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + clearAutoExpandTimer(); + const sourcePath = draggedItem?.node.fullPath; + + if (!sourcePath || !dropTargetPath || !event.over) { + setDraggedItem(null); + setDropTargetPath(null); + return; + } + + const destDir = dropTargetPath; + const sourceParent = sourcePath.substring(0, sourcePath.lastIndexOf('/')); + + // Validation: same folder = no-op + if (sourceParent === destDir) { + setDraggedItem(null); + setDropTargetPath(null); + return; + } + + // Validation: parent → child prevention + if (destDir.startsWith(sourcePath + '/') || destDir === sourcePath) { + setDraggedItem(null); + setDropTargetPath(null); + return; + } + + // Validation: sensitive files + if (draggedItem?.node.data?.isSensitive) { + setDraggedItem(null); + setDropTargetPath(null); + return; + } + + void moveFileInTree(sourcePath, destDir); + + setDraggedItem(null); + setDropTargetPath(null); + }, + [draggedItem, dropTargetPath, moveFileInTree, clearAutoExpandTimer] + ); + + const handleDragCancel = useCallback(() => { + clearAutoExpandTimer(); + setDraggedItem(null); + setDropTargetPath(null); + }, [clearAutoExpandTimer]); + + // ─── Early returns ───────────────────────────────────────────────────────── + + if (error) { + return
    Failed to load files: {error}
    ; + } + + if (loading && !fileTree) { + return
    Loading files...
    ; + } + + if (treeNodes.length === 0) { + return
    No files found
    ; + } + + return ( + + + +
    + {virtualizer.getVirtualItems().map((virtualItem) => { + const item = flatItems[virtualItem.index]; + return ( + + ); + })} +
    +
    + + {draggedItem && } + +
    + {newItemState && ( + + )} +
    + ); +}; + +// ============================================================================= +// Root drop zone (drop files to project root) +// ============================================================================= + +const RootDropZone = React.forwardRef< + HTMLDivElement, + { projectPath: string | null; children: React.ReactNode } +>(({ projectPath, children }, ref) => { + const { setNodeRef } = useDroppable({ + id: 'root-drop-zone', + data: { isRoot: true, path: projectPath }, + }); + + // Combine forwarded ref with droppable ref + const combinedRef = useCallback( + (el: HTMLDivElement | null) => { + setNodeRef(el); + if (typeof ref === 'function') ref(el); + // eslint-disable-next-line no-param-reassign -- combining forwarded ref with droppable ref + else if (ref) ref.current = el; + }, + [ref, setNodeRef] + ); + + return ( +
    + {children} +
    + ); +}); + +RootDropZone.displayName = 'RootDropZone'; + +// ============================================================================= +// Draggable + droppable tree item +// ============================================================================= + +interface DraggableTreeItemProps { + item: FlatTreeItem; + activeNodePath: string | null; + gitStatusMap: Map; + dropTargetPath: string | null; + isDragActive: boolean; + onClick: (node: TreeNode) => void; + style: React.CSSProperties; +} + +/* eslint-disable react/jsx-props-no-spreading -- dnd-kit requires prop spreading for drag attributes, listeners, and data attributes */ +const DraggableTreeItem = React.memo( + ({ + item, + activeNodePath, + gitStatusMap, + dropTargetPath, + isDragActive, + onClick, + style, + }: DraggableTreeItemProps): React.ReactElement => { + const { node, depth, isExpanded } = item; + const isSelected = activeNodePath === node.fullPath; + const visualDepth = Math.min(depth, MAX_DEPTH); + const isSensitive = node.data?.isSensitive; + + // Draggable setup + const { + attributes, + listeners, + setNodeRef: setDragRef, + isDragging, + } = useDraggable({ + id: node.fullPath, + data: { node, depth }, + disabled: !!isSensitive, + }); + + // Droppable setup — only directories are drop targets + const { setNodeRef: setDropRef } = useDroppable({ + id: 'drop:' + node.fullPath, + data: { node }, + disabled: node.isFile, + }); + + // Combine refs + const ref = useCallback( + (el: HTMLDivElement | null) => { + setDragRef(el); + if (!node.isFile) setDropRef(el); + }, + [setDragRef, setDropRef, node.isFile] + ); + + // Visual: highlight drop target directory + const isDropTarget = !node.isFile && dropTargetPath === node.fullPath; + + const dataAttrs: Record = {}; + if (node.data) { + dataAttrs['data-editor-path'] = node.data.path; + dataAttrs['data-editor-type'] = node.data.type; + if (node.data.isSensitive) dataAttrs['data-editor-sensitive'] = 'true'; + } + + const handleClick = (): void => { + if (!isDragActive) onClick(node); + }; + const handleKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleClick(); + } + }; + + // Render icon + let icon: React.ReactNode; + if (node.data?.isSensitive) { + icon = ; + } else if (node.isFile) { + const fileIcon = getFileIcon(node.name); + const FileIcon = fileIcon.icon; + icon = ; + } else if (isExpanded) { + icon = ; + } else { + icon = ; + } + + return ( +
    + {!node.isFile && + (isExpanded ? ( + + ) : ( + + ))} + {icon} + {node.name} + {node.data && gitStatusMap.has(node.data.path) && ( + + )} +
    + ); + } +); + +DraggableTreeItem.displayName = 'DraggableTreeItem'; +/* eslint-enable react/jsx-props-no-spreading -- re-enable after DraggableTreeItem component */ + +// ============================================================================= +// Drag overlay ghost +// ============================================================================= + +const DragOverlayFileItem = ({ item }: { item: FlatTreeItem }): React.ReactElement => { + const { node } = item; + + let icon: React.ReactNode; + if (node.isFile) { + const fileIcon = getFileIcon(node.name); + const FileIcon = fileIcon.icon; + icon = ; + } else { + icon = ; + } + + return ( +
    + {icon} + {node.name} +
    + ); +}; + +// ============================================================================= +// Helpers +// ============================================================================= + +/** Convert hierarchical FileTreeEntry[] into TreeNode[] using entry.type for classification */ +function convertEntriesToNodes(entries: FileTreeEntry[]): TreeNode[] { + return entries.map((entry) => ({ + name: entry.name, + fullPath: entry.path, // absolute path — matches expandedDirs keys + isFile: entry.type === 'file', + data: entry, + children: entry.children ? convertEntriesToNodes(entry.children) : [], + })); +} + +/** Flatten tree into visible items list (DFS, respecting expanded state) */ +function flattenVisible( + nodes: TreeNode[], + expandedPaths: Record, + result: FlatTreeItem[], + depth: number +): void { + for (const node of nodes) { + const isExpanded = !node.isFile && expandedPaths[node.fullPath] === true; + result.push({ node, depth, isExpanded }); + if (isExpanded && node.children.length > 0) { + flattenVisible(node.children, expandedPaths, result, depth + 1); + } + } +} diff --git a/src/renderer/components/team/editor/EditorSelectionMenu.tsx b/src/renderer/components/team/editor/EditorSelectionMenu.tsx new file mode 100644 index 00000000..700ddf7a --- /dev/null +++ b/src/renderer/components/team/editor/EditorSelectionMenu.tsx @@ -0,0 +1,110 @@ +/** + * Floating action menu shown near text selection in the editor. + * + * Positioned absolutely relative to the editor content container. + * Uses onMouseDown preventDefault to avoid deselecting text in CM6. + */ + +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { ListTodo, MessageSquare } from 'lucide-react'; + +import type { EditorSelectionInfo } from '@shared/types/editor'; + +// ============================================================================= +// Types +// ============================================================================= + +interface EditorSelectionMenuProps { + info: EditorSelectionInfo; + /** Bounding rect of the editor content container (for viewport → container conversion) */ + containerRect: DOMRect; + onSendMessage: () => void; + onCreateTask: () => void; +} + +// ============================================================================= +// Constants +// ============================================================================= + +const MENU_GAP = 8; // px gap between selection end and menu +const MENU_WIDTH = 68; // approximate menu width for clamping +const MENU_HEIGHT = 32; // approximate menu height for clamping + +// ============================================================================= +// Component +// ============================================================================= + +export const EditorSelectionMenu = ({ + info, + containerRect, + onSendMessage, + onCreateTask, +}: EditorSelectionMenuProps): React.ReactElement | null => { + if (!info.text.trim()) return null; + + // Convert viewport coords → container-relative + const rawTop = info.screenRect.top - containerRect.top; + const rawLeft = info.screenRect.right - containerRect.left + MENU_GAP; + + // Check if selection is within visible container bounds + const selTopInContainer = info.screenRect.top - containerRect.top; + const selBottomInContainer = info.screenRect.bottom - containerRect.top; + if (selBottomInContainer < 0 || selTopInContainer > containerRect.height) { + return null; // selection is scrolled out of view + } + + // Clamp to container bounds + const top = Math.max(0, Math.min(rawTop, containerRect.height - MENU_HEIGHT)); + const left = + rawLeft + MENU_WIDTH > containerRect.width + ? info.screenRect.right - containerRect.left - MENU_WIDTH - MENU_GAP // flip to left side + : rawLeft; + + return ( +
    + } + label="Write Teammate" + onClick={onSendMessage} + /> + } + label="Create Task" + onClick={onCreateTask} + /> +
    + ); +}; + +// ============================================================================= +// Menu button +// ============================================================================= + +interface MenuButtonProps { + icon: React.ReactNode; + label: string; + onClick: () => void; +} + +const MenuButton = ({ icon, label, onClick }: MenuButtonProps): React.ReactElement => ( + + + + + + {label} + + +); diff --git a/src/renderer/components/team/editor/EditorShortcutsHelp.tsx b/src/renderer/components/team/editor/EditorShortcutsHelp.tsx new file mode 100644 index 00000000..db341a6a --- /dev/null +++ b/src/renderer/components/team/editor/EditorShortcutsHelp.tsx @@ -0,0 +1,142 @@ +/** + * Keyboard shortcuts help modal for the project editor. + * + * Cross-platform: detects Mac vs Windows/Linux and shows + * the appropriate modifier symbols. + */ + +import { useEffect, useMemo } from 'react'; + +import { IS_MAC } from '@renderer/utils/platformKeys'; +import { X } from 'lucide-react'; + +// ============================================================================= +// Types +// ============================================================================= + +interface EditorShortcutsHelpProps { + onClose: () => void; +} + +interface ShortcutDef { + mac: string; + other: string; + description: string; +} + +// ============================================================================= +// Shortcuts data +// ============================================================================= + +const SHORTCUT_GROUPS: { title: string; shortcuts: ShortcutDef[] }[] = [ + { + title: 'File Operations', + shortcuts: [ + { mac: '⌘ P', other: 'Ctrl+P', description: 'Quick Open' }, + { mac: '⌘ S', other: 'Ctrl+S', description: 'Save' }, + { mac: '⌘ ⇧ S', other: 'Ctrl+Shift+S', description: 'Save All' }, + { mac: '⌘ W', other: 'Ctrl+W', description: 'Close Tab' }, + ], + }, + { + title: 'Search', + shortcuts: [ + { mac: '⌘ F', other: 'Ctrl+F', description: 'Find in File' }, + { mac: '⌘ ⇧ F', other: 'Ctrl+Shift+F', description: 'Search in Files' }, + { mac: '⌘ G', other: 'Ctrl+G', description: 'Go to Line' }, + ], + }, + { + title: 'Navigation', + shortcuts: [ + { mac: '⌘ ⇧ ]', other: 'Ctrl+Shift+]', description: 'Next Tab' }, + { mac: '⌘ ⇧ [', other: 'Ctrl+Shift+[', description: 'Previous Tab' }, + { mac: '⌃ Tab', other: 'Ctrl+Tab', description: 'Cycle Tabs' }, + { mac: '⌘ B', other: 'Ctrl+B', description: 'Toggle Sidebar' }, + ], + }, + { + title: 'Editing', + shortcuts: [ + { mac: '⌘ Z', other: 'Ctrl+Z', description: 'Undo' }, + { mac: '⌘ ⇧ Z', other: 'Ctrl+Y', description: 'Redo' }, + { mac: '⌘ D', other: 'Ctrl+D', description: 'Select Next Match' }, + { mac: '⌘ /', other: 'Ctrl+/', description: 'Toggle Comment' }, + ], + }, + { + title: 'General', + shortcuts: [{ mac: 'Esc', other: 'Esc', description: 'Close Editor' }], + }, +]; + +// ============================================================================= +// Component +// ============================================================================= + +export const EditorShortcutsHelp = ({ onClose }: EditorShortcutsHelpProps): React.ReactElement => { + // Escape closes help (capture phase) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + }; + window.addEventListener('keydown', handleKeyDown, true); + return () => window.removeEventListener('keydown', handleKeyDown, true); + }, [onClose]); + + // Resolve platform-specific keys once + const resolvedGroups = useMemo( + () => + SHORTCUT_GROUPS.map((group) => ({ + ...group, + shortcuts: group.shortcuts.map((s) => ({ + keys: IS_MAC ? s.mac : s.other, + description: s.description, + })), + })), + [] + ); + + return ( +
    + {/* Backdrop */} +
    + + {/* Dialog */} +
    +
    +

    Keyboard Shortcuts

    + +
    + +
    + {resolvedGroups.map((group) => ( +
    +

    {group.title}

    +
    + {group.shortcuts.map((shortcut) => ( +
    + {shortcut.description} + + {shortcut.keys} + +
    + ))} +
    +
    + ))} +
    +
    +
    + ); +}; diff --git a/src/renderer/components/team/editor/EditorStatusBar.tsx b/src/renderer/components/team/editor/EditorStatusBar.tsx new file mode 100644 index 00000000..ac4bf959 --- /dev/null +++ b/src/renderer/components/team/editor/EditorStatusBar.tsx @@ -0,0 +1,48 @@ +/** + * Status bar: cursor position, language, encoding, indent style, git branch. + */ + +import { useStore } from '@renderer/store'; +import { GitBranch } from 'lucide-react'; + +interface EditorStatusBarProps { + line: number; + col: number; + language: string; +} + +export const EditorStatusBar = ({ + line, + col, + language, +}: EditorStatusBarProps): React.ReactElement => { + const gitBranch = useStore((s) => s.editorGitBranch); + const isGitRepo = useStore((s) => s.editorIsGitRepo); + const watcherEnabled = useStore((s) => s.editorWatcherEnabled); + + return ( +
    +
    + + Ln {line}, Col {col} + + {isGitRepo && gitBranch && ( + + + {gitBranch} + + )} +
    +
    + {watcherEnabled && ( + + watching + + )} + {language} + UTF-8 + Spaces: 2 +
    +
    + ); +}; diff --git a/src/renderer/components/team/editor/EditorTabBar.tsx b/src/renderer/components/team/editor/EditorTabBar.tsx new file mode 100644 index 00000000..aba2de75 --- /dev/null +++ b/src/renderer/components/team/editor/EditorTabBar.tsx @@ -0,0 +1,125 @@ +/** + * Tab bar for the project editor. + * Shows open files as tabs with dirty indicator (dot) and close button. + */ + +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { useStore } from '@renderer/store'; +import { X } from 'lucide-react'; + +import { getFileIcon } from './fileIcons'; + +import type { EditorFileTab } from '@shared/types/editor'; + +// ============================================================================= +// Types +// ============================================================================= + +interface EditorTabBarProps { + /** Called instead of direct closeTab — allows parent to intercept dirty tabs */ + onRequestCloseTab: (tabId: string) => void; +} + +// ============================================================================= +// Component +// ============================================================================= + +export const EditorTabBar = ({ + onRequestCloseTab, +}: EditorTabBarProps): React.ReactElement | null => { + const tabs = useStore((s) => s.editorOpenTabs); + const activeTabId = useStore((s) => s.editorActiveTabId); + const modifiedFiles = useStore((s) => s.editorModifiedFiles); + const setActiveTab = useStore((s) => s.setActiveTab); + + if (tabs.length === 0) return null; + + return ( +
    + {tabs.map((tab) => ( + setActiveTab(tab.id)} + onClose={() => onRequestCloseTab(tab.id)} + /> + ))} +
    + ); +}; + +// ============================================================================= +// Tab item +// ============================================================================= + +interface TabProps { + tab: EditorFileTab; + isActive: boolean; + isModified: boolean; + onActivate: () => void; + onClose: () => void; +} + +const Tab = ({ tab, isActive, isModified, onActivate, onClose }: TabProps): React.ReactElement => { + const handleClose = (e: React.MouseEvent) => { + e.stopPropagation(); + onClose(); + }; + + const handleAuxClick = (e: React.MouseEvent) => { + if (e.button === 1) { + e.preventDefault(); + onClose(); + } + }; + + const iconInfo = getFileIcon(tab.fileName); + const FileIcon = iconInfo.icon; + + return ( + + + + + {tab.filePath} + + ); +}; diff --git a/src/renderer/components/team/editor/EditorToolbar.tsx b/src/renderer/components/team/editor/EditorToolbar.tsx new file mode 100644 index 00000000..a2db2bda --- /dev/null +++ b/src/renderer/components/team/editor/EditorToolbar.tsx @@ -0,0 +1,115 @@ +/** + * Toolbar with Save, Undo, Redo buttons. + */ + +import { redo, undo } from '@codemirror/commands'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { useStore } from '@renderer/store'; +import { editorBridge } from '@renderer/utils/editorBridge'; +import { shortcutLabel } from '@renderer/utils/platformKeys'; +import { Redo2, Save, Undo2, WrapText } from 'lucide-react'; + +// ============================================================================= +// Component +// ============================================================================= + +export const EditorToolbar = (): React.ReactElement | null => { + const activeTabId = useStore((s) => s.editorActiveTabId); + const modifiedFiles = useStore((s) => s.editorModifiedFiles); + const saving = useStore((s) => s.editorSaving); + const saveFile = useStore((s) => s.saveFile); + const lineWrap = useStore((s) => s.editorLineWrap); + const toggleLineWrap = useStore((s) => s.toggleLineWrap); + + if (!activeTabId) return null; + + const isDirty = !!modifiedFiles[activeTabId]; + const isSaving = !!saving[activeTabId]; + + const handleSave = () => { + void saveFile(activeTabId); + }; + + const handleUndo = () => { + const view = editorBridge.getView(); + if (view) undo(view); + }; + + const handleRedo = () => { + const view = editorBridge.getView(); + if (view) redo(view); + }; + + return ( +
    + } + label="Save" + shortcut={shortcutLabel('⌘ S', 'Ctrl+S')} + onClick={handleSave} + disabled={!isDirty || isSaving} + /> + } + label="Undo" + shortcut={shortcutLabel('⌘ Z', 'Ctrl+Z')} + onClick={handleUndo} + /> + } + label="Redo" + shortcut={shortcutLabel('⌘ ⇧ Z', 'Ctrl+Y')} + onClick={handleRedo} + /> +
    + } + label={lineWrap ? 'Disable word wrap' : 'Enable word wrap'} + shortcut={shortcutLabel('⌘ ⇧ W', 'Ctrl+Shift+W')} + onClick={toggleLineWrap} + active={lineWrap} + /> +
    + ); +}; + +// ============================================================================= +// Toolbar button +// ============================================================================= + +interface ToolbarButtonProps { + icon: React.ReactNode; + label: string; + shortcut: string; + onClick: () => void; + disabled?: boolean; + active?: boolean; +} + +const ToolbarButton = ({ + icon, + label, + shortcut, + onClick, + disabled = false, + active = false, +}: ToolbarButtonProps): React.ReactElement => ( + + + + + + {label} ({shortcut}) + + +); diff --git a/src/renderer/components/team/editor/GitStatusBadge.tsx b/src/renderer/components/team/editor/GitStatusBadge.tsx new file mode 100644 index 00000000..61695503 --- /dev/null +++ b/src/renderer/components/team/editor/GitStatusBadge.tsx @@ -0,0 +1,43 @@ +/** + * Git status badge for file tree entries. + * + * Shows single-letter indicators: + * - M (modified) — orange + * - U (untracked) — green + * - A (staged/added) — green + * - D (deleted) — red + * - C (conflict) — red, bold + * - R (renamed) — cyan + */ + +import type { GitFileStatusType } from '@shared/types/editor'; + +// ============================================================================= +// Badge config +// ============================================================================= + +const STATUS_CONFIG: Record = { + modified: { letter: 'M', color: 'text-orange-400' }, + untracked: { letter: 'U', color: 'text-green-400' }, + staged: { letter: 'A', color: 'text-green-400' }, + deleted: { letter: 'D', color: 'text-red-400' }, + conflict: { letter: 'C', color: 'text-red-400 font-bold' }, + renamed: { letter: 'R', color: 'text-cyan-400' }, +}; + +// ============================================================================= +// Component +// ============================================================================= + +interface GitStatusBadgeProps { + status: GitFileStatusType; +} + +export const GitStatusBadge = ({ status }: GitStatusBadgeProps): React.ReactElement => { + const config = STATUS_CONFIG[status]; + return ( + + {config.letter} + + ); +}; diff --git a/src/renderer/components/team/editor/NewFileDialog.tsx b/src/renderer/components/team/editor/NewFileDialog.tsx new file mode 100644 index 00000000..880d8756 --- /dev/null +++ b/src/renderer/components/team/editor/NewFileDialog.tsx @@ -0,0 +1,106 @@ +/** + * Inline input for creating a new file or directory in the file tree. + * + * Auto-focuses, validates on the client side, submits on Enter, cancels on Escape/blur. + */ + +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import { FilePlus, FolderPlus } from 'lucide-react'; + +// ============================================================================= +// Types +// ============================================================================= + +interface NewFileDialogProps { + type: 'file' | 'directory'; + parentDir: string; + onSubmit: (name: string) => void; + onCancel: () => void; +} + +// ============================================================================= +// Validation +// ============================================================================= + +// eslint-disable-next-line no-control-regex, sonarjs/no-control-regex -- Intentional: validating filenames against control characters +const INVALID_CHARS = /[\x00-\x1f/\\:*?"<>|]/; + +function validateName(name: string): string | null { + const trimmed = name.trim(); + if (trimmed.length === 0) return 'Name cannot be empty'; + if (trimmed === '.' || trimmed === '..') return 'Invalid name'; + if (INVALID_CHARS.test(trimmed)) return 'Name contains invalid characters'; + if (trimmed.length > 255) return 'Name is too long'; + return null; +} + +// ============================================================================= +// Component +// ============================================================================= + +export const NewFileDialog = ({ + type, + parentDir: _parentDir, + onSubmit, + onCancel, +}: NewFileDialogProps): React.ReactElement => { + const [value, setValue] = useState(''); + const [error, setError] = useState(null); + const inputRef = useRef(null); + + useEffect(() => { + // Auto-focus on mount + inputRef.current?.focus(); + }, []); + + const handleSubmit = useCallback(() => { + const trimmed = value.trim(); + const validationError = validateName(trimmed); + if (validationError) { + setError(validationError); + return; + } + onSubmit(trimmed); + }, [value, onSubmit]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSubmit(); + } else if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + } + }, + [handleSubmit, onCancel] + ); + + const handleChange = useCallback((e: React.ChangeEvent) => { + setValue(e.target.value); + setError(null); + }, []); + + const Icon = type === 'file' ? FilePlus : FolderPlus; + + return ( +
    +
    + + +
    + {error && {error}} +
    + ); +}; diff --git a/src/renderer/components/team/editor/ProjectEditorOverlay.tsx b/src/renderer/components/team/editor/ProjectEditorOverlay.tsx new file mode 100644 index 00000000..0e81877f --- /dev/null +++ b/src/renderer/components/team/editor/ProjectEditorOverlay.tsx @@ -0,0 +1,766 @@ +/** + * Full-screen project editor overlay. + * + * Pattern: follows ChangeReviewDialog.tsx — raw
    with fixed inset-0, not Radix Dialog. + * macOS traffic light padding, inert on background, Escape to close. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { useEditorKeyboardShortcuts } from '@renderer/hooks/useEditorKeyboardShortcuts'; +import { useStore } from '@renderer/store'; +import { buildSelectionAction } from '@renderer/utils/buildSelectionAction'; +import { shortcutLabel } from '@renderer/utils/platformKeys'; +import { + AlertTriangle, + HelpCircle, + Loader2, + PanelLeftClose, + PanelLeftOpen, + RefreshCw, + RotateCcw, + X, +} from 'lucide-react'; + +import { CodeMirrorEditor } from './CodeMirrorEditor'; +import { EditorBinaryState } from './EditorBinaryState'; +import { EditorEmptyState } from './EditorEmptyState'; +import { EditorErrorBoundary } from './EditorErrorBoundary'; +import { EditorErrorState } from './EditorErrorState'; +import { EditorFileTree } from './EditorFileTree'; +import { EditorSelectionMenu } from './EditorSelectionMenu'; +import { EditorShortcutsHelp } from './EditorShortcutsHelp'; +import { EditorStatusBar } from './EditorStatusBar'; +import { EditorTabBar } from './EditorTabBar'; +import { EditorToolbar } from './EditorToolbar'; +import { QuickOpenDialog } from './QuickOpenDialog'; +import { SearchInFilesPanel } from './SearchInFilesPanel'; + +import type { + EditorSelectionAction, + EditorSelectionInfo, + ReadFileResult, +} from '@shared/types/editor'; + +// ============================================================================= +// Types +// ============================================================================= + +interface ProjectEditorOverlayProps { + projectPath: string; + onClose: () => void; + /** Called when user triggers an action from the selection menu */ + onEditorAction?: (action: EditorSelectionAction) => void; +} + +// ============================================================================= +// Component +// ============================================================================= + +export const ProjectEditorOverlay = ({ + projectPath, + onClose, + onEditorAction, +}: ProjectEditorOverlayProps): React.ReactElement => { + const openEditor = useStore((s) => s.openEditor); + const closeEditor = useStore((s) => s.closeEditor); + const openFile = useStore((s) => s.openFile); + const closeTab = useStore((s) => s.closeTab); + const saveFile = useStore((s) => s.saveFile); + const activeTabId = useStore((s) => s.editorActiveTabId); + const openTabs = useStore((s) => s.editorOpenTabs); + const modifiedFiles = useStore((s) => s.editorModifiedFiles); + const saveErrors = useStore((s) => s.editorSaveError); + const hasUnsavedChanges = useStore((s) => s.hasUnsavedChanges); + const saveAllFiles = useStore((s) => s.saveAllFiles); + const discardChanges = useStore((s) => s.discardChanges); + + // Iter-5: git, watcher, conflict + const externalChanges = useStore((s) => s.editorExternalChanges); + const clearExternalChange = useStore((s) => s.clearExternalChange); + const conflictFile = useStore((s) => s.editorConflictFile); + const forceOverwrite = useStore((s) => s.forceOverwrite); + const resolveConflict = useStore((s) => s.resolveConflict); + const setFileMtime = useStore((s) => s.setFileMtime); + const fetchGitStatus = useStore((s) => s.fetchGitStatus); + + const [fileContent, setFileContent] = useState(null); + const [fileLoading, setFileLoading] = useState(false); + const [fileError, setFileError] = useState(null); + const [cursorLine, setCursorLine] = useState(1); + const [cursorCol, setCursorCol] = useState(1); + + // Unsaved changes confirmation (overlay close) + const [showConfirmClose, setShowConfirmClose] = useState(false); + // Unsaved changes confirmation (single tab close) + const [confirmCloseTabId, setConfirmCloseTabId] = useState(null); + // Draft recovery banner + const [draftRecoveredFile, setDraftRecoveredFile] = useState(null); + // Bumped on draft discard to force CodeMirrorEditor remount (fresh state cache) + const [editorResetKey, setEditorResetKey] = useState(0); + // Selection action menu + const [selectionInfo, setSelectionInfo] = useState(null); + const editorContentRef = useRef(null); + const [containerRect, setContainerRect] = useState(() => new DOMRect()); + + // Iter-4: New state + const [quickOpenVisible, setQuickOpenVisible] = useState(false); + const [searchPanelVisible, setSearchPanelVisible] = useState(false); + const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false); + const [sidebarVisible, setSidebarVisibleRaw] = useState(() => { + try { + return localStorage.getItem('editor-sidebar-visible') !== 'false'; + } catch { + return true; + } + }); + + const overlayRef = useRef(null); + + // IPC deduplication: reuse in-flight readFile promise for same path + const pendingReads = useRef(new Map>()); + + // Active tab metadata + const activeTab = openTabs.find((t) => t.id === activeTabId) ?? null; + + const loadFileContent = useCallback( + async (filePath: string) => { + setFileLoading(true); + setFileError(null); + setFileContent(null); + + try { + let promise = pendingReads.current.get(filePath); + if (!promise) { + promise = window.electronAPI.editor.readFile(filePath); + pendingReads.current.set(filePath, promise); + void promise.finally(() => pendingReads.current.delete(filePath)); + } + const result = await promise; + setFileContent(result); + + // Track baseline mtime for conflict detection + if (result.mtimeMs) { + setFileMtime(filePath, result.mtimeMs); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setFileError(message); + } finally { + setFileLoading(false); + } + }, + [setFileMtime] + ); + + // Active tab save error + const activeSaveError = activeTabId ? (saveErrors[activeTabId] ?? null) : null; + + // Initialize editor on mount + useEffect(() => { + void openEditor(projectPath); + return () => { + closeEditor(); + }; + }, [projectPath, openEditor, closeEditor]); + + // Keep container rect fresh for selection menu positioning (resize, sidebar toggle) + useEffect(() => { + const el = editorContentRef.current; + if (!el) return; + const updateRect = (): void => setContainerRect(el.getBoundingClientRect()); + updateRect(); + const observer = new ResizeObserver(updateRect); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + // Escape to close + F5 to refresh (with dialog guard) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + // Don't close overlay if a dialog is open — dialog handles its own Escape + if (quickOpenVisible || searchPanelVisible || shortcutsHelpVisible) return; + if (showConfirmClose || confirmCloseTabId) return; + if (conflictFile) return; + + e.preventDefault(); + handleCloseRequest(); + } + + // F5: Manual refresh (git status + file tree) + if (e.key === 'F5') { + e.preventDefault(); + handleManualRefresh(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + // eslint-disable-next-line react-hooks/exhaustive-deps -- handleCloseRequest and handleManualRefresh are stable callbacks; listing dialog visibility guards as deps is sufficient + }, [ + quickOpenVisible, + searchPanelVisible, + shortcutsHelpVisible, + showConfirmClose, + confirmCloseTabId, + conflictFile, + ]); + + // Focus trap — focus overlay on mount + useEffect(() => { + overlayRef.current?.focus(); + }, []); + + // Load file content when active tab changes + useEffect(() => { + // Clear selection menu from previous tab + setSelectionInfo(null); + + if (!activeTabId) { + setFileContent(null); + setFileLoading(false); + setFileError(null); + return; + } + + void loadFileContent(activeTabId); + }, [activeTabId, loadFileContent]); + + // Clear draft recovery banner when switching tabs + useEffect(() => { + if (activeTabId !== draftRecoveredFile) { + setDraftRecoveredFile(null); + } + }, [activeTabId, draftRecoveredFile]); + + const handleFileSelect = useCallback( + (filePath: string) => { + openFile(filePath); + }, + [openFile] + ); + + const handleRetry = useCallback(() => { + if (activeTabId) { + void loadFileContent(activeTabId); + } + }, [activeTabId, loadFileContent]); + + const handleCursorChange = useCallback((line: number, col: number) => { + setCursorLine(line); + setCursorCol(col); + }, []); + + // --- Overlay close handlers --- + + const handleCloseRequest = useCallback(() => { + if (hasUnsavedChanges()) { + setShowConfirmClose(true); + } else { + onClose(); + } + }, [onClose, hasUnsavedChanges]); + + const handleSaveAndClose = useCallback(async () => { + await saveAllFiles(); + setShowConfirmClose(false); + onClose(); + }, [saveAllFiles, onClose]); + + const handleDiscardAndClose = useCallback(() => { + setShowConfirmClose(false); + onClose(); + }, [onClose]); + + const handleCancelClose = useCallback(() => { + setShowConfirmClose(false); + }, []); + + // --- Tab close handlers (with dirty check) --- + + const handleRequestCloseTab = useCallback( + (tabId: string) => { + if (modifiedFiles[tabId]) { + setConfirmCloseTabId(tabId); + } else { + closeTab(tabId); + } + }, + [modifiedFiles, closeTab] + ); + + // Listen for editor-close-tab custom events from keyboard shortcut hook + useEffect(() => { + const handler = (e: Event) => { + const tabId = (e as CustomEvent).detail as string; + handleRequestCloseTab(tabId); + }; + window.addEventListener('editor-close-tab', handler); + return () => window.removeEventListener('editor-close-tab', handler); + }, [handleRequestCloseTab]); + + const handleSaveAndCloseTab = useCallback(async () => { + if (!confirmCloseTabId) return; + await saveFile(confirmCloseTabId); + closeTab(confirmCloseTabId); + setConfirmCloseTabId(null); + }, [confirmCloseTabId, saveFile, closeTab]); + + const handleDiscardAndCloseTab = useCallback(() => { + if (!confirmCloseTabId) return; + closeTab(confirmCloseTabId); + setConfirmCloseTabId(null); + }, [confirmCloseTabId, closeTab]); + + const handleCancelCloseTab = useCallback(() => { + setConfirmCloseTabId(null); + }, []); + + // --- Draft recovery handlers --- + + const handleDraftRecovered = useCallback((filePath: string) => { + setDraftRecoveredFile(filePath); + }, []); + + const handleDiscardDraft = useCallback(() => { + if (!draftRecoveredFile || !activeTabId) return; + discardChanges(draftRecoveredFile); + setDraftRecoveredFile(null); + setFileContent(null); + setEditorResetKey((k) => k + 1); + void loadFileContent(activeTabId); + }, [draftRecoveredFile, activeTabId, discardChanges, loadFileContent]); + + const handleDismissDraftBanner = useCallback(() => { + setDraftRecoveredFile(null); + }, []); + + // --- Iter-5: Conflict handlers --- + + const handleForceOverwrite = useCallback(() => { + if (!conflictFile) return; + void forceOverwrite(conflictFile); + }, [conflictFile, forceOverwrite]); + + const handleCancelConflict = useCallback(() => { + resolveConflict(); + }, [resolveConflict]); + + // --- Iter-5: External change handlers --- + + const handleReloadExternalChange = useCallback(() => { + if (!activeTabId) return; + clearExternalChange(activeTabId); + discardChanges(activeTabId); + setFileContent(null); + setEditorResetKey((k) => k + 1); + void loadFileContent(activeTabId); + }, [activeTabId, clearExternalChange, discardChanges, loadFileContent]); + + const handleKeepMine = useCallback(() => { + if (!activeTabId) return; + clearExternalChange(activeTabId); + }, [activeTabId, clearExternalChange]); + + // --- Iter-5: Watcher toggle --- + + // --- Iter-5: Manual refresh (F5) --- + + const handleManualRefresh = useCallback(() => { + void fetchGitStatus(); + }, [fetchGitStatus]); + + // --- Iter-4: Toggle handlers --- + + const toggleQuickOpen = useCallback(() => { + setQuickOpenVisible((v) => !v); + }, []); + + const toggleSearchPanel = useCallback(() => { + setSearchPanelVisible((v) => !v); + }, []); + + const toggleSidebar = useCallback(() => { + setSidebarVisibleRaw((v) => { + const next = !v; + try { + localStorage.setItem('editor-sidebar-visible', String(next)); + } catch { + // localStorage unavailable + } + return next; + }); + }, []); + + // --- Iter-4: Search result selection --- + + const handleSearchSelectMatch = useCallback( + (filePath: string, _line: number) => { + openFile(filePath); + // Future enhancement: scroll to line in CM6 after file loads + }, + [openFile] + ); + + // --- Keyboard shortcuts --- + + useEditorKeyboardShortcuts({ + onToggleQuickOpen: toggleQuickOpen, + onToggleSearchPanel: toggleSearchPanel, + onToggleSidebar: toggleSidebar, + onClose: handleCloseRequest, + }); + + const projectName = projectPath.split('/').pop() ?? projectPath; + + return ( +
    + {/* Header */} +
    +
    + {projectName} + {projectPath} +
    +
    + + + + + Refresh git status (F5) + + + + + + Keyboard shortcuts + + +
    +
    + + {/* Main content */} +
    + {/* Search in files panel (replaces sidebar when visible) */} + {searchPanelVisible && ( +
    + setSearchPanelVisible(false)} + onSelectMatch={handleSearchSelectMatch} + /> +
    + )} + + {/* File tree sidebar */} + {sidebarVisible && !searchPanelVisible && ( +
    +
    + + Explorer + + + + + + + Hide sidebar ({shortcutLabel('⌘ B', 'Ctrl+B')}) + + +
    +
    + +
    +
    + )} + + {/* Sidebar toggle (when hidden) */} + {!sidebarVisible && !searchPanelVisible && ( + + + + + + Show sidebar ({shortcutLabel('⌘ B', 'Ctrl+B')}) + + + )} + + {/* Editor area */} +
    + {/* Tab bar */} + + + {/* Toolbar */} + + + {/* Draft recovery banner */} + {draftRecoveredFile && activeTabId === draftRecoveredFile && ( +
    + + Recovered unsaved changes from a previous session. + + +
    + )} + + {/* Save error banner */} + {activeSaveError && ( +
    + + Save failed: {activeSaveError} + +
    + )} + + {/* External change banner */} + {activeTabId && externalChanges[activeTabId] && ( +
    + + + {externalChanges[activeTabId] === 'delete' + ? 'File no longer exists on disk.' + : 'File changed on disk.'} + + {externalChanges[activeTabId] === 'delete' ? ( + + ) : ( + <> + + + + )} +
    + )} + + {/* Editor content */} +
    + {fileLoading && ( +
    + +
    + )} + + {fileError && } + + {fileContent?.isBinary && activeTabId && ( + + )} + + {fileContent && !fileContent.isBinary && activeTabId && ( + + + + )} + + {!fileLoading && !fileError && !fileContent && !activeTabId && } + + {/* Selection action menu */} + {selectionInfo && onEditorAction && ( + { + onEditorAction(buildSelectionAction('sendMessage', selectionInfo)); + setSelectionInfo(null); + }} + onCreateTask={() => { + onEditorAction(buildSelectionAction('createTask', selectionInfo)); + setSelectionInfo(null); + }} + /> + )} +
    + + {/* Status bar */} + {activeTab && ( + + )} +
    +
    + + {/* Quick Open dialog */} + {quickOpenVisible && ( + setQuickOpenVisible(false)} + onSelectFile={handleFileSelect} + /> + )} + + {/* Shortcuts help modal */} + {shortcutsHelpVisible && ( + setShortcutsHelpVisible(false)} /> + )} + + {/* Unsaved changes confirmation dialog — overlay close */} + {showConfirmClose && ( +
    +
    +

    Unsaved Changes

    +

    + You have unsaved changes. What would you like to do? +

    +
    + + + +
    +
    +
    + )} + + {/* Save conflict dialog */} + {conflictFile && ( +
    +
    +

    Save Conflict

    +

    + The file has been modified externally since you opened it. Overwrite with your + changes? +

    +
    + + +
    +
    +
    + )} + + {/* Unsaved changes confirmation dialog — single tab close */} + {confirmCloseTabId && ( +
    +
    +

    Unsaved Changes

    +

    + This file has unsaved changes. What would you like to do? +

    +
    + + + +
    +
    +
    + )} +
    + ); +}; diff --git a/src/renderer/components/team/editor/QuickOpenDialog.tsx b/src/renderer/components/team/editor/QuickOpenDialog.tsx new file mode 100644 index 00000000..c671af54 --- /dev/null +++ b/src/renderer/components/team/editor/QuickOpenDialog.tsx @@ -0,0 +1,147 @@ +/** + * Quick Open dialog (Cmd+P) — fuzzy file search using cmdk. + * + * Escape closes dialog (not the editor overlay). + * Flatten file tree on mount, filter with cmdk built-in fuzzy matching. + */ + +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +import { useStore } from '@renderer/store'; +import { Command } from 'cmdk'; + +import { getFileIcon } from './fileIcons'; + +import type { FileTreeEntry } from '@shared/types/editor'; + +// ============================================================================= +// Types +// ============================================================================= + +interface QuickOpenDialogProps { + onClose: () => void; + onSelectFile: (filePath: string) => void; +} + +// ============================================================================= +// Component +// ============================================================================= + +export const QuickOpenDialog = ({ + onClose, + onSelectFile, +}: QuickOpenDialogProps): React.ReactElement => { + const fileTree = useStore((s) => s.editorFileTree); + const projectPath = useStore((s) => s.editorProjectPath); + const dialogRef = useRef(null); + + // Flatten file tree into searchable list + const flatFiles = useMemo(() => { + if (!fileTree) return []; + const files: { path: string; name: string; relativePath: string }[] = []; + flattenTree(fileTree, files, projectPath ?? ''); + return files; + }, [fileTree, projectPath]); + + // Escape to close dialog (not overlay) + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + }, + [onClose] + ); + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown, true); + return () => window.removeEventListener('keydown', handleKeyDown, true); + }, [handleKeyDown]); + + const handleSelect = useCallback( + (value: string) => { + onSelectFile(value); + onClose(); + }, + [onSelectFile, onClose] + ); + + return ( +
    + {/* Backdrop */} +
    { + if (e.key === 'Escape') onClose(); + }} + role="presentation" + /> + + {/* Dialog */} +
    + + + + + No files found + + {flatFiles.map((file) => { + const iconInfo = getFileIcon(file.name); + const Icon = iconInfo.icon; + return ( + handleSelect(file.path)} + className="flex cursor-pointer items-center gap-2 rounded px-3 py-1.5 text-sm text-text-secondary aria-selected:bg-surface-raised aria-selected:text-text" + > + + {file.name} + + {file.relativePath} + + + ); + })} + + +
    +
    + ); +}; + +// ============================================================================= +// Helpers +// ============================================================================= + +function flattenTree( + entries: FileTreeEntry[], + result: { path: string; name: string; relativePath: string }[], + projectRoot: string +): void { + for (const entry of entries) { + if (entry.type === 'file' && !entry.isSensitive) { + const relativePath = entry.path.startsWith(projectRoot) + ? entry.path.slice(projectRoot.length + 1) + : entry.name; + result.push({ + path: entry.path, + name: entry.name, + relativePath, + }); + } + if (entry.children) { + flattenTree(entry.children, result, projectRoot); + } + } +} diff --git a/src/renderer/components/team/editor/SearchInFilesPanel.tsx b/src/renderer/components/team/editor/SearchInFilesPanel.tsx new file mode 100644 index 00000000..cfcbc802 --- /dev/null +++ b/src/renderer/components/team/editor/SearchInFilesPanel.tsx @@ -0,0 +1,354 @@ +/** + * Search in files panel (Cmd+Shift+F). + * + * Debounced literal string search with cancellation. + * Results are clickable to open the file at the matched line. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { api } from '@renderer/api'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { Loader2, Search, X } from 'lucide-react'; + +import { getFileIcon } from './fileIcons'; + +import type { SearchFileResult, SearchInFilesResult } from '@shared/types/editor'; + +// ============================================================================= +// Types +// ============================================================================= + +interface SearchInFilesPanelProps { + projectPath: string; + onClose: () => void; + onSelectMatch: (filePath: string, line: number) => void; +} + +// ============================================================================= +// Constants +// ============================================================================= + +const DEBOUNCE_MS = 300; + +// ============================================================================= +// Component +// ============================================================================= + +export const SearchInFilesPanel = ({ + projectPath, + onClose, + onSelectMatch, +}: SearchInFilesPanelProps): React.ReactElement => { + const [query, setQuery] = useState(''); + const [caseSensitive, setCaseSensitive] = useState(false); + const [results, setResults] = useState(null); + const [searching, setSearching] = useState(false); + const [error, setError] = useState(null); + const [expandedFiles, setExpandedFiles] = useState>(new Set()); + + const inputRef = useRef(null); + const debounceRef = useRef>(); + // Monotonic request ID — prevents stale results from overwriting fresh ones + const requestIdRef = useRef(0); + + // Focus input on mount + useEffect(() => { + inputRef.current?.focus(); + }, []); + + // Escape closes panel (capture phase to prevent overlay close) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + }; + window.addEventListener('keydown', handleKeyDown, true); + return () => window.removeEventListener('keydown', handleKeyDown, true); + }, [onClose]); + + const doSearch = useCallback(async (searchQuery: string, isCaseSensitive: boolean) => { + if (!searchQuery.trim()) { + setResults(null); + setSearching(false); + setError(null); + return; + } + + // Bump request ID — any in-flight request with a lower ID is stale + const myRequestId = ++requestIdRef.current; + + setSearching(true); + setError(null); + + try { + const result = await api.editor.searchInFiles({ + query: searchQuery, + caseSensitive: isCaseSensitive, + }); + + // Discard result if a newer request was fired while we were waiting + if (requestIdRef.current !== myRequestId) return; + + setResults(result); + + // Auto-expand first few files + const firstFiles = new Set(result.results.slice(0, 5).map((r) => r.filePath)); + setExpandedFiles(firstFiles); + } catch (err) { + if (requestIdRef.current !== myRequestId) return; + const message = err instanceof Error ? err.message : String(err); + setError(message); + } finally { + if (requestIdRef.current === myRequestId) { + setSearching(false); + } + } + }, []); + + const handleQueryChange = useCallback( + (value: string) => { + setQuery(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + + debounceRef.current = setTimeout(() => { + void doSearch(value, caseSensitive); + }, DEBOUNCE_MS); + }, + [caseSensitive, doSearch] + ); + + const handleCaseSensitiveToggle = useCallback(() => { + const newValue = !caseSensitive; + setCaseSensitive(newValue); + if (query.trim()) { + if (debounceRef.current) clearTimeout(debounceRef.current); + void doSearch(query, newValue); + } + }, [caseSensitive, query, doSearch]); + + const toggleFileExpanded = useCallback((filePath: string) => { + setExpandedFiles((prev) => { + const next = new Set(prev); + if (next.has(filePath)) { + next.delete(filePath); + } else { + next.add(filePath); + } + return next; + }); + }, []); + + const getRelativePath = useCallback( + (filePath: string) => { + return filePath.startsWith(projectPath) ? filePath.slice(projectPath.length + 1) : filePath; + }, + [projectPath] + ); + + return ( +
    + {/* Header */} +
    + Search in Files + +
    + + {/* Search input */} +
    +
    + + handleQueryChange(e.target.value)} + placeholder="Search..." + className="flex-1 bg-transparent text-xs text-text outline-none placeholder:text-text-muted" + /> + {searching && } +
    +
    + + + + + Match Case + +
    +
    + + {/* Results */} +
    + {error &&
    {error}
    } + + {results?.totalMatches === 0 && query.trim() && ( +
    No results found
    + )} + + {results && results.totalMatches > 0 && ( + <> +
    + {results.totalMatches} match{results.totalMatches !== 1 ? 'es' : ''} in{' '} + {results.results.length} file{results.results.length !== 1 ? 's' : ''} + {results.truncated && ' (truncated)'} +
    + {results.results.map((fileResult) => ( + toggleFileExpanded(fileResult.filePath)} + onSelectMatch={(line) => onSelectMatch(fileResult.filePath, line)} + query={query} + caseSensitive={caseSensitive} + /> + ))} + + )} +
    +
    + ); +}; + +// ============================================================================= +// File group +// ============================================================================= + +interface SearchFileGroupProps { + fileResult: SearchFileResult; + relativePath: string; + expanded: boolean; + onToggle: () => void; + onSelectMatch: (line: number) => void; + query: string; + caseSensitive: boolean; +} + +const SearchFileGroup = ({ + fileResult, + relativePath, + expanded, + onToggle, + onSelectMatch, + query, + caseSensitive, +}: SearchFileGroupProps): React.ReactElement => { + const fileName = relativePath.split('/').pop() ?? relativePath; + const dirPath = relativePath.includes('/') + ? relativePath.slice(0, relativePath.lastIndexOf('/')) + : ''; + const iconInfo = getFileIcon(fileName); + const Icon = iconInfo.icon; + + return ( +
    + + {expanded && ( +
    + {fileResult.matches.map((match, idx) => ( + + ))} +
    + )} +
    + ); +}; + +// ============================================================================= +// Highlighted line +// ============================================================================= + +interface HighlightedLineProps { + text: string; + query: string; + caseSensitive: boolean; +} + +const HighlightedLine = ({ + text, + query, + caseSensitive, +}: HighlightedLineProps): React.ReactElement => { + if (!query) { + return {text}; + } + + const searchText = caseSensitive ? text : text.toLowerCase(); + const searchQuery = caseSensitive ? query : query.toLowerCase(); + const parts: React.ReactNode[] = []; + let lastIndex = 0; + + let idx = searchText.indexOf(searchQuery); + while (idx !== -1) { + if (idx > lastIndex) { + parts.push( + + {text.slice(lastIndex, idx)} + + ); + } + parts.push( + + {text.slice(idx, idx + query.length)} + + ); + lastIndex = idx + query.length; + idx = searchText.indexOf(searchQuery, lastIndex); + } + + if (lastIndex < text.length) { + parts.push( + + {text.slice(lastIndex)} + + ); + } + + return {parts}; +}; diff --git a/src/renderer/components/team/editor/fileIcons.ts b/src/renderer/components/team/editor/fileIcons.ts new file mode 100644 index 00000000..bb833f84 --- /dev/null +++ b/src/renderer/components/team/editor/fileIcons.ts @@ -0,0 +1,173 @@ +/** + * File icon mapping — maps file extensions to lucide-react icon names and colors. + */ + +import { + Braces, + Code, + Database, + File, + FileCode, + FileJson, + FileText, + FileType, + Image, + Lock, + Settings, + Terminal, +} from 'lucide-react'; + +import type { LucideIcon } from 'lucide-react'; + +// ============================================================================= +// Types +// ============================================================================= + +export interface FileIconInfo { + icon: LucideIcon; + color: string; +} + +// ============================================================================= +// Extension → Icon mapping +// ============================================================================= + +const EXTENSION_MAP: Record = { + // TypeScript / JavaScript + ts: { icon: FileCode, color: '#3178c6' }, + tsx: { icon: FileCode, color: '#3178c6' }, + js: { icon: FileCode, color: '#f7df1e' }, + jsx: { icon: FileCode, color: '#61dafb' }, + mjs: { icon: FileCode, color: '#f7df1e' }, + cjs: { icon: FileCode, color: '#f7df1e' }, + + // Web + html: { icon: Code, color: '#e34c26' }, + htm: { icon: Code, color: '#e34c26' }, + css: { icon: FileCode, color: '#563d7c' }, + scss: { icon: FileCode, color: '#c6538c' }, + less: { icon: FileCode, color: '#1d365d' }, + vue: { icon: FileCode, color: '#42b883' }, + svelte: { icon: FileCode, color: '#ff3e00' }, + + // Data / Config + json: { icon: FileJson, color: '#cbcb41' }, + jsonl: { icon: FileJson, color: '#cbcb41' }, + yaml: { icon: Settings, color: '#cb171e' }, + yml: { icon: Settings, color: '#cb171e' }, + toml: { icon: Settings, color: '#9c4121' }, + xml: { icon: Code, color: '#e37933' }, + csv: { icon: Database, color: '#4caf50' }, + + // Markdown / Text + md: { icon: FileText, color: '#519aba' }, + mdx: { icon: FileText, color: '#519aba' }, + txt: { icon: FileText, color: '#89949f' }, + rst: { icon: FileText, color: '#89949f' }, + + // Python + py: { icon: FileCode, color: '#3572a5' }, + pyx: { icon: FileCode, color: '#3572a5' }, + pyi: { icon: FileCode, color: '#3572a5' }, + + // Rust + rs: { icon: FileCode, color: '#dea584' }, + + // Go + go: { icon: FileCode, color: '#00add8' }, + + // Ruby + rb: { icon: FileCode, color: '#cc342d' }, + gemspec: { icon: FileCode, color: '#cc342d' }, + + // Java / Kotlin + java: { icon: FileCode, color: '#b07219' }, + kt: { icon: FileCode, color: '#a97bff' }, + kts: { icon: FileCode, color: '#a97bff' }, + + // C / C++ + c: { icon: FileCode, color: '#555555' }, + h: { icon: FileCode, color: '#555555' }, + cpp: { icon: FileCode, color: '#f34b7d' }, + hpp: { icon: FileCode, color: '#f34b7d' }, + cc: { icon: FileCode, color: '#f34b7d' }, + + // Shell + sh: { icon: Terminal, color: '#89e051' }, + bash: { icon: Terminal, color: '#89e051' }, + zsh: { icon: Terminal, color: '#89e051' }, + fish: { icon: Terminal, color: '#89e051' }, + + // SQL + sql: { icon: Database, color: '#e38c00' }, + + // Images + png: { icon: Image, color: '#a074c4' }, + jpg: { icon: Image, color: '#a074c4' }, + jpeg: { icon: Image, color: '#a074c4' }, + gif: { icon: Image, color: '#a074c4' }, + svg: { icon: Image, color: '#ffb13b' }, + ico: { icon: Image, color: '#a074c4' }, + webp: { icon: Image, color: '#a074c4' }, + + // Fonts + woff: { icon: FileType, color: '#89949f' }, + woff2: { icon: FileType, color: '#89949f' }, + ttf: { icon: FileType, color: '#89949f' }, + otf: { icon: FileType, color: '#89949f' }, + + // Config files + env: { icon: Lock, color: '#e5a00d' }, + ini: { icon: Settings, color: '#89949f' }, + conf: { icon: Settings, color: '#89949f' }, + cfg: { icon: Settings, color: '#89949f' }, + + // Other + graphql: { icon: Braces, color: '#e535ab' }, + gql: { icon: Braces, color: '#e535ab' }, + proto: { icon: Code, color: '#89949f' }, + dart: { icon: FileCode, color: '#00b4ab' }, + swift: { icon: FileCode, color: '#f05138' }, + php: { icon: FileCode, color: '#4f5d95' }, +}; + +// Special full filename mapping +const FILENAME_MAP: Record = { + Dockerfile: { icon: FileCode, color: '#2496ed' }, + 'docker-compose.yml': { icon: FileCode, color: '#2496ed' }, + 'docker-compose.yaml': { icon: FileCode, color: '#2496ed' }, + Makefile: { icon: Terminal, color: '#427819' }, + Rakefile: { icon: Terminal, color: '#cc342d' }, + Gemfile: { icon: FileCode, color: '#cc342d' }, + '.gitignore': { icon: Settings, color: '#f05032' }, + '.gitattributes': { icon: Settings, color: '#f05032' }, + '.eslintrc': { icon: Settings, color: '#4b32c3' }, + '.prettierrc': { icon: Settings, color: '#56b3b4' }, + 'tsconfig.json': { icon: Settings, color: '#3178c6' }, + 'package.json': { icon: FileJson, color: '#cb3837' }, + 'pnpm-lock.yaml': { icon: Lock, color: '#f69220' }, + 'package-lock.json': { icon: Lock, color: '#cb3837' }, + 'yarn.lock': { icon: Lock, color: '#2c8ebb' }, + LICENSE: { icon: FileText, color: '#d9b611' }, + 'CLAUDE.md': { icon: FileText, color: '#d97706' }, +}; + +const DEFAULT_ICON: FileIconInfo = { icon: File, color: '#89949f' }; + +// ============================================================================= +// Public API +// ============================================================================= + +/** + * Get icon info for a file by name. + */ +export function getFileIcon(fileName: string): FileIconInfo { + // Check full filename first + if (FILENAME_MAP[fileName]) return FILENAME_MAP[fileName]; + + // Check extension + const ext = fileName.includes('.') ? fileName.split('.').pop()?.toLowerCase() : undefined; + if (ext && EXTENSION_MAP[ext]) return EXTENSION_MAP[ext]; + + return DEFAULT_ICON; +} diff --git a/src/renderer/components/team/review/CodeMirrorDiffView.tsx b/src/renderer/components/team/review/CodeMirrorDiffView.tsx index fed829fb..68f415e5 100644 --- a/src/renderer/components/team/review/CodeMirrorDiffView.tsx +++ b/src/renderer/components/team/review/CodeMirrorDiffView.tsx @@ -1,28 +1,16 @@ import React, { useCallback, useEffect, useRef } from 'react'; import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; -import { cpp } from '@codemirror/lang-cpp'; -import { css } from '@codemirror/lang-css'; -import { go } from '@codemirror/lang-go'; -import { html } from '@codemirror/lang-html'; -import { java } from '@codemirror/lang-java'; -import { javascript } from '@codemirror/lang-javascript'; -import { json } from '@codemirror/lang-json'; -import { less } from '@codemirror/lang-less'; -import { markdown } from '@codemirror/lang-markdown'; -import { php } from '@codemirror/lang-php'; -import { python } from '@codemirror/lang-python'; -import { rust } from '@codemirror/lang-rust'; -import { sass } from '@codemirror/lang-sass'; -import { sql } from '@codemirror/lang-sql'; -import { xml } from '@codemirror/lang-xml'; -import { yaml } from '@codemirror/lang-yaml'; -import { indentUnit, LanguageDescription, syntaxHighlighting } from '@codemirror/language'; -import { languages } from '@codemirror/language-data'; +import { indentUnit, syntaxHighlighting } from '@codemirror/language'; import { goToNextChunk, goToPreviousChunk, unifiedMergeView } from '@codemirror/merge'; import { Compartment, EditorState, type Extension } from '@codemirror/state'; import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark'; import { EditorView, keymap, lineNumbers } from '@codemirror/view'; +import { + getAsyncLanguageDesc, + getSyncLanguageExtension, +} from '@renderer/utils/codemirrorLanguages'; +import { baseEditorTheme } from '@renderer/utils/codemirrorTheme'; import { acceptChunk, @@ -60,73 +48,6 @@ interface CodeMirrorDiffViewProps { portionSize?: number; } -/** Synchronous language extension for common file types (bundled by Vite) */ -function getSyncLanguageExtension(fileName: string): Extension | null { - const ext = fileName.split('.').pop()?.toLowerCase(); - switch (ext) { - case 'ts': - case 'tsx': - case 'js': - case 'jsx': - case 'mjs': - case 'cjs': - return javascript({ - jsx: ext === 'tsx' || ext === 'jsx', - typescript: ext === 'ts' || ext === 'tsx', - }); - case 'py': - return python(); - case 'json': - case 'jsonl': - return json(); - case 'css': - return css(); - case 'scss': - return sass({ indented: false }); - case 'sass': - return sass({ indented: true }); - case 'less': - return less(); - case 'html': - case 'htm': - return html(); - case 'xml': - case 'svg': - return xml(); - case 'md': - case 'mdx': - case 'markdown': - return markdown(); - case 'yaml': - case 'yml': - return yaml(); - case 'rs': - return rust(); - case 'go': - return go(); - case 'java': - return java(); - case 'c': - case 'h': - case 'cpp': - case 'cxx': - case 'cc': - case 'hpp': - return cpp(); - case 'php': - return php(); - case 'sql': - return sql(); - default: - return null; - } -} - -/** Async fallback: match by filename via @codemirror/language-data for rare languages */ -function getAsyncLanguageDesc(fileName: string): LanguageDescription | null { - return LanguageDescription.matchFilename(languages, fileName); -} - /** Compute hunk index for the chunk at a given position (B-side / modified doc). * If the position falls inside a chunk, returns that chunk's index. * Otherwise returns the nearest chunk by distance (avoids defaulting to 0). */ @@ -154,49 +75,8 @@ function computeHunkIndexAtPos(state: EditorState, pos: number): number { return nearestIndex; } -/** Custom dark theme for diff view using CSS variables */ -const diffTheme = EditorView.theme({ - '&': { - backgroundColor: 'var(--color-surface)', - color: 'var(--color-text)', - fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', - fontSize: '13px', - }, - '&.cm-focused': { - outline: 'none', - }, - '.cm-gutters': { - backgroundColor: 'var(--color-surface)', - borderRight: '1px solid var(--color-border)', - color: 'var(--color-text-muted)', - fontSize: '11px', - minWidth: 'auto', - }, - '.cm-lineNumbers .cm-gutterElement': { - padding: '0 4px 0 8px', - minWidth: '2ch', - textAlign: 'right', - opacity: '0.5', - }, - '.cm-activeLineGutter': { - backgroundColor: 'transparent', - }, - '.cm-activeLine': { - backgroundColor: 'transparent', - }, - '.cm-scroller': { - overflow: 'auto', - }, - '.cm-content': { - caretColor: 'var(--color-text)', - }, - '.cm-cursor': { - borderLeftColor: 'var(--color-text)', - }, - '.cm-selectionBackground': { - backgroundColor: 'rgba(59, 130, 246, 0.3) !important', - }, - // Diff-specific line/block backgrounds +/** Diff-specific theme — merge toolbar, changed/deleted line backgrounds, collapse markers */ +const diffSpecificTheme = EditorView.theme({ '.cm-changedLine': { backgroundColor: '#1a3a1a !important' }, '.cm-deletedChunk': { backgroundColor: '#241517', position: 'relative', overflow: 'visible' }, '.cm-insertedLine': { backgroundColor: '#1a3a1a !important' }, @@ -476,7 +356,8 @@ export const CodeMirrorDiffView = ({ const buildExtensions = useCallback(() => { const extensions: Extension[] = [ - diffTheme, + baseEditorTheme, + diffSpecificTheme, lineNumbers(), syntaxHighlighting(oneDarkHighlightStyle), EditorView.editable.of(!readOnly), diff --git a/src/renderer/components/team/review/ReviewFileTree.tsx b/src/renderer/components/team/review/ReviewFileTree.tsx index 6076dae2..97b92370 100644 --- a/src/renderer/components/team/review/ReviewFileTree.tsx +++ b/src/renderer/components/team/review/ReviewFileTree.tsx @@ -4,6 +4,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { getFileHunkCount } from '@renderer/store/slices/changeReviewSlice'; +import { buildTree, sortTreeNodes } from '@renderer/utils/fileTreeBuilder'; import { Check, ChevronRight, @@ -16,6 +17,7 @@ import { X as XIcon, } from 'lucide-react'; +import type { TreeNode } from '@renderer/utils/fileTreeBuilder'; import type { HunkDecision } from '@shared/types'; import type { FileChangeSummary } from '@shared/types/review'; @@ -29,59 +31,8 @@ interface ReviewFileTreeProps { activeFilePath?: string; } -interface TreeNode { - name: string; - fullPath: string; - isFile: boolean; - file?: FileChangeSummary; - children: TreeNode[]; -} - type FileStatus = 'pending' | 'accepted' | 'rejected' | 'mixed'; -function buildTree(files: FileChangeSummary[]): TreeNode[] { - const root: TreeNode = { name: '', fullPath: '', isFile: false, children: [] }; - - for (const file of files) { - const parts = file.relativePath.split('/'); - let current = root; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - const isLast = i === parts.length - 1; - const fullPath = parts.slice(0, i + 1).join('/'); - - let child = current.children.find((c) => c.name === part); - if (!child) { - child = { - name: part, - fullPath, - isFile: isLast, - file: isLast ? file : undefined, - children: [], - }; - current.children.push(child); - } - current = child; - } - } - - function collapse(node: TreeNode): TreeNode { - const collapsed: TreeNode = { ...node, children: node.children.map(collapse) }; - if (!collapsed.isFile && collapsed.children.length === 1 && !collapsed.children[0].isFile) { - const child = collapsed.children[0]; - return { - ...child, - name: `${collapsed.name}/${child.name}`, - children: child.children, - }; - } - return collapsed; - } - - return collapse(root).children; -} - function getFileStatus( file: FileChangeSummary, hunkDecisions: Record, @@ -157,7 +108,7 @@ const TreeItem = ({ collapsedFolders, onToggleFolder, }: { - node: TreeNode; + node: TreeNode; selectedFilePath: string | null; activeFilePath?: string; onSelectFile: (filePath: string) => void; @@ -169,14 +120,14 @@ const TreeItem = ({ collapsedFolders: Set; onToggleFolder: (fullPath: string) => void; }): JSX.Element => { - if (node.isFile && node.file) { - const isSelected = node.file.filePath === selectedFilePath; - const isActive = node.file.filePath === activeFilePath && !isSelected; - const status = getFileStatus(node.file, hunkDecisions, fileDecisions, fileChunkCounts); + if (node.isFile && node.data) { + const isSelected = node.data.filePath === selectedFilePath; + const isActive = node.data.filePath === activeFilePath && !isSelected; + const status = getFileStatus(node.data, hunkDecisions, fileDecisions, fileChunkCounts); return ( @@ -239,27 +190,22 @@ const TreeItem = ({ {node.name} {isOpen && - [...node.children] - .sort((a, b) => { - if (a.isFile !== b.isFile) return a.isFile ? 1 : -1; - return a.name.localeCompare(b.name); - }) - .map((child) => ( - - ))} + sortTreeNodes(node.children).map((child) => ( + + ))}
    ); }; @@ -274,12 +220,12 @@ function applyExpandAncestors(prev: Set, ancestors: string[]): Set[], filePath: string): string[] { const paths: string[] = []; - function walk(nodes: TreeNode[], ancestors: string[]): boolean { + function walk(nodes: TreeNode[], ancestors: string[]): boolean { for (const node of nodes) { - if (node.isFile && node.file?.filePath === filePath) { + if (node.isFile && node.data?.filePath === filePath) { paths.push(...ancestors); return true; } @@ -304,7 +250,7 @@ export const ReviewFileTree = ({ const hunkDecisions = useStore((state) => state.hunkDecisions); const fileDecisions = useStore((state) => state.fileDecisions); const fileChunkCounts = useStore((state) => state.fileChunkCounts); - const tree = useMemo(() => buildTree(files), [files]); + const tree = useMemo(() => buildTree(files, (f) => f.relativePath), [files]); const [collapsedFolders, setCollapsedFolders] = useState>(() => new Set()); const toggleFolder = useCallback((fullPath: string) => { @@ -350,27 +296,22 @@ export const ReviewFileTree = ({ return (
    - {[...tree] - .sort((a, b) => { - if (a.isFile !== b.isFile) return a.isFile ? 1 : -1; - return a.name.localeCompare(b.name); - }) - .map((node) => ( - - ))} + {sortTreeNodes(tree).map((node) => ( + + ))}
    ); }; diff --git a/src/renderer/hooks/useEditorKeyboardShortcuts.ts b/src/renderer/hooks/useEditorKeyboardShortcuts.ts new file mode 100644 index 00000000..d2f87e0c --- /dev/null +++ b/src/renderer/hooks/useEditorKeyboardShortcuts.ts @@ -0,0 +1,218 @@ +/** + * useEditorKeyboardShortcuts — keyboard shortcuts scoped to the project editor overlay. + * + * All shortcuts use stopPropagation to prevent conflicts with global useKeyboardShortcuts. + * CM6-internal shortcuts (Cmd+Z, Cmd+Shift+Z, Cmd+A, Cmd+D) are handled by CodeMirror directly. + */ + +import { useCallback, useEffect } from 'react'; + +import { gotoLine, openSearchPanel } from '@codemirror/search'; +import { useStore } from '@renderer/store'; +import { editorBridge } from '@renderer/utils/editorBridge'; + +import type { EditorFileTab } from '@shared/types/editor'; + +// ============================================================================= +// Types +// ============================================================================= + +interface UseEditorKeyboardShortcutsOptions { + onToggleQuickOpen: () => void; + onToggleSearchPanel: () => void; + onToggleSidebar: () => void; + onClose: () => void; +} + +/** Dependencies injected into the key handler for testability. */ +export interface EditorKeyHandlerDeps { + activeTabId: string | null; + openTabs: EditorFileTab[]; + setActiveTab: (id: string) => void; + saveFile: (tabId: string) => Promise; + saveAllFiles: () => Promise; + hasUnsavedChanges: () => boolean; + onToggleQuickOpen: () => void; + onToggleSearchPanel: () => void; + onToggleSidebar: () => void; + getEditorView: () => { dispatch: unknown } | null; +} + +// ============================================================================= +// Pure key handler (exported for testing) +// ============================================================================= + +/** + * Create a keyboard event handler for editor shortcuts. + * Extracted from the hook for unit-testability. + */ +export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: KeyboardEvent) => void { + return (e: KeyboardEvent) => { + const isMod = e.metaKey || e.ctrlKey; + if (!isMod) return; + + // Cmd+P: Quick Open + if (e.key === 'p' && !e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + deps.onToggleQuickOpen(); + return; + } + + // Cmd+Shift+F: Search in files + if (e.key === 'f' && e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + deps.onToggleSearchPanel(); + return; + } + + // Cmd+F: Find in current file (CM6) + if (e.key === 'f' && !e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + const view = deps.getEditorView(); + if (view) openSearchPanel(view as Parameters[0]); + return; + } + + // Cmd+G: Go to line + if (e.key === 'g' && !e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + const view = deps.getEditorView(); + if (view) gotoLine(view as Parameters[0]); + return; + } + + // Cmd+S: Save current file + if (e.key === 's' && !e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + if (deps.activeTabId) void deps.saveFile(deps.activeTabId); + return; + } + + // Cmd+Shift+S: Save all files + if (e.key === 's' && e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + if (deps.hasUnsavedChanges()) void deps.saveAllFiles(); + return; + } + + // Cmd+W: Close current editor tab + if (e.key === 'w' && !e.shiftKey && !e.altKey) { + e.preventDefault(); + e.stopPropagation(); + if (deps.activeTabId) { + // Let overlay handle dirty check via onRequestCloseTab + const closeEvent = new CustomEvent('editor-close-tab', { detail: deps.activeTabId }); + window.dispatchEvent(closeEvent); + } + return; + } + + // Cmd+B: Toggle sidebar + if (e.key === 'b') { + e.preventDefault(); + e.stopPropagation(); + deps.onToggleSidebar(); + return; + } + + // Cmd+Shift+]: Next tab + if (e.key === ']' && e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + const idx = deps.openTabs.findIndex((t) => t.id === deps.activeTabId); + if (idx !== -1 && idx < deps.openTabs.length - 1) { + deps.setActiveTab(deps.openTabs[idx + 1].id); + } else if (deps.openTabs.length > 0) { + deps.setActiveTab(deps.openTabs[0].id); // wrap + } + return; + } + + // Cmd+Shift+[: Previous tab + if (e.key === '[' && e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + const idx = deps.openTabs.findIndex((t) => t.id === deps.activeTabId); + if (idx > 0) { + deps.setActiveTab(deps.openTabs[idx - 1].id); + } else if (deps.openTabs.length > 0) { + deps.setActiveTab(deps.openTabs[deps.openTabs.length - 1].id); // wrap + } + return; + } + + // Ctrl+Tab / Ctrl+Shift+Tab: Tab cycling + if (e.ctrlKey && e.key === 'Tab') { + e.preventDefault(); + e.stopPropagation(); + const idx = deps.openTabs.findIndex((t) => t.id === deps.activeTabId); + if (e.shiftKey) { + const prev = idx > 0 ? idx - 1 : deps.openTabs.length - 1; + if (deps.openTabs[prev]) deps.setActiveTab(deps.openTabs[prev].id); + } else { + const next = idx < deps.openTabs.length - 1 ? idx + 1 : 0; + if (deps.openTabs[next]) deps.setActiveTab(deps.openTabs[next].id); + } + } + + // Escape: Close editor (handled separately in overlay with dialog guards) + }; +} + +// ============================================================================= +// Hook +// ============================================================================= + +export function useEditorKeyboardShortcuts({ + onToggleQuickOpen, + onToggleSearchPanel, + onToggleSidebar, + onClose: _onClose, +}: UseEditorKeyboardShortcutsOptions): void { + const openTabs = useStore((s) => s.editorOpenTabs); + const activeTabId = useStore((s) => s.editorActiveTabId); + const setActiveTab = useStore((s) => s.setActiveTab); + const saveFile = useStore((s) => s.saveFile); + const saveAllFiles = useStore((s) => s.saveAllFiles); + const hasUnsavedChanges = useStore((s) => s.hasUnsavedChanges); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + const handler = createEditorKeyHandler({ + activeTabId, + openTabs, + setActiveTab, + saveFile, + saveAllFiles, + hasUnsavedChanges, + onToggleQuickOpen, + onToggleSearchPanel, + onToggleSidebar, + getEditorView: () => editorBridge.getView(), + }); + handler(e); + }, + [ + activeTabId, + openTabs, + setActiveTab, + saveFile, + saveAllFiles, + hasUnsavedChanges, + onToggleQuickOpen, + onToggleSearchPanel, + onToggleSidebar, + ] + ); + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown, true); // capture phase + return () => window.removeEventListener('keydown', handleKeyDown, true); + }, [handleKeyDown]); +} diff --git a/src/renderer/hooks/useKeyboardShortcuts.ts b/src/renderer/hooks/useKeyboardShortcuts.ts index 3706c897..de229a33 100644 --- a/src/renderer/hooks/useKeyboardShortcuts.ts +++ b/src/renderer/hooks/useKeyboardShortcuts.ts @@ -42,6 +42,7 @@ export function useKeyboardShortcuts(): void { activeContextId, switchContext, isContextSwitching, + editorOpen, } = useStore( useShallow((s) => ({ openTabs: s.openTabs, @@ -69,6 +70,7 @@ export function useKeyboardShortcuts(): void { activeContextId: s.activeContextId, switchContext: s.switchContext, isContextSwitching: s.isContextSwitching, + editorOpen: s.editorProjectPath !== null, })) ); @@ -77,6 +79,24 @@ export function useKeyboardShortcuts(): void { // Check if Cmd (macOS) or Ctrl (Windows/Linux) is pressed const isMod = event.metaKey || event.ctrlKey; + // Editor scope guard: when the editor overlay is open, these shortcuts are + // handled by useEditorKeyboardShortcuts — yield control to avoid conflicts. + if (editorOpen) { + const isConflicting = + // Ctrl+Tab — editor tab cycling + (event.ctrlKey && event.key === 'Tab') || + // Cmd+W — editor close tab + (isMod && event.key === 'w' && !event.altKey && !event.shiftKey) || + // Cmd+B — editor sidebar toggle + (isMod && event.key === 'b') || + // Cmd+F — editor find in file (CM6) + (isMod && event.key === 'f') || + // Cmd+Shift+[ / ] — editor tab switching + (isMod && event.shiftKey && (event.key === '[' || event.key === ']')); + + if (isConflicting) return; + } + // Ctrl+Tab / Ctrl+Shift+Tab: Switch tabs within focused pane (universal shortcut) if (event.ctrlKey && event.key === 'Tab') { event.preventDefault(); @@ -303,5 +323,6 @@ export function useKeyboardShortcuts(): void { activeContextId, switchContext, isContextSwitching, + editorOpen, ]); } diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index a97e396d..9a22828b 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -12,6 +12,7 @@ import { createConfigSlice } from './slices/configSlice'; import { createConnectionSlice } from './slices/connectionSlice'; import { createContextSlice } from './slices/contextSlice'; import { createConversationSlice } from './slices/conversationSlice'; +import { createEditorSlice } from './slices/editorSlice'; import { createNotificationSlice } from './slices/notificationSlice'; import { createPaneSlice } from './slices/paneSlice'; import { createProjectSlice } from './slices/projectSlice'; @@ -52,6 +53,7 @@ export const useStore = create()((...args) => ({ ...createUpdateSlice(...args), ...createChangeReviewSlice(...args), ...createCliInstallerSlice(...args), + ...createEditorSlice(...args), })); // ============================================================================= @@ -363,6 +365,19 @@ export function initializeNotificationListeners(): () => void { } } + // Listen for editor file change events (chokidar watcher → renderer) + if (api.editor?.onEditorChange) { + const cleanup = api.editor.onEditorChange((event) => { + const state = useStore.getState(); + if (state.editorProjectPath) { + state.handleExternalFileChange(event); + } + }); + if (typeof cleanup === 'function') { + cleanupFns.push(cleanup); + } + } + // Auto-check CLI status on startup if (api.cliInstaller) { void useStore.getState().fetchCliStatus(); diff --git a/src/renderer/store/slices/editorSlice.ts b/src/renderer/store/slices/editorSlice.ts new file mode 100644 index 00000000..f8eedeb5 --- /dev/null +++ b/src/renderer/store/slices/editorSlice.ts @@ -0,0 +1,928 @@ +/** + * Editor slice — manages project editor state. + * + * Group 1: File tree state + actions (iter-1) + * Group 2: Tab management (iter-2) + * Group 3: Dirty/save state (iter-2) + * Group 4: File operations (iter-3) + */ + +import { api } from '@renderer/api'; +import { getLanguageFromFileName } from '@renderer/utils/codemirrorLanguages'; +import { editorBridge } from '@renderer/utils/editorBridge'; +import { computeDisambiguatedTabs } from '@renderer/utils/tabLabelDisambiguation'; +import { createLogger } from '@shared/utils/logger'; + +import type { AppState } from '../types'; +import type { + EditorFileChangeEvent, + EditorFileTab, + FileTreeEntry, + GitFileStatus, +} from '@shared/types/editor'; +import type { StateCreator } from 'zustand'; + +const log = createLogger('Store:editor'); + +/** Remove a key from a record without triggering unused-variable linting. */ +function omitKey(record: Record, key: string): Record { + const result = { ...record }; + delete result[key]; + return result; +} + +/** + * Cooldown map: filePath → timestamp of last successful save. + * + * Used to suppress watcher events that arrive after editorSaving is cleared + * (race condition: atomic write → IPC response → clear saving flag → watcher fires). + * macOS FSEvents can delay up to ~1s; 2s cooldown covers all platforms safely. + * + * Module-level (not in store state) to avoid unnecessary re-renders. + */ +const recentSaveTimestamps = new Map(); +const SAVE_COOLDOWN_MS = 2000; + +/** + * Cooldown map: filePath → timestamp of last successful move. + * Suppresses watcher events triggered by our own move operations. + */ +const recentMoveTimestamps = new Map(); +const MOVE_COOLDOWN_MS = 2000; + +// ============================================================================= +// Slice Interface +// ============================================================================= + +export interface EditorSlice { + // ═══════════════════════════════════════════════════════ + // Group 1: File tree state + actions + // ═══════════════════════════════════════════════════════ + editorProjectPath: string | null; + editorFileTree: FileTreeEntry[] | null; + editorFileTreeLoading: boolean; + editorFileTreeError: string | null; + editorExpandedDirs: Record; + + openEditor: (projectPath: string) => Promise; + closeEditor: () => void; + loadFileTree: (dirPath: string) => Promise; + expandDirectory: (dirPath: string) => Promise; + collapseDirectory: (dirPath: string) => void; + + // ═══════════════════════════════════════════════════════ + // Group 2: Tab management + // ═══════════════════════════════════════════════════════ + editorOpenTabs: EditorFileTab[]; + editorActiveTabId: string | null; + + openFile: (filePath: string) => void; + closeTab: (tabId: string) => void; + setActiveTab: (tabId: string) => void; + + // ═══════════════════════════════════════════════════════ + // Group 3: Content + Save + // Content lives in EditorState (Map in useRef). + // Store only tracks dirty flags, loading, and save status. + // ═══════════════════════════════════════════════════════ + editorFileLoading: Record; + editorModifiedFiles: Record; + editorSaving: Record; + editorSaveError: Record; + + markFileModified: (filePath: string) => void; + markFileSaved: (filePath: string) => void; + saveFile: (filePath: string) => Promise; + saveAllFiles: () => Promise; + discardChanges: (filePath: string) => void; + hasUnsavedChanges: () => boolean; + + // ═══════════════════════════════════════════════════════ + // Group 4: File operations (iter-3) + // ═══════════════════════════════════════════════════════ + editorCreating: boolean; + editorCreateError: string | null; + + createFileInTree: (parentDir: string, fileName: string) => Promise; + createDirInTree: (parentDir: string, dirName: string) => Promise; + deleteFileFromTree: (filePath: string) => Promise; + moveFileInTree: (sourcePath: string, destDir: string) => Promise; + + // ═══════════════════════════════════════════════════════ + // Group 5: Git status + file watcher + line wrap (iter-5) + // ═══════════════════════════════════════════════════════ + editorGitFiles: GitFileStatus[]; + editorGitBranch: string | null; + editorIsGitRepo: boolean; + editorGitLoading: boolean; + editorWatcherEnabled: boolean; + editorLineWrap: boolean; + /** Files changed on disk while open (absolute paths) */ + editorExternalChanges: Record; + /** Baseline mtime per file (for conflict detection) */ + editorFileMtimes: Record; + /** File path with active save conflict (null = no conflict) */ + editorConflictFile: string | null; + + fetchGitStatus: () => Promise; + toggleWatcher: (enable: boolean) => Promise; + toggleLineWrap: () => void; + handleExternalFileChange: (event: EditorFileChangeEvent) => void; + clearExternalChange: (filePath: string) => void; + setFileMtime: (filePath: string, mtimeMs: number) => void; + forceOverwrite: (filePath: string) => Promise; + resolveConflict: () => void; +} + +// ============================================================================= +// Slice Creator +// ============================================================================= + +export const createEditorSlice: StateCreator = (set, get) => ({ + // Group 1 initial state + editorProjectPath: null, + editorFileTree: null, + editorFileTreeLoading: false, + editorFileTreeError: null, + editorExpandedDirs: {}, + + // Group 2 initial state + editorOpenTabs: [], + editorActiveTabId: null, + + // Group 3 initial state + editorFileLoading: {}, + editorModifiedFiles: {}, + editorSaving: {}, + editorSaveError: {}, + + // Group 4 initial state + editorCreating: false, + editorCreateError: null, + + // Group 5 initial state + editorGitFiles: [], + editorGitBranch: null, + editorIsGitRepo: false, + editorGitLoading: false, + editorWatcherEnabled: false, + editorLineWrap: (() => { + try { + return localStorage.getItem('editor-line-wrap') === 'true'; + } catch { + return false; + } + })(), + editorExternalChanges: {}, + editorFileMtimes: {}, + editorConflictFile: null, + + // ═══════════════════════════════════════════════════════ + // Group 1: File tree actions + // ═══════════════════════════════════════════════════════ + + openEditor: async (projectPath: string) => { + set({ + editorProjectPath: projectPath, + editorFileTree: null, + editorFileTreeLoading: true, + editorFileTreeError: null, + editorExpandedDirs: {}, + editorOpenTabs: [], + editorActiveTabId: null, + editorFileLoading: {}, + editorModifiedFiles: {}, + editorSaving: {}, + editorSaveError: {}, + editorCreating: false, + editorCreateError: null, + editorGitFiles: [], + editorGitBranch: null, + editorIsGitRepo: false, + editorGitLoading: false, + editorWatcherEnabled: false, + editorExternalChanges: {}, + editorFileMtimes: {}, + editorConflictFile: null, + }); + + try { + await api.editor.open(projectPath); + const result = await api.editor.readDir(projectPath); + set({ + editorFileTree: result.entries, + editorFileTreeLoading: false, + }); + + // Fetch git status in background (non-blocking) + void get().fetchGitStatus(); + + // Auto-enable file watcher (standard editor behavior) + void get().toggleWatcher(true); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log.error('Failed to open editor:', message); + set({ + editorFileTreeLoading: false, + editorFileTreeError: message, + }); + } + }, + + closeEditor: () => { + // Clear cooldown timestamps (no stale entries across editor sessions) + recentSaveTimestamps.clear(); + recentMoveTimestamps.clear(); + + // Best-effort IPC cleanup + api.editor.close().catch((e: unknown) => { + log.error('editor:close failed:', e); + }); + + // Cleanup bridge (destroys EditorView, clears caches) + editorBridge.destroy(); + + set({ + editorProjectPath: null, + editorFileTree: null, + editorFileTreeLoading: false, + editorFileTreeError: null, + editorExpandedDirs: {}, + editorOpenTabs: [], + editorActiveTabId: null, + editorFileLoading: {}, + editorModifiedFiles: {}, + editorSaving: {}, + editorSaveError: {}, + editorCreating: false, + editorCreateError: null, + editorGitFiles: [], + editorGitBranch: null, + editorIsGitRepo: false, + editorGitLoading: false, + editorWatcherEnabled: false, + editorExternalChanges: {}, + editorFileMtimes: {}, + editorConflictFile: null, + }); + }, + + loadFileTree: async (dirPath: string) => { + set({ editorFileTreeLoading: true, editorFileTreeError: null }); + + try { + const result = await api.editor.readDir(dirPath); + set({ + editorFileTree: result.entries, + editorFileTreeLoading: false, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log.error('Failed to load file tree:', message); + set({ + editorFileTreeLoading: false, + editorFileTreeError: message, + }); + } + }, + + expandDirectory: async (dirPath: string) => { + const { editorExpandedDirs, editorFileTree } = get(); + + // Mark as expanded immediately for responsive UI + set({ + editorExpandedDirs: { ...editorExpandedDirs, [dirPath]: true }, + }); + + try { + const result = await api.editor.readDir(dirPath); + const updatedTree = mergeChildrenIntoTree(editorFileTree ?? [], dirPath, result.entries); + set({ editorFileTree: updatedTree }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log.error('Failed to expand directory:', message); + const current = get().editorExpandedDirs; + set({ editorExpandedDirs: omitKey(current, dirPath) }); + } + }, + + collapseDirectory: (dirPath: string) => { + const { editorExpandedDirs } = get(); + set({ editorExpandedDirs: omitKey(editorExpandedDirs, dirPath) }); + }, + + // ═══════════════════════════════════════════════════════ + // Group 2: Tab management + // ═══════════════════════════════════════════════════════ + + openFile: (filePath: string) => { + const { editorOpenTabs } = get(); + + // Dedup: if file already open, just activate it + const existing = editorOpenTabs.find((t) => t.filePath === filePath); + if (existing) { + set({ editorActiveTabId: existing.id }); + return; + } + + const fileName = filePath.split('/').pop() ?? 'file'; + const language = getLanguageFromFileName(fileName); + + const tab: EditorFileTab = { + id: filePath, + filePath, + fileName, + language, + }; + + const newTabs = computeDisambiguatedTabs([...editorOpenTabs, tab]); + + set({ + editorOpenTabs: newTabs, + editorActiveTabId: tab.id, + }); + }, + + closeTab: (tabId: string) => { + const { editorOpenTabs, editorActiveTabId, editorModifiedFiles, editorSaveError } = get(); + const filtered = editorOpenTabs.filter((t) => t.id !== tabId); + + // Clean up dirty/error state for closed tab + const restModified = omitKey(editorModifiedFiles, tabId); + const restErrors = omitKey(editorSaveError, tabId); + + // Clear cached EditorState from bridge + editorBridge.deleteState(tabId); + + // Clear draft from localStorage + try { + localStorage.removeItem(`editor-draft:${tabId}`); + } catch { + // localStorage may not be available + } + + let newActiveId = editorActiveTabId; + if (editorActiveTabId === tabId) { + // Activate adjacent tab + const closedIndex = editorOpenTabs.findIndex((t) => t.id === tabId); + if (filtered.length > 0) { + newActiveId = filtered[Math.min(closedIndex, filtered.length - 1)].id; + } else { + newActiveId = null; + } + } + + // Recompute disambiguation after removing tab + const disambiguated = computeDisambiguatedTabs(filtered); + + set({ + editorOpenTabs: disambiguated, + editorActiveTabId: newActiveId, + editorModifiedFiles: restModified, + editorSaveError: restErrors, + }); + }, + + setActiveTab: (tabId: string) => { + set({ editorActiveTabId: tabId }); + }, + + // ═══════════════════════════════════════════════════════ + // Group 3: Content + Save + // ═══════════════════════════════════════════════════════ + + markFileModified: (filePath: string) => { + const { editorModifiedFiles } = get(); + if (editorModifiedFiles[filePath]) return; // Already marked + set({ editorModifiedFiles: { ...editorModifiedFiles, [filePath]: true } }); + }, + + markFileSaved: (filePath: string) => { + const { editorModifiedFiles } = get(); + set({ editorModifiedFiles: omitKey(editorModifiedFiles, filePath) }); + }, + + saveFile: async (filePath: string) => { + const content = editorBridge.getContent(filePath); + if (content === null) { + log.error('saveFile: no content available for', filePath); + return; + } + + set((s) => ({ + editorSaving: { ...s.editorSaving, [filePath]: true }, + editorSaveError: omitKey(s.editorSaveError, filePath), + })); + + try { + // Pass baseline mtime for conflict detection (if available) + const baselineMtime = get().editorFileMtimes[filePath]; + const result = await api.editor.writeFile(filePath, content, baselineMtime); + + // Record save timestamp BEFORE clearing editorSaving (watcher race guard) + recentSaveTimestamps.set(filePath, Date.now()); + + // Update baseline mtime with the new value after successful save + set((s) => ({ + editorModifiedFiles: omitKey(s.editorModifiedFiles, filePath), + editorSaving: omitKey(s.editorSaving, filePath), + editorFileMtimes: { ...s.editorFileMtimes, [filePath]: result.mtimeMs }, + editorExternalChanges: omitKey(s.editorExternalChanges, filePath), + })); + + try { + localStorage.removeItem(`editor-draft:${filePath}`); + } catch { + // localStorage may not be available + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + // Handle conflict errors specifically + if (message.startsWith('CONFLICT')) { + log.error('Save conflict detected:', filePath); + set((s) => ({ + editorSaving: omitKey(s.editorSaving, filePath), + editorConflictFile: filePath, + })); + return; + } + + log.error('Failed to save file:', message); + set((s) => ({ + editorSaving: omitKey(s.editorSaving, filePath), + editorSaveError: { ...s.editorSaveError, [filePath]: message }, + })); + } + }, + + saveAllFiles: async () => { + const { editorModifiedFiles } = get(); + const modifiedContent = editorBridge.getAllModifiedContent(editorModifiedFiles); + + const promises: Promise[] = []; + for (const [filePath, content] of modifiedContent) { + promises.push( + (async () => { + set((s) => ({ + editorSaving: { ...s.editorSaving, [filePath]: true }, + })); + + try { + const baselineMtime = get().editorFileMtimes[filePath]; + const result = await api.editor.writeFile(filePath, content, baselineMtime); + + // Record save timestamp BEFORE clearing editorSaving (watcher race guard) + recentSaveTimestamps.set(filePath, Date.now()); + + set((s) => ({ + editorModifiedFiles: omitKey(s.editorModifiedFiles, filePath), + editorSaving: omitKey(s.editorSaving, filePath), + editorFileMtimes: { ...s.editorFileMtimes, [filePath]: result.mtimeMs }, + editorExternalChanges: omitKey(s.editorExternalChanges, filePath), + })); + try { + localStorage.removeItem(`editor-draft:${filePath}`); + } catch { + // ignore + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + if (message.startsWith('CONFLICT')) { + log.error('Save conflict detected:', filePath); + set((s) => ({ + editorSaving: omitKey(s.editorSaving, filePath), + editorConflictFile: filePath, + })); + return; + } + + log.error('Failed to save file:', filePath, message); + set((s) => ({ + editorSaving: omitKey(s.editorSaving, filePath), + editorSaveError: { ...s.editorSaveError, [filePath]: message }, + })); + } + })() + ); + } + + await Promise.allSettled(promises); + }, + + discardChanges: (filePath: string) => { + const { editorModifiedFiles, editorSaveError } = get(); + set({ + editorModifiedFiles: omitKey(editorModifiedFiles, filePath), + editorSaveError: omitKey(editorSaveError, filePath), + }); + + try { + localStorage.removeItem(`editor-draft:${filePath}`); + } catch { + // localStorage may not be available + } + }, + + hasUnsavedChanges: () => { + return Object.keys(get().editorModifiedFiles).length > 0; + }, + + // ═══════════════════════════════════════════════════════ + // Group 4: File operations + // ═══════════════════════════════════════════════════════ + + createFileInTree: async (parentDir: string, fileName: string) => { + set({ editorCreating: true, editorCreateError: null }); + + try { + const result = await api.editor.createFile(parentDir, fileName); + + // Refresh parent directory in the tree + await refreshDirectory(get, set, parentDir); + + set({ editorCreating: false }); + return result.filePath; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log.error('Failed to create file:', message); + set({ editorCreating: false, editorCreateError: message }); + return null; + } + }, + + createDirInTree: async (parentDir: string, dirName: string) => { + set({ editorCreating: true, editorCreateError: null }); + + try { + const result = await api.editor.createDir(parentDir, dirName); + + // Refresh parent directory in the tree + await refreshDirectory(get, set, parentDir); + + set({ editorCreating: false }); + return result.dirPath; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log.error('Failed to create directory:', message); + set({ editorCreating: false, editorCreateError: message }); + return null; + } + }, + + deleteFileFromTree: async (filePath: string) => { + try { + await api.editor.deleteFile(filePath); + + // Close tab if the deleted file is open + const { editorOpenTabs } = get(); + const tabsToClose = editorOpenTabs.filter( + (t) => t.filePath === filePath || t.filePath.startsWith(filePath + '/') + ); + for (const tab of tabsToClose) { + get().closeTab(tab.id); + } + + // Refresh parent directory + const parentDir = filePath.substring(0, filePath.lastIndexOf('/')); + if (parentDir) { + await refreshDirectory(get, set, parentDir); + } + + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log.error('Failed to delete file:', message); + return false; + } + }, + + moveFileInTree: async (sourcePath: string, destDir: string) => { + const { editorSaving } = get(); + + // Guard: don't move during save + if (editorSaving[sourcePath]) { + log.error('moveFileInTree: blocked — file is being saved:', sourcePath); + return false; + } + + try { + const result = await api.editor.moveFile(sourcePath, destDir); + const newPath = result.newPath; + const oldParent = sourcePath.substring(0, sourcePath.lastIndexOf('/')); + + // Record move timestamps for watcher cooldown + recentMoveTimestamps.set(sourcePath, Date.now()); + recentMoveTimestamps.set(newPath, Date.now()); + + // Check if source was a directory (for prefix-based remapping) + const isDir = !sourcePath.includes('.') || sourcePath.endsWith('/'); + + // Atomic remap of all path-keyed state + set((s) => { + const tabs = s.editorOpenTabs.map((tab) => { + const remapped = remapPath(tab.filePath, sourcePath, newPath); + if (remapped === tab.filePath) return tab; + const fileName = remapped.split('/').pop() ?? 'file'; + return { + ...tab, + id: remapped, + filePath: remapped, + fileName, + language: getLanguageFromFileName(fileName), + }; + }); + + return { + editorOpenTabs: computeDisambiguatedTabs(tabs), + editorActiveTabId: + remapPath(s.editorActiveTabId ?? '', sourcePath, newPath) || s.editorActiveTabId, + editorModifiedFiles: remapRecord(s.editorModifiedFiles, sourcePath, newPath), + editorSaving: remapRecord(s.editorSaving, sourcePath, newPath), + editorSaveError: remapRecord(s.editorSaveError, sourcePath, newPath), + editorFileLoading: remapRecord(s.editorFileLoading, sourcePath, newPath), + editorExternalChanges: remapRecord(s.editorExternalChanges, sourcePath, newPath), + editorFileMtimes: remapRecord(s.editorFileMtimes, sourcePath, newPath), + editorExpandedDirs: remapRecord(s.editorExpandedDirs, sourcePath, newPath), + }; + }); + + // Remap bridge state for each affected tab + const { editorOpenTabs } = get(); + for (const tab of editorOpenTabs) { + // Check if this tab was affected by the move + const originalPath = reverseRemapPath(tab.filePath, sourcePath, newPath); + if (originalPath !== tab.filePath) { + editorBridge.remapState(originalPath, tab.filePath); + } + } + // Also remap for single file case + if (!isDir) { + editorBridge.remapState(sourcePath, newPath); + } + + // Remap localStorage drafts + try { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key?.startsWith('editor-draft:')) { + const draftPath = key.slice('editor-draft:'.length); + const remapped = remapPath(draftPath, sourcePath, newPath); + if (remapped !== draftPath) { + const value = localStorage.getItem(key); + localStorage.removeItem(key); + if (value !== null) localStorage.setItem(`editor-draft:${remapped}`, value); + } + } + } + } catch { + // localStorage may not be available + } + + // Remap recentSaveTimestamps + for (const [key, ts] of [...recentSaveTimestamps.entries()]) { + const remapped = remapPath(key, sourcePath, newPath); + if (remapped !== key) { + recentSaveTimestamps.delete(key); + recentSaveTimestamps.set(remapped, ts); + } + } + + // Refresh directories and git status in background + void refreshDirectory(get, set, oldParent); + void refreshDirectory(get, set, destDir); + void get().fetchGitStatus(); + + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log.error('moveFileInTree failed:', message); + return false; + } + }, + + // ═══════════════════════════════════════════════════════ + // Group 5: Git status + file watcher + line wrap + // ═══════════════════════════════════════════════════════ + + fetchGitStatus: async () => { + set({ editorGitLoading: true }); + try { + const result = await api.editor.gitStatus(); + set({ + editorGitFiles: result.files, + editorGitBranch: result.branch, + editorIsGitRepo: result.isGitRepo, + editorGitLoading: false, + }); + } catch (error) { + log.error('Failed to fetch git status:', error); + set({ editorGitLoading: false }); + } + }, + + toggleWatcher: async (enable: boolean) => { + try { + await api.editor.watchDir(enable); + set({ editorWatcherEnabled: enable }); + } catch (error) { + log.error('Failed to toggle watcher:', error); + } + }, + + toggleLineWrap: () => { + set((s) => { + const next = !s.editorLineWrap; + try { + localStorage.setItem('editor-line-wrap', String(next)); + } catch { + // localStorage may not be available + } + return { editorLineWrap: next }; + }); + }, + + handleExternalFileChange: (event: EditorFileChangeEvent) => { + const { editorOpenTabs, editorProjectPath, editorSaving } = get(); + + // Ignore watcher events for files we are currently saving (our own write) + if (editorSaving[event.path]) return; + + // Ignore watcher events within cooldown after save + // (covers race: save completes → editorSaving cleared → watcher fires late) + const lastSaveTime = recentSaveTimestamps.get(event.path); + if (lastSaveTime && Date.now() - lastSaveTime < SAVE_COOLDOWN_MS) return; + + // Ignore watcher events within cooldown after move + const lastMoveTime = recentMoveTimestamps.get(event.path); + if (lastMoveTime && Date.now() - lastMoveTime < MOVE_COOLDOWN_MS) return; + + // Track changes for open files + const isOpenFile = editorOpenTabs.some((t) => t.filePath === event.path); + if (isOpenFile || event.type === 'delete') { + set((s) => ({ + editorExternalChanges: { + ...s.editorExternalChanges, + [event.path]: event.type, + }, + })); + } + + // Refresh git status on any change + void get().fetchGitStatus(); + + // Refresh parent directory in tree for create/delete + if (event.type === 'create' || event.type === 'delete') { + const parentDir = event.path.substring(0, event.path.lastIndexOf('/')); + if (parentDir && editorProjectPath) { + void refreshDirectory(get, set, parentDir); + } + } + }, + + clearExternalChange: (filePath: string) => { + set((s) => ({ + editorExternalChanges: omitKey(s.editorExternalChanges, filePath), + })); + }, + + setFileMtime: (filePath: string, mtimeMs: number) => { + set((s) => ({ + editorFileMtimes: { ...s.editorFileMtimes, [filePath]: mtimeMs }, + })); + }, + + forceOverwrite: async (filePath: string) => { + const content = editorBridge.getContent(filePath); + if (content === null) { + log.error('forceOverwrite: no content available for', filePath); + return; + } + + set((s) => ({ + editorSaving: { ...s.editorSaving, [filePath]: true }, + editorConflictFile: null, + })); + + try { + // No baselineMtimeMs → skip conflict check on backend + const result = await api.editor.writeFile(filePath, content); + + // Record save timestamp BEFORE clearing editorSaving (watcher race guard) + recentSaveTimestamps.set(filePath, Date.now()); + + set((s) => ({ + editorModifiedFiles: omitKey(s.editorModifiedFiles, filePath), + editorSaving: omitKey(s.editorSaving, filePath), + editorFileMtimes: { ...s.editorFileMtimes, [filePath]: result.mtimeMs }, + editorExternalChanges: omitKey(s.editorExternalChanges, filePath), + })); + + try { + localStorage.removeItem(`editor-draft:${filePath}`); + } catch { + // localStorage may not be available + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log.error('Failed to force overwrite:', message); + set((s) => ({ + editorSaving: omitKey(s.editorSaving, filePath), + editorSaveError: { ...s.editorSaveError, [filePath]: message }, + })); + } + }, + + resolveConflict: () => { + set({ editorConflictFile: null }); + }, +}); + +// ============================================================================= +// Helpers +// ============================================================================= + +/** + * Refresh a directory's children in the file tree via IPC readDir + merge. + */ +async function refreshDirectory( + get: () => AppState, + set: (partial: Partial) => void, + dirPath: string +): Promise { + try { + const result = await api.editor.readDir(dirPath); + const currentTree = get().editorFileTree; + if (currentTree) { + const updatedTree = mergeChildrenIntoTree(currentTree, dirPath, result.entries); + set({ editorFileTree: updatedTree }); + } + } catch (error) { + log.error('Failed to refresh directory:', error); + } +} + +/** + * Remap a single path: if it matches oldPath exactly or is a child of oldPath, + * replace the prefix with newPath. + */ +function remapPath(p: string, oldPath: string, newPath: string): string { + if (p === oldPath) return newPath; + if (p.startsWith(oldPath + '/')) { + return newPath + p.slice(oldPath.length); + } + return p; +} + +/** + * Reverse remap: given a potentially-remapped path, recover the original path. + * Used to identify which bridge caches to remap. + */ +function reverseRemapPath(p: string, oldPath: string, newPath: string): string { + if (p === newPath) return oldPath; + if (p.startsWith(newPath + '/')) { + return oldPath + p.slice(newPath.length); + } + return p; +} + +/** + * Remap all keys in a Record that match or are children of oldPath. + */ +function remapRecord( + record: Record, + oldPath: string, + newPath: string +): Record { + const result: Record = {}; + let changed = false; + for (const [key, value] of Object.entries(record)) { + const remapped = remapPath(key, oldPath, newPath); + if (remapped !== key) changed = true; + result[remapped] = value; + } + return changed ? result : record; +} + +/** + * Recursively merge children into the tree at the matching directory path. + */ +function mergeChildrenIntoTree( + tree: FileTreeEntry[], + targetPath: string, + children: FileTreeEntry[] +): FileTreeEntry[] { + return tree.map((entry) => { + if (entry.path === targetPath && entry.type === 'directory') { + return { ...entry, children }; + } + if (entry.children) { + return { + ...entry, + children: mergeChildrenIntoTree(entry.children, targetPath, children), + }; + } + return entry; + }); +} diff --git a/src/renderer/store/types.ts b/src/renderer/store/types.ts index e6db4a6c..455d20fb 100644 --- a/src/renderer/store/types.ts +++ b/src/renderer/store/types.ts @@ -9,6 +9,7 @@ import type { ConfigSlice } from './slices/configSlice'; import type { ConnectionSlice } from './slices/connectionSlice'; import type { ContextSlice } from './slices/contextSlice'; import type { ConversationSlice } from './slices/conversationSlice'; +import type { EditorSlice } from './slices/editorSlice'; import type { NotificationSlice } from './slices/notificationSlice'; import type { PaneSlice } from './slices/paneSlice'; import type { ProjectSlice } from './slices/projectSlice'; @@ -96,4 +97,5 @@ export type AppState = ProjectSlice & ContextSlice & UpdateSlice & ChangeReviewSlice & - CliInstallerSlice; + CliInstallerSlice & + EditorSlice; diff --git a/src/renderer/utils/buildSelectionAction.ts b/src/renderer/utils/buildSelectionAction.ts new file mode 100644 index 00000000..fc4adedc --- /dev/null +++ b/src/renderer/utils/buildSelectionAction.ts @@ -0,0 +1,80 @@ +/** + * Builds an EditorSelectionAction from a selection info + action type. + * + * Extracted as a utility so it can be imported in tests + * without pulling in CodeMirror dependencies. + */ + +import type { EditorSelectionAction, EditorSelectionInfo } from '@shared/types/editor'; + +// ============================================================================= +// Code fence language map (lowercase identifiers for markdown) +// ============================================================================= + +const CODE_FENCE_LANG: Record = { + ts: 'typescript', + tsx: 'tsx', + js: 'javascript', + jsx: 'jsx', + mjs: 'javascript', + cjs: 'javascript', + py: 'python', + json: 'json', + jsonl: 'json', + css: 'css', + scss: 'scss', + sass: 'sass', + less: 'less', + html: 'html', + htm: 'html', + xml: 'xml', + svg: 'xml', + md: 'markdown', + mdx: 'markdown', + yaml: 'yaml', + yml: 'yaml', + rs: 'rust', + go: 'go', + java: 'java', + c: 'c', + h: 'c', + cpp: 'cpp', + cxx: 'cpp', + cc: 'cpp', + hpp: 'cpp', + php: 'php', + sql: 'sql', + sh: 'bash', + bash: 'bash', + zsh: 'bash', + toml: 'toml', + ini: 'ini', +}; + +/** Maps file extension to a code fence language identifier (lowercase). */ +export function getCodeFenceLanguage(fileName: string): string { + const ext = fileName.split('.').pop()?.toLowerCase() ?? ''; + return CODE_FENCE_LANG[ext] ?? ''; +} + +/** Builds a selection action with a formatted markdown code fence context. */ +export function buildSelectionAction( + type: EditorSelectionAction['type'], + info: EditorSelectionInfo +): EditorSelectionAction { + const fileName = info.filePath.split('/').pop() ?? 'file'; + const lang = getCodeFenceLanguage(fileName); + const lineRef = + info.fromLine === info.toLine + ? `line ${info.fromLine}` + : `lines ${info.fromLine}-${info.toLine}`; + const formattedContext = `**${fileName}** (${lineRef}):\n\`\`\`${lang}\n${info.text}\n\`\`\``; + return { + type, + filePath: info.filePath, + fromLine: info.fromLine, + toLine: info.toLine, + selectedText: info.text, + formattedContext, + }; +} diff --git a/src/renderer/utils/codemirrorLanguages.ts b/src/renderer/utils/codemirrorLanguages.ts new file mode 100644 index 00000000..02c02f3f --- /dev/null +++ b/src/renderer/utils/codemirrorLanguages.ts @@ -0,0 +1,141 @@ +/** + * CodeMirror 6 language support — synchronous (bundled) + async fallback. + * + * Extracted from CodeMirrorDiffView.tsx for reuse by editor and diff views. + */ + +import { cpp } from '@codemirror/lang-cpp'; +import { css } from '@codemirror/lang-css'; +import { go } from '@codemirror/lang-go'; +import { html } from '@codemirror/lang-html'; +import { java } from '@codemirror/lang-java'; +import { javascript } from '@codemirror/lang-javascript'; +import { json } from '@codemirror/lang-json'; +import { less } from '@codemirror/lang-less'; +import { markdown } from '@codemirror/lang-markdown'; +import { php } from '@codemirror/lang-php'; +import { python } from '@codemirror/lang-python'; +import { rust } from '@codemirror/lang-rust'; +import { sass } from '@codemirror/lang-sass'; +import { sql } from '@codemirror/lang-sql'; +import { xml } from '@codemirror/lang-xml'; +import { yaml } from '@codemirror/lang-yaml'; +import { LanguageDescription } from '@codemirror/language'; +import { languages } from '@codemirror/language-data'; + +import type { Extension } from '@codemirror/state'; + +/** Synchronous language extension for common file types (bundled by Vite) */ +export function getSyncLanguageExtension(fileName: string): Extension | null { + const ext = fileName.split('.').pop()?.toLowerCase(); + switch (ext) { + case 'ts': + case 'tsx': + case 'js': + case 'jsx': + case 'mjs': + case 'cjs': + return javascript({ + jsx: ext === 'tsx' || ext === 'jsx', + typescript: ext === 'ts' || ext === 'tsx', + }); + case 'py': + return python(); + case 'json': + case 'jsonl': + return json(); + case 'css': + return css(); + case 'scss': + return sass({ indented: false }); + case 'sass': + return sass({ indented: true }); + case 'less': + return less(); + case 'html': + case 'htm': + return html(); + case 'xml': + case 'svg': + return xml(); + case 'md': + case 'mdx': + case 'markdown': + return markdown(); + case 'yaml': + case 'yml': + return yaml(); + case 'rs': + return rust(); + case 'go': + return go(); + case 'java': + return java(); + case 'c': + case 'h': + case 'cpp': + case 'cxx': + case 'cc': + case 'hpp': + return cpp(); + case 'php': + return php(); + case 'sql': + return sql(); + default: + return null; + } +} + +/** Async fallback: match by filename via @codemirror/language-data for rare languages */ +export function getAsyncLanguageDesc(fileName: string): LanguageDescription | null { + return LanguageDescription.matchFilename(languages, fileName); +} + +/** Human-readable language name from file extension (for status bar / tab labels) */ +export function getLanguageFromFileName(fileName: string): string { + const ext = fileName.split('.').pop()?.toLowerCase(); + const map: Record = { + ts: 'TypeScript', + tsx: 'TypeScript (JSX)', + js: 'JavaScript', + jsx: 'JavaScript (JSX)', + mjs: 'JavaScript', + cjs: 'JavaScript', + py: 'Python', + json: 'JSON', + jsonl: 'JSON Lines', + css: 'CSS', + scss: 'SCSS', + sass: 'Sass', + less: 'Less', + html: 'HTML', + htm: 'HTML', + xml: 'XML', + svg: 'SVG', + md: 'Markdown', + mdx: 'MDX', + markdown: 'Markdown', + yaml: 'YAML', + yml: 'YAML', + rs: 'Rust', + go: 'Go', + java: 'Java', + c: 'C', + h: 'C/C++ Header', + cpp: 'C++', + cxx: 'C++', + cc: 'C++', + hpp: 'C++ Header', + php: 'PHP', + sql: 'SQL', + sh: 'Shell', + bash: 'Bash', + zsh: 'Zsh', + toml: 'TOML', + ini: 'INI', + conf: 'Config', + txt: 'Plain Text', + }; + return map[ext ?? ''] ?? 'Plain Text'; +} diff --git a/src/renderer/utils/codemirrorTheme.ts b/src/renderer/utils/codemirrorTheme.ts new file mode 100644 index 00000000..be76b321 --- /dev/null +++ b/src/renderer/utils/codemirrorTheme.ts @@ -0,0 +1,52 @@ +/** + * Base CodeMirror 6 theme using CSS variables. + * + * Extracted from CodeMirrorDiffView.tsx — shared between diff view and project editor. + * Diff-specific styles (changedLine, deletedChunk, merge toolbar) stay in CodeMirrorDiffView. + */ + +import { EditorView } from '@codemirror/view'; + +/** Base editor theme — general styling without diff-specific rules */ +export const baseEditorTheme = EditorView.theme({ + '&': { + backgroundColor: 'var(--color-surface)', + color: 'var(--color-text)', + fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', + fontSize: '13px', + }, + '&.cm-focused': { + outline: 'none', + }, + '.cm-gutters': { + backgroundColor: 'var(--color-surface)', + borderRight: '1px solid var(--color-border)', + color: 'var(--color-text-muted)', + fontSize: '11px', + minWidth: 'auto', + }, + '.cm-lineNumbers .cm-gutterElement': { + padding: '0 4px 0 8px', + minWidth: '2ch', + textAlign: 'right', + opacity: '0.5', + }, + '.cm-activeLineGutter': { + backgroundColor: 'transparent', + }, + '.cm-activeLine': { + backgroundColor: 'transparent', + }, + '.cm-scroller': { + overflow: 'auto', + }, + '.cm-content': { + caretColor: 'var(--color-text)', + }, + '.cm-cursor': { + borderLeftColor: 'var(--color-text)', + }, + '.cm-selectionBackground': { + backgroundColor: 'rgba(59, 130, 246, 0.3) !important', + }, +}); diff --git a/src/renderer/utils/editorBridge.ts b/src/renderer/utils/editorBridge.ts new file mode 100644 index 00000000..910adbbd --- /dev/null +++ b/src/renderer/utils/editorBridge.ts @@ -0,0 +1,90 @@ +/** + * Module-level singleton bridging Zustand store ↔ CodeMirror refs. + * + * CodeMirrorEditor calls register() on mount, unregister() on unmount. + * Store actions (saveFile, saveAllFiles, closeEditor) use getContent()/destroy(). + * + * Pattern: analogous to ConfirmDialog.tsx (module-level globalSetState). + */ + +import type { EditorState } from '@codemirror/state'; +import type { EditorView } from '@codemirror/view'; + +let stateCache: Map | null = null; +let scrollTopCache: Map | null = null; +let activeView: EditorView | null = null; + +export const editorBridge = { + /** Called by CodeMirrorEditor on mount */ + register(sc: Map, stc: Map, view: EditorView): void { + stateCache = sc; + scrollTopCache = stc; + activeView = view; + }, + + /** Called by CodeMirrorEditor on unmount */ + unregister(): void { + stateCache = null; + scrollTopCache = null; + activeView = null; + }, + + /** Check if bridge is registered (HMR guard) */ + get isRegistered(): boolean { + return stateCache !== null; + }, + + /** Get content for a single file from cached EditorState */ + getContent(filePath: string): string | null { + return stateCache?.get(filePath)?.doc.toString() ?? null; + }, + + /** Get content for all modified files */ + getAllModifiedContent(modifiedFiles: Record): Map { + const result = new Map(); + for (const fp of Object.keys(modifiedFiles)) { + if (!modifiedFiles[fp]) continue; + const content = stateCache?.get(fp)?.doc.toString(); + if (content !== undefined) result.set(fp, content); + } + return result; + }, + + /** Remove cached state for a single tab — called by closeTab() */ + deleteState(tabId: string): void { + stateCache?.delete(tabId); + scrollTopCache?.delete(tabId); + }, + + /** Full cleanup — called by closeEditor() */ + destroy(): void { + activeView?.destroy(); + stateCache?.clear(); + scrollTopCache?.clear(); + activeView = null; + }, + + /** Remap cached state from oldPath to newPath (used by moveFileInTree) */ + remapState(oldPath: string, newPath: string): void { + const state = stateCache?.get(oldPath); + if (state) { + stateCache!.delete(oldPath); + stateCache!.set(newPath, state); + } + const scroll = scrollTopCache?.get(oldPath); + if (scroll !== undefined) { + scrollTopCache!.delete(oldPath); + scrollTopCache!.set(newPath, scroll); + } + }, + + /** Update view reference (on tab switch, view may be recreated) */ + updateView(view: EditorView): void { + activeView = view; + }, + + /** Get current EditorView (for undo/redo toolbar) */ + getView(): EditorView | null { + return activeView; + }, +}; diff --git a/src/renderer/utils/fileTreeBuilder.ts b/src/renderer/utils/fileTreeBuilder.ts new file mode 100644 index 00000000..076ed0d4 --- /dev/null +++ b/src/renderer/utils/fileTreeBuilder.ts @@ -0,0 +1,82 @@ +/** + * Generic tree builder — converts a flat list of items with paths + * into a hierarchical tree structure with single-child directory collapsing. + * + * Used by ReviewFileTree (FileChangeSummary) and EditorFileTree (FileTreeEntry). + */ + +export interface TreeNode { + name: string; + fullPath: string; + isFile: boolean; + data?: T; + children: TreeNode[]; +} + +/** + * Build a hierarchical tree from a flat list of items. + * + * @param items - Flat list of items (files/entries) + * @param getPath - Extract relative path from item (using '/' separator) + * @param options.collapse - Merge single-child intermediate directories (default: true) + */ +export function buildTree( + items: T[], + getPath: (item: T) => string, + options?: { collapse?: boolean } +): TreeNode[] { + const root: TreeNode = { name: '', fullPath: '', isFile: false, children: [] }; + + for (const item of items) { + const parts = getPath(item).split('/'); + let current = root; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const isLast = i === parts.length - 1; + const fullPath = parts.slice(0, i + 1).join('/'); + + let child = current.children.find((c) => c.name === part); + if (!child) { + child = { + name: part, + fullPath, + isFile: isLast, + data: isLast ? item : undefined, + children: [], + }; + current.children.push(child); + } + current = child; + } + } + + if (options?.collapse === false) { + return root.children; + } + + // Collapse children individually — root itself has empty name and must not participate + return root.children.map(collapseTree); +} + +/** Merge single-child intermediate directories: a/ → b/ → c becomes a/b/c */ +function collapseTree(node: TreeNode): TreeNode { + const collapsed: TreeNode = { ...node, children: node.children.map(collapseTree) }; + if (!collapsed.isFile && collapsed.children.length === 1 && !collapsed.children[0].isFile) { + const child = collapsed.children[0]; + return { + ...child, + name: `${collapsed.name}/${child.name}`, + children: child.children, + }; + } + return collapsed; +} + +/** Sort tree nodes: directories first, then alphabetical */ +export function sortTreeNodes(nodes: TreeNode[]): TreeNode[] { + return [...nodes].sort((a, b) => { + if (a.isFile !== b.isFile) return a.isFile ? 1 : -1; + return a.name.localeCompare(b.name); + }); +} diff --git a/src/renderer/utils/platformKeys.ts b/src/renderer/utils/platformKeys.ts new file mode 100644 index 00000000..e50646c1 --- /dev/null +++ b/src/renderer/utils/platformKeys.ts @@ -0,0 +1,24 @@ +/** + * Cross-platform keyboard shortcut display helpers. + * + * Mac shows symbols (cmd, shift, option, ctrl), Windows/Linux shows words (Ctrl+, Shift+, Alt+). + */ + +function detectMac(): boolean { + if (typeof navigator === 'undefined') return false; + // Prefer userAgentData (modern API) over deprecated navigator.platform + const platform = + (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ?? + navigator.userAgent; + return /mac/i.test(platform); +} + +export const IS_MAC = detectMac(); + +/** Return platform-appropriate modifier prefix: "cmd" on Mac, "Ctrl+" on others */ +export const MOD = IS_MAC ? '\u2318' : 'Ctrl+'; + +/** Return platform-appropriate shortcut string */ +export function shortcutLabel(mac: string, other: string): string { + return IS_MAC ? mac : other; +} diff --git a/src/renderer/utils/tabLabelDisambiguation.ts b/src/renderer/utils/tabLabelDisambiguation.ts new file mode 100644 index 00000000..eda75458 --- /dev/null +++ b/src/renderer/utils/tabLabelDisambiguation.ts @@ -0,0 +1,97 @@ +/** + * Tab label disambiguation — adds suffix labels when multiple tabs share the same file name. + * + * Algorithm: + * 1. Group tabs by fileName + * 2. For groups with >1 tab, find the minimal unique path suffix + * 3. Format as "(parent/dir)" — e.g. "(main/utils)", "(renderer/hooks)" + * 4. Unique file names get no label (disambiguatedLabel = undefined) + */ + +import type { EditorFileTab } from '@shared/types/editor'; + +/** + * Compute disambiguated labels for all tabs. + * Returns a new array with `disambiguatedLabel` set where needed. + */ +export function computeDisambiguatedTabs(tabs: EditorFileTab[]): EditorFileTab[] { + if (tabs.length === 0) return tabs; + + // Single tab — just clear any stale label + if (tabs.length === 1) { + const tab = tabs[0]; + if (tab.disambiguatedLabel === undefined) return tabs; + return [{ ...tab, disambiguatedLabel: undefined }]; + } + + // Group tabs by fileName + const groups = new Map(); + for (const tab of tabs) { + const existing = groups.get(tab.fileName); + if (existing) { + existing.push(tab); + } else { + groups.set(tab.fileName, [tab]); + } + } + + // Build a map of tabId → disambiguatedLabel + const labels = new Map(); + + for (const [, group] of groups) { + if (group.length <= 1) { + // Unique name — no label needed + for (const tab of group) { + labels.set(tab.id, undefined); + } + continue; + } + + // Split paths into segments for comparison + const pathSegments = group.map((tab) => { + const parts = tab.filePath.split('/'); + // Remove the file name (last segment) + parts.pop(); + return parts; + }); + + // Find minimal unique suffix depth + // Start from depth=1 (immediate parent) and go deeper until all labels are unique + let depth = 1; + const maxDepth = Math.max(...pathSegments.map((s) => s.length)); + + while (depth <= maxDepth) { + const suffixes = pathSegments.map((parts) => { + const start = Math.max(0, parts.length - depth); + return parts.slice(start).join('/'); + }); + + // Check if all suffixes are unique + const unique = new Set(suffixes); + if (unique.size === suffixes.length) { + // All unique — assign labels + for (let i = 0; i < group.length; i++) { + labels.set(group[i].id, `(${suffixes[i]})`); + } + break; + } + depth++; + } + + // If we couldn't find unique suffixes (shouldn't happen with different file paths), + // use full parent path + if (depth > maxDepth) { + for (let i = 0; i < group.length; i++) { + const fullParent = pathSegments[i].join('/'); + labels.set(group[i].id, `(${fullParent})`); + } + } + } + + // Apply labels to tabs + return tabs.map((tab) => { + const label = labels.get(tab.id); + if (label === tab.disambiguatedLabel) return tab; + return { ...tab, disambiguatedLabel: label }; + }); +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 831b5f53..9fb7da75 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -8,6 +8,7 @@ */ import type { CliInstallerAPI } from './cliInstaller'; +import type { EditorAPI } from './editor'; import type { AppConfig, DetectedError, @@ -638,6 +639,9 @@ export interface ElectronAPI { // Embedded Terminal API (xterm.js + node-pty) terminal: TerminalAPI; + + // Project Editor API (file browser + CodeMirror) + editor: EditorAPI; } // ============================================================================= diff --git a/src/shared/types/editor.ts b/src/shared/types/editor.ts new file mode 100644 index 00000000..7ab372c6 --- /dev/null +++ b/src/shared/types/editor.ts @@ -0,0 +1,214 @@ +/** + * Editor types shared between main and renderer processes. + */ + +// ============================================================================= +// File Tree +// ============================================================================= + +export interface FileTreeEntry { + name: string; + /** Absolute path */ + path: string; + type: 'file' | 'directory'; + /** File size in bytes (files only) */ + size?: number; + /** True for .env, .key, credentials, etc. — shown with lock icon */ + isSensitive?: boolean; + /** Lazy-loaded children (populated on expand) */ + children?: FileTreeEntry[]; +} + +// ============================================================================= +// IPC Results +// ============================================================================= + +export interface ReadDirResult { + entries: FileTreeEntry[]; + /** True when entries were truncated at MAX_DIR_ENTRIES */ + truncated: boolean; +} + +export interface ReadFileResult { + content: string; + size: number; + /** Unix timestamp (stats.mtimeMs) — baseline for conflict detection */ + mtimeMs: number; + /** True when file was too large and only preview was returned */ + truncated: boolean; + encoding: string; + isBinary: boolean; +} + +// ============================================================================= +// Write Request/Response +// ============================================================================= + +export interface WriteFileRequest { + filePath: string; + content: string; +} + +export interface WriteFileResponse { + /** Unix timestamp after write (new mtimeMs) */ + mtimeMs: number; + /** Bytes written */ + size: number; +} + +// ============================================================================= +// File Operations +// ============================================================================= + +export interface CreateFileResponse { + filePath: string; + mtimeMs: number; +} + +export interface CreateDirResponse { + dirPath: string; +} + +export interface DeleteFileResponse { + deletedPath: string; +} + +export interface MoveFileResponse { + newPath: string; +} + +// ============================================================================= +// Search +// ============================================================================= + +export interface SearchMatch { + /** 1-based line number */ + line: number; + /** 0-based column offset */ + column: number; + /** The matching line text (trimmed) */ + lineContent: string; +} + +export interface SearchFileResult { + filePath: string; + matches: SearchMatch[]; +} + +export interface SearchInFilesResult { + results: SearchFileResult[]; + /** Total number of matches across all files */ + totalMatches: number; + /** True when results were truncated at limit */ + truncated: boolean; +} + +export interface SearchInFilesOptions { + query: string; + caseSensitive?: boolean; + /** Maximum number of result files (default 100) */ + maxFiles?: number; + /** Maximum number of total matches (default 500) */ + maxMatches?: number; +} + +// ============================================================================= +// Tab +// ============================================================================= + +export interface EditorFileTab { + /** Unique key = filePath */ + id: string; + filePath: string; + fileName: string; + /** Disambiguation suffix for duplicate names, e.g. "(main/utils)" */ + disambiguatedLabel?: string; + /** Language identifier (from file extension) */ + language: string; +} + +// ============================================================================= +// Git Status +// ============================================================================= + +export type GitFileStatusType = + | 'modified' + | 'untracked' + | 'staged' + | 'deleted' + | 'conflict' + | 'renamed'; + +export interface GitFileStatus { + /** Relative path from project root */ + path: string; + status: GitFileStatusType; + /** Original path for renamed files */ + renamedFrom?: string; +} + +export interface GitStatusResult { + files: GitFileStatus[]; + /** True if the project is inside a git repository */ + isGitRepo: boolean; + /** Branch name (null if detached HEAD) */ + branch: string | null; +} + +// ============================================================================= +// File Watcher Events +// ============================================================================= + +export interface EditorFileChangeEvent { + type: 'change' | 'create' | 'delete'; + /** Absolute path of the changed file */ + path: string; +} + +// ============================================================================= +// Editor API +// ============================================================================= + +export interface EditorAPI { + open: (projectPath: string) => Promise; + close: () => Promise; + readDir: (dirPath: string, maxEntries?: number) => Promise; + readFile: (filePath: string) => Promise; + writeFile: ( + filePath: string, + content: string, + baselineMtimeMs?: number + ) => Promise; + createFile: (parentDir: string, fileName: string) => Promise; + createDir: (parentDir: string, dirName: string) => Promise; + deleteFile: (filePath: string) => Promise; + moveFile: (sourcePath: string, destDir: string) => Promise; + searchInFiles: (options: SearchInFilesOptions) => Promise; + gitStatus: () => Promise; + watchDir: (enable: boolean) => Promise; + /** Subscribe to file change events (main → renderer). Returns cleanup function. */ + onEditorChange: (callback: (event: EditorFileChangeEvent) => void) => () => void; +} + +// ============================================================================= +// Selection Action Menu +// ============================================================================= + +export interface EditorSelectionInfo { + text: string; + filePath: string; + fromLine: number; + toLine: number; + /** Screen coords of selection end (for menu positioning) */ + screenRect: { top: number; right: number; bottom: number }; +} + +export interface EditorSelectionAction { + type: 'sendMessage' | 'createTask'; + filePath: string; + fromLine: number; + toLine: number; + selectedText: string; + /** Pre-formatted context block (markdown code fence) */ + formattedContext: string; +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 44cc0d30..04fd0dd4 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -35,3 +35,6 @@ export type * from './cliInstaller'; // Re-export Terminal types export type * from './terminal'; + +// Re-export Editor types +export type * from './editor'; diff --git a/test/main/ipc/editor.test.ts b/test/main/ipc/editor.test.ts new file mode 100644 index 00000000..80617189 --- /dev/null +++ b/test/main/ipc/editor.test.ts @@ -0,0 +1,384 @@ +/** + * Tests for editor IPC handlers — validation, security, module-level state. + */ + +import * as os from 'os'; +import * as path from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock electron +vi.mock('electron', () => ({ + app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp') }, + Notification: Object.assign(vi.fn(), { isSupported: vi.fn(() => false) }), + BrowserWindow: { getAllWindows: vi.fn(() => []) }, +})); + +// Mock fs/promises +vi.mock('fs/promises', () => ({ + stat: vi.fn(), + lstat: vi.fn(), + readdir: vi.fn(), + readFile: vi.fn(), + realpath: vi.fn(), +})); + +// Mock isbinaryfile +vi.mock('isbinaryfile', () => ({ + isBinaryFile: vi.fn(), +})); + +// Mock IPC channels +vi.mock('@preload/constants/ipcChannels', () => ({ + EDITOR_OPEN: 'editor:open', + EDITOR_CLOSE: 'editor:close', + EDITOR_READ_DIR: 'editor:readDir', + EDITOR_READ_FILE: 'editor:readFile', + EDITOR_WRITE_FILE: 'editor:writeFile', + EDITOR_CREATE_FILE: 'editor:createFile', + EDITOR_CREATE_DIR: 'editor:createDir', + EDITOR_DELETE_FILE: 'editor:deleteFile', + EDITOR_MOVE_FILE: 'editor:moveFile', + EDITOR_SEARCH_IN_FILES: 'editor:searchInFiles', + EDITOR_GIT_STATUS: 'editor:gitStatus', + EDITOR_WATCH_DIR: 'editor:watchDir', + EDITOR_CHANGE: 'editor:change', +})); + +// Mock atomicWrite used by ProjectFileService +vi.mock('@main/utils/atomicWrite', () => ({ + atomicWriteAsync: vi.fn(), +})); + +// Mock simple-git (used by GitStatusService) +vi.mock('simple-git', () => { + const mockGit = { + status: vi.fn(), + revparse: vi.fn(), + env: vi.fn().mockReturnThis(), + }; + return { simpleGit: vi.fn(() => mockGit) }; +}); + +// Mock chokidar (used by EditorFileWatcher) +vi.mock('chokidar', () => ({ + watch: vi.fn(() => ({ + on: vi.fn().mockReturnThis(), + close: vi.fn().mockResolvedValue(undefined), + })), +})); + +// Mock logger +vi.mock('@shared/utils/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), +})); + +// Mock pathDecoder +vi.mock('@main/utils/pathDecoder', () => ({ + getClaudeBasePath: () => path.join(os.homedir(), '.claude'), +})); + +import * as fs from 'fs/promises'; + +import { + cleanupEditorState, + initializeEditorHandlers, + registerEditorHandlers, + removeEditorHandlers, +} from '../../../src/main/ipc/editor'; + +import type { IpcMain, IpcMainInvokeEvent } from 'electron'; + +// ============================================================================= +// Helpers +// ============================================================================= + +function createMockIpcMain() { + const handlers = new Map unknown>(); + return { + handle: vi.fn((channel: string, handler: (...args: unknown[]) => unknown) => { + handlers.set(channel, handler); + }), + removeHandler: vi.fn((channel: string) => { + handlers.delete(channel); + }), + invoke: async (channel: string, ...args: unknown[]) => { + const handler = handlers.get(channel); + if (!handler) throw new Error(`No handler for ${channel}`); + return handler({} as IpcMainInvokeEvent, ...args); + }, + _handlers: handlers, + }; +} + +function createStats( + overrides: Partial> = {} +): Awaited> { + return { + isFile: () => overrides.isFile ?? false, + isDirectory: () => overrides.isDirectory ?? true, + isSymbolicLink: () => overrides.isSymbolicLink ?? false, + size: overrides.size ?? 1024, + mtimeMs: overrides.mtimeMs ?? Date.now(), + } as Awaited>; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('Editor IPC handlers', () => { + let mockIpc: ReturnType; + + beforeEach(() => { + vi.resetAllMocks(); + mockIpc = createMockIpcMain(); + initializeEditorHandlers(); + registerEditorHandlers(mockIpc as unknown as IpcMain); + // Always start with clean state + cleanupEditorState(); + }); + + describe('registration', () => { + it('registers all 12 editor channels', () => { + expect(mockIpc.handle).toHaveBeenCalledTimes(12); + expect(mockIpc._handlers.has('editor:open')).toBe(true); + expect(mockIpc._handlers.has('editor:close')).toBe(true); + expect(mockIpc._handlers.has('editor:readDir')).toBe(true); + expect(mockIpc._handlers.has('editor:readFile')).toBe(true); + expect(mockIpc._handlers.has('editor:writeFile')).toBe(true); + expect(mockIpc._handlers.has('editor:createFile')).toBe(true); + expect(mockIpc._handlers.has('editor:createDir')).toBe(true); + expect(mockIpc._handlers.has('editor:deleteFile')).toBe(true); + expect(mockIpc._handlers.has('editor:moveFile')).toBe(true); + expect(mockIpc._handlers.has('editor:searchInFiles')).toBe(true); + expect(mockIpc._handlers.has('editor:gitStatus')).toBe(true); + expect(mockIpc._handlers.has('editor:watchDir')).toBe(true); + }); + + it('removeEditorHandlers clears all channels', () => { + removeEditorHandlers(mockIpc as unknown as IpcMain); + expect(mockIpc.removeHandler).toHaveBeenCalledTimes(12); + }); + }); + + describe('editor:open', () => { + it('accepts valid absolute directory path', async () => { + const projectPath = '/Users/test/my-project'; + vi.mocked(fs.stat).mockResolvedValue(createStats({ isDirectory: true })); + + const result = await mockIpc.invoke('editor:open', projectPath); + + expect(result).toEqual({ success: true, data: undefined }); + }); + + it('rejects empty path', async () => { + const result = await mockIpc.invoke('editor:open', ''); + + expect(result).toEqual({ + success: false, + error: expect.stringContaining('Invalid project path'), + }); + }); + + it('rejects relative path', async () => { + const result = await mockIpc.invoke('editor:open', 'relative/path'); + + expect(result).toEqual({ + success: false, + error: expect.stringContaining('must be absolute'), + }); + }); + + it('rejects filesystem root (SEC-15)', async () => { + const result = await mockIpc.invoke('editor:open', '/'); + + expect(result).toEqual({ + success: false, + error: expect.stringContaining('filesystem root'), + }); + }); + + it('rejects ~/.claude directory (SEC-15)', async () => { + const claudeDir = path.join(os.homedir(), '.claude'); + const result = await mockIpc.invoke('editor:open', claudeDir); + + expect(result).toEqual({ + success: false, + error: expect.stringContaining('Claude data directory'), + }); + }); + + it('rejects path to a file (not directory)', async () => { + vi.mocked(fs.stat).mockResolvedValue(createStats({ isDirectory: false, isFile: true })); + + const result = await mockIpc.invoke('editor:open', '/Users/test/file.ts'); + + expect(result).toEqual({ + success: false, + error: expect.stringContaining('not a directory'), + }); + }); + + it('rejects non-existent path', async () => { + vi.mocked(fs.stat).mockRejectedValue(new Error('ENOENT')); + + const result = await mockIpc.invoke('editor:open', '/nonexistent/path'); + + expect(result).toEqual({ + success: false, + error: expect.stringContaining('ENOENT'), + }); + }); + }); + + describe('editor:close', () => { + it('resets state successfully', async () => { + // Open first + vi.mocked(fs.stat).mockResolvedValue(createStats({ isDirectory: true })); + await mockIpc.invoke('editor:open', '/Users/test/project'); + + const result = await mockIpc.invoke('editor:close'); + + expect(result).toEqual({ success: true, data: undefined }); + }); + }); + + describe('editor:readDir', () => { + it('rejects if editor not initialized', async () => { + const result = await mockIpc.invoke('editor:readDir', '/some/path'); + + expect(result).toEqual({ + success: false, + error: expect.stringContaining('not initialized'), + }); + }); + + it('works after editor:open', async () => { + // Open project + vi.mocked(fs.stat).mockResolvedValue(createStats({ isDirectory: true })); + await mockIpc.invoke('editor:open', '/Users/test/project'); + + // Mock readDir + vi.mocked(fs.lstat).mockResolvedValue(createStats({ isDirectory: true }) as never); + vi.mocked(fs.readdir).mockResolvedValue([] as never); + + const result = await mockIpc.invoke('editor:readDir', '/Users/test/project'); + + expect(result).toEqual({ + success: true, + data: { entries: [], truncated: false }, + }); + }); + }); + + describe('editor:readFile', () => { + it('rejects if editor not initialized', async () => { + const result = await mockIpc.invoke('editor:readFile', '/some/file.ts'); + + expect(result).toEqual({ + success: false, + error: expect.stringContaining('not initialized'), + }); + }); + }); + + describe('editor:createFile', () => { + it('rejects if editor not initialized', async () => { + const result = await mockIpc.invoke('editor:createFile', '/some/path', 'file.ts'); + + expect(result).toEqual({ + success: false, + error: expect.stringContaining('not initialized'), + }); + }); + }); + + describe('editor:createDir', () => { + it('rejects if editor not initialized', async () => { + const result = await mockIpc.invoke('editor:createDir', '/some/path', 'new-dir'); + + expect(result).toEqual({ + success: false, + error: expect.stringContaining('not initialized'), + }); + }); + }); + + describe('editor:deleteFile', () => { + it('rejects if editor not initialized', async () => { + const result = await mockIpc.invoke('editor:deleteFile', '/some/file.ts'); + + expect(result).toEqual({ + success: false, + error: expect.stringContaining('not initialized'), + }); + }); + }); + + describe('editor:moveFile', () => { + it('rejects if editor not initialized', async () => { + const result = await mockIpc.invoke('editor:moveFile', '/some/file.ts', '/other/dir'); + + expect(result).toEqual({ + success: false, + error: expect.stringContaining('not initialized'), + }); + }); + }); + + describe('editor:searchInFiles', () => { + it('rejects if editor not initialized', async () => { + const result = await mockIpc.invoke('editor:searchInFiles', { query: 'test' }); + + expect(result).toEqual({ + success: false, + error: expect.stringContaining('not initialized'), + }); + }); + }); + + describe('editor:gitStatus', () => { + it('rejects if editor not initialized', async () => { + const result = await mockIpc.invoke('editor:gitStatus'); + + expect(result).toEqual({ + success: false, + error: expect.stringContaining('not initialized'), + }); + }); + }); + + describe('editor:watchDir', () => { + it('rejects if editor not initialized', async () => { + const result = await mockIpc.invoke('editor:watchDir', true); + + expect(result).toEqual({ + success: false, + error: expect.stringContaining('not initialized'), + }); + }); + }); + + describe('cleanupEditorState', () => { + it('resets state so readDir fails with not initialized', async () => { + // Open project + vi.mocked(fs.stat).mockResolvedValue(createStats({ isDirectory: true })); + await mockIpc.invoke('editor:open', '/Users/test/project'); + + // Cleanup + cleanupEditorState(); + + // Now readDir should fail + const result = await mockIpc.invoke('editor:readDir', '/Users/test/project'); + expect(result).toEqual({ + success: false, + error: expect.stringContaining('not initialized'), + }); + }); + }); +}); diff --git a/test/main/ipc/ipcWrapper.test.ts b/test/main/ipc/ipcWrapper.test.ts new file mode 100644 index 00000000..8c8287b3 --- /dev/null +++ b/test/main/ipc/ipcWrapper.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createIpcWrapper } from '@main/ipc/ipcWrapper'; + +vi.mock('@shared/utils/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +describe('createIpcWrapper', () => { + it('returns success result on successful handler', async () => { + const wrap = createIpcWrapper('test'); + const result = await wrap('op', async () => 42); + + expect(result).toEqual({ success: true, data: 42 }); + }); + + it('returns success with complex data', async () => { + const wrap = createIpcWrapper('test'); + const data = { items: [1, 2, 3], meta: { count: 3 } }; + const result = await wrap('op', async () => data); + + expect(result).toEqual({ success: true, data }); + }); + + it('returns error result when handler throws Error', async () => { + const wrap = createIpcWrapper('test'); + const result = await wrap('op', async () => { + throw new Error('Something went wrong'); + }); + + expect(result).toEqual({ + success: false, + error: 'Something went wrong', + }); + }); + + it('returns error result when handler throws non-Error', async () => { + const wrap = createIpcWrapper('test'); + const result = await wrap('op', async () => { + throw 'string error'; + }); + + expect(result).toEqual({ + success: false, + error: 'string error', + }); + }); + + it('handles void return', async () => { + const wrap = createIpcWrapper('test'); + const result = await wrap('op', async () => { + // void + }); + + expect(result).toEqual({ success: true, data: undefined }); + }); + + it('handles null return', async () => { + const wrap = createIpcWrapper('test'); + const result = await wrap('op', async () => null); + + expect(result).toEqual({ success: true, data: null }); + }); + + it('creates independent wrappers with different prefixes', async () => { + const wrap1 = createIpcWrapper('prefix1'); + const wrap2 = createIpcWrapper('prefix2'); + + const result1 = await wrap1('op', async () => 'a'); + const result2 = await wrap2('op', async () => 'b'); + + expect(result1).toEqual({ success: true, data: 'a' }); + expect(result2).toEqual({ success: true, data: 'b' }); + }); +}); diff --git a/test/main/services/editor/EditorFileWatcher.test.ts b/test/main/services/editor/EditorFileWatcher.test.ts new file mode 100644 index 00000000..cf5e588a --- /dev/null +++ b/test/main/services/editor/EditorFileWatcher.test.ts @@ -0,0 +1,170 @@ +/** + * Tests for EditorFileWatcher — start/stop, event filtering, path security. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock chokidar +const mockOn = vi.fn().mockReturnThis(); +const mockClose = vi.fn().mockResolvedValue(undefined); + +vi.mock('chokidar', () => ({ + watch: vi.fn(() => ({ + on: mockOn, + close: mockClose, + })), +})); + +vi.mock('@main/utils/pathValidation', () => ({ + isPathWithinRoot: vi.fn((filePath: string, root: string) => { + return filePath.startsWith(root); + }), +})); + +vi.mock('@shared/utils/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), +})); + +import { watch } from 'chokidar'; + +import { isPathWithinRoot } from '../../../../src/main/utils/pathValidation'; +import { EditorFileWatcher } from '../../../../src/main/services/editor/EditorFileWatcher'; + +// ============================================================================= +// Tests +// ============================================================================= + +describe('EditorFileWatcher', () => { + let watcher: EditorFileWatcher; + + beforeEach(() => { + vi.resetAllMocks(); + mockOn.mockReturnThis(); + watcher = new EditorFileWatcher(); + }); + + describe('start', () => { + it('creates chokidar watcher with correct options', () => { + const onChange = vi.fn(); + watcher.start('/Users/test/project', onChange); + + expect(watch).toHaveBeenCalledWith('/Users/test/project', { + ignored: expect.any(RegExp), + ignoreInitial: true, + followSymlinks: false, + depth: 20, + }); + }); + + it('registers change, add, unlink, and error handlers', () => { + const onChange = vi.fn(); + watcher.start('/Users/test/project', onChange); + + const registeredEvents = mockOn.mock.calls.map((c) => c[0]); + expect(registeredEvents).toContain('change'); + expect(registeredEvents).toContain('add'); + expect(registeredEvents).toContain('unlink'); + expect(registeredEvents).toContain('error'); + }); + + it('emits normalized events through onChange callback', () => { + const onChange = vi.fn(); + watcher.start('/Users/test/project', onChange); + + // Simulate chokidar 'change' event + const changeHandler = mockOn.mock.calls.find((c) => c[0] === 'change')?.[1]; + changeHandler?.('/Users/test/project/src/index.ts'); + + expect(onChange).toHaveBeenCalledWith({ + type: 'change', + path: '/Users/test/project/src/index.ts', + }); + }); + + it('emits create event for add', () => { + const onChange = vi.fn(); + watcher.start('/Users/test/project', onChange); + + const addHandler = mockOn.mock.calls.find((c) => c[0] === 'add')?.[1]; + addHandler?.('/Users/test/project/new-file.ts'); + + expect(onChange).toHaveBeenCalledWith({ + type: 'create', + path: '/Users/test/project/new-file.ts', + }); + }); + + it('emits delete event for unlink', () => { + const onChange = vi.fn(); + watcher.start('/Users/test/project', onChange); + + const unlinkHandler = mockOn.mock.calls.find((c) => c[0] === 'unlink')?.[1]; + unlinkHandler?.('/Users/test/project/old-file.ts'); + + expect(onChange).toHaveBeenCalledWith({ + type: 'delete', + path: '/Users/test/project/old-file.ts', + }); + }); + + it('ignores events outside project root (SEC-2)', () => { + vi.mocked(isPathWithinRoot).mockReturnValueOnce(false); + + const onChange = vi.fn(); + watcher.start('/Users/test/project', onChange); + + const changeHandler = mockOn.mock.calls.find((c) => c[0] === 'change')?.[1]; + changeHandler?.('/etc/passwd'); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('stops previous watcher on re-start (idempotent)', () => { + const onChange = vi.fn(); + watcher.start('/Users/test/project1', onChange); + watcher.start('/Users/test/project2', onChange); + + expect(mockClose).toHaveBeenCalledTimes(1); + expect(watch).toHaveBeenCalledTimes(2); + }); + }); + + describe('stop', () => { + it('closes the watcher', () => { + const onChange = vi.fn(); + watcher.start('/Users/test/project', onChange); + + watcher.stop(); + + expect(mockClose).toHaveBeenCalled(); + }); + + it('is safe to call multiple times', () => { + watcher.stop(); + watcher.stop(); + // No error thrown + }); + }); + + describe('isWatching', () => { + it('returns false when not started', () => { + expect(watcher.isWatching()).toBe(false); + }); + + it('returns true after start', () => { + watcher.start('/Users/test/project', vi.fn()); + expect(watcher.isWatching()).toBe(true); + }); + + it('returns false after stop', () => { + watcher.start('/Users/test/project', vi.fn()); + watcher.stop(); + expect(watcher.isWatching()).toBe(false); + }); + }); +}); diff --git a/test/main/services/editor/FileSearchService.test.ts b/test/main/services/editor/FileSearchService.test.ts new file mode 100644 index 00000000..bbe40f34 --- /dev/null +++ b/test/main/services/editor/FileSearchService.test.ts @@ -0,0 +1,170 @@ +/** + * Tests for FileSearchService — literal string search across project files. + */ + +import * as path from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('fs/promises', () => ({ + readdir: vi.fn(), + readFile: vi.fn(), + stat: vi.fn(), +})); + +vi.mock('isbinaryfile', () => ({ + isBinaryFile: vi.fn(), +})); + +vi.mock('@shared/utils/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), +})); + +import * as fs from 'fs/promises'; +import { isBinaryFile } from 'isbinaryfile'; + +import { FileSearchService } from '@main/services/editor/FileSearchService'; + +const PROJECT_ROOT = '/test/project'; + +describe('FileSearchService', () => { + let service: FileSearchService; + + beforeEach(() => { + vi.resetAllMocks(); + service = new FileSearchService(); + }); + + function mockFileSystem(files: Record) { + const entries = Object.keys(files).map((filePath) => { + const name = path.basename(filePath); + return { name, isFile: () => true, isDirectory: () => false }; + }); + + vi.mocked(fs.readdir).mockResolvedValue(entries as never); + vi.mocked(isBinaryFile).mockResolvedValue(false); + + vi.mocked(fs.stat).mockImplementation(async (filePath: unknown) => { + const p = String(filePath); + const content = files[p]; + if (content === undefined) throw new Error('ENOENT'); + return { size: content.length } as never; + }); + + vi.mocked(fs.readFile).mockImplementation(async (filePath: unknown) => { + const p = String(filePath); + const content = files[p]; + if (content === undefined) throw new Error('ENOENT'); + return content as never; + }); + } + + it('finds matches in files', async () => { + const files = { + [`${PROJECT_ROOT}/hello.ts`]: 'const foo = "hello";\nconst bar = "world";\n', + [`${PROJECT_ROOT}/world.ts`]: 'export const baz = "hello world";\n', + }; + mockFileSystem(files); + + const result = await service.searchInFiles(PROJECT_ROOT, { query: 'hello' }); + + expect(result.totalMatches).toBeGreaterThanOrEqual(1); + expect(result.results.length).toBeGreaterThanOrEqual(1); + const match = result.results[0].matches[0]; + expect(match.line).toBe(1); + expect(match.lineContent).toContain('hello'); + }); + + it('returns empty results for empty query', async () => { + const result = await service.searchInFiles(PROJECT_ROOT, { query: '' }); + expect(result.results).toEqual([]); + expect(result.totalMatches).toBe(0); + }); + + it('supports case-sensitive search', async () => { + const files = { + [`${PROJECT_ROOT}/test.ts`]: 'Hello World\nhello world\n', + }; + mockFileSystem(files); + + const caseInsensitive = await service.searchInFiles(PROJECT_ROOT, { query: 'Hello' }); + expect(caseInsensitive.totalMatches).toBe(2); // both lines match + + const caseSensitive = await service.searchInFiles(PROJECT_ROOT, { + query: 'Hello', + caseSensitive: true, + }); + expect(caseSensitive.totalMatches).toBe(1); // only first line + }); + + it('respects maxMatches limit', async () => { + const lines = Array.from({ length: 20 }, (_, i) => `match line ${i}`).join('\n'); + const files = { + [`${PROJECT_ROOT}/many.ts`]: lines, + }; + mockFileSystem(files); + + const result = await service.searchInFiles(PROJECT_ROOT, { + query: 'match', + maxMatches: 5, + }); + + expect(result.totalMatches).toBeLessThanOrEqual(5); + expect(result.truncated).toBe(true); + }); + + it('skips binary files', async () => { + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'binary.bin', isFile: () => true, isDirectory: () => false }, + ] as never); + + vi.mocked(fs.stat).mockResolvedValue({ size: 100 } as never); + vi.mocked(isBinaryFile).mockResolvedValue(true); + + const result = await service.searchInFiles(PROJECT_ROOT, { query: 'test' }); + expect(result.results).toEqual([]); + }); + + it('skips files larger than 1MB', async () => { + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'large.ts', isFile: () => true, isDirectory: () => false }, + ] as never); + + vi.mocked(fs.stat).mockResolvedValue({ size: 2 * 1024 * 1024 } as never); + + const result = await service.searchInFiles(PROJECT_ROOT, { query: 'test' }); + expect(result.results).toEqual([]); + }); + + it('respects AbortController cancellation', async () => { + const files = { + [`${PROJECT_ROOT}/file.ts`]: 'hello world\n', + }; + mockFileSystem(files); + + const controller = new AbortController(); + controller.abort(); // Already aborted + + const result = await service.searchInFiles(PROJECT_ROOT, { query: 'hello' }, controller.signal); + // Should return empty or partial results since aborted + expect(result.totalMatches).toBe(0); + }); + + it('finds multiple matches in same line', async () => { + const files = { + [`${PROJECT_ROOT}/multi.ts`]: 'foo foo foo\n', + }; + mockFileSystem(files); + + const result = await service.searchInFiles(PROJECT_ROOT, { query: 'foo' }); + expect(result.totalMatches).toBe(3); + expect(result.results[0].matches).toHaveLength(3); + expect(result.results[0].matches[0].column).toBe(0); + expect(result.results[0].matches[1].column).toBe(4); + expect(result.results[0].matches[2].column).toBe(8); + }); +}); diff --git a/test/main/services/editor/GitStatusService.test.ts b/test/main/services/editor/GitStatusService.test.ts new file mode 100644 index 00000000..220dcdba --- /dev/null +++ b/test/main/services/editor/GitStatusService.test.ts @@ -0,0 +1,217 @@ +/** + * Tests for GitStatusService — caching, error handling, status mapping. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock simple-git +const mockStatus = vi.fn(); +const mockRevparse = vi.fn(); +const mockEnv = vi.fn(); + +vi.mock('simple-git', () => ({ + simpleGit: vi.fn(() => { + const git = { + status: mockStatus, + revparse: mockRevparse, + env: mockEnv, + }; + mockEnv.mockReturnValue(git); + return git; + }), +})); + +vi.mock('@shared/utils/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), +})); + +import { simpleGit } from 'simple-git'; + +import { + GitStatusService, + mapStatusResult, +} from '../../../../src/main/services/editor/GitStatusService'; + +import type { StatusResult } from 'simple-git'; + +// ============================================================================= +// Helpers +// ============================================================================= + +function createMockStatusResult(overrides: Partial = {}): StatusResult { + return { + not_added: [], + conflicted: [], + created: [], + deleted: [], + ignored: [], + modified: [], + renamed: [], + staged: [], + files: [], + ahead: 0, + behind: 0, + current: 'main', + tracking: 'origin/main', + detached: false, + isClean: () => true, + ...overrides, + } as StatusResult; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('GitStatusService', () => { + let service: GitStatusService; + + beforeEach(() => { + vi.resetAllMocks(); + service = new GitStatusService(); + }); + + describe('init', () => { + it('initializes simple-git with project root and GIT_OPTIONAL_LOCKS=0', () => { + service.init('/Users/test/project'); + + expect(vi.mocked(simpleGit)).toHaveBeenCalledWith({ + baseDir: '/Users/test/project', + timeout: { block: 10_000 }, + }); + expect(mockEnv).toHaveBeenCalledWith('GIT_OPTIONAL_LOCKS', '0'); + }); + }); + + describe('getStatus', () => { + it('returns empty non-repo result when not initialized', async () => { + const result = await service.getStatus(); + + expect(result).toEqual({ files: [], isGitRepo: false, branch: null }); + }); + + it('returns isGitRepo: false for non-git directories', async () => { + mockRevparse.mockRejectedValue(new Error('not a git repo')); + + service.init('/Users/test/not-a-repo'); + const result = await service.getStatus(); + + expect(result.isGitRepo).toBe(false); + expect(result.files).toEqual([]); + expect(result.branch).toBeNull(); + }); + + it('returns file statuses for a git repo', async () => { + mockRevparse.mockResolvedValue('true'); + mockStatus.mockResolvedValue( + createMockStatusResult({ + modified: ['src/index.ts'], + not_added: ['new-file.txt'], + deleted: ['old.ts'], + current: 'feature-branch', + }) + ); + + service.init('/Users/test/project'); + const result = await service.getStatus(); + + expect(result.isGitRepo).toBe(true); + expect(result.branch).toBe('feature-branch'); + expect(result.files).toContainEqual({ path: 'src/index.ts', status: 'modified' }); + expect(result.files).toContainEqual({ path: 'new-file.txt', status: 'untracked' }); + expect(result.files).toContainEqual({ path: 'old.ts', status: 'deleted' }); + }); + + it('caches results within TTL (5s)', async () => { + mockRevparse.mockResolvedValue('true'); + mockStatus.mockResolvedValue(createMockStatusResult({ modified: ['a.ts'] })); + + service.init('/Users/test/project'); + + // First call → hits git + await service.getStatus(); + expect(mockStatus).toHaveBeenCalledTimes(1); + + // Second call within TTL → cached + await service.getStatus(); + expect(mockStatus).toHaveBeenCalledTimes(1); + }); + + it('invalidateCache forces re-fetch', async () => { + mockRevparse.mockResolvedValue('true'); + mockStatus.mockResolvedValue(createMockStatusResult({ modified: ['a.ts'] })); + + service.init('/Users/test/project'); + + await service.getStatus(); + expect(mockStatus).toHaveBeenCalledTimes(1); + + service.invalidateCache(); + await service.getStatus(); + expect(mockStatus).toHaveBeenCalledTimes(2); + }); + + it('returns empty result on git error (graceful degradation)', async () => { + mockRevparse.mockResolvedValue('true'); + mockStatus.mockRejectedValue(new Error('git timeout')); + + service.init('/Users/test/project'); + const result = await service.getStatus(); + + expect(result).toEqual({ files: [], isGitRepo: false, branch: null }); + }); + }); + + describe('destroy', () => { + it('resets all internal state', async () => { + mockRevparse.mockResolvedValue('true'); + mockStatus.mockResolvedValue(createMockStatusResult()); + + service.init('/Users/test/project'); + await service.getStatus(); + + service.destroy(); + + // After destroy, should return empty result (no git instance) + const result = await service.getStatus(); + expect(result).toEqual({ files: [], isGitRepo: false, branch: null }); + }); + }); +}); + +describe('mapStatusResult', () => { + it('maps all status categories', () => { + const statusResult = createMockStatusResult({ + modified: ['a.ts'], + not_added: ['b.ts'], + staged: ['c.ts'], + deleted: ['d.ts'], + conflicted: ['e.ts'], + renamed: [{ from: 'old.ts', to: 'new.ts' }] as StatusResult['renamed'], + }); + + const files = mapStatusResult(statusResult); + + expect(files).toContainEqual({ path: 'a.ts', status: 'modified' }); + expect(files).toContainEqual({ path: 'b.ts', status: 'untracked' }); + expect(files).toContainEqual({ path: 'c.ts', status: 'staged' }); + expect(files).toContainEqual({ path: 'd.ts', status: 'deleted' }); + expect(files).toContainEqual({ path: 'e.ts', status: 'conflict' }); + expect(files).toContainEqual({ + path: 'new.ts', + status: 'renamed', + renamedFrom: 'old.ts', + }); + }); + + it('returns empty array for clean repo', () => { + const statusResult = createMockStatusResult(); + const files = mapStatusResult(statusResult); + expect(files).toEqual([]); + }); +}); diff --git a/test/main/services/editor/ProjectFileService.test.ts b/test/main/services/editor/ProjectFileService.test.ts new file mode 100644 index 00000000..d59655ae --- /dev/null +++ b/test/main/services/editor/ProjectFileService.test.ts @@ -0,0 +1,687 @@ +/** + * Tests for ProjectFileService — path security, binary detection, size limits. + */ + +import * as path from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock fs/promises before importing the service +vi.mock('fs/promises', () => ({ + lstat: vi.fn(), + stat: vi.fn(), + readdir: vi.fn(), + readFile: vi.fn(), + realpath: vi.fn(), + writeFile: vi.fn(), + access: vi.fn(), + mkdir: vi.fn(), + rename: vi.fn(), + cp: vi.fn(), + copyFile: vi.fn(), + rm: vi.fn(), +})); + +vi.mock('@main/utils/atomicWrite', () => ({ + atomicWriteAsync: vi.fn(), +})); + +vi.mock('isbinaryfile', () => ({ + isBinaryFile: vi.fn(), +})); + +vi.mock('electron', () => ({ + shell: { + trashItem: vi.fn(), + }, +})); + +vi.mock('@shared/utils/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), +})); + +import { shell } from 'electron'; +import * as fs from 'fs/promises'; +import { isBinaryFile } from 'isbinaryfile'; + +import { atomicWriteAsync } from '../../../../src/main/utils/atomicWrite'; +import { ProjectFileService } from '../../../../src/main/services/editor/ProjectFileService'; + +// ============================================================================= +// Setup +// ============================================================================= + +const PROJECT_ROOT = '/Users/test/my-project'; +let service: ProjectFileService; + +const mockLstat = vi.mocked(fs.lstat); +const mockStat = vi.mocked(fs.stat); +const mockReaddir = vi.mocked(fs.readdir); +const mockReadFile = vi.mocked(fs.readFile); +const mockRealpath = vi.mocked(fs.realpath); +const mockIsBinary = vi.mocked(isBinaryFile); +const mockRename = vi.mocked(fs.rename); +const mockCp = vi.mocked(fs.cp); +const mockRm = vi.mocked(fs.rm); + +function createStats( + overrides: Partial> = {} +): ReturnType { + return { + isFile: () => overrides.isFile ?? true, + isDirectory: () => overrides.isDirectory ?? false, + isSymbolicLink: () => overrides.isSymbolicLink ?? false, + size: overrides.size ?? 1024, + mtimeMs: overrides.mtimeMs ?? Date.now(), + } as Awaited>; +} + +function createDirent( + name: string, + type: 'file' | 'directory' | 'symlink' +): { + name: string; + isFile: () => boolean; + isDirectory: () => boolean; + isSymbolicLink: () => boolean; +} { + return { + name, + isFile: () => type === 'file', + isDirectory: () => type === 'directory', + isSymbolicLink: () => type === 'symlink', + }; +} + +beforeEach(() => { + vi.resetAllMocks(); + service = new ProjectFileService(); +}); + +// ============================================================================= +// readDir +// ============================================================================= + +describe('ProjectFileService.readDir', () => { + it('returns sorted directory listing (dirs first, then alpha)', async () => { + mockLstat.mockResolvedValue(createStats({ isDirectory: true, isFile: false })); + mockReaddir.mockResolvedValue([ + createDirent('zebra.ts', 'file'), + createDirent('src', 'directory'), + createDirent('alpha.ts', 'file'), + createDirent('docs', 'directory'), + ] as never); + mockStat.mockResolvedValue(createStats({ size: 512 })); + + const result = await service.readDir(PROJECT_ROOT, PROJECT_ROOT); + + expect(result.truncated).toBe(false); + expect(result.entries.map((e) => e.name)).toEqual(['docs', 'src', 'alpha.ts', 'zebra.ts']); + expect(result.entries[0].type).toBe('directory'); + expect(result.entries[2].type).toBe('file'); + }); + + it('filters out ignored directories (node_modules, .git, etc.)', async () => { + mockLstat.mockResolvedValue(createStats({ isDirectory: true, isFile: false })); + mockReaddir.mockResolvedValue([ + createDirent('node_modules', 'directory'), + createDirent('.git', 'directory'), + createDirent('src', 'directory'), + createDirent('.next', 'directory'), + ] as never); + + const result = await service.readDir(PROJECT_ROOT, PROJECT_ROOT); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0].name).toBe('src'); + }); + + it('filters out ignored files (.DS_Store, Thumbs.db)', async () => { + mockLstat.mockResolvedValue(createStats({ isDirectory: true, isFile: false })); + mockReaddir.mockResolvedValue([ + createDirent('.DS_Store', 'file'), + createDirent('Thumbs.db', 'file'), + createDirent('index.ts', 'file'), + ] as never); + mockStat.mockResolvedValue(createStats({ size: 100 })); + + const result = await service.readDir(PROJECT_ROOT, PROJECT_ROOT); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0].name).toBe('index.ts'); + }); + + it('marks sensitive files with isSensitive flag', async () => { + const projectWithEnv = PROJECT_ROOT; + mockLstat.mockResolvedValue(createStats({ isDirectory: true, isFile: false })); + mockReaddir.mockResolvedValue([ + createDirent('.env', 'file'), + createDirent('.env.local', 'file'), + createDirent('index.ts', 'file'), + ] as never); + mockStat.mockResolvedValue(createStats({ size: 100 })); + + const result = await service.readDir(projectWithEnv, projectWithEnv); + + const envEntry = result.entries.find((e) => e.name === '.env'); + const envLocalEntry = result.entries.find((e) => e.name === '.env.local'); + const indexEntry = result.entries.find((e) => e.name === 'index.ts'); + + expect(envEntry?.isSensitive).toBe(true); + expect(envLocalEntry?.isSensitive).toBe(true); + expect(indexEntry?.isSensitive).toBeUndefined(); + }); + + it('rejects paths outside project root (SEC-1)', async () => { + await expect(service.readDir(PROJECT_ROOT, '/etc/passwd')).rejects.toThrow( + 'Directory is outside project root' + ); + }); + + it('rejects path traversal via ../ (SEC-1)', async () => { + const traversalPath = path.join(PROJECT_ROOT, '..', '..', 'etc'); + await expect(service.readDir(PROJECT_ROOT, traversalPath)).rejects.toThrow( + 'Directory is outside project root' + ); + }); + + it('rejects non-directory paths', async () => { + mockLstat.mockResolvedValue(createStats({ isDirectory: false, isFile: true })); + + await expect(service.readDir(PROJECT_ROOT, PROJECT_ROOT + '/file.txt')).rejects.toThrow( + 'Not a directory' + ); + }); + + it('truncates at maxEntries', async () => { + mockLstat.mockResolvedValue(createStats({ isDirectory: true, isFile: false })); + const dirents = Array.from({ length: 10 }, (_, i) => createDirent(`file${i}.ts`, 'file')); + mockReaddir.mockResolvedValue(dirents as never); + mockStat.mockResolvedValue(createStats({ size: 100 })); + + const result = await service.readDir(PROJECT_ROOT, PROJECT_ROOT, 3); + + expect(result.entries).toHaveLength(3); + expect(result.truncated).toBe(true); + }); + + it('silently skips symlinks that escape project root (SEC-2)', async () => { + mockLstat.mockResolvedValue(createStats({ isDirectory: true, isFile: false })); + mockReaddir.mockResolvedValue([ + createDirent('safe-link', 'symlink'), + createDirent('escape-link', 'symlink'), + createDirent('normal.ts', 'file'), + ] as never); + + mockRealpath.mockImplementation(async (p) => { + const name = path.basename(String(p)); + if (name === 'safe-link') return PROJECT_ROOT + '/actual-dir'; + return '/etc/shadow'; // escapes project + }); + + mockStat.mockResolvedValue(createStats({ size: 100, isDirectory: true, isFile: false })); + + const result = await service.readDir(PROJECT_ROOT, PROJECT_ROOT); + + const names = result.entries.map((e) => e.name); + expect(names).toContain('safe-link'); + expect(names).toContain('normal.ts'); + expect(names).not.toContain('escape-link'); + }); + + it('silently skips broken symlinks', async () => { + mockLstat.mockResolvedValue(createStats({ isDirectory: true, isFile: false })); + mockReaddir.mockResolvedValue([ + createDirent('broken-link', 'symlink'), + createDirent('normal.ts', 'file'), + ] as never); + + mockRealpath.mockRejectedValue(new Error('ENOENT')); + mockStat.mockResolvedValue(createStats({ size: 100 })); + + const result = await service.readDir(PROJECT_ROOT, PROJECT_ROOT); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0].name).toBe('normal.ts'); + }); +}); + +// ============================================================================= +// readFile +// ============================================================================= + +describe('ProjectFileService.readFile', () => { + it('returns file content with metadata', async () => { + const filePath = PROJECT_ROOT + '/src/index.ts'; + const content = 'export const hello = "world";'; + const now = Date.now(); + + mockLstat.mockResolvedValue(createStats({ size: content.length, mtimeMs: now })); + mockIsBinary.mockResolvedValue(false); + mockReadFile.mockResolvedValue(content); + mockRealpath.mockResolvedValue(filePath); + + const result = await service.readFile(PROJECT_ROOT, filePath); + + expect(result.content).toBe(content); + expect(result.size).toBe(content.length); + expect(result.mtimeMs).toBe(now); + expect(result.isBinary).toBe(false); + expect(result.encoding).toBe('utf-8'); + expect(result.truncated).toBe(false); + }); + + it('returns binary indicator for binary files', async () => { + const filePath = PROJECT_ROOT + '/image.png'; + + mockLstat.mockResolvedValue(createStats({ size: 4096, mtimeMs: Date.now() })); + mockIsBinary.mockResolvedValue(true); + + const result = await service.readFile(PROJECT_ROOT, filePath); + + expect(result.isBinary).toBe(true); + expect(result.content).toBe(''); + expect(result.encoding).toBe('binary'); + }); + + it('rejects files larger than 5MB preview limit', async () => { + const filePath = PROJECT_ROOT + '/huge.log'; + const hugeSize = 6 * 1024 * 1024; + + mockLstat.mockResolvedValue(createStats({ size: hugeSize })); + + await expect(service.readFile(PROJECT_ROOT, filePath)).rejects.toThrow('File too large'); + }); + + it('returns preview (100 lines) for files between 2-5MB', async () => { + const filePath = PROJECT_ROOT + '/large.json'; + const fileSize = 3 * 1024 * 1024; + const lines = Array.from({ length: 200 }, (_, i) => `line ${i}`); + const fullContent = lines.join('\n'); + + mockLstat.mockResolvedValue(createStats({ size: fileSize, mtimeMs: Date.now() })); + mockIsBinary.mockResolvedValue(false); + mockReadFile.mockResolvedValue(fullContent); + mockRealpath.mockResolvedValue(filePath); + + const result = await service.readFile(PROJECT_ROOT, filePath); + + expect(result.truncated).toBe(true); + expect(result.content.split('\n')).toHaveLength(100); + }); + + it('rejects sensitive file paths (.env, .ssh)', async () => { + const envPath = PROJECT_ROOT + '/.env'; + await expect(service.readFile(PROJECT_ROOT, envPath)).rejects.toThrow( + 'Access to sensitive files is not allowed' + ); + }); + + it('rejects paths outside project root', async () => { + await expect(service.readFile(PROJECT_ROOT, '/etc/passwd')).rejects.toThrow(); + }); + + it('rejects device paths (SEC-4)', async () => { + const devPath = '/dev/zero'; + // /dev/zero is outside project root, so it should throw before device check + await expect(service.readFile(PROJECT_ROOT, devPath)).rejects.toThrow(); + }); + + it('rejects non-regular files (directories, etc.)', async () => { + const dirPath = PROJECT_ROOT + '/src'; + mockLstat.mockResolvedValue(createStats({ isFile: false, isDirectory: true })); + + await expect(service.readFile(PROJECT_ROOT, dirPath)).rejects.toThrow('Not a regular file'); + }); + + it('detects TOCTOU — rejects if path changed during read (SEC-3)', async () => { + const filePath = PROJECT_ROOT + '/safe.ts'; + + mockLstat.mockResolvedValue(createStats({ size: 100, mtimeMs: Date.now() })); + mockIsBinary.mockResolvedValue(false); + mockReadFile.mockResolvedValue('content'); + // realpath returns a path OUTSIDE project root (symlink swapped) + mockRealpath.mockResolvedValue('/etc/shadow'); + + await expect(service.readFile(PROJECT_ROOT, filePath)).rejects.toThrow( + 'Path changed during read (TOCTOU)' + ); + }); +}); + +// ============================================================================= +// writeFile +// ============================================================================= + +const mockAtomicWrite = vi.mocked(atomicWriteAsync); + +describe('ProjectFileService.writeFile', () => { + const CONTENT = 'export const hello = "world";'; + + beforeEach(() => { + mockAtomicWrite.mockResolvedValue(undefined); + mockStat.mockResolvedValue(createStats({ size: CONTENT.length, mtimeMs: Date.now() })); + }); + + it('writes file via atomic write and returns stats', async () => { + const filePath = PROJECT_ROOT + '/src/index.ts'; + const now = Date.now(); + mockStat.mockResolvedValue(createStats({ size: 28, mtimeMs: now })); + + const result = await service.writeFile(PROJECT_ROOT, filePath, CONTENT); + + expect(mockAtomicWrite).toHaveBeenCalledWith(path.resolve(filePath), CONTENT); + expect(result.size).toBe(28); + expect(result.mtimeMs).toBe(now); + }); + + it('rejects paths outside project root (SEC-14)', async () => { + await expect(service.writeFile(PROJECT_ROOT, '/etc/passwd', 'malicious')).rejects.toThrow(); + }); + + it('rejects path traversal via ../ (SEC-1)', async () => { + const traversalPath = path.join(PROJECT_ROOT, '..', '..', 'etc', 'passwd'); + await expect(service.writeFile(PROJECT_ROOT, traversalPath, 'malicious')).rejects.toThrow(); + }); + + it('rejects .git/ internal paths (SEC-12)', async () => { + const gitPath = PROJECT_ROOT + '/.git/config'; + await expect(service.writeFile(PROJECT_ROOT, gitPath, 'malicious')).rejects.toThrow( + 'Cannot write to .git/ directory' + ); + }); + + it('rejects sensitive file paths (.env)', async () => { + const envPath = PROJECT_ROOT + '/.env'; + await expect(service.writeFile(PROJECT_ROOT, envPath, 'SECRET=key')).rejects.toThrow(); + }); + + it('rejects content larger than 2MB', async () => { + const filePath = PROJECT_ROOT + '/src/large.ts'; + const largeContent = 'a'.repeat(3 * 1024 * 1024); + + await expect(service.writeFile(PROJECT_ROOT, filePath, largeContent)).rejects.toThrow( + 'Content too large' + ); + }); + + it('rejects device paths (SEC-4)', async () => { + const devPath = '/dev/null'; + await expect(service.writeFile(PROJECT_ROOT, devPath, 'data')).rejects.toThrow(); + }); + + it('passes through atomic write errors', async () => { + const filePath = PROJECT_ROOT + '/src/index.ts'; + mockAtomicWrite.mockRejectedValue(new Error('Disk full')); + + await expect(service.writeFile(PROJECT_ROOT, filePath, CONTENT)).rejects.toThrow('Disk full'); + }); +}); + +// ============================================================================= +// createFile +// ============================================================================= + +const mockWriteFile = vi.mocked(fs.writeFile); +const mockAccess = vi.mocked(fs.access); +const mockMkdir = vi.mocked(fs.mkdir); +const mockTrashItem = vi.mocked(shell.trashItem); + +describe('ProjectFileService.createFile', () => { + beforeEach(() => { + mockLstat.mockResolvedValue(createStats({ isDirectory: true, isFile: false })); + mockAccess.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + mockWriteFile.mockResolvedValue(undefined); + mockStat.mockResolvedValue(createStats({ size: 0, mtimeMs: 1234567890 })); + }); + + it('creates an empty file and returns stats', async () => { + const parentDir = PROJECT_ROOT + '/src'; + const result = await service.createFile(PROJECT_ROOT, parentDir, 'new-file.ts'); + + expect(result.filePath).toBe(path.join(PROJECT_ROOT, 'src', 'new-file.ts')); + expect(result.mtimeMs).toBe(1234567890); + expect(mockWriteFile).toHaveBeenCalledWith( + path.join(PROJECT_ROOT, 'src', 'new-file.ts'), + '', + 'utf8' + ); + }); + + it('rejects invalid file name (empty)', async () => { + await expect(service.createFile(PROJECT_ROOT, PROJECT_ROOT, '')).rejects.toThrow( + 'Name is required' + ); + }); + + it('rejects invalid file name (..)', async () => { + await expect(service.createFile(PROJECT_ROOT, PROJECT_ROOT, '..')).rejects.toThrow( + 'Invalid name' + ); + }); + + it('rejects paths outside project root', async () => { + await expect(service.createFile(PROJECT_ROOT, '/etc', 'file.ts')).rejects.toThrow(); + }); + + it('rejects if file already exists', async () => { + mockAccess.mockResolvedValue(undefined); // File exists + + await expect( + service.createFile(PROJECT_ROOT, PROJECT_ROOT + '/src', 'existing.ts') + ).rejects.toThrow('File already exists'); + }); + + it('blocks .git/ internal paths (SEC-12)', async () => { + await expect( + service.createFile(PROJECT_ROOT, PROJECT_ROOT + '/.git', 'config') + ).rejects.toThrow(); + }); +}); + +// ============================================================================= +// createDir +// ============================================================================= + +describe('ProjectFileService.createDir', () => { + beforeEach(() => { + mockLstat.mockResolvedValue(createStats({ isDirectory: true, isFile: false })); + mockAccess.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + mockMkdir.mockResolvedValue(undefined); + }); + + it('creates a directory', async () => { + const parentDir = PROJECT_ROOT + '/src'; + const result = await service.createDir(PROJECT_ROOT, parentDir, 'new-dir'); + + expect(result.dirPath).toBe(path.join(PROJECT_ROOT, 'src', 'new-dir')); + expect(mockMkdir).toHaveBeenCalledWith(path.join(PROJECT_ROOT, 'src', 'new-dir')); + }); + + it('rejects invalid dir name', async () => { + await expect(service.createDir(PROJECT_ROOT, PROJECT_ROOT, '..')).rejects.toThrow( + 'Invalid name' + ); + }); + + it('rejects paths outside project root', async () => { + await expect(service.createDir(PROJECT_ROOT, '/tmp', 'dir')).rejects.toThrow(); + }); + + it('rejects if directory already exists', async () => { + mockAccess.mockResolvedValue(undefined); + + await expect( + service.createDir(PROJECT_ROOT, PROJECT_ROOT + '/src', 'existing-dir') + ).rejects.toThrow('Directory already exists'); + }); +}); + +// ============================================================================= +// deleteFile +// ============================================================================= + +describe('ProjectFileService.deleteFile', () => { + beforeEach(() => { + mockLstat.mockResolvedValue(createStats({ isFile: true })); + mockTrashItem.mockResolvedValue(undefined); + }); + + it('moves file to trash', async () => { + const filePath = PROJECT_ROOT + '/src/old-file.ts'; + const result = await service.deleteFile(PROJECT_ROOT, filePath); + + expect(result.deletedPath).toBe(path.resolve(filePath)); + expect(mockTrashItem).toHaveBeenCalledWith(path.resolve(filePath)); + }); + + it('rejects paths outside project root', async () => { + await expect(service.deleteFile(PROJECT_ROOT, '/etc/passwd')).rejects.toThrow(); + }); + + it('blocks .git/ internal paths (SEC-12)', async () => { + await expect(service.deleteFile(PROJECT_ROOT, PROJECT_ROOT + '/.git/config')).rejects.toThrow( + 'Cannot delete files in .git/ directory' + ); + }); + + it('rejects sensitive file paths', async () => { + await expect(service.deleteFile(PROJECT_ROOT, PROJECT_ROOT + '/.env')).rejects.toThrow(); + }); +}); + +// ============================================================================= +// moveFile +// ============================================================================= + +describe('ProjectFileService.moveFile', () => { + const SRC_DIR = PROJECT_ROOT + '/src'; + const DEST_DIR = PROJECT_ROOT + '/lib'; + + beforeEach(() => { + mockRename.mockResolvedValue(undefined); + mockAccess.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + }); + + it('moves a file to a new directory (happy path)', async () => { + const sourcePath = SRC_DIR + '/index.ts'; + mockLstat + .mockResolvedValueOnce(createStats({ isFile: true })) // source exists + .mockResolvedValueOnce(createStats({ isDirectory: true, isFile: false })); // dest is dir + + const result = await service.moveFile(PROJECT_ROOT, sourcePath, DEST_DIR); + + expect(result.newPath).toBe(path.join(DEST_DIR, 'index.ts')); + expect(mockRename).toHaveBeenCalledWith( + path.resolve(sourcePath), + path.join(DEST_DIR, 'index.ts') + ); + }); + + it('moves a directory to a new directory (happy path)', async () => { + const sourceDir = PROJECT_ROOT + '/utils'; + mockLstat + .mockResolvedValueOnce(createStats({ isDirectory: true, isFile: false })) // source + .mockResolvedValueOnce(createStats({ isDirectory: true, isFile: false })); // dest + + const result = await service.moveFile(PROJECT_ROOT, sourceDir, DEST_DIR); + + expect(result.newPath).toBe(path.join(DEST_DIR, 'utils')); + expect(mockRename).toHaveBeenCalled(); + }); + + it('rejects parent → child move', async () => { + const sourceDir = SRC_DIR; + const childDir = SRC_DIR + '/nested'; + mockLstat + .mockResolvedValueOnce(createStats({ isDirectory: true, isFile: false })) + .mockResolvedValueOnce(createStats({ isDirectory: true, isFile: false })); + + await expect(service.moveFile(PROJECT_ROOT, sourceDir, childDir)).rejects.toThrow( + 'Cannot move a directory into itself' + ); + }); + + it('rejects when destination file already exists', async () => { + const sourcePath = SRC_DIR + '/index.ts'; + mockLstat + .mockResolvedValueOnce(createStats({ isFile: true })) + .mockResolvedValueOnce(createStats({ isDirectory: true, isFile: false })); + mockAccess.mockResolvedValue(undefined); // file exists at dest + + await expect(service.moveFile(PROJECT_ROOT, sourcePath, DEST_DIR)).rejects.toThrow( + 'File already exists at destination' + ); + }); + + it('rejects .git/ source paths (SEC-12)', async () => { + const gitPath = PROJECT_ROOT + '/.git/hooks'; + + await expect(service.moveFile(PROJECT_ROOT, gitPath, DEST_DIR)).rejects.toThrow( + 'Cannot move files from .git/ directory' + ); + }); + + it('rejects .git/ destination paths (SEC-12)', async () => { + const sourcePath = SRC_DIR + '/index.ts'; + const gitDest = PROJECT_ROOT + '/.git'; + + await expect(service.moveFile(PROJECT_ROOT, sourcePath, gitDest)).rejects.toThrow( + 'Cannot move files into .git/ directory' + ); + }); + + it('rejects paths outside project root', async () => { + await expect(service.moveFile(PROJECT_ROOT, '/etc/passwd', DEST_DIR)).rejects.toThrow(); + await expect(service.moveFile(PROJECT_ROOT, SRC_DIR + '/index.ts', '/tmp')).rejects.toThrow(); + }); + + it('falls back to cp+rm on EXDEV error (cross-device)', async () => { + const sourcePath = SRC_DIR + '/index.ts'; + mockLstat + .mockResolvedValueOnce(createStats({ isFile: true })) // source exists + .mockResolvedValueOnce(createStats({ isDirectory: true, isFile: false })) // dest is dir + .mockResolvedValueOnce(createStats({ isFile: true })); // EXDEV fallback stat + + const exdevError = Object.assign(new Error('EXDEV'), { code: 'EXDEV' }); + mockRename.mockRejectedValueOnce(exdevError); + + const mockCopyFile = vi.mocked(fs.copyFile); + mockCopyFile.mockResolvedValue(undefined); + mockRm.mockResolvedValue(undefined); + + const result = await service.moveFile(PROJECT_ROOT, sourcePath, DEST_DIR); + + expect(result.newPath).toBe(path.join(DEST_DIR, 'index.ts')); + expect(mockCopyFile).toHaveBeenCalled(); + expect(mockRm).toHaveBeenCalled(); + }); + + it('falls back to cp+rm for directories on EXDEV error', async () => { + const sourceDir = PROJECT_ROOT + '/utils'; + mockLstat + .mockResolvedValueOnce(createStats({ isDirectory: true, isFile: false })) // source + .mockResolvedValueOnce(createStats({ isDirectory: true, isFile: false })) // dest + .mockResolvedValueOnce(createStats({ isDirectory: true, isFile: false })); // EXDEV fallback + + const exdevError = Object.assign(new Error('EXDEV'), { code: 'EXDEV' }); + mockRename.mockRejectedValueOnce(exdevError); + mockCp.mockResolvedValue(undefined); + mockRm.mockResolvedValue(undefined); + + const result = await service.moveFile(PROJECT_ROOT, sourceDir, DEST_DIR); + + expect(result.newPath).toBe(path.join(DEST_DIR, 'utils')); + expect(mockCp).toHaveBeenCalledWith(path.resolve(sourceDir), path.join(DEST_DIR, 'utils'), { + recursive: true, + }); + expect(mockRm).toHaveBeenCalledWith(path.resolve(sourceDir), { + recursive: true, + force: true, + }); + }); +}); diff --git a/test/main/services/editor/conflictDetection.test.ts b/test/main/services/editor/conflictDetection.test.ts new file mode 100644 index 00000000..261ae939 --- /dev/null +++ b/test/main/services/editor/conflictDetection.test.ts @@ -0,0 +1,95 @@ +/** + * Tests for conflictDetection — mtime comparison, deleted files, tolerance. + */ + +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('fs/promises', () => ({ + stat: vi.fn(), +})); + +import * as fs from 'fs/promises'; + +import { checkFileConflict } from '../../../../src/main/services/editor/conflictDetection'; + +// ============================================================================= +// Helpers +// ============================================================================= + +function mockStat(mtimeMs: number): void { + vi.mocked(fs.stat).mockResolvedValue({ mtimeMs } as Awaited>); +} + +function mockStatError(code: string): void { + const err = new Error(`${code}: no such file`) as NodeJS.ErrnoException; + err.code = code; + vi.mocked(fs.stat).mockRejectedValue(err); +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('checkFileConflict', () => { + it('returns no conflict when mtime matches exactly', async () => { + mockStat(1000); + + const result = await checkFileConflict('/test/file.ts', 1000); + + expect(result.hasConflict).toBe(false); + expect(result.currentMtimeMs).toBe(1000); + expect(result.deleted).toBe(false); + }); + + it('returns no conflict within 1ms tolerance', async () => { + mockStat(1000.5); + + const result = await checkFileConflict('/test/file.ts', 1000); + + expect(result.hasConflict).toBe(false); + }); + + it('detects conflict when mtime differs by more than 1ms', async () => { + mockStat(2000); + + const result = await checkFileConflict('/test/file.ts', 1000); + + expect(result.hasConflict).toBe(true); + expect(result.currentMtimeMs).toBe(2000); + expect(result.deleted).toBe(false); + }); + + it('detects deleted file (ENOENT)', async () => { + mockStatError('ENOENT'); + + const result = await checkFileConflict('/test/file.ts', 1000); + + expect(result.hasConflict).toBe(true); + expect(result.currentMtimeMs).toBe(0); + expect(result.deleted).toBe(true); + }); + + it('re-throws non-ENOENT errors', async () => { + mockStatError('EPERM'); + + await expect(checkFileConflict('/test/file.ts', 1000)).rejects.toThrow('EPERM'); + }); + + it('handles mtime slightly earlier than baseline (e.g. clock drift)', async () => { + mockStat(999); + + const result = await checkFileConflict('/test/file.ts', 1000); + + // |999 - 1000| = 1, which is <= 1ms tolerance + expect(result.hasConflict).toBe(false); + }); + + it('detects conflict for mtime 2ms earlier than baseline', async () => { + mockStat(998); + + const result = await checkFileConflict('/test/file.ts', 1000); + + // |998 - 1000| = 2, which is > 1ms tolerance + expect(result.hasConflict).toBe(true); + }); +}); diff --git a/test/main/utils/atomicWrite.test.ts b/test/main/utils/atomicWrite.test.ts new file mode 100644 index 00000000..f4a6ffef --- /dev/null +++ b/test/main/utils/atomicWrite.test.ts @@ -0,0 +1,153 @@ +/** + * Tests for atomicWriteAsync — tmp + fsync + rename atomic write pattern. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('fs', () => ({ + promises: { + mkdir: vi.fn(), + writeFile: vi.fn(), + open: vi.fn(), + rename: vi.fn(), + copyFile: vi.fn(), + unlink: vi.fn(), + }, +})); + +import { atomicWriteAsync } from '../../../src/main/utils/atomicWrite'; + +// ============================================================================= +// Setup +// ============================================================================= + +const mockMkdir = vi.mocked(fs.promises.mkdir); +const mockWriteFile = vi.mocked(fs.promises.writeFile); +const mockOpen = vi.mocked(fs.promises.open); +const mockRename = vi.mocked(fs.promises.rename); +const mockCopyFile = vi.mocked(fs.promises.copyFile); +const mockUnlink = vi.mocked(fs.promises.unlink); + +const TARGET_PATH = '/Users/test/project/src/index.ts'; +const TARGET_DIR = path.dirname(TARGET_PATH); +const CONTENT = 'export const hello = "world";'; + +/** Extract the tmp path from writeFile calls */ +function getTmpPath(): string { + const call = mockWriteFile.mock.calls[0]; + return String(call[0]); +} + +beforeEach(() => { + vi.resetAllMocks(); + + // Default happy path + mockMkdir.mockResolvedValue(undefined); + mockWriteFile.mockResolvedValue(undefined); + mockOpen.mockResolvedValue({ + sync: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + } as unknown as fs.promises.FileHandle); + mockRename.mockResolvedValue(undefined); + mockUnlink.mockResolvedValue(undefined); +}); + +// ============================================================================= +// Tests +// ============================================================================= + +describe('atomicWriteAsync', () => { + it('writes to tmp file in same directory then renames to target', async () => { + await atomicWriteAsync(TARGET_PATH, CONTENT); + + // writeFile should be called with a tmp path in the same directory + expect(mockWriteFile).toHaveBeenCalledTimes(1); + const tmpPath = getTmpPath(); + expect(tmpPath).toMatch(new RegExp(`^${TARGET_DIR}/\\.tmp\\.[a-f0-9-]+$`)); + + // rename from tmp to target + expect(mockRename).toHaveBeenCalledWith(tmpPath, TARGET_PATH); + }); + + it('creates parent directories recursively', async () => { + await atomicWriteAsync(TARGET_PATH, CONTENT); + + expect(mockMkdir).toHaveBeenCalledWith(TARGET_DIR, { recursive: true }); + }); + + it('writes content with utf8 encoding', async () => { + await atomicWriteAsync(TARGET_PATH, CONTENT); + + expect(mockWriteFile).toHaveBeenCalledWith(expect.any(String), CONTENT, 'utf8'); + }); + + it('calls fsync on tmp file before rename', async () => { + const mockSync = vi.fn().mockResolvedValue(undefined); + const mockClose = vi.fn().mockResolvedValue(undefined); + mockOpen.mockResolvedValue({ + sync: mockSync, + close: mockClose, + } as unknown as fs.promises.FileHandle); + + await atomicWriteAsync(TARGET_PATH, CONTENT); + + const tmpPath = getTmpPath(); + expect(mockOpen).toHaveBeenCalledWith(tmpPath, 'r+'); + expect(mockSync).toHaveBeenCalled(); + expect(mockClose).toHaveBeenCalled(); + }); + + it('still renames even if fsync fails (best-effort)', async () => { + mockOpen.mockRejectedValue(new Error('fsync not supported')); + + await atomicWriteAsync(TARGET_PATH, CONTENT); + + expect(mockRename).toHaveBeenCalled(); + }); + + it('falls back to copyFile + unlink on EXDEV error', async () => { + const exdevError = Object.assign(new Error('Cross-device link'), { code: 'EXDEV' }); + mockRename.mockRejectedValue(exdevError); + + await atomicWriteAsync(TARGET_PATH, CONTENT); + + const tmpPath = getTmpPath(); + expect(mockCopyFile).toHaveBeenCalledWith(tmpPath, TARGET_PATH); + expect(mockUnlink).toHaveBeenCalledWith(tmpPath); + }); + + it('still succeeds EXDEV fallback even if tmp cleanup fails', async () => { + const exdevError = Object.assign(new Error('Cross-device link'), { code: 'EXDEV' }); + mockRename.mockRejectedValue(exdevError); + mockUnlink.mockRejectedValue(new Error('permission denied')); + + // Should not throw + await atomicWriteAsync(TARGET_PATH, CONTENT); + + expect(mockCopyFile).toHaveBeenCalled(); + }); + + it('re-throws non-EXDEV rename errors and cleans tmp', async () => { + const permError = Object.assign(new Error('Permission denied'), { code: 'EACCES' }); + mockRename.mockRejectedValue(permError); + + await expect(atomicWriteAsync(TARGET_PATH, CONTENT)).rejects.toThrow('Permission denied'); + expect(mockUnlink).toHaveBeenCalled(); + }); + + it('cleans up tmp file on writeFile failure', async () => { + mockWriteFile.mockRejectedValue(new Error('Disk full')); + + await expect(atomicWriteAsync(TARGET_PATH, CONTENT)).rejects.toThrow('Disk full'); + expect(mockUnlink).toHaveBeenCalled(); + }); + + it('creates parent directories for deeply nested paths', async () => { + const deepPath = '/Users/test/project/src/deep/nested/file.ts'; + await atomicWriteAsync(deepPath, CONTENT); + + expect(mockMkdir).toHaveBeenCalledWith(path.dirname(deepPath), { recursive: true }); + }); +}); diff --git a/test/renderer/components/team/editor/EditorSelectionMenu.test.ts b/test/renderer/components/team/editor/EditorSelectionMenu.test.ts new file mode 100644 index 00000000..f4e66797 --- /dev/null +++ b/test/renderer/components/team/editor/EditorSelectionMenu.test.ts @@ -0,0 +1,229 @@ +/** + * Unit tests for EditorSelectionMenu positioning logic + * and buildSelectionAction helper. + * + * Since @testing-library/react is not available in this project, + * we test the positioning logic and the real buildSelectionAction directly. + */ + +import { describe, expect, it } from 'vitest'; + +import { buildSelectionAction, getCodeFenceLanguage } from '@renderer/utils/buildSelectionAction'; + +import type { EditorSelectionInfo } from '@shared/types/editor'; + +// --------------------------------------------------------------------------- +// buildSelectionAction (real import, not a copy) +// --------------------------------------------------------------------------- + +describe('buildSelectionAction', () => { + const baseInfo: EditorSelectionInfo = { + text: 'const x = 42;', + filePath: '/project/src/main.ts', + fromLine: 10, + toLine: 10, + screenRect: { top: 100, right: 200, bottom: 120 }, + }; + + it('builds sendMessage action with code fence', () => { + const action = buildSelectionAction('sendMessage', baseInfo); + + expect(action.type).toBe('sendMessage'); + expect(action.filePath).toBe('/project/src/main.ts'); + expect(action.fromLine).toBe(10); + expect(action.toLine).toBe(10); + expect(action.selectedText).toBe('const x = 42;'); + expect(action.formattedContext).toBe( + '**main.ts** (line 10):\n```typescript\nconst x = 42;\n```' + ); + }); + + it('builds createTask action', () => { + const action = buildSelectionAction('createTask', baseInfo); + + expect(action.type).toBe('createTask'); + expect(action.formattedContext).toContain('```typescript'); + }); + + it('formats multi-line selection range', () => { + const info = { ...baseInfo, fromLine: 5, toLine: 15 }; + const action = buildSelectionAction('sendMessage', info); + + expect(action.formattedContext).toContain('lines 5-15'); + }); + + it('detects language from file extension', () => { + const pyInfo = { ...baseInfo, filePath: '/project/script.py' }; + const action = buildSelectionAction('sendMessage', pyInfo); + + expect(action.formattedContext).toContain('```python'); + expect(action.formattedContext).toContain('**script.py**'); + }); + + it('handles unknown file extensions gracefully', () => { + const unknownInfo = { ...baseInfo, filePath: '/project/data.xyz' }; + const action = buildSelectionAction('sendMessage', unknownInfo); + + // Empty language string → plain code block + expect(action.formattedContext).toContain('```\n'); + }); +}); + +// --------------------------------------------------------------------------- +// getCodeFenceLanguage +// --------------------------------------------------------------------------- + +describe('getCodeFenceLanguage', () => { + it('maps common extensions to lowercase code fence identifiers', () => { + expect(getCodeFenceLanguage('app.ts')).toBe('typescript'); + expect(getCodeFenceLanguage('component.tsx')).toBe('tsx'); + expect(getCodeFenceLanguage('index.js')).toBe('javascript'); + expect(getCodeFenceLanguage('main.py')).toBe('python'); + expect(getCodeFenceLanguage('lib.rs')).toBe('rust'); + expect(getCodeFenceLanguage('main.go')).toBe('go'); + expect(getCodeFenceLanguage('style.css')).toBe('css'); + expect(getCodeFenceLanguage('page.html')).toBe('html'); + expect(getCodeFenceLanguage('config.yaml')).toBe('yaml'); + expect(getCodeFenceLanguage('config.yml')).toBe('yaml'); + expect(getCodeFenceLanguage('script.sh')).toBe('bash'); + }); + + it('returns empty string for unknown extensions', () => { + expect(getCodeFenceLanguage('data.xyz')).toBe(''); + expect(getCodeFenceLanguage('file')).toBe(''); + }); + + it('is case-insensitive for extensions', () => { + expect(getCodeFenceLanguage('App.TS')).toBe('typescript'); + expect(getCodeFenceLanguage('Main.PY')).toBe('python'); + }); +}); + +// --------------------------------------------------------------------------- +// EditorSelectionInfo type shape +// --------------------------------------------------------------------------- + +describe('EditorSelectionInfo type', () => { + it('has expected shape', () => { + const info: EditorSelectionInfo = { + text: 'hello', + filePath: '/a/b.ts', + fromLine: 1, + toLine: 1, + screenRect: { top: 0, right: 0, bottom: 0 }, + }; + + expect(info.text).toBe('hello'); + expect(info.screenRect).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Menu positioning logic (mirrors EditorSelectionMenu.tsx) +// --------------------------------------------------------------------------- + +describe('Menu positioning logic', () => { + const MENU_GAP = 8; + const MENU_WIDTH = 68; + const MENU_HEIGHT = 32; + + function computeMenuPosition( + info: EditorSelectionInfo, + containerRect: { top: number; left: number; width: number; height: number } + ): { top: number; left: number } | null { + // Check visibility + const selBottomInContainer = info.screenRect.bottom - containerRect.top; + const selTopInContainer = info.screenRect.top - containerRect.top; + if (selBottomInContainer < 0 || selTopInContainer > containerRect.height) { + return null; // hidden + } + + const rawTop = info.screenRect.top - containerRect.top; + const rawLeft = info.screenRect.right - containerRect.left + MENU_GAP; + + const top = Math.max(0, Math.min(rawTop, containerRect.height - MENU_HEIGHT)); + const left = + rawLeft + MENU_WIDTH > containerRect.width + ? info.screenRect.right - containerRect.left - MENU_WIDTH - MENU_GAP + : rawLeft; + + return { top, left: Math.max(0, left) }; + } + + it('positions menu to the right of selection', () => { + const info: EditorSelectionInfo = { + text: 'x', + filePath: '/a.ts', + fromLine: 1, + toLine: 1, + screenRect: { top: 100, right: 200, bottom: 120 }, + }; + const container = { top: 50, left: 50, width: 600, height: 400 }; + + const pos = computeMenuPosition(info, container); + expect(pos).not.toBeNull(); + // top = 100 - 50 = 50 + expect(pos!.top).toBe(50); + // left = 200 - 50 + 8 = 158 + expect(pos!.left).toBe(158); + }); + + it('returns null when selection is above container', () => { + const info: EditorSelectionInfo = { + text: 'x', + filePath: '/a.ts', + fromLine: 1, + toLine: 1, + screenRect: { top: 10, right: 200, bottom: 30 }, + }; + const container = { top: 50, left: 50, width: 600, height: 400 }; + + expect(computeMenuPosition(info, container)).toBeNull(); + }); + + it('returns null when selection is below container', () => { + const info: EditorSelectionInfo = { + text: 'x', + filePath: '/a.ts', + fromLine: 1, + toLine: 1, + screenRect: { top: 500, right: 200, bottom: 520 }, + }; + const container = { top: 50, left: 50, width: 600, height: 400 }; + + expect(computeMenuPosition(info, container)).toBeNull(); + }); + + it('clamps top to prevent overflow below container', () => { + const info: EditorSelectionInfo = { + text: 'x', + filePath: '/a.ts', + fromLine: 1, + toLine: 1, + screenRect: { top: 430, right: 200, bottom: 445 }, + }; + const container = { top: 50, left: 50, width: 600, height: 400 }; + + const pos = computeMenuPosition(info, container); + expect(pos).not.toBeNull(); + // rawTop = 430-50 = 380, max = 400-32 = 368 → clamped to 368 + expect(pos!.top).toBe(368); + }); + + it('flips menu to left when it would overflow right', () => { + const info: EditorSelectionInfo = { + text: 'x', + filePath: '/a.ts', + fromLine: 1, + toLine: 1, + screenRect: { top: 100, right: 620, bottom: 120 }, + }; + const container = { top: 50, left: 50, width: 600, height: 400 }; + + const pos = computeMenuPosition(info, container); + expect(pos).not.toBeNull(); + // rawLeft = 620-50+8 = 578, 578+68=646 > 600 → flip + // flipped = 620-50-68-8 = 494 + expect(pos!.left).toBe(494); + }); +}); diff --git a/test/renderer/components/team/editor/fileIcons.test.ts b/test/renderer/components/team/editor/fileIcons.test.ts new file mode 100644 index 00000000..66debd16 --- /dev/null +++ b/test/renderer/components/team/editor/fileIcons.test.ts @@ -0,0 +1,88 @@ +/** + * Tests for fileIcons utility — extension-to-icon mapping. + */ + +import { describe, expect, it } from 'vitest'; + +import { getFileIcon } from '@renderer/components/team/editor/fileIcons'; + +describe('getFileIcon', () => { + it('returns TypeScript icon for .ts files', () => { + const info = getFileIcon('index.ts'); + expect(info.color).toBe('#3178c6'); + }); + + it('returns TypeScript icon for .tsx files', () => { + const info = getFileIcon('App.tsx'); + expect(info.color).toBe('#3178c6'); + }); + + it('returns JavaScript icon for .js files', () => { + const info = getFileIcon('app.js'); + expect(info.color).toBe('#f7df1e'); + }); + + it('returns JSON icon for .json files', () => { + const info = getFileIcon('package.json'); + // package.json has special mapping + expect(info.color).toBe('#cb3837'); + }); + + it('returns markdown icon for .md files', () => { + const info = getFileIcon('README.md'); + expect(info.color).toBe('#519aba'); + }); + + it('returns Python icon for .py files', () => { + const info = getFileIcon('main.py'); + expect(info.color).toBe('#3572a5'); + }); + + it('returns Rust icon for .rs files', () => { + const info = getFileIcon('lib.rs'); + expect(info.color).toBe('#dea584'); + }); + + it('returns default icon for unknown extensions', () => { + const info = getFileIcon('file.xyz123'); + expect(info.color).toBe('#89949f'); + }); + + it('returns default icon for files without extension', () => { + const info = getFileIcon('Procfile'); + expect(info.color).toBe('#89949f'); + }); + + it('matches special filenames exactly', () => { + const docker = getFileIcon('Dockerfile'); + expect(docker.color).toBe('#2496ed'); + + const gitignore = getFileIcon('.gitignore'); + expect(gitignore.color).toBe('#f05032'); + + const claudeMd = getFileIcon('CLAUDE.md'); + expect(claudeMd.color).toBe('#d97706'); + }); + + it('prefers filename match over extension match', () => { + // tsconfig.json should match FILENAME_MAP, not generic .json + const tsconfig = getFileIcon('tsconfig.json'); + expect(tsconfig.color).toBe('#3178c6'); + }); + + it('returns lock icon for sensitive files', () => { + const env = getFileIcon('.env'); + expect(env.color).toBe('#e5a00d'); + + const pnpmLock = getFileIcon('pnpm-lock.yaml'); + expect(pnpmLock.color).toBe('#f69220'); + }); + + it('handles image files', () => { + const png = getFileIcon('logo.png'); + expect(png.color).toBe('#a074c4'); + + const svg = getFileIcon('icon.svg'); + expect(svg.color).toBe('#ffb13b'); + }); +}); diff --git a/test/renderer/hooks/useEditorKeyboardShortcuts.test.ts b/test/renderer/hooks/useEditorKeyboardShortcuts.test.ts new file mode 100644 index 00000000..81aa3212 --- /dev/null +++ b/test/renderer/hooks/useEditorKeyboardShortcuts.test.ts @@ -0,0 +1,278 @@ +/** + * Tests for createEditorKeyHandler — the pure keyboard dispatch logic + * extracted from useEditorKeyboardShortcuts. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock @codemirror/search — handler calls openSearchPanel/gotoLine when view exists +vi.mock('@codemirror/search', () => ({ + openSearchPanel: vi.fn(), + gotoLine: vi.fn(), +})); + +import { gotoLine, openSearchPanel } from '@codemirror/search'; +import { createEditorKeyHandler } from '@renderer/hooks/useEditorKeyboardShortcuts'; + +import type { EditorKeyHandlerDeps } from '@renderer/hooks/useEditorKeyboardShortcuts'; +import type { EditorFileTab } from '@shared/types/editor'; + +// ============================================================================= +// Helpers +// ============================================================================= + +function createMockDeps(overrides: Partial = {}): EditorKeyHandlerDeps { + return { + activeTabId: '/project/file1.ts', + openTabs: [ + { + id: '/project/file1.ts', + filePath: '/project/file1.ts', + fileName: 'file1.ts', + language: 'typescript', + }, + { + id: '/project/file2.ts', + filePath: '/project/file2.ts', + fileName: 'file2.ts', + language: 'typescript', + }, + { + id: '/project/file3.ts', + filePath: '/project/file3.ts', + fileName: 'file3.ts', + language: 'typescript', + }, + ] as EditorFileTab[], + setActiveTab: vi.fn(), + saveFile: vi.fn().mockResolvedValue(undefined), + saveAllFiles: vi.fn().mockResolvedValue(undefined), + hasUnsavedChanges: vi.fn().mockReturnValue(false), + onToggleQuickOpen: vi.fn(), + onToggleSearchPanel: vi.fn(), + onToggleSidebar: vi.fn(), + getEditorView: vi.fn().mockReturnValue(null), + ...overrides, + }; +} + +function createKeyEvent(key: string, opts: Partial = {}): KeyboardEvent { + return new KeyboardEvent('keydown', { + key, + metaKey: opts.metaKey ?? true, + ctrlKey: opts.ctrlKey ?? false, + shiftKey: opts.shiftKey ?? false, + altKey: opts.altKey ?? false, + bubbles: true, + cancelable: true, + }); +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('createEditorKeyHandler', () => { + let deps: EditorKeyHandlerDeps; + + beforeEach(() => { + vi.resetAllMocks(); + deps = createMockDeps(); + }); + + it('ignores events without modifier key', () => { + const handler = createEditorKeyHandler(deps); + const event = new KeyboardEvent('keydown', { key: 'p', bubbles: true, cancelable: true }); + handler(event); + expect(deps.onToggleQuickOpen).not.toHaveBeenCalled(); + }); + + describe('Cmd+P — Quick Open', () => { + it('calls onToggleQuickOpen', () => { + const handler = createEditorKeyHandler(deps); + const event = createKeyEvent('p'); + handler(event); + expect(deps.onToggleQuickOpen).toHaveBeenCalledOnce(); + expect(event.defaultPrevented).toBe(true); + }); + + it('does not trigger with Shift', () => { + const handler = createEditorKeyHandler(deps); + handler(createKeyEvent('p', { shiftKey: true })); + expect(deps.onToggleQuickOpen).not.toHaveBeenCalled(); + }); + }); + + describe('Cmd+Shift+F — Search in Files', () => { + it('calls onToggleSearchPanel', () => { + const handler = createEditorKeyHandler(deps); + const event = createKeyEvent('f', { shiftKey: true }); + handler(event); + expect(deps.onToggleSearchPanel).toHaveBeenCalledOnce(); + }); + }); + + describe('Cmd+F — Find in File (CM6)', () => { + it('calls openSearchPanel when editor view exists', () => { + const mockView = { dispatch: vi.fn() }; + deps = createMockDeps({ getEditorView: vi.fn().mockReturnValue(mockView) }); + const handler = createEditorKeyHandler(deps); + handler(createKeyEvent('f')); + expect(openSearchPanel).toHaveBeenCalledWith(mockView); + }); + + it('does nothing when no editor view', () => { + const handler = createEditorKeyHandler(deps); + handler(createKeyEvent('f')); + expect(openSearchPanel).not.toHaveBeenCalled(); + }); + }); + + describe('Cmd+G — Go to Line', () => { + it('calls gotoLine when editor view exists', () => { + const mockView = { dispatch: vi.fn() }; + deps = createMockDeps({ getEditorView: vi.fn().mockReturnValue(mockView) }); + const handler = createEditorKeyHandler(deps); + handler(createKeyEvent('g')); + expect(gotoLine).toHaveBeenCalledWith(mockView); + }); + }); + + describe('Cmd+S — Save', () => { + it('calls saveFile with active tab id', () => { + const handler = createEditorKeyHandler(deps); + handler(createKeyEvent('s')); + expect(deps.saveFile).toHaveBeenCalledWith('/project/file1.ts'); + }); + + it('does nothing when no active tab', () => { + deps = createMockDeps({ activeTabId: null }); + const handler = createEditorKeyHandler(deps); + handler(createKeyEvent('s')); + expect(deps.saveFile).not.toHaveBeenCalled(); + }); + }); + + describe('Cmd+Shift+S — Save All', () => { + it('calls saveAllFiles when unsaved changes exist', () => { + deps = createMockDeps({ hasUnsavedChanges: vi.fn().mockReturnValue(true) }); + const handler = createEditorKeyHandler(deps); + handler(createKeyEvent('s', { shiftKey: true })); + expect(deps.saveAllFiles).toHaveBeenCalledOnce(); + }); + + it('does nothing when no unsaved changes', () => { + const handler = createEditorKeyHandler(deps); + handler(createKeyEvent('s', { shiftKey: true })); + expect(deps.saveAllFiles).not.toHaveBeenCalled(); + }); + }); + + describe('Cmd+W — Close Tab', () => { + it('dispatches editor-close-tab CustomEvent with active tab id', () => { + const handler = createEditorKeyHandler(deps); + const eventSpy = vi.fn(); + window.addEventListener('editor-close-tab', eventSpy); + + handler(createKeyEvent('w')); + + expect(eventSpy).toHaveBeenCalledOnce(); + const detail = (eventSpy.mock.calls[0][0] as CustomEvent).detail; + expect(detail).toBe('/project/file1.ts'); + + window.removeEventListener('editor-close-tab', eventSpy); + }); + + it('does nothing with Alt modifier', () => { + const handler = createEditorKeyHandler(deps); + const eventSpy = vi.fn(); + window.addEventListener('editor-close-tab', eventSpy); + + handler(createKeyEvent('w', { altKey: true })); + expect(eventSpy).not.toHaveBeenCalled(); + + window.removeEventListener('editor-close-tab', eventSpy); + }); + }); + + describe('Cmd+B — Toggle Sidebar', () => { + it('calls onToggleSidebar', () => { + const handler = createEditorKeyHandler(deps); + handler(createKeyEvent('b')); + expect(deps.onToggleSidebar).toHaveBeenCalledOnce(); + }); + }); + + describe('Cmd+Shift+] / [ — Tab Navigation', () => { + it('moves to next tab with Cmd+Shift+]', () => { + const handler = createEditorKeyHandler(deps); + handler(createKeyEvent(']', { shiftKey: true })); + expect(deps.setActiveTab).toHaveBeenCalledWith('/project/file2.ts'); + }); + + it('wraps to first tab when on last', () => { + deps = createMockDeps({ activeTabId: '/project/file3.ts' }); + const handler = createEditorKeyHandler(deps); + handler(createKeyEvent(']', { shiftKey: true })); + expect(deps.setActiveTab).toHaveBeenCalledWith('/project/file1.ts'); + }); + + it('moves to previous tab with Cmd+Shift+[', () => { + deps = createMockDeps({ activeTabId: '/project/file2.ts' }); + const handler = createEditorKeyHandler(deps); + handler(createKeyEvent('[', { shiftKey: true })); + expect(deps.setActiveTab).toHaveBeenCalledWith('/project/file1.ts'); + }); + + it('wraps to last tab when on first with Cmd+Shift+[', () => { + const handler = createEditorKeyHandler(deps); + handler(createKeyEvent('[', { shiftKey: true })); + expect(deps.setActiveTab).toHaveBeenCalledWith('/project/file3.ts'); + }); + }); + + describe('Ctrl+Tab — Tab Cycling', () => { + it('moves to next tab', () => { + const handler = createEditorKeyHandler(deps); + handler(createKeyEvent('Tab', { metaKey: false, ctrlKey: true })); + expect(deps.setActiveTab).toHaveBeenCalledWith('/project/file2.ts'); + }); + + it('moves to previous tab with Shift', () => { + deps = createMockDeps({ activeTabId: '/project/file2.ts' }); + const handler = createEditorKeyHandler(deps); + handler(createKeyEvent('Tab', { metaKey: false, ctrlKey: true, shiftKey: true })); + expect(deps.setActiveTab).toHaveBeenCalledWith('/project/file1.ts'); + }); + + it('wraps forward on last tab', () => { + deps = createMockDeps({ activeTabId: '/project/file3.ts' }); + const handler = createEditorKeyHandler(deps); + handler(createKeyEvent('Tab', { metaKey: false, ctrlKey: true })); + expect(deps.setActiveTab).toHaveBeenCalledWith('/project/file1.ts'); + }); + + it('wraps backward on first tab', () => { + const handler = createEditorKeyHandler(deps); + handler(createKeyEvent('Tab', { metaKey: false, ctrlKey: true, shiftKey: true })); + expect(deps.setActiveTab).toHaveBeenCalledWith('/project/file3.ts'); + }); + }); + + describe('edge cases', () => { + it('does nothing when openTabs is empty', () => { + deps = createMockDeps({ openTabs: [], activeTabId: null }); + const handler = createEditorKeyHandler(deps); + handler(createKeyEvent(']', { shiftKey: true })); + expect(deps.setActiveTab).not.toHaveBeenCalled(); + }); + + it('stopPropagation is called on handled shortcuts', () => { + const handler = createEditorKeyHandler(deps); + const event = createKeyEvent('p'); + const spy = vi.spyOn(event, 'stopPropagation'); + handler(event); + expect(spy).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/test/renderer/store/editorSlice.test.ts b/test/renderer/store/editorSlice.test.ts new file mode 100644 index 00000000..dfb28195 --- /dev/null +++ b/test/renderer/store/editorSlice.test.ts @@ -0,0 +1,852 @@ +/** + * Tests for editorSlice — openEditor, closeEditor, expandDirectory, collapseDirectory. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createTestStore } from './storeTestUtils'; + +import type { TestStore } from './storeTestUtils'; +import type { FileTreeEntry, ReadDirResult } from '../../../src/shared/types/editor'; + +// ============================================================================= +// Mock API +// ============================================================================= + +const mockEditorAPI = { + open: vi.fn(), + close: vi.fn(), + readDir: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + createFile: vi.fn(), + createDir: vi.fn(), + deleteFile: vi.fn(), + moveFile: vi.fn(), +}; + +vi.mock('@renderer/api', () => ({ + api: { + editor: { + open: (...args: unknown[]) => mockEditorAPI.open(...args), + close: (...args: unknown[]) => mockEditorAPI.close(...args), + readDir: (...args: unknown[]) => mockEditorAPI.readDir(...args), + readFile: (...args: unknown[]) => mockEditorAPI.readFile(...args), + writeFile: (...args: unknown[]) => mockEditorAPI.writeFile(...args), + createFile: (...args: unknown[]) => mockEditorAPI.createFile(...args), + createDir: (...args: unknown[]) => mockEditorAPI.createDir(...args), + deleteFile: (...args: unknown[]) => mockEditorAPI.deleteFile(...args), + moveFile: (...args: unknown[]) => mockEditorAPI.moveFile(...args), + }, + // Provide stubs for other API domains if needed + getProjects: vi.fn(), + getSessions: vi.fn(), + }, +})); + +const mockBridge = { + getContent: vi.fn(), + getAllModifiedContent: vi.fn(), + destroy: vi.fn(), + deleteState: vi.fn(), + remapState: vi.fn(), +}; + +vi.mock('@renderer/utils/editorBridge', () => ({ + editorBridge: { + getContent: (...args: unknown[]) => mockBridge.getContent(...args), + getAllModifiedContent: (...args: unknown[]) => mockBridge.getAllModifiedContent(...args), + destroy: (...args: unknown[]) => mockBridge.destroy(...args), + deleteState: (...args: unknown[]) => mockBridge.deleteState(...args), + remapState: (...args: unknown[]) => mockBridge.remapState(...args), + register: vi.fn(), + unregister: vi.fn(), + isRegistered: false, + updateView: vi.fn(), + getView: vi.fn(), + }, +})); + +vi.mock('@renderer/utils/codemirrorLanguages', () => ({ + getLanguageFromFileName: (name: string) => { + const ext = name.split('.').pop()?.toLowerCase(); + const map: Record = { + ts: 'TypeScript', + tsx: 'TSX', + js: 'JavaScript', + json: 'JSON', + md: 'Markdown', + py: 'Python', + }; + return map[ext ?? ''] ?? 'Plain Text'; + }, + getSyncLanguageExtension: vi.fn(), + getAsyncLanguageDesc: vi.fn(), +})); + +vi.mock('@shared/utils/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), +})); + +// ============================================================================= +// Helpers +// ============================================================================= + +const PROJECT_PATH = '/Users/test/my-project'; + +function makeEntry(name: string, type: 'file' | 'directory', absPath?: string): FileTreeEntry { + return { + name, + path: absPath ?? `${PROJECT_PATH}/${name}`, + type, + }; +} + +function makeDirResult(entries: FileTreeEntry[], truncated = false): ReadDirResult { + return { entries, truncated }; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('editorSlice', () => { + let store: TestStore; + + beforeEach(() => { + vi.resetAllMocks(); + store = createTestStore(); + }); + + describe('initial state', () => { + it('has null/empty defaults', () => { + const state = store.getState(); + expect(state.editorProjectPath).toBeNull(); + expect(state.editorFileTree).toBeNull(); + expect(state.editorFileTreeLoading).toBe(false); + expect(state.editorFileTreeError).toBeNull(); + expect(state.editorExpandedDirs).toEqual({}); + }); + }); + + describe('openEditor', () => { + it('sets loading state, calls API, and stores file tree', async () => { + const entries = [makeEntry('src', 'directory'), makeEntry('README.md', 'file')]; + mockEditorAPI.open.mockResolvedValue(undefined); + mockEditorAPI.readDir.mockResolvedValue(makeDirResult(entries)); + + await store.getState().openEditor(PROJECT_PATH); + + const state = store.getState(); + expect(state.editorProjectPath).toBe(PROJECT_PATH); + expect(state.editorFileTree).toEqual(entries); + expect(state.editorFileTreeLoading).toBe(false); + expect(state.editorFileTreeError).toBeNull(); + expect(mockEditorAPI.open).toHaveBeenCalledWith(PROJECT_PATH); + expect(mockEditorAPI.readDir).toHaveBeenCalledWith(PROJECT_PATH); + }); + + it('sets error state on API failure', async () => { + mockEditorAPI.open.mockRejectedValue(new Error('Permission denied')); + + await store.getState().openEditor(PROJECT_PATH); + + const state = store.getState(); + expect(state.editorFileTreeLoading).toBe(false); + expect(state.editorFileTreeError).toBe('Permission denied'); + expect(state.editorFileTree).toBeNull(); + }); + + it('resets expanded dirs on new open', async () => { + // Set some expanded dirs manually + store.setState({ editorExpandedDirs: { '/old/path': true } }); + + mockEditorAPI.open.mockResolvedValue(undefined); + mockEditorAPI.readDir.mockResolvedValue(makeDirResult([])); + + await store.getState().openEditor(PROJECT_PATH); + + expect(store.getState().editorExpandedDirs).toEqual({}); + }); + }); + + describe('closeEditor', () => { + it('resets all editor state', async () => { + // Setup non-default state + store.setState({ + editorProjectPath: PROJECT_PATH, + editorFileTree: [makeEntry('file.ts', 'file')], + editorFileTreeLoading: true, + editorFileTreeError: 'some error', + editorExpandedDirs: { '/path': true }, + }); + + mockEditorAPI.close.mockResolvedValue(undefined); + + store.getState().closeEditor(); + + const state = store.getState(); + expect(state.editorProjectPath).toBeNull(); + expect(state.editorFileTree).toBeNull(); + expect(state.editorFileTreeLoading).toBe(false); + expect(state.editorFileTreeError).toBeNull(); + expect(state.editorExpandedDirs).toEqual({}); + }); + + it('still resets local state even if IPC close fails', async () => { + store.setState({ editorProjectPath: PROJECT_PATH }); + mockEditorAPI.close.mockRejectedValue(new Error('IPC error')); + + store.getState().closeEditor(); + + // Local state reset immediately (fire-and-forget IPC) + expect(store.getState().editorProjectPath).toBeNull(); + }); + }); + + describe('expandDirectory', () => { + it('marks directory expanded immediately, then merges children', async () => { + const srcDir = makeEntry('src', 'directory'); + store.setState({ + editorProjectPath: PROJECT_PATH, + editorFileTree: [srcDir, makeEntry('README.md', 'file')], + }); + + const children = [makeEntry('index.ts', 'file', `${PROJECT_PATH}/src/index.ts`)]; + mockEditorAPI.readDir.mockResolvedValue(makeDirResult(children)); + + const expandPromise = store.getState().expandDirectory(srcDir.path); + + // Immediately expanded (optimistic UI) + expect(store.getState().editorExpandedDirs[srcDir.path]).toBe(true); + + await expandPromise; + + // Children merged into tree + const tree = store.getState().editorFileTree!; + const srcNode = tree.find((e) => e.name === 'src'); + expect(srcNode?.children).toEqual(children); + }); + + it('reverts expansion on error', async () => { + const srcDir = makeEntry('src', 'directory'); + store.setState({ + editorProjectPath: PROJECT_PATH, + editorFileTree: [srcDir], + editorExpandedDirs: {}, + }); + + mockEditorAPI.readDir.mockRejectedValue(new Error('Network error')); + + await store.getState().expandDirectory(srcDir.path); + + // Expansion reverted + expect(store.getState().editorExpandedDirs[srcDir.path]).toBeUndefined(); + }); + }); + + describe('collapseDirectory', () => { + it('removes directory from expandedDirs', () => { + const dirPath = PROJECT_PATH + '/src'; + store.setState({ editorExpandedDirs: { [dirPath]: true, '/other': true } }); + + store.getState().collapseDirectory(dirPath); + + expect(store.getState().editorExpandedDirs).toEqual({ '/other': true }); + }); + + it('no-op when directory not expanded', () => { + store.setState({ editorExpandedDirs: { '/other': true } }); + + store.getState().collapseDirectory('/not-expanded'); + + expect(store.getState().editorExpandedDirs).toEqual({ '/other': true }); + }); + }); + + // ═══════════════════════════════════════════════════════ + // Group 2: Tab management + // ═══════════════════════════════════════════════════════ + + describe('openFile', () => { + it('creates a tab and activates it', () => { + store.getState().openFile('/project/src/index.ts'); + + const state = store.getState(); + expect(state.editorOpenTabs).toHaveLength(1); + expect(state.editorOpenTabs[0].filePath).toBe('/project/src/index.ts'); + expect(state.editorOpenTabs[0].fileName).toBe('index.ts'); + expect(state.editorOpenTabs[0].language).toBe('TypeScript'); + expect(state.editorActiveTabId).toBe('/project/src/index.ts'); + }); + + it('activates existing tab instead of creating duplicate', () => { + store.getState().openFile('/project/src/index.ts'); + store.getState().openFile('/project/src/app.tsx'); + store.getState().openFile('/project/src/index.ts'); + + const state = store.getState(); + expect(state.editorOpenTabs).toHaveLength(2); + expect(state.editorActiveTabId).toBe('/project/src/index.ts'); + }); + + it('detects language from file extension', () => { + store.getState().openFile('/project/data.json'); + + expect(store.getState().editorOpenTabs[0].language).toBe('JSON'); + }); + + it('uses "Plain Text" for unknown extensions', () => { + store.getState().openFile('/project/Dockerfile'); + + expect(store.getState().editorOpenTabs[0].language).toBe('Plain Text'); + }); + }); + + describe('closeTab', () => { + it('removes tab and activates adjacent', () => { + store.getState().openFile('/project/a.ts'); + store.getState().openFile('/project/b.ts'); + store.getState().openFile('/project/c.ts'); + + // Active is c.ts, close it + store.getState().closeTab('/project/c.ts'); + + const state = store.getState(); + expect(state.editorOpenTabs).toHaveLength(2); + expect(state.editorActiveTabId).toBe('/project/b.ts'); + }); + + it('activates first remaining tab when first is closed', () => { + store.getState().openFile('/project/a.ts'); + store.getState().openFile('/project/b.ts'); + store.getState().setActiveTab('/project/a.ts'); + + store.getState().closeTab('/project/a.ts'); + + expect(store.getState().editorActiveTabId).toBe('/project/b.ts'); + }); + + it('sets null when last tab is closed', () => { + store.getState().openFile('/project/a.ts'); + store.getState().closeTab('/project/a.ts'); + + expect(store.getState().editorActiveTabId).toBeNull(); + expect(store.getState().editorOpenTabs).toHaveLength(0); + }); + + it('cleans up dirty and error state for closed tab', () => { + store.getState().openFile('/project/a.ts'); + store.setState({ + editorModifiedFiles: { '/project/a.ts': true }, + editorSaveError: { '/project/a.ts': 'Save failed' }, + }); + + store.getState().closeTab('/project/a.ts'); + + expect(store.getState().editorModifiedFiles).toEqual({}); + expect(store.getState().editorSaveError).toEqual({}); + }); + + it('does not change activeTabId when closing non-active tab', () => { + store.getState().openFile('/project/a.ts'); + store.getState().openFile('/project/b.ts'); + // b.ts is active + + store.getState().closeTab('/project/a.ts'); + + expect(store.getState().editorActiveTabId).toBe('/project/b.ts'); + expect(store.getState().editorOpenTabs).toHaveLength(1); + }); + }); + + describe('setActiveTab', () => { + it('changes the active tab', () => { + store.getState().openFile('/project/a.ts'); + store.getState().openFile('/project/b.ts'); + // b.ts is active + + store.getState().setActiveTab('/project/a.ts'); + + expect(store.getState().editorActiveTabId).toBe('/project/a.ts'); + }); + }); + + // ═══════════════════════════════════════════════════════ + // Group 3: Dirty/Save + // ═══════════════════════════════════════════════════════ + + describe('markFileModified', () => { + it('sets dirty flag', () => { + store.getState().markFileModified('/project/a.ts'); + + expect(store.getState().editorModifiedFiles['/project/a.ts']).toBe(true); + }); + + it('is idempotent', () => { + store.getState().markFileModified('/project/a.ts'); + const first = store.getState().editorModifiedFiles; + + store.getState().markFileModified('/project/a.ts'); + const second = store.getState().editorModifiedFiles; + + // Same reference (no unnecessary update) + expect(first).toBe(second); + }); + }); + + describe('markFileSaved', () => { + it('removes dirty flag', () => { + store.setState({ editorModifiedFiles: { '/project/a.ts': true, '/project/b.ts': true } }); + + store.getState().markFileSaved('/project/a.ts'); + + expect(store.getState().editorModifiedFiles).toEqual({ '/project/b.ts': true }); + }); + }); + + describe('hasUnsavedChanges', () => { + it('returns false when no modified files', () => { + expect(store.getState().hasUnsavedChanges()).toBe(false); + }); + + it('returns true when modified files exist', () => { + store.setState({ editorModifiedFiles: { '/project/a.ts': true } }); + expect(store.getState().hasUnsavedChanges()).toBe(true); + }); + }); + + describe('saveFile', () => { + it('saves file via API and clears dirty flag', async () => { + const filePath = '/project/src/index.ts'; + mockBridge.getContent.mockReturnValue('new content'); + mockEditorAPI.writeFile.mockResolvedValue({ mtimeMs: Date.now(), size: 11 }); + + store.setState({ editorModifiedFiles: { [filePath]: true } }); + await store.getState().saveFile(filePath); + + expect(mockBridge.getContent).toHaveBeenCalledWith(filePath); + expect(mockEditorAPI.writeFile).toHaveBeenCalledWith(filePath, 'new content', undefined); + expect(store.getState().editorModifiedFiles[filePath]).toBeUndefined(); + expect(store.getState().editorSaving[filePath]).toBeUndefined(); + }); + + it('sets saving flag during save', async () => { + const filePath = '/project/src/index.ts'; + let savingDuringCall = false; + + mockBridge.getContent.mockReturnValue('content'); + mockEditorAPI.writeFile.mockImplementation(async () => { + savingDuringCall = !!store.getState().editorSaving[filePath]; + return { mtimeMs: Date.now(), size: 7 }; + }); + + await store.getState().saveFile(filePath); + + expect(savingDuringCall).toBe(true); + expect(store.getState().editorSaving[filePath]).toBeUndefined(); + }); + + it('does nothing when bridge has no content', async () => { + mockBridge.getContent.mockReturnValue(null); + + await store.getState().saveFile('/project/src/index.ts'); + + expect(mockEditorAPI.writeFile).not.toHaveBeenCalled(); + }); + + it('sets error on save failure', async () => { + const filePath = '/project/src/index.ts'; + mockBridge.getContent.mockReturnValue('content'); + mockEditorAPI.writeFile.mockRejectedValue(new Error('Permission denied')); + + store.setState({ editorModifiedFiles: { [filePath]: true } }); + await store.getState().saveFile(filePath); + + expect(store.getState().editorSaveError[filePath]).toBe('Permission denied'); + // Dirty flag preserved on error + expect(store.getState().editorModifiedFiles[filePath]).toBe(true); + expect(store.getState().editorSaving[filePath]).toBeUndefined(); + }); + }); + + describe('saveAllFiles', () => { + it('saves all modified files', async () => { + const files = new Map([ + ['/project/a.ts', 'content a'], + ['/project/b.ts', 'content b'], + ]); + mockBridge.getAllModifiedContent.mockReturnValue(files); + mockEditorAPI.writeFile.mockResolvedValue({ mtimeMs: Date.now(), size: 10 }); + + store.setState({ + editorModifiedFiles: { '/project/a.ts': true, '/project/b.ts': true }, + }); + + await store.getState().saveAllFiles(); + + expect(mockEditorAPI.writeFile).toHaveBeenCalledTimes(2); + expect(store.getState().editorModifiedFiles).toEqual({}); + }); + + it('handles partial failures', async () => { + const files = new Map([ + ['/project/a.ts', 'content a'], + ['/project/b.ts', 'content b'], + ]); + mockBridge.getAllModifiedContent.mockReturnValue(files); + mockEditorAPI.writeFile + .mockResolvedValueOnce({ mtimeMs: Date.now(), size: 10 }) + .mockRejectedValueOnce(new Error('Disk full')); + + store.setState({ + editorModifiedFiles: { '/project/a.ts': true, '/project/b.ts': true }, + }); + + await store.getState().saveAllFiles(); + + // a.ts saved, b.ts still dirty + expect(store.getState().editorModifiedFiles['/project/a.ts']).toBeUndefined(); + expect(store.getState().editorModifiedFiles['/project/b.ts']).toBe(true); + expect(store.getState().editorSaveError['/project/b.ts']).toBe('Disk full'); + }); + }); + + describe('discardChanges', () => { + it('clears dirty flag and error for the file', () => { + store.setState({ + editorModifiedFiles: { '/project/a.ts': true, '/project/b.ts': true }, + editorSaveError: { '/project/a.ts': 'Error' }, + }); + + store.getState().discardChanges('/project/a.ts'); + + expect(store.getState().editorModifiedFiles).toEqual({ '/project/b.ts': true }); + expect(store.getState().editorSaveError).toEqual({}); + }); + }); + + describe('closeEditor resets all state including Group 2+3', () => { + it('resets tabs, dirty, saving, errors', () => { + store.setState({ + editorProjectPath: PROJECT_PATH, + editorOpenTabs: [ + { id: '/a.ts', filePath: '/a.ts', fileName: 'a.ts', language: 'TypeScript' }, + ], + editorActiveTabId: '/a.ts', + editorModifiedFiles: { '/a.ts': true }, + editorSaving: { '/a.ts': true }, + editorSaveError: { '/a.ts': 'Error' }, + }); + + mockEditorAPI.close.mockResolvedValue(undefined); + store.getState().closeEditor(); + + const state = store.getState(); + expect(state.editorOpenTabs).toEqual([]); + expect(state.editorActiveTabId).toBeNull(); + expect(state.editorModifiedFiles).toEqual({}); + expect(state.editorSaving).toEqual({}); + expect(state.editorSaveError).toEqual({}); + expect(mockBridge.destroy).toHaveBeenCalled(); + }); + }); + + // ═══════════════════════════════════════════════════════ + // Tab disambiguation + // ═══════════════════════════════════════════════════════ + + describe('openFile with disambiguation', () => { + it('adds disambiguation labels when 2 files share the same name', () => { + store.getState().openFile('/project/src/main/index.ts'); + store.getState().openFile('/project/src/renderer/index.ts'); + + const tabs = store.getState().editorOpenTabs; + expect(tabs).toHaveLength(2); + expect(tabs[0].disambiguatedLabel).toBe('(main)'); + expect(tabs[1].disambiguatedLabel).toBe('(renderer)'); + }); + + it('no labels when names are unique', () => { + store.getState().openFile('/project/src/app.ts'); + store.getState().openFile('/project/src/index.ts'); + + const tabs = store.getState().editorOpenTabs; + expect(tabs[0].disambiguatedLabel).toBeUndefined(); + expect(tabs[1].disambiguatedLabel).toBeUndefined(); + }); + }); + + describe('closeTab clears disambiguation when names become unique', () => { + it('removes label after closing duplicate', () => { + store.getState().openFile('/project/src/main/index.ts'); + store.getState().openFile('/project/src/renderer/index.ts'); + + // Both have labels + expect(store.getState().editorOpenTabs[0].disambiguatedLabel).toBe('(main)'); + + // Close one + store.getState().closeTab('/project/src/main/index.ts'); + + // Remaining should lose its label + const tabs = store.getState().editorOpenTabs; + expect(tabs).toHaveLength(1); + expect(tabs[0].disambiguatedLabel).toBeUndefined(); + }); + }); + + describe('closeTab calls editorBridge.deleteState', () => { + it('clears cached state for the closed tab', () => { + store.getState().openFile('/project/a.ts'); + store.getState().closeTab('/project/a.ts'); + + expect(mockBridge.deleteState).toHaveBeenCalledWith('/project/a.ts'); + }); + }); + + // ═══════════════════════════════════════════════════════ + // Group 4: File operations + // ═══════════════════════════════════════════════════════ + + describe('createFileInTree', () => { + it('creates file, refreshes tree, and returns path', async () => { + const createdPath = '/project/src/new-file.ts'; + mockEditorAPI.createFile.mockResolvedValue({ filePath: createdPath, mtimeMs: 123 }); + mockEditorAPI.readDir.mockResolvedValue( + makeDirResult([makeEntry('new-file.ts', 'file', createdPath)]) + ); + + store.setState({ + editorProjectPath: PROJECT_PATH, + editorFileTree: [makeEntry('src', 'directory')], + }); + + const result = await store.getState().createFileInTree('/project/src', 'new-file.ts'); + + expect(result).toBe(createdPath); + expect(mockEditorAPI.createFile).toHaveBeenCalledWith('/project/src', 'new-file.ts'); + expect(store.getState().editorCreating).toBe(false); + expect(store.getState().editorCreateError).toBeNull(); + }); + + it('sets error on failure', async () => { + mockEditorAPI.createFile.mockRejectedValue(new Error('File already exists')); + + const result = await store.getState().createFileInTree('/project/src', 'existing.ts'); + + expect(result).toBeNull(); + expect(store.getState().editorCreating).toBe(false); + expect(store.getState().editorCreateError).toBe('File already exists'); + }); + }); + + describe('createDirInTree', () => { + it('creates directory, refreshes tree, and returns path', async () => { + const createdPath = '/project/src/new-dir'; + mockEditorAPI.createDir.mockResolvedValue({ dirPath: createdPath }); + mockEditorAPI.readDir.mockResolvedValue( + makeDirResult([makeEntry('new-dir', 'directory', createdPath)]) + ); + + store.setState({ + editorProjectPath: PROJECT_PATH, + editorFileTree: [makeEntry('src', 'directory')], + }); + + const result = await store.getState().createDirInTree('/project/src', 'new-dir'); + + expect(result).toBe(createdPath); + expect(mockEditorAPI.createDir).toHaveBeenCalledWith('/project/src', 'new-dir'); + }); + }); + + describe('deleteFileFromTree', () => { + it('deletes file and closes its tab if open', async () => { + mockEditorAPI.deleteFile.mockResolvedValue({ deletedPath: '/project/src/old.ts' }); + mockEditorAPI.readDir.mockResolvedValue(makeDirResult([])); + + store.getState().openFile('/project/src/old.ts'); + store.setState({ + editorProjectPath: PROJECT_PATH, + editorFileTree: [makeEntry('src', 'directory')], + }); + + const result = await store.getState().deleteFileFromTree('/project/src/old.ts'); + + expect(result).toBe(true); + expect(mockEditorAPI.deleteFile).toHaveBeenCalledWith('/project/src/old.ts'); + // Tab should be closed + expect(store.getState().editorOpenTabs).toHaveLength(0); + }); + + it('returns false on failure', async () => { + mockEditorAPI.deleteFile.mockRejectedValue(new Error('Permission denied')); + + const result = await store.getState().deleteFileFromTree('/project/src/file.ts'); + + expect(result).toBe(false); + }); + + it('closes tabs for files inside deleted directory', async () => { + mockEditorAPI.deleteFile.mockResolvedValue({ deletedPath: '/project/src' }); + mockEditorAPI.readDir.mockResolvedValue(makeDirResult([])); + + store.getState().openFile('/project/src/a.ts'); + store.getState().openFile('/project/src/b.ts'); + store.getState().openFile('/project/other.ts'); + + store.setState({ + editorProjectPath: PROJECT_PATH, + editorFileTree: [makeEntry('src', 'directory'), makeEntry('other.ts', 'file')], + }); + + await store.getState().deleteFileFromTree('/project/src'); + + // Only other.ts should remain + expect(store.getState().editorOpenTabs).toHaveLength(1); + expect(store.getState().editorOpenTabs[0].filePath).toBe('/project/other.ts'); + }); + }); + + // ═══════════════════════════════════════════════════════ + // moveFileInTree + // ═══════════════════════════════════════════════════════ + + describe('moveFileInTree', () => { + const SRC_DIR = PROJECT_PATH + '/src'; + const LIB_DIR = PROJECT_PATH + '/lib'; + + it('moves file, updates tabs, and returns true', async () => { + const oldPath = SRC_DIR + '/utils.ts'; + const newPath = LIB_DIR + '/utils.ts'; + mockEditorAPI.moveFile.mockResolvedValue({ newPath }); + mockEditorAPI.readDir.mockResolvedValue(makeDirResult([])); + + store.getState().openFile(oldPath); + store.setState({ + editorProjectPath: PROJECT_PATH, + editorFileTree: [makeEntry('src', 'directory'), makeEntry('lib', 'directory')], + }); + + const result = await store.getState().moveFileInTree(oldPath, LIB_DIR); + + expect(result).toBe(true); + expect(mockEditorAPI.moveFile).toHaveBeenCalledWith(oldPath, LIB_DIR); + + // Tab should be remapped to new path + const tabs = store.getState().editorOpenTabs; + expect(tabs).toHaveLength(1); + expect(tabs[0].filePath).toBe(newPath); + expect(tabs[0].id).toBe(newPath); + expect(tabs[0].fileName).toBe('utils.ts'); + }); + + it('remaps activeTabId when moved file is active', async () => { + const oldPath = SRC_DIR + '/index.ts'; + const newPath = LIB_DIR + '/index.ts'; + mockEditorAPI.moveFile.mockResolvedValue({ newPath }); + mockEditorAPI.readDir.mockResolvedValue(makeDirResult([])); + + store.getState().openFile(oldPath); + store.setState({ editorProjectPath: PROJECT_PATH }); + + await store.getState().moveFileInTree(oldPath, LIB_DIR); + + expect(store.getState().editorActiveTabId).toBe(newPath); + }); + + it('remaps modifiedFiles and fileMtimes', async () => { + const oldPath = SRC_DIR + '/dirty.ts'; + const newPath = LIB_DIR + '/dirty.ts'; + mockEditorAPI.moveFile.mockResolvedValue({ newPath }); + mockEditorAPI.readDir.mockResolvedValue(makeDirResult([])); + + store.setState({ + editorProjectPath: PROJECT_PATH, + editorModifiedFiles: { [oldPath]: true }, + editorFileMtimes: { [oldPath]: 123456 }, + }); + + await store.getState().moveFileInTree(oldPath, LIB_DIR); + + expect(store.getState().editorModifiedFiles[newPath]).toBe(true); + expect(store.getState().editorModifiedFiles[oldPath]).toBeUndefined(); + expect(store.getState().editorFileMtimes[newPath]).toBe(123456); + expect(store.getState().editorFileMtimes[oldPath]).toBeUndefined(); + }); + + it('handles directory move (prefix remapping of nested tabs)', async () => { + const oldDir = SRC_DIR + '/components'; + const newDir = LIB_DIR + '/components'; + const oldFilePath = oldDir + '/Button.tsx'; + const newFilePath = newDir + '/Button.tsx'; + mockEditorAPI.moveFile.mockResolvedValue({ newPath: newDir }); + mockEditorAPI.readDir.mockResolvedValue(makeDirResult([])); + + store.getState().openFile(oldFilePath); + store.setState({ + editorProjectPath: PROJECT_PATH, + editorModifiedFiles: { [oldFilePath]: true }, + editorExpandedDirs: { [oldDir]: true }, + }); + + await store.getState().moveFileInTree(oldDir, LIB_DIR); + + // Tab should be remapped + const tabs = store.getState().editorOpenTabs; + expect(tabs).toHaveLength(1); + expect(tabs[0].filePath).toBe(newFilePath); + + // Modified files remapped + expect(store.getState().editorModifiedFiles[newFilePath]).toBe(true); + expect(store.getState().editorModifiedFiles[oldFilePath]).toBeUndefined(); + + // Expanded dirs remapped + expect(store.getState().editorExpandedDirs[newDir]).toBe(true); + expect(store.getState().editorExpandedDirs[oldDir]).toBeUndefined(); + }); + + it('blocks during save', async () => { + const filePath = SRC_DIR + '/saving.ts'; + store.setState({ + editorProjectPath: PROJECT_PATH, + editorSaving: { [filePath]: true }, + }); + + const result = await store.getState().moveFileInTree(filePath, LIB_DIR); + + expect(result).toBe(false); + expect(mockEditorAPI.moveFile).not.toHaveBeenCalled(); + }); + + it('returns false on API error', async () => { + const filePath = SRC_DIR + '/index.ts'; + mockEditorAPI.moveFile.mockRejectedValue(new Error('Permission denied')); + + store.setState({ editorProjectPath: PROJECT_PATH }); + + const result = await store.getState().moveFileInTree(filePath, LIB_DIR); + + expect(result).toBe(false); + }); + + it('calls editorBridge.remapState for affected files', async () => { + const oldPath = SRC_DIR + '/bridge.ts'; + const newPath = LIB_DIR + '/bridge.ts'; + mockEditorAPI.moveFile.mockResolvedValue({ newPath }); + mockEditorAPI.readDir.mockResolvedValue(makeDirResult([])); + + store.getState().openFile(oldPath); + store.setState({ editorProjectPath: PROJECT_PATH }); + + await store.getState().moveFileInTree(oldPath, LIB_DIR); + + expect(mockBridge.remapState).toHaveBeenCalledWith(oldPath, newPath); + }); + }); +}); diff --git a/test/renderer/store/storeTestUtils.ts b/test/renderer/store/storeTestUtils.ts index 747305be..895a703a 100644 --- a/test/renderer/store/storeTestUtils.ts +++ b/test/renderer/store/storeTestUtils.ts @@ -5,6 +5,7 @@ import { create } from 'zustand'; import { createConfigSlice } from '../../../src/renderer/store/slices/configSlice'; +import { createEditorSlice } from '../../../src/renderer/store/slices/editorSlice'; import { createConversationSlice } from '../../../src/renderer/store/slices/conversationSlice'; import { createNotificationSlice } from '../../../src/renderer/store/slices/notificationSlice'; import { createPaneSlice } from '../../../src/renderer/store/slices/paneSlice'; @@ -37,6 +38,7 @@ export function createTestStore() { ...createUISlice(...args), ...createNotificationSlice(...args), ...createConfigSlice(...args), + ...createEditorSlice(...args), })); } diff --git a/test/renderer/utils/codemirrorLanguages.test.ts b/test/renderer/utils/codemirrorLanguages.test.ts new file mode 100644 index 00000000..6b9af67c --- /dev/null +++ b/test/renderer/utils/codemirrorLanguages.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; + +import { + getAsyncLanguageDesc, + getSyncLanguageExtension, +} from '@renderer/utils/codemirrorLanguages'; + +describe('getSyncLanguageExtension', () => { + it.each([ + ['file.ts', true], + ['file.tsx', true], + ['file.js', true], + ['file.jsx', true], + ['file.mjs', true], + ['file.cjs', true], + ['file.py', true], + ['file.json', true], + ['file.jsonl', true], + ['file.css', true], + ['file.scss', true], + ['file.sass', true], + ['file.less', true], + ['file.html', true], + ['file.htm', true], + ['file.xml', true], + ['file.svg', true], + ['file.md', true], + ['file.mdx', true], + ['file.markdown', true], + ['file.yaml', true], + ['file.yml', true], + ['file.rs', true], + ['file.go', true], + ['file.java', true], + ['file.c', true], + ['file.h', true], + ['file.cpp', true], + ['file.cxx', true], + ['file.cc', true], + ['file.hpp', true], + ['file.php', true], + ['file.sql', true], + ])('returns extension for %s', (fileName, expected) => { + const ext = getSyncLanguageExtension(fileName); + expect(ext !== null).toBe(expected); + }); + + it('returns null for unknown extensions', () => { + expect(getSyncLanguageExtension('file.unknown')).toBeNull(); + expect(getSyncLanguageExtension('file.dat')).toBeNull(); + expect(getSyncLanguageExtension('file.bin')).toBeNull(); + }); + + it('handles files without extension', () => { + expect(getSyncLanguageExtension('Makefile')).toBeNull(); + expect(getSyncLanguageExtension('Dockerfile')).toBeNull(); + }); + + it('is case-insensitive for extensions', () => { + expect(getSyncLanguageExtension('file.TS')).not.toBeNull(); + expect(getSyncLanguageExtension('file.JSON')).not.toBeNull(); + expect(getSyncLanguageExtension('file.Py')).not.toBeNull(); + }); + + it('handles nested paths', () => { + expect(getSyncLanguageExtension('src/main/index.ts')).not.toBeNull(); + expect(getSyncLanguageExtension('deeply/nested/path/file.py')).not.toBeNull(); + }); +}); + +describe('getAsyncLanguageDesc', () => { + it('returns a LanguageDescription for known file types', () => { + const desc = getAsyncLanguageDesc('file.rb'); + expect(desc).not.toBeNull(); + expect(desc!.name).toBeDefined(); + }); + + it('returns null for completely unknown types', () => { + const desc = getAsyncLanguageDesc('file.xyzabc123'); + expect(desc).toBeNull(); + }); + + it('works with full path', () => { + const desc = getAsyncLanguageDesc('src/main.rb'); + expect(desc).not.toBeNull(); + }); +}); diff --git a/test/renderer/utils/fileTreeBuilder.test.ts b/test/renderer/utils/fileTreeBuilder.test.ts new file mode 100644 index 00000000..c4eee0bc --- /dev/null +++ b/test/renderer/utils/fileTreeBuilder.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest'; + +import { buildTree, sortTreeNodes } from '@renderer/utils/fileTreeBuilder'; + +import type { TreeNode } from '@renderer/utils/fileTreeBuilder'; + +interface TestItem { + path: string; + size: number; +} + +const getPath = (item: TestItem) => item.path; + +describe('buildTree', () => { + it('builds a flat list of files into a tree', () => { + const items: TestItem[] = [ + { path: 'src/main.ts', size: 100 }, + { path: 'src/utils.ts', size: 50 }, + { path: 'README.md', size: 30 }, + ]; + + const tree = buildTree(items, getPath); + + expect(tree).toHaveLength(2); + + const src = tree.find((n) => n.name === 'src'); + expect(src).toBeDefined(); + expect(src!.isFile).toBe(false); + expect(src!.children).toHaveLength(2); + + const readme = tree.find((n) => n.name === 'README.md'); + expect(readme).toBeDefined(); + expect(readme!.isFile).toBe(true); + expect(readme!.data).toEqual({ path: 'README.md', size: 30 }); + }); + + it('collapses single-child intermediate directories by default', () => { + const items: TestItem[] = [{ path: 'a/b/c/file.ts', size: 10 }]; + + const tree = buildTree(items, getPath); + + // a/b/c collapsed into one node + expect(tree).toHaveLength(1); + expect(tree[0].name).toBe('a/b/c'); + expect(tree[0].isFile).toBe(false); + expect(tree[0].children).toHaveLength(1); + expect(tree[0].children[0].name).toBe('file.ts'); + expect(tree[0].children[0].isFile).toBe(true); + }); + + it('does not collapse when collapse=false', () => { + const items: TestItem[] = [{ path: 'a/b/file.ts', size: 10 }]; + + const tree = buildTree(items, getPath, { collapse: false }); + + expect(tree).toHaveLength(1); + expect(tree[0].name).toBe('a'); + expect(tree[0].children).toHaveLength(1); + expect(tree[0].children[0].name).toBe('b'); + expect(tree[0].children[0].children).toHaveLength(1); + expect(tree[0].children[0].children[0].name).toBe('file.ts'); + }); + + it('does not collapse directories with multiple children', () => { + const items: TestItem[] = [ + { path: 'src/a/file1.ts', size: 10 }, + { path: 'src/b/file2.ts', size: 20 }, + ]; + + const tree = buildTree(items, getPath); + + expect(tree).toHaveLength(1); + expect(tree[0].name).toBe('src'); + expect(tree[0].children).toHaveLength(2); + // Each child is collapsed: a/ → file1.ts, b/ → file2.ts + expect(tree[0].children.map((c) => c.name).sort()).toEqual(['a', 'b']); + }); + + it('preserves data only on leaf nodes', () => { + const items: TestItem[] = [ + { path: 'src/index.ts', size: 100 }, + { path: 'src/utils/helper.ts', size: 50 }, + ]; + + const tree = buildTree(items, getPath); + const src = tree[0]; + + expect(src.data).toBeUndefined(); + const indexFile = src.children.find((c) => c.name === 'index.ts'); + expect(indexFile!.data).toEqual({ path: 'src/index.ts', size: 100 }); + }); + + it('handles empty input', () => { + const tree = buildTree([], getPath); + expect(tree).toEqual([]); + }); + + it('handles single file at root level', () => { + const items: TestItem[] = [{ path: 'file.ts', size: 10 }]; + const tree = buildTree(items, getPath); + + expect(tree).toHaveLength(1); + expect(tree[0].name).toBe('file.ts'); + expect(tree[0].isFile).toBe(true); + expect(tree[0].children).toHaveLength(0); + }); + + it('handles deeply nested paths', () => { + const items: TestItem[] = [{ path: 'a/b/c/d/e/f.ts', size: 1 }]; + const tree = buildTree(items, getPath); + + // Collapsed: a/b/c/d/e → f.ts + expect(tree).toHaveLength(1); + expect(tree[0].name).toBe('a/b/c/d/e'); + expect(tree[0].children[0].name).toBe('f.ts'); + }); + + it('sets correct fullPath for all nodes', () => { + const items: TestItem[] = [ + { path: 'src/components/Button.tsx', size: 100 }, + { path: 'src/components/Input.tsx', size: 80 }, + ]; + + const tree = buildTree(items, getPath, { collapse: false }); + + const src = tree[0]; + expect(src.fullPath).toBe('src'); + const components = src.children[0]; + expect(components.fullPath).toBe('src/components'); + const button = components.children.find((c) => c.name === 'Button.tsx'); + expect(button!.fullPath).toBe('src/components/Button.tsx'); + }); +}); + +describe('sortTreeNodes', () => { + it('sorts directories before files', () => { + const nodes: TreeNode[] = [ + { name: 'beta.ts', fullPath: 'beta.ts', isFile: true, children: [] }, + { name: 'src', fullPath: 'src', isFile: false, children: [] }, + { name: 'alpha.ts', fullPath: 'alpha.ts', isFile: true, children: [] }, + { name: 'lib', fullPath: 'lib', isFile: false, children: [] }, + ]; + + const sorted = sortTreeNodes(nodes); + const dirs = sorted.filter((n) => !n.isFile); + const files = sorted.filter((n) => n.isFile); + + // Directories come first + expect(dirs.map((n) => n.name)).toEqual(['lib', 'src']); + // Files come after + expect(files.map((n) => n.name)).toEqual(['alpha.ts', 'beta.ts']); + // Combined order + expect(sorted.slice(0, 2).every((n) => !n.isFile)).toBe(true); + expect(sorted.slice(2).every((n) => n.isFile)).toBe(true); + }); + + it('sorts alphabetically within same type', () => { + const nodes: TreeNode[] = [ + { name: 'zebra.ts', fullPath: 'zebra.ts', isFile: true, children: [] }, + { name: 'alpha.ts', fullPath: 'alpha.ts', isFile: true, children: [] }, + { name: 'mid.ts', fullPath: 'mid.ts', isFile: true, children: [] }, + ]; + + const sorted = sortTreeNodes(nodes); + + expect(sorted.map((n) => n.name)).toEqual(['alpha.ts', 'mid.ts', 'zebra.ts']); + }); + + it('does not mutate the original array', () => { + const nodes: TreeNode[] = [ + { name: 'b.ts', fullPath: 'b.ts', isFile: true, children: [] }, + { name: 'a.ts', fullPath: 'a.ts', isFile: true, children: [] }, + ]; + + const sorted = sortTreeNodes(nodes); + + expect(sorted).not.toBe(nodes); + expect(nodes[0].name).toBe('b.ts'); + }); + + it('handles empty array', () => { + expect(sortTreeNodes([])).toEqual([]); + }); +}); diff --git a/test/renderer/utils/tabLabelDisambiguation.test.ts b/test/renderer/utils/tabLabelDisambiguation.test.ts new file mode 100644 index 00000000..d49fb59e --- /dev/null +++ b/test/renderer/utils/tabLabelDisambiguation.test.ts @@ -0,0 +1,127 @@ +/** + * Tests for tab label disambiguation utility. + */ + +import { describe, expect, it } from 'vitest'; + +import { computeDisambiguatedTabs } from '../../../src/renderer/utils/tabLabelDisambiguation'; + +import type { EditorFileTab } from '../../../src/shared/types/editor'; + +// ============================================================================= +// Helpers +// ============================================================================= + +function makeTab(filePath: string): EditorFileTab { + const fileName = filePath.split('/').pop() ?? 'file'; + return { + id: filePath, + filePath, + fileName, + language: 'TypeScript', + }; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('computeDisambiguatedTabs', () => { + it('returns tabs unchanged when all names are unique', () => { + const tabs = [makeTab('/project/src/app.ts'), makeTab('/project/src/index.ts')]; + + const result = computeDisambiguatedTabs(tabs); + + expect(result[0].disambiguatedLabel).toBeUndefined(); + expect(result[1].disambiguatedLabel).toBeUndefined(); + }); + + it('adds labels for 2 tabs with the same file name', () => { + const tabs = [ + makeTab('/project/src/main/utils/index.ts'), + makeTab('/project/src/renderer/hooks/index.ts'), + ]; + + const result = computeDisambiguatedTabs(tabs); + + expect(result[0].disambiguatedLabel).toBe('(utils)'); + expect(result[1].disambiguatedLabel).toBe('(hooks)'); + }); + + it('goes deeper when parent dirs also match', () => { + const tabs = [ + makeTab('/project/src/main/utils/index.ts'), + makeTab('/project/src/renderer/utils/index.ts'), + ]; + + const result = computeDisambiguatedTabs(tabs); + + // Both have "utils" parent, need deeper suffix + expect(result[0].disambiguatedLabel).toBe('(main/utils)'); + expect(result[1].disambiguatedLabel).toBe('(renderer/utils)'); + }); + + it('handles 3 tabs with the same name', () => { + const tabs = [ + makeTab('/project/src/main/utils/index.ts'), + makeTab('/project/src/renderer/utils/index.ts'), + makeTab('/project/src/shared/utils/index.ts'), + ]; + + const result = computeDisambiguatedTabs(tabs); + + expect(result[0].disambiguatedLabel).toBe('(main/utils)'); + expect(result[1].disambiguatedLabel).toBe('(renderer/utils)'); + expect(result[2].disambiguatedLabel).toBe('(shared/utils)'); + }); + + it('does not add labels for unique names among duplicates', () => { + const tabs = [ + makeTab('/project/src/main/index.ts'), + makeTab('/project/src/renderer/index.ts'), + makeTab('/project/src/app.tsx'), + ]; + + const result = computeDisambiguatedTabs(tabs); + + expect(result[0].disambiguatedLabel).toBe('(main)'); + expect(result[1].disambiguatedLabel).toBe('(renderer)'); + expect(result[2].disambiguatedLabel).toBeUndefined(); // unique name + }); + + it('handles single tab (no disambiguation needed)', () => { + const tabs = [makeTab('/project/src/index.ts')]; + + const result = computeDisambiguatedTabs(tabs); + + expect(result[0].disambiguatedLabel).toBeUndefined(); + }); + + it('handles empty array', () => { + const result = computeDisambiguatedTabs([]); + expect(result).toEqual([]); + }); + + it('clears labels when tab is closed and names become unique', () => { + // Start with 2 index.ts + const tabs = [makeTab('/project/src/main/index.ts'), makeTab('/project/src/renderer/index.ts')]; + + const withLabels = computeDisambiguatedTabs(tabs); + expect(withLabels[0].disambiguatedLabel).toBe('(main)'); + expect(withLabels[1].disambiguatedLabel).toBe('(renderer)'); + + // Close one — remaining should lose its label + const afterClose = computeDisambiguatedTabs([withLabels[1]]); + expect(afterClose[0].disambiguatedLabel).toBeUndefined(); + }); + + it('preserves tab reference when label unchanged', () => { + const tab = makeTab('/project/src/app.ts'); + const tabs = [tab]; + + const result = computeDisambiguatedTabs(tabs); + + // Same object reference (no unnecessary re-render) + expect(result[0]).toBe(tab); + }); +});