feat: add project editor with drag & drop file management

- Backend: ProjectFileService with file CRUD, search, git status, file watcher
- IPC: 12 editor channels with security validation and path containment
- Store: editorSlice with multi-tab management, draft persistence, conflict detection
- UI: CodeMirror 6 editor, file tree with DnD, search-in-files, context menus
- Move: fs.rename with EXDEV fallback, full path remapping across all caches
- Tests: comprehensive coverage for services, IPC handlers, store, and utilities
This commit is contained in:
iliya 2026-02-28 23:40:41 +02:00
parent 263c683b42
commit 5b0c7d13fc
74 changed files with 12317 additions and 1039 deletions

View file

@ -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"
]
}

View file

@ -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

View file

@ -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');
}

363
src/main/ipc/editor.ts Normal file
View file

@ -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<IpcResult<void>> {
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<IpcResult<void>> {
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<IpcResult<ReadDirResult>> {
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<IpcResult<ReadFileResult>> {
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<IpcResult<WriteFileResponse>> {
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<IpcResult<CreateFileResponse>> {
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<IpcResult<CreateDirResponse>> {
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<IpcResult<DeleteFileResponse>> {
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<IpcResult<MoveFileResponse>> {
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<IpcResult<SearchInFilesResult>> {
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<IpcResult<GitStatusResult>> {
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<IpcResult<void>> {
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;
}

View file

@ -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);

View file

@ -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<T> 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<T>(operation: string, fn: () => Promise<T>): Promise<IpcResult<T>> {
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 };
}
};
}

View file

@ -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<T>(
operation: string,
handler: () => Promise<T>
): Promise<IpcResult<T>> {
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(

View file

@ -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;
}
}

View file

@ -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<SearchInFilesResult> {
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<void> {
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<SearchMatch[]> {
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;
}

View file

@ -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<GitStatusResult> {
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<boolean> {
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;
}

View file

@ -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<ReadDirResult> {
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<ReadFileResult> {
// 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<WriteFileResponse> {
// 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<CreateFileResponse> {
// 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<CreateDirResponse> {
// 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<DeleteFileResponse> {
// 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<MoveFileResponse> {
// 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 };

View file

@ -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<ConflictCheckResult> {
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;
}
}

View file

@ -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';

View file

@ -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<void> {
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';

View file

@ -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<void> {
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;
}
}

View file

@ -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');
}

View file

@ -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';

View file

@ -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<void>(EDITOR_OPEN, projectPath),
close: () => invokeIpcWithResult<void>(EDITOR_CLOSE),
readDir: (dirPath: string, maxEntries?: number) =>
invokeIpcWithResult<ReadDirResult>(EDITOR_READ_DIR, dirPath, maxEntries),
readFile: (filePath: string) => invokeIpcWithResult<ReadFileResult>(EDITOR_READ_FILE, filePath),
writeFile: (filePath: string, content: string, baselineMtimeMs?: number) =>
invokeIpcWithResult<WriteFileResponse>(EDITOR_WRITE_FILE, filePath, content, baselineMtimeMs),
createFile: (parentDir: string, fileName: string) =>
invokeIpcWithResult<CreateFileResponse>(EDITOR_CREATE_FILE, parentDir, fileName),
createDir: (parentDir: string, dirName: string) =>
invokeIpcWithResult<CreateDirResponse>(EDITOR_CREATE_DIR, parentDir, dirName),
deleteFile: (filePath: string) =>
invokeIpcWithResult<DeleteFileResponse>(EDITOR_DELETE_FILE, filePath),
moveFile: (sourcePath: string, destDir: string) =>
invokeIpcWithResult<MoveFileResponse>(EDITOR_MOVE_FILE, sourcePath, destDir),
searchInFiles: (options: SearchInFilesOptions) =>
invokeIpcWithResult<SearchInFilesResult>(EDITOR_SEARCH_IN_FILES, options),
gitStatus: () => invokeIpcWithResult<GitStatusResult>(EDITOR_GIT_STATUS),
watchDir: (enable: boolean) => invokeIpcWithResult<void>(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

View file

@ -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 () => {};
},
};
}

View file

@ -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<T> {
nodes: TreeNode<T>[];
activeNodePath: string | null;
onNodeClick: (node: TreeNode<T>) => void;
expandedPaths: Record<string, boolean>;
onToggleExpand: (fullPath: string) => void;
renderLeafNode?: (node: TreeNode<T>, isSelected: boolean, depth: number) => React.ReactNode;
renderFolderLabel?: (node: TreeNode<T>, isOpen: boolean, depth: number) => React.ReactNode;
renderNodeIcon?: (node: TreeNode<T>) => React.ReactNode;
/** Optional data attributes placed on each <li> for event delegation (e.g. context menu) */
getNodeDataAttrs?: (node: TreeNode<T>) => Record<string, string>;
maxDepth?: number;
}
const MAX_VISUAL_DEPTH = 12;
const INDENT_PX = 12;
// =============================================================================
// Component
// =============================================================================
export const FileTree = <T,>(props: Readonly<FileTreeProps<T>>): React.ReactElement => {
const { nodes, maxDepth = MAX_VISUAL_DEPTH } = props;
return (
<ul role="tree" className="select-none text-sm">
{nodes.map((node) => (
<TreeItem key={node.fullPath} node={node} depth={0} maxDepth={maxDepth} {...props} />
))}
</ul>
);
};
// =============================================================================
// TreeItem (recursive)
// =============================================================================
interface TreeItemProps<T> extends FileTreeProps<T> {
node: TreeNode<T>;
depth: number;
}
const TreeItemInner = <T,>({
node,
depth,
activeNodePath,
onNodeClick,
expandedPaths,
onToggleExpand,
renderLeafNode,
renderFolderLabel,
renderNodeIcon,
getNodeDataAttrs,
maxDepth = MAX_VISUAL_DEPTH,
nodes: _nodes,
...rest
}: Readonly<TreeItemProps<T>>): 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
<li role="treeitem" aria-selected={isSelected} {...dataAttrs}>
{renderLeafNode(node, isSelected, visualDepth)}
</li>
);
}
return (
<li
role="treeitem"
aria-selected={isSelected}
className={`flex cursor-pointer items-center gap-1 truncate px-2 py-0.5 hover:bg-surface-raised ${
isSelected ? 'bg-surface-raised text-text' : 'text-text-secondary'
}`}
style={{ paddingLeft: `${visualDepth * INDENT_PX + 8}px` }}
onClick={handleClick}
onKeyDown={handleKeyDown}
tabIndex={0}
title={node.fullPath}
// eslint-disable-next-line react/jsx-props-no-spreading -- data attributes from getNodeDataAttrs require spreading
{...dataAttrs}
>
{renderNodeIcon?.(node)}
<span className="truncate">{node.name}</span>
</li>
);
}
// Folder node
const isExpanded = expandedPaths[node.fullPath] === true;
return (
// eslint-disable-next-line react/jsx-props-no-spreading -- data attributes from getNodeDataAttrs require spreading
<li role="treeitem" aria-expanded={isExpanded} aria-selected={isSelected} {...dataAttrs}>
{renderFolderLabel ? (
renderFolderLabel(node, isExpanded, visualDepth)
) : (
<div
className="flex cursor-pointer items-center gap-1 truncate px-2 py-0.5 text-text-secondary hover:bg-surface-raised"
style={{ paddingLeft: `${visualDepth * INDENT_PX + 8}px` }}
onClick={handleClick}
onKeyDown={handleKeyDown}
role="button"
tabIndex={0}
title={depth >= maxDepth ? node.fullPath : undefined}
>
{isExpanded ? (
<ChevronDown className="size-3.5 shrink-0 text-text-muted" />
) : (
<ChevronRight className="size-3.5 shrink-0 text-text-muted" />
)}
{renderNodeIcon?.(node)}
<span className="truncate">{node.name}</span>
</div>
)}
{isExpanded && node.children.length > 0 && (
<ul role="group">
{node.children.map((child) => (
<TreeItemInner
key={child.fullPath}
node={child}
depth={depth + 1}
activeNodePath={activeNodePath}
onNodeClick={onNodeClick}
expandedPaths={expandedPaths}
onToggleExpand={onToggleExpand}
renderLeafNode={renderLeafNode}
renderFolderLabel={renderFolderLabel}
renderNodeIcon={renderNodeIcon}
getNodeDataAttrs={getNodeDataAttrs}
maxDepth={maxDepth}
nodes={[]}
{...rest}
/>
))}
</ul>
)}
</li>
);
};
const TreeItem = React.memo(TreeItemInner) as typeof TreeItemInner;

File diff suppressed because it is too large Load diff

View file

@ -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);

View file

@ -0,0 +1,481 @@
/**
* Editable CodeMirror 6 editor with EditorState pooling.
*
* Single EditorView, Map<filePath, EditorState> 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<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const stateCacheRef = useRef(new Map<string, EditorState>());
const scrollTopCacheRef = useRef(new Map<string, number>());
const lruOrderRef = useRef<string[]>([]);
// Dirty flag debounce
const dirtyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Autosave debounce
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Selection debounce
const selectionTimerRef = useRef<ReturnType<typeof setTimeout> | 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 <div ref={containerRef} className="size-full overflow-auto" />;
};

View file

@ -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 (
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
<FileQuestion className="size-12 opacity-30" />
<p className="text-sm font-medium text-text-secondary">{fileName}</p>
<p className="text-xs">Binary file ({sizeFormatted})</p>
<button
onClick={handleOpenExternal}
className="mt-2 rounded border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised"
>
Open in System Viewer
</button>
</div>
);
};

View file

@ -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 (
<div className="flex items-center gap-0.5 overflow-x-auto px-3 py-1 text-xs text-text-muted">
{segments.map((segment, idx) => {
const isLast = idx === segments.length - 1;
return (
<span key={idx} className="flex shrink-0 items-center gap-0.5">
{idx > 0 && <ChevronRight className="text-text-muted/50 size-3" />}
{isLast ? (
<span className="flex items-center gap-1 text-text-secondary">
<Icon className="size-3" style={{ color: iconInfo.color }} />
{segment}
</span>
) : (
<button
onClick={() => handleSegmentClick(idx)}
className="rounded px-0.5 transition-colors hover:bg-surface-raised hover:text-text-secondary"
>
{segment}
</button>
)}
</span>
);
})}
</div>
);
};

View file

@ -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<TargetEntry | null>(null);
const triggerRef = useRef<HTMLDivElement>(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 (
<ContextMenu.Root>
<ContextMenu.Trigger asChild>
<div ref={triggerRef} onContextMenu={handleContextMenu}>
{children}
</div>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content className="z-50 min-w-[180px] rounded-md border border-border-emphasis bg-surface-overlay p-1 shadow-lg animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95">
{parentDir && (
<>
<ContextMenu.Item
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
onSelect={() => onNewFile(parentDir)}
>
<FilePlus className="size-3.5 text-text-muted" />
New File
</ContextMenu.Item>
<ContextMenu.Item
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
onSelect={() => onNewFolder(parentDir)}
>
<FolderPlus className="size-3.5 text-text-muted" />
New Folder
</ContextMenu.Item>
<ContextMenu.Separator className="my-1 h-px bg-border" />
</>
)}
{target && (
<>
<ContextMenu.Item
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-red-400 outline-none hover:bg-surface-raised focus:bg-surface-raised disabled:cursor-not-allowed disabled:opacity-50"
disabled={target.isSensitive}
onSelect={() => onDelete(target.path)}
>
<Trash2 className="size-3.5" />
Delete
</ContextMenu.Item>
<ContextMenu.Separator className="my-1 h-px bg-border" />
</>
)}
{target && (
<ContextMenu.Item
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
onSelect={() => {
void window.electronAPI.showInFolder(target.path);
}}
>
<FolderOpen className="size-3.5 text-text-muted" />
Reveal in Finder
</ContextMenu.Item>
)}
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
);
};

View file

@ -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 (
<div className="flex h-full flex-col items-center justify-center gap-4 text-text-muted">
<FileCode className="size-12 opacity-30" />
<p className="text-sm">Select a file from the tree to edit</p>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-1.5">
{SHORTCUTS.map((s) => (
<div key={s.keys} className="flex items-center justify-between gap-4 text-xs">
<span className="text-text-muted">{s.label}</span>
<kbd className="rounded border border-border bg-surface-raised px-1.5 py-0.5 font-mono text-[10px] text-text-secondary">
{s.keys}
</kbd>
</div>
))}
</div>
</div>
);
};

View file

@ -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<Props, State> {
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 (
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
<AlertTriangle className="size-12 text-red-400 opacity-50" />
<p className="max-w-md text-center text-sm text-text-secondary">
Editor crashed: {this.state.error ?? 'Unknown error'}
</p>
<button
onClick={this.handleRetry}
className="rounded border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised"
>
Retry
</button>
</div>
);
}
return <>{this.props.children}</>;
}
}

View file

@ -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 (
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
<AlertTriangle className="size-12 text-yellow-500 opacity-50" />
<p className="max-w-md text-center text-sm text-text-secondary">{error}</p>
<div className="flex gap-2">
{onRetry && (
<button
onClick={onRetry}
className="rounded border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised"
>
Retry
</button>
)}
{onClose && (
<button
onClick={onClose}
className="rounded border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised"
>
Close Tab
</button>
)}
</div>
</div>
);
};

View file

@ -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<FileTreeEntry>;
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<NewItemState | null>(null);
const [draggedItem, setDraggedItem] = useState<FlatTreeItem | null>(null);
const [dropTargetPath, setDropTargetPath] = useState<string | null>(null);
const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scrollRef = useRef<HTMLDivElement>(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<string, FlatTreeItem>();
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<string, GitFileStatusType>();
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<FileTreeEntry>) => {
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 <div className="p-3 text-xs text-red-400">Failed to load files: {error}</div>;
}
if (loading && !fileTree) {
return <div className="p-3 text-xs text-text-muted">Loading files...</div>;
}
if (treeNodes.length === 0) {
return <div className="p-3 text-xs text-text-muted">No files found</div>;
}
return (
<EditorContextMenu
onNewFile={handleNewFile}
onNewFolder={handleNewFolder}
onDelete={handleDelete}
>
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
autoScroll={{ threshold: { x: 0, y: 0.15 } }}
>
<RootDropZone ref={scrollRef} projectPath={projectPath}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const item = flatItems[virtualItem.index];
return (
<DraggableTreeItem
key={item.node.fullPath}
item={item}
activeNodePath={activeNodePath}
gitStatusMap={gitStatusMap}
dropTargetPath={dropTargetPath}
isDragActive={!!draggedItem}
onClick={handleNodeClick}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
/>
);
})}
</div>
</RootDropZone>
<DragOverlay dropAnimation={null}>
{draggedItem && <DragOverlayFileItem item={draggedItem} />}
</DragOverlay>
</DndContext>
{newItemState && (
<NewFileDialog
type={newItemState.type}
parentDir={newItemState.parentDir}
onSubmit={handleNewItemSubmit}
onCancel={handleNewItemCancel}
/>
)}
</EditorContextMenu>
);
};
// =============================================================================
// 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 (
<div ref={combinedRef} className="h-full overflow-y-auto" role="tree">
{children}
</div>
);
});
RootDropZone.displayName = 'RootDropZone';
// =============================================================================
// Draggable + droppable tree item
// =============================================================================
interface DraggableTreeItemProps {
item: FlatTreeItem;
activeNodePath: string | null;
gitStatusMap: Map<string, GitFileStatusType>;
dropTargetPath: string | null;
isDragActive: boolean;
onClick: (node: TreeNode<FileTreeEntry>) => 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<string, string> = {};
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 = <Lock className="size-3.5 shrink-0 text-yellow-500" />;
} else if (node.isFile) {
const fileIcon = getFileIcon(node.name);
const FileIcon = fileIcon.icon;
icon = <FileIcon className="size-3.5 shrink-0" style={{ color: fileIcon.color }} />;
} else if (isExpanded) {
icon = <FolderOpen className="size-3.5 shrink-0 text-text-muted" />;
} else {
icon = <Folder className="size-3.5 shrink-0 text-text-muted" />;
}
return (
<div
ref={ref}
{...attributes}
{...listeners}
role="treeitem"
aria-selected={node.isFile ? isSelected : undefined}
aria-expanded={!node.isFile ? isExpanded : undefined}
className={`flex cursor-pointer select-none items-center gap-1 truncate px-2 text-xs transition-colors hover:bg-surface-raised ${
isSelected ? 'bg-surface-raised text-text' : 'text-text-secondary'
} ${isDragging ? 'opacity-30' : ''} ${
isDropTarget ? 'rounded bg-blue-400/10 ring-2 ring-blue-400/50' : ''
}`}
style={{
...style,
paddingLeft: `${visualDepth * INDENT_PX + 8}px`,
display: 'flex',
alignItems: 'center',
}}
onClick={handleClick}
onKeyDown={handleKeyDown}
tabIndex={0}
title={node.data?.path ?? node.fullPath}
{...dataAttrs}
>
{!node.isFile &&
(isExpanded ? (
<ChevronDown className="size-3 shrink-0 text-text-muted" />
) : (
<ChevronRight className="size-3 shrink-0 text-text-muted" />
))}
{icon}
<span className="truncate">{node.name}</span>
{node.data && gitStatusMap.has(node.data.path) && (
<GitStatusBadge status={gitStatusMap.get(node.data.path)!} />
)}
</div>
);
}
);
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 = <FileIcon className="size-3.5" style={{ color: fileIcon.color }} />;
} else {
icon = <FolderOpen className="size-3.5 text-text-muted" />;
}
return (
<div className="flex items-center gap-1.5 rounded border border-border-emphasis bg-surface-overlay px-3 py-1 text-xs text-text shadow-lg">
{icon}
<span className="truncate">{node.name}</span>
</div>
);
};
// =============================================================================
// Helpers
// =============================================================================
/** Convert hierarchical FileTreeEntry[] into TreeNode[] using entry.type for classification */
function convertEntriesToNodes(entries: FileTreeEntry[]): TreeNode<FileTreeEntry>[] {
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<FileTreeEntry>[],
expandedPaths: Record<string, boolean>,
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);
}
}
}

View file

@ -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 (
<div
className="pointer-events-auto absolute z-20 flex items-center gap-0.5 rounded-md border border-border-emphasis bg-surface-overlay p-0.5 shadow-lg animate-in fade-in-0 zoom-in-95"
style={{ top, left: Math.max(0, left) }}
>
<MenuButton
icon={<MessageSquare className="size-3.5" />}
label="Write Teammate"
onClick={onSendMessage}
/>
<MenuButton
icon={<ListTodo className="size-3.5" />}
label="Create Task"
onClick={onCreateTask}
/>
</div>
);
};
// =============================================================================
// Menu button
// =============================================================================
interface MenuButtonProps {
icon: React.ReactNode;
label: string;
onClick: () => void;
}
const MenuButton = ({ icon, label, onClick }: MenuButtonProps): React.ReactElement => (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
tabIndex={-1}
aria-label={label}
onClick={onClick}
onMouseDown={(e) => e.preventDefault()} // prevent CM6 selection loss
className="rounded p-1.5 text-text-secondary transition-colors hover:bg-surface-raised hover:text-text"
>
{icon}
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{label}
</TooltipContent>
</Tooltip>
);

View file

@ -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 (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
{/* Backdrop */}
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
{/* Dialog */}
<div className="relative z-10 w-[480px] rounded-lg border border-border-emphasis bg-surface p-6 shadow-2xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-text">Keyboard Shortcuts</h2>
<button
onClick={onClose}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
aria-label="Close"
>
<X className="size-4" />
</button>
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-4">
{resolvedGroups.map((group) => (
<div key={group.title}>
<h3 className="mb-1.5 text-xs font-medium text-text-secondary">{group.title}</h3>
<div className="space-y-1">
{group.shortcuts.map((shortcut) => (
<div key={shortcut.keys} className="flex items-center justify-between text-xs">
<span className="text-text-muted">{shortcut.description}</span>
<kbd className="rounded border border-border bg-surface-raised px-1.5 py-0.5 font-mono text-[10px] text-text-secondary">
{shortcut.keys}
</kbd>
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
);
};

View file

@ -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 (
<div className="flex h-6 shrink-0 items-center justify-between border-t border-border bg-surface-sidebar px-3 text-[11px] text-text-muted">
<div className="flex items-center gap-4">
<span>
Ln {line}, Col {col}
</span>
{isGitRepo && gitBranch && (
<span className="flex items-center gap-1">
<GitBranch className="size-3" />
{gitBranch}
</span>
)}
</div>
<div className="flex items-center gap-4">
{watcherEnabled && (
<span className="text-green-400" title="File watcher active">
watching
</span>
)}
<span>{language}</span>
<span>UTF-8</span>
<span>Spaces: 2</span>
</div>
</div>
);
};

View file

@ -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 (
<div
className="flex h-8 shrink-0 items-center overflow-x-auto border-b border-border bg-surface-sidebar"
role="tablist"
>
{tabs.map((tab) => (
<Tab
key={tab.id}
tab={tab}
isActive={tab.id === activeTabId}
isModified={!!modifiedFiles[tab.filePath]}
onActivate={() => setActiveTab(tab.id)}
onClose={() => onRequestCloseTab(tab.id)}
/>
))}
</div>
);
};
// =============================================================================
// 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 (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onActivate}
onAuxClick={handleAuxClick}
role="tab"
aria-selected={isActive}
className={`group flex h-full shrink-0 items-center gap-1.5 border-r border-border px-3 text-xs transition-colors ${
isActive
? 'bg-surface text-text'
: 'bg-surface-sidebar text-text-muted hover:bg-surface-raised hover:text-text-secondary'
}`}
>
{isModified && (
<span
className="size-1.5 shrink-0 rounded-full bg-amber-400"
aria-label="Unsaved changes"
/>
)}
<FileIcon className="size-3.5 shrink-0" style={{ color: iconInfo.color }} />
<span className="max-w-40 truncate">
{tab.fileName}
{tab.disambiguatedLabel && (
<span className="ml-1 text-text-muted">{tab.disambiguatedLabel}</span>
)}
</span>
<span
onClick={handleClose}
className="ml-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-surface-raised group-hover:opacity-100"
role="button"
aria-label={`Close ${tab.fileName}`}
tabIndex={-1}
>
<X className="size-3" />
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{tab.filePath}</TooltipContent>
</Tooltip>
);
};

View file

@ -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 (
<div className="flex h-8 shrink-0 items-center gap-1 border-b border-border bg-surface px-2">
<ToolbarButton
icon={<Save className="size-3.5" />}
label="Save"
shortcut={shortcutLabel('⌘ S', 'Ctrl+S')}
onClick={handleSave}
disabled={!isDirty || isSaving}
/>
<ToolbarButton
icon={<Undo2 className="size-3.5" />}
label="Undo"
shortcut={shortcutLabel('⌘ Z', 'Ctrl+Z')}
onClick={handleUndo}
/>
<ToolbarButton
icon={<Redo2 className="size-3.5" />}
label="Redo"
shortcut={shortcutLabel('⌘ ⇧ Z', 'Ctrl+Y')}
onClick={handleRedo}
/>
<div className="mx-1 h-4 w-px bg-border" />
<ToolbarButton
icon={<WrapText className="size-3.5" />}
label={lineWrap ? 'Disable word wrap' : 'Enable word wrap'}
shortcut={shortcutLabel('⌘ ⇧ W', 'Ctrl+Shift+W')}
onClick={toggleLineWrap}
active={lineWrap}
/>
</div>
);
};
// =============================================================================
// 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 => (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onClick}
disabled={disabled}
className={`flex items-center gap-1 rounded px-1.5 py-0.5 text-xs transition-colors hover:bg-surface-raised hover:text-text disabled:opacity-40 disabled:hover:bg-transparent ${
active ? 'bg-surface-raised text-text' : 'text-text-muted'
}`}
aria-label={`${label} (${shortcut})`}
aria-pressed={active}
>
{icon}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{label} ({shortcut})
</TooltipContent>
</Tooltip>
);

View file

@ -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<GitFileStatusType, { letter: string; color: string }> = {
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 (
<span className={`ml-auto shrink-0 text-[10px] leading-none ${config.color}`} title={status}>
{config.letter}
</span>
);
};

View file

@ -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<string | null>(null);
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
setValue(e.target.value);
setError(null);
}, []);
const Icon = type === 'file' ? FilePlus : FolderPlus;
return (
<div className="flex flex-col px-2 py-1">
<div className="flex items-center gap-1.5">
<Icon className="size-3.5 shrink-0 text-text-muted" />
<input
ref={inputRef}
type="text"
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={onCancel}
placeholder={type === 'file' ? 'File name...' : 'Folder name...'}
className="min-w-0 flex-1 rounded border border-border-emphasis bg-surface px-1.5 py-0.5 text-xs text-text outline-none focus:border-blue-500"
aria-label={type === 'file' ? 'New file name' : 'New folder name'}
/>
</div>
{error && <span className="mt-0.5 pl-5 text-[10px] text-red-400">{error}</span>}
</div>
);
};

View file

@ -0,0 +1,766 @@
/**
* Full-screen project editor overlay.
*
* Pattern: follows ChangeReviewDialog.tsx raw <div> 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<ReadFileResult | null>(null);
const [fileLoading, setFileLoading] = useState(false);
const [fileError, setFileError] = useState<string | null>(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<string | null>(null);
// Draft recovery banner
const [draftRecoveredFile, setDraftRecoveredFile] = useState<string | null>(null);
// Bumped on draft discard to force CodeMirrorEditor remount (fresh state cache)
const [editorResetKey, setEditorResetKey] = useState(0);
// Selection action menu
const [selectionInfo, setSelectionInfo] = useState<EditorSelectionInfo | null>(null);
const editorContentRef = useRef<HTMLDivElement>(null);
const [containerRect, setContainerRect] = useState<DOMRect>(() => 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<HTMLDivElement>(null);
// IPC deduplication: reuse in-flight readFile promise for same path
const pendingReads = useRef(new Map<string, Promise<ReadFileResult>>());
// 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 (
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex flex-col bg-surface"
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-label="Project Editor"
>
{/* Header */}
<div
className="flex h-10 shrink-0 items-center justify-between border-b border-border px-3"
style={{ paddingLeft: 'var(--macos-traffic-light-padding-left, 72px)' }}
>
<div className="flex items-center gap-2 text-sm text-text-secondary">
<span className="font-medium text-text">{projectName}</span>
<span className="text-text-muted">{projectPath}</span>
</div>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleManualRefresh}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
aria-label="Refresh (F5)"
>
<RefreshCw className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Refresh git status (F5)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setShortcutsHelpVisible(true)}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
aria-label="Keyboard shortcuts"
>
<HelpCircle className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Keyboard shortcuts</TooltipContent>
</Tooltip>
<button
onClick={handleCloseRequest}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
aria-label="Close editor"
>
<X className="size-4" />
</button>
</div>
</div>
{/* Main content */}
<div className="flex flex-1 overflow-hidden">
{/* Search in files panel (replaces sidebar when visible) */}
{searchPanelVisible && (
<div className="w-72 shrink-0">
<SearchInFilesPanel
projectPath={projectPath}
onClose={() => setSearchPanelVisible(false)}
onSelectMatch={handleSearchSelectMatch}
/>
</div>
)}
{/* File tree sidebar */}
{sidebarVisible && !searchPanelVisible && (
<div className="flex w-60 shrink-0 flex-col border-r border-border bg-surface-sidebar">
<div className="flex items-center justify-between border-b border-border px-2 py-1">
<span className="text-[10px] font-medium uppercase tracking-wider text-text-muted">
Explorer
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={toggleSidebar}
className="rounded p-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
aria-label="Hide sidebar"
>
<PanelLeftClose className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
Hide sidebar ({shortcutLabel('⌘ B', 'Ctrl+B')})
</TooltipContent>
</Tooltip>
</div>
<div className="flex-1 overflow-hidden">
<EditorFileTree selectedFilePath={activeTabId} onFileSelect={handleFileSelect} />
</div>
</div>
)}
{/* Sidebar toggle (when hidden) */}
{!sidebarVisible && !searchPanelVisible && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={toggleSidebar}
className="flex h-full w-6 shrink-0 items-start justify-center border-r border-border bg-surface-sidebar pt-2 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
aria-label="Show sidebar"
>
<PanelLeftOpen className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">
Show sidebar ({shortcutLabel('⌘ B', 'Ctrl+B')})
</TooltipContent>
</Tooltip>
)}
{/* Editor area */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* Tab bar */}
<EditorTabBar onRequestCloseTab={handleRequestCloseTab} />
{/* Toolbar */}
<EditorToolbar />
{/* Draft recovery banner */}
{draftRecoveredFile && activeTabId === draftRecoveredFile && (
<div className="flex shrink-0 items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-300">
<RotateCcw className="size-3.5 shrink-0" />
<span>Recovered unsaved changes from a previous session.</span>
<button
onClick={handleDismissDraftBanner}
className="ml-auto rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
>
Keep
</button>
<button
onClick={handleDiscardDraft}
className="rounded px-2 py-0.5 text-red-400 transition-colors hover:bg-red-400/10"
>
Discard
</button>
</div>
)}
{/* Save error banner */}
{activeSaveError && (
<div className="flex shrink-0 items-center gap-2 border-b border-red-500/30 bg-red-500/10 px-3 py-1.5 text-xs text-red-300">
<AlertTriangle className="size-3.5 shrink-0" />
<span className="truncate">Save failed: {activeSaveError}</span>
<button
onClick={() => activeTabId && void saveFile(activeTabId)}
className="ml-auto shrink-0 rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
>
Retry
</button>
</div>
)}
{/* External change banner */}
{activeTabId && externalChanges[activeTabId] && (
<div className="flex shrink-0 items-center gap-2 border-b border-blue-500/30 bg-blue-500/10 px-3 py-1.5 text-xs text-blue-300">
<RefreshCw className="size-3.5 shrink-0" />
<span>
{externalChanges[activeTabId] === 'delete'
? 'File no longer exists on disk.'
: 'File changed on disk.'}
</span>
{externalChanges[activeTabId] === 'delete' ? (
<button
onClick={() => closeTab(activeTabId)}
className="ml-auto rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
>
Close tab
</button>
) : (
<>
<button
onClick={handleReloadExternalChange}
className="ml-auto rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
>
Reload
</button>
<button
onClick={handleKeepMine}
className="rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
>
Keep mine
</button>
</>
)}
</div>
)}
{/* Editor content */}
<div ref={editorContentRef} className="relative flex-1 overflow-hidden">
{fileLoading && (
<div className="flex h-full items-center justify-center text-text-muted">
<Loader2 className="size-5 animate-spin" />
</div>
)}
{fileError && <EditorErrorState error={fileError} onRetry={handleRetry} />}
{fileContent?.isBinary && activeTabId && (
<EditorBinaryState filePath={activeTabId} size={fileContent.size} />
)}
{fileContent && !fileContent.isBinary && activeTabId && (
<EditorErrorBoundary filePath={activeTabId} onRetry={handleRetry}>
<CodeMirrorEditor
key={`${activeTabId}-${editorResetKey}`}
filePath={activeTabId}
content={fileContent.content}
fileName={activeTabId.split('/').pop() ?? 'file'}
mtimeMs={fileContent.mtimeMs}
onCursorChange={handleCursorChange}
onDraftRecovered={handleDraftRecovered}
onSelectionChange={setSelectionInfo}
/>
</EditorErrorBoundary>
)}
{!fileLoading && !fileError && !fileContent && !activeTabId && <EditorEmptyState />}
{/* Selection action menu */}
{selectionInfo && onEditorAction && (
<EditorSelectionMenu
info={selectionInfo}
containerRect={containerRect}
onSendMessage={() => {
onEditorAction(buildSelectionAction('sendMessage', selectionInfo));
setSelectionInfo(null);
}}
onCreateTask={() => {
onEditorAction(buildSelectionAction('createTask', selectionInfo));
setSelectionInfo(null);
}}
/>
)}
</div>
{/* Status bar */}
{activeTab && (
<EditorStatusBar line={cursorLine} col={cursorCol} language={activeTab.language} />
)}
</div>
</div>
{/* Quick Open dialog */}
{quickOpenVisible && (
<QuickOpenDialog
onClose={() => setQuickOpenVisible(false)}
onSelectFile={handleFileSelect}
/>
)}
{/* Shortcuts help modal */}
{shortcutsHelpVisible && (
<EditorShortcutsHelp onClose={() => setShortcutsHelpVisible(false)} />
)}
{/* Unsaved changes confirmation dialog — overlay close */}
{showConfirmClose && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
<div className="w-96 rounded-lg border border-border bg-surface p-6 shadow-xl">
<h3 className="mb-2 text-sm font-semibold text-text">Unsaved Changes</h3>
<p className="mb-4 text-sm text-text-secondary">
You have unsaved changes. What would you like to do?
</p>
<div className="flex justify-end gap-2">
<button
onClick={handleCancelClose}
className="rounded px-3 py-1.5 text-sm text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
>
Cancel
</button>
<button
onClick={handleDiscardAndClose}
className="rounded px-3 py-1.5 text-sm text-red-400 transition-colors hover:bg-red-400/10"
>
Discard & Close
</button>
<button
onClick={() => void handleSaveAndClose()}
className="rounded bg-blue-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-blue-500"
>
Save All & Close
</button>
</div>
</div>
</div>
)}
{/* Save conflict dialog */}
{conflictFile && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
<div className="w-96 rounded-lg border border-border bg-surface p-6 shadow-xl">
<h3 className="mb-2 text-sm font-semibold text-text">Save Conflict</h3>
<p className="mb-4 text-sm text-text-secondary">
The file has been modified externally since you opened it. Overwrite with your
changes?
</p>
<div className="flex justify-end gap-2">
<button
onClick={handleCancelConflict}
className="rounded px-3 py-1.5 text-sm text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
>
Cancel
</button>
<button
onClick={handleForceOverwrite}
className="rounded bg-orange-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-orange-500"
>
Overwrite
</button>
</div>
</div>
</div>
)}
{/* Unsaved changes confirmation dialog — single tab close */}
{confirmCloseTabId && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
<div className="w-96 rounded-lg border border-border bg-surface p-6 shadow-xl">
<h3 className="mb-2 text-sm font-semibold text-text">Unsaved Changes</h3>
<p className="mb-4 text-sm text-text-secondary">
This file has unsaved changes. What would you like to do?
</p>
<div className="flex justify-end gap-2">
<button
onClick={handleCancelCloseTab}
className="rounded px-3 py-1.5 text-sm text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
>
Cancel
</button>
<button
onClick={handleDiscardAndCloseTab}
className="rounded px-3 py-1.5 text-sm text-red-400 transition-colors hover:bg-red-400/10"
>
Discard
</button>
<button
onClick={() => void handleSaveAndCloseTab()}
className="rounded bg-blue-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-blue-500"
>
Save
</button>
</div>
</div>
</div>
)}
</div>
);
};

View file

@ -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<HTMLDivElement>(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 (
<div className="fixed inset-0 z-[60] flex items-start justify-center pt-[15vh]">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/40"
onClick={onClose}
onKeyDown={(e) => {
if (e.key === 'Escape') onClose();
}}
role="presentation"
/>
{/* Dialog */}
<div
ref={dialogRef}
className="relative z-10 w-[520px] overflow-hidden rounded-lg border border-border-emphasis bg-surface shadow-2xl"
>
<Command label="Quick Open" shouldFilter={true}>
<Command.Input
placeholder="Search files by name..."
className="w-full border-b border-border bg-transparent px-4 py-3 text-sm text-text outline-none placeholder:text-text-muted"
autoFocus
/>
<Command.List className="max-h-80 overflow-y-auto p-1">
<Command.Empty className="p-6 text-center text-sm text-text-muted">
No files found
</Command.Empty>
{flatFiles.map((file) => {
const iconInfo = getFileIcon(file.name);
const Icon = iconInfo.icon;
return (
<Command.Item
key={file.path}
value={file.relativePath}
onSelect={() => 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"
>
<Icon className="size-4 shrink-0" style={{ color: iconInfo.color }} />
<span className="truncate font-medium">{file.name}</span>
<span className="ml-auto truncate text-xs text-text-muted">
{file.relativePath}
</span>
</Command.Item>
);
})}
</Command.List>
</Command>
</div>
</div>
);
};
// =============================================================================
// 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);
}
}
}

View file

@ -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<SearchInFilesResult | null>(null);
const [searching, setSearching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
// 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 (
<div className="flex h-full flex-col border-r border-border bg-surface-sidebar">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<span className="text-xs font-medium text-text-secondary">Search in Files</span>
<button
onClick={onClose}
className="rounded p-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
aria-label="Close search"
>
<X className="size-3.5" />
</button>
</div>
{/* Search input */}
<div className="border-b border-border p-2">
<div className="flex items-center gap-1 rounded border border-border bg-surface px-2 py-1">
<Search className="size-3.5 shrink-0 text-text-muted" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
placeholder="Search..."
className="flex-1 bg-transparent text-xs text-text outline-none placeholder:text-text-muted"
/>
{searching && <Loader2 className="size-3 shrink-0 animate-spin text-text-muted" />}
</div>
<div className="mt-1.5 flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleCaseSensitiveToggle}
className={`rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
caseSensitive
? 'bg-blue-500/20 text-blue-400'
: 'text-text-muted hover:bg-surface-raised'
}`}
aria-label="Match Case"
aria-pressed={caseSensitive}
>
Aa
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Match Case</TooltipContent>
</Tooltip>
</div>
</div>
{/* Results */}
<div className="flex-1 overflow-y-auto">
{error && <div className="p-3 text-xs text-red-400">{error}</div>}
{results?.totalMatches === 0 && query.trim() && (
<div className="p-4 text-center text-xs text-text-muted">No results found</div>
)}
{results && results.totalMatches > 0 && (
<>
<div className="border-b border-border px-3 py-1.5 text-[10px] text-text-muted">
{results.totalMatches} match{results.totalMatches !== 1 ? 'es' : ''} in{' '}
{results.results.length} file{results.results.length !== 1 ? 's' : ''}
{results.truncated && ' (truncated)'}
</div>
{results.results.map((fileResult) => (
<SearchFileGroup
key={fileResult.filePath}
fileResult={fileResult}
relativePath={getRelativePath(fileResult.filePath)}
expanded={expandedFiles.has(fileResult.filePath)}
onToggle={() => toggleFileExpanded(fileResult.filePath)}
onSelectMatch={(line) => onSelectMatch(fileResult.filePath, line)}
query={query}
caseSensitive={caseSensitive}
/>
))}
</>
)}
</div>
</div>
);
};
// =============================================================================
// 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 (
<div className="border-border/50 border-b">
<button
onClick={onToggle}
className="flex w-full items-center gap-1.5 px-3 py-1 text-left transition-colors hover:bg-surface-raised"
>
<span className="text-[10px] text-text-muted">{expanded ? '▼' : '▶'}</span>
<Icon className="size-3.5 shrink-0" style={{ color: iconInfo.color }} />
<span className="truncate text-xs font-medium text-text">{fileName}</span>
{dirPath && <span className="ml-1 truncate text-[10px] text-text-muted">{dirPath}</span>}
<span className="ml-auto shrink-0 text-[10px] text-text-muted">
{fileResult.matches.length}
</span>
</button>
{expanded && (
<div className="pb-1">
{fileResult.matches.map((match, idx) => (
<button
key={`${match.line}-${idx}`}
onClick={() => onSelectMatch(match.line)}
className="flex w-full items-center gap-2 px-6 py-0.5 text-left transition-colors hover:bg-surface-raised"
>
<span className="w-8 shrink-0 text-right text-[10px] text-text-muted">
{match.line}
</span>
<HighlightedLine
text={match.lineContent}
query={query}
caseSensitive={caseSensitive}
/>
</button>
))}
</div>
)}
</div>
);
};
// =============================================================================
// Highlighted line
// =============================================================================
interface HighlightedLineProps {
text: string;
query: string;
caseSensitive: boolean;
}
const HighlightedLine = ({
text,
query,
caseSensitive,
}: HighlightedLineProps): React.ReactElement => {
if (!query) {
return <span className="truncate text-[11px] text-text-secondary">{text}</span>;
}
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(
<span key={`t-${lastIndex}`} className="text-text-secondary">
{text.slice(lastIndex, idx)}
</span>
);
}
parts.push(
<span key={`h-${idx}`} className="rounded bg-yellow-500/30 text-yellow-200">
{text.slice(idx, idx + query.length)}
</span>
);
lastIndex = idx + query.length;
idx = searchText.indexOf(searchQuery, lastIndex);
}
if (lastIndex < text.length) {
parts.push(
<span key={`t-${lastIndex}`} className="text-text-secondary">
{text.slice(lastIndex)}
</span>
);
}
return <span className="truncate text-[11px]">{parts}</span>;
};

View file

@ -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<string, FileIconInfo> = {
// 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<string, FileIconInfo> = {
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;
}

View file

@ -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),

View file

@ -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<string, HunkDecision>,
@ -157,7 +108,7 @@ const TreeItem = ({
collapsedFolders,
onToggleFolder,
}: {
node: TreeNode;
node: TreeNode<FileChangeSummary>;
selectedFilePath: string | null;
activeFilePath?: string;
onSelectFile: (filePath: string) => void;
@ -169,14 +120,14 @@ const TreeItem = ({
collapsedFolders: Set<string>;
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 (
<button
data-tree-file={node.file.filePath}
onClick={() => onSelectFile(node.file!.filePath)}
data-tree-file={node.data.filePath}
onClick={() => onSelectFile(node.data!.filePath)}
className={cn(
'flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs transition-colors',
isSelected
@ -189,7 +140,7 @@ const TreeItem = ({
>
<FileStatusIcon status={status} />
<File className="size-3.5 shrink-0" />
{viewedSet && viewedSet.has(node.file.filePath) && (
{viewedSet && viewedSet.has(node.data.filePath) && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0">
@ -208,11 +159,11 @@ const TreeItem = ({
{node.name}
</span>
<span className="ml-1 flex shrink-0 items-center gap-1">
{node.file.linesAdded > 0 && (
<span className="text-green-400">+{node.file.linesAdded}</span>
{node.data.linesAdded > 0 && (
<span className="text-green-400">+{node.data.linesAdded}</span>
)}
{node.file.linesRemoved > 0 && (
<span className="text-red-400">-{node.file.linesRemoved}</span>
{node.data.linesRemoved > 0 && (
<span className="text-red-400">-{node.data.linesRemoved}</span>
)}
</span>
</button>
@ -239,27 +190,22 @@ const TreeItem = ({
<span className="truncate">{node.name}</span>
</button>
{isOpen &&
[...node.children]
.sort((a, b) => {
if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
return a.name.localeCompare(b.name);
})
.map((child) => (
<TreeItem
key={child.fullPath}
node={child}
selectedFilePath={selectedFilePath}
activeFilePath={activeFilePath}
onSelectFile={onSelectFile}
depth={depth + 1}
hunkDecisions={hunkDecisions}
fileDecisions={fileDecisions}
fileChunkCounts={fileChunkCounts}
viewedSet={viewedSet}
collapsedFolders={collapsedFolders}
onToggleFolder={onToggleFolder}
/>
))}
sortTreeNodes(node.children).map((child) => (
<TreeItem
key={child.fullPath}
node={child}
selectedFilePath={selectedFilePath}
activeFilePath={activeFilePath}
onSelectFile={onSelectFile}
depth={depth + 1}
hunkDecisions={hunkDecisions}
fileDecisions={fileDecisions}
fileChunkCounts={fileChunkCounts}
viewedSet={viewedSet}
collapsedFolders={collapsedFolders}
onToggleFolder={onToggleFolder}
/>
))}
</div>
);
};
@ -274,12 +220,12 @@ function applyExpandAncestors(prev: Set<string>, ancestors: string[]): Set<strin
return next;
}
function getAncestorFolderPaths(tree: TreeNode[], filePath: string): string[] {
function getAncestorFolderPaths(tree: TreeNode<FileChangeSummary>[], filePath: string): string[] {
const paths: string[] = [];
function walk(nodes: TreeNode[], ancestors: string[]): boolean {
function walk(nodes: TreeNode<FileChangeSummary>[], 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<Set<string>>(() => new Set());
const toggleFolder = useCallback((fullPath: string) => {
@ -350,27 +296,22 @@ export const ReviewFileTree = ({
return (
<div className="py-1">
{[...tree]
.sort((a, b) => {
if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
return a.name.localeCompare(b.name);
})
.map((node) => (
<TreeItem
key={node.fullPath}
node={node}
selectedFilePath={selectedFilePath}
activeFilePath={activeFilePath}
onSelectFile={onSelectFile}
depth={0}
hunkDecisions={hunkDecisions}
fileDecisions={fileDecisions}
fileChunkCounts={fileChunkCounts}
viewedSet={viewedSet}
collapsedFolders={collapsedFolders}
onToggleFolder={toggleFolder}
/>
))}
{sortTreeNodes(tree).map((node) => (
<TreeItem
key={node.fullPath}
node={node}
selectedFilePath={selectedFilePath}
activeFilePath={activeFilePath}
onSelectFile={onSelectFile}
depth={0}
hunkDecisions={hunkDecisions}
fileDecisions={fileDecisions}
fileChunkCounts={fileChunkCounts}
viewedSet={viewedSet}
collapsedFolders={collapsedFolders}
onToggleFolder={toggleFolder}
/>
))}
</div>
);
};

View file

@ -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<void>;
saveAllFiles: () => Promise<void>;
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<typeof openSearchPanel>[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<typeof gotoLine>[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]);
}

View file

@ -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,
]);
}

View file

@ -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<AppState>()((...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();

View file

@ -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<V>(record: Record<string, V>, key: string): Record<string, V> {
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<string, number>();
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<string, number>();
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<string, boolean>;
openEditor: (projectPath: string) => Promise<void>;
closeEditor: () => void;
loadFileTree: (dirPath: string) => Promise<void>;
expandDirectory: (dirPath: string) => Promise<void>;
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<tabId, EditorState> in useRef).
// Store only tracks dirty flags, loading, and save status.
// ═══════════════════════════════════════════════════════
editorFileLoading: Record<string, boolean>;
editorModifiedFiles: Record<string, boolean>;
editorSaving: Record<string, boolean>;
editorSaveError: Record<string, string>;
markFileModified: (filePath: string) => void;
markFileSaved: (filePath: string) => void;
saveFile: (filePath: string) => Promise<void>;
saveAllFiles: () => Promise<void>;
discardChanges: (filePath: string) => void;
hasUnsavedChanges: () => boolean;
// ═══════════════════════════════════════════════════════
// Group 4: File operations (iter-3)
// ═══════════════════════════════════════════════════════
editorCreating: boolean;
editorCreateError: string | null;
createFileInTree: (parentDir: string, fileName: string) => Promise<string | null>;
createDirInTree: (parentDir: string, dirName: string) => Promise<string | null>;
deleteFileFromTree: (filePath: string) => Promise<boolean>;
moveFileInTree: (sourcePath: string, destDir: string) => Promise<boolean>;
// ═══════════════════════════════════════════════════════
// 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<string, EditorFileChangeEvent['type']>;
/** Baseline mtime per file (for conflict detection) */
editorFileMtimes: Record<string, number>;
/** File path with active save conflict (null = no conflict) */
editorConflictFile: string | null;
fetchGitStatus: () => Promise<void>;
toggleWatcher: (enable: boolean) => Promise<void>;
toggleLineWrap: () => void;
handleExternalFileChange: (event: EditorFileChangeEvent) => void;
clearExternalChange: (filePath: string) => void;
setFileMtime: (filePath: string, mtimeMs: number) => void;
forceOverwrite: (filePath: string) => Promise<void>;
resolveConflict: () => void;
}
// =============================================================================
// Slice Creator
// =============================================================================
export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<void>[] = [];
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<AppState>) => void,
dirPath: string
): Promise<void> {
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<V>(
record: Record<string, V>,
oldPath: string,
newPath: string
): Record<string, V> {
const result: Record<string, V> = {};
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;
});
}

View file

@ -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;

View file

@ -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<string, string> = {
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,
};
}

View file

@ -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<string, string> = {
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';
}

View file

@ -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',
},
});

View file

@ -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<string, EditorState> | null = null;
let scrollTopCache: Map<string, number> | null = null;
let activeView: EditorView | null = null;
export const editorBridge = {
/** Called by CodeMirrorEditor on mount */
register(sc: Map<string, EditorState>, stc: Map<string, number>, 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<string, boolean>): Map<string, string> {
const result = new Map<string, string>();
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;
},
};

View file

@ -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<T> {
name: string;
fullPath: string;
isFile: boolean;
data?: T;
children: TreeNode<T>[];
}
/**
* 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<T>(
items: T[],
getPath: (item: T) => string,
options?: { collapse?: boolean }
): TreeNode<T>[] {
const root: TreeNode<T> = { 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<T>(node: TreeNode<T>): TreeNode<T> {
const collapsed: TreeNode<T> = { ...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<T>(nodes: TreeNode<T>[]): TreeNode<T>[] {
return [...nodes].sort((a, b) => {
if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
return a.name.localeCompare(b.name);
});
}

View file

@ -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;
}

View file

@ -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<string, EditorFileTab[]>();
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<string, string | undefined>();
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 };
});
}

View file

@ -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;
}
// =============================================================================

214
src/shared/types/editor.ts Normal file
View file

@ -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<void>;
close: () => Promise<void>;
readDir: (dirPath: string, maxEntries?: number) => Promise<ReadDirResult>;
readFile: (filePath: string) => Promise<ReadFileResult>;
writeFile: (
filePath: string,
content: string,
baselineMtimeMs?: number
) => Promise<WriteFileResponse>;
createFile: (parentDir: string, fileName: string) => Promise<CreateFileResponse>;
createDir: (parentDir: string, dirName: string) => Promise<CreateDirResponse>;
deleteFile: (filePath: string) => Promise<DeleteFileResponse>;
moveFile: (sourcePath: string, destDir: string) => Promise<MoveFileResponse>;
searchInFiles: (options: SearchInFilesOptions) => Promise<SearchInFilesResult>;
gitStatus: () => Promise<GitStatusResult>;
watchDir: (enable: boolean) => Promise<void>;
/** 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;
}

View file

@ -35,3 +35,6 @@ export type * from './cliInstaller';
// Re-export Terminal types
export type * from './terminal';
// Re-export Editor types
export type * from './editor';

View file

@ -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<string, (...args: unknown[]) => 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<Record<string, unknown>> = {}
): Awaited<ReturnType<typeof fs.stat>> {
return {
isFile: () => overrides.isFile ?? false,
isDirectory: () => overrides.isDirectory ?? true,
isSymbolicLink: () => overrides.isSymbolicLink ?? false,
size: overrides.size ?? 1024,
mtimeMs: overrides.mtimeMs ?? Date.now(),
} as Awaited<ReturnType<typeof fs.stat>>;
}
// =============================================================================
// Tests
// =============================================================================
describe('Editor IPC handlers', () => {
let mockIpc: ReturnType<typeof createMockIpcMain>;
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'),
});
});
});
});

View file

@ -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' });
});
});

View file

@ -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);
});
});
});

View file

@ -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<string, string>) {
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);
});
});

View file

@ -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> = {}): 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([]);
});
});

View file

@ -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<Record<string, unknown>> = {}
): ReturnType<typeof fs.lstat> {
return {
isFile: () => overrides.isFile ?? true,
isDirectory: () => overrides.isDirectory ?? false,
isSymbolicLink: () => overrides.isSymbolicLink ?? false,
size: overrides.size ?? 1024,
mtimeMs: overrides.mtimeMs ?? Date.now(),
} as Awaited<ReturnType<typeof fs.lstat>>;
}
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,
});
});
});

View file

@ -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<ReturnType<typeof fs.stat>>);
}
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);
});
});

View file

@ -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 });
});
});

View file

@ -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);
});
});

View file

@ -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');
});
});

View file

@ -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> = {}): 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> = {}): 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();
});
});
});

View file

@ -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<string, string> = {
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);
});
});
});

View file

@ -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),
}));
}

View file

@ -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();
});
});

View file

@ -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<TestItem>[] = [
{ 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<TestItem>[] = [
{ 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<TestItem>[] = [
{ 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([]);
});
});

View file

@ -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);
});
});