From ccee484adc8bdc4f71a0b38430b33b4ad9ab3836 Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 1 Mar 2026 07:55:50 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20editor=20improvements=20=E2=80=94=20isDi?= =?UTF-8?q?r=20bug,=20scroll-to-line,=20Quick=20Open,=20a11y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix isDir heuristic: use backend-provided isDirectory instead of filename-based guessing (breaks for Makefile, .github, etc.) - Add scroll-to-line on search result click via editorPendingGoToLine - Add Cmd+Shift+W shortcut for toggling line wrap - Rewrite Quick Open to fetch all project files from backend API instead of flattening the loaded tree (limited to expanded dirs) - Fix fd leak in atomicWrite: close file handle in finally block - Add a11y: role=dialog/alert, aria-modal, aria-label on modals - Add type=button on error state buttons --- src/main/ipc/editor.ts | 14 +++ src/main/services/editor/FileSearchService.ts | 56 +++++++++ .../services/editor/ProjectFileService.ts | 7 +- src/main/utils/atomicWrite.ts | 6 +- src/preload/constants/ipcChannels.ts | 3 + src/preload/index.ts | 3 + src/renderer/api/httpClient.ts | 3 + .../team/editor/CodeMirrorEditor.tsx | 19 +++ .../team/editor/EditorErrorBoundary.tsx | 9 +- .../team/editor/EditorErrorState.tsx | 10 +- .../team/editor/EditorShortcutsHelp.tsx | 13 ++- .../team/editor/ProjectEditorOverlay.tsx | 8 +- .../team/editor/QuickOpenDialog.tsx | 110 ++++++++++-------- .../hooks/useEditorKeyboardShortcuts.ts | 12 ++ src/renderer/store/slices/editorSlice.ts | 18 ++- src/shared/types/editor.ts | 8 ++ test/main/ipc/editor.test.ts | 8 +- .../editor/ProjectFileService.test.ts | 2 + .../hooks/useEditorKeyboardShortcuts.test.ts | 11 ++ test/renderer/store/editorSlice.test.ts | 10 +- 20 files changed, 253 insertions(+), 77 deletions(-) diff --git a/src/main/ipc/editor.ts b/src/main/ipc/editor.ts index 6fbf4eeb..741447ce 100644 --- a/src/main/ipc/editor.ts +++ b/src/main/ipc/editor.ts @@ -14,6 +14,7 @@ import { EDITOR_CREATE_FILE, EDITOR_DELETE_FILE, EDITOR_GIT_STATUS, + EDITOR_LIST_FILES, EDITOR_MOVE_FILE, EDITOR_OPEN, EDITOR_READ_DIR, @@ -44,6 +45,7 @@ import type { DeleteFileResponse, GitStatusResult, MoveFileResponse, + QuickOpenFile, ReadDirResult, ReadFileResult, SearchInFilesOptions, @@ -270,6 +272,16 @@ async function handleEditorSearchInFiles( }); } +/** + * List all project files recursively (for Quick Open). + */ +async function handleEditorListFiles(): Promise> { + return wrapHandler('listFiles', async () => { + if (!activeProjectRoot) throw new Error('Editor not initialized'); + return fileSearchService.listFiles(activeProjectRoot); + }); +} + /** * Get git status for current project (cached 5s). */ @@ -333,6 +345,7 @@ export function registerEditorHandlers(ipcMain: IpcMain): void { ipcMain.handle(EDITOR_DELETE_FILE, handleEditorDeleteFile); ipcMain.handle(EDITOR_MOVE_FILE, handleEditorMoveFile); ipcMain.handle(EDITOR_SEARCH_IN_FILES, handleEditorSearchInFiles); + ipcMain.handle(EDITOR_LIST_FILES, handleEditorListFiles); ipcMain.handle(EDITOR_GIT_STATUS, handleEditorGitStatus); ipcMain.handle(EDITOR_WATCH_DIR, handleEditorWatchDir); } @@ -348,6 +361,7 @@ export function removeEditorHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(EDITOR_DELETE_FILE); ipcMain.removeHandler(EDITOR_MOVE_FILE); ipcMain.removeHandler(EDITOR_SEARCH_IN_FILES); + ipcMain.removeHandler(EDITOR_LIST_FILES); ipcMain.removeHandler(EDITOR_GIT_STATUS); ipcMain.removeHandler(EDITOR_WATCH_DIR); } diff --git a/src/main/services/editor/FileSearchService.ts b/src/main/services/editor/FileSearchService.ts index 3982c367..14357444 100644 --- a/src/main/services/editor/FileSearchService.ts +++ b/src/main/services/editor/FileSearchService.ts @@ -52,6 +52,20 @@ const log = createLogger('FileSearchService'); // ============================================================================= export class FileSearchService { + /** + * List all files in the project recursively (for Quick Open). + * Lightweight — no content reading, no binary checks, no stat. + * Returns relative paths for display and absolute paths for opening. + */ + async listFiles( + projectRoot: string, + signal?: AbortSignal + ): Promise<{ path: string; name: string; relativePath: string }[]> { + const files: { path: string; name: string; relativePath: string }[] = []; + await this.collectFilePaths(projectRoot, projectRoot, files, signal); + return files; + } + /** * Search for a literal string across project files. * @@ -113,6 +127,48 @@ export class FileSearchService { return { results, totalMatches, truncated }; } + /** + * Lightweight recursive file path collection (no stat, no binary check). + * Used by listFiles() for Quick Open — needs to be fast. + */ + private async collectFilePaths( + projectRoot: string, + dirPath: string, + files: { path: string; name: string; relativePath: string }[], + signal?: AbortSignal + ): Promise { + if (signal?.aborted || files.length >= MAX_FILES) return; + + let entries; + try { + entries = await fs.readdir(dirPath, { withFileTypes: true }); + } catch { + return; + } + + const sorted = [...entries].sort((a, b) => 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); + + if (!isPathWithinRoot(fullPath, projectRoot)) continue; + if (isGitInternalPath(fullPath)) continue; + + if (entry.isDirectory()) { + if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + await this.collectFilePaths(projectRoot, fullPath, files, signal); + } else if (entry.isFile()) { + if (IGNORED_FILES.has(entry.name)) continue; + const relativePath = fullPath.startsWith(projectRoot) + ? fullPath.slice(projectRoot.length + 1) + : entry.name; + files.push({ path: fullPath, name: entry.name, relativePath }); + } + } + } + /** * Recursively collect all searchable files. */ diff --git a/src/main/services/editor/ProjectFileService.ts b/src/main/services/editor/ProjectFileService.ts index e2ac49da..7ac0a2ee 100644 --- a/src/main/services/editor/ProjectFileService.ts +++ b/src/main/services/editor/ProjectFileService.ts @@ -491,8 +491,9 @@ export class ProjectFileService { throw new Error('Cannot move files into .git/ directory'); } - // 5. Verify source exists - await fs.lstat(normalizedSrc); + // 5. Verify source exists and determine type + const srcStat = await fs.lstat(normalizedSrc); + const isDirectory = srcStat.isDirectory(); // 6. Verify destination is a directory const destStat = await fs.lstat(normalizedDest); @@ -541,7 +542,7 @@ export class ProjectFileService { } log.info('File moved:', normalizedSrc, '→', newPath); - return { newPath }; + return { newPath, isDirectory }; } // --------------------------------------------------------------------------- diff --git a/src/main/utils/atomicWrite.ts b/src/main/utils/atomicWrite.ts index 500c038c..bf6fd237 100644 --- a/src/main/utils/atomicWrite.ts +++ b/src/main/utils/atomicWrite.ts @@ -14,12 +14,14 @@ export async function atomicWriteAsync(targetPath: string, data: string): Promis await fs.promises.mkdir(dir, { recursive: true }); await fs.promises.writeFile(tmpPath, data, 'utf8'); + let fd: fs.promises.FileHandle | null = null; try { - const fd = await fs.promises.open(tmpPath, 'r+'); + fd = await fs.promises.open(tmpPath, 'r+'); await fd.sync(); - await fd.close(); } catch { // fsync is best-effort. + } finally { + await fd?.close(); } try { diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index d51b8458..550a7038 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -434,6 +434,9 @@ export const EDITOR_MOVE_FILE = 'editor:moveFile'; /** Search in files (literal string search) */ export const EDITOR_SEARCH_IN_FILES = 'editor:searchInFiles'; +/** List all project files (for Quick Open) */ +export const EDITOR_LIST_FILES = 'editor:listFiles'; + /** Get git status for current project */ export const EDITOR_GIT_STATUS = 'editor:gitStatus'; diff --git a/src/preload/index.ts b/src/preload/index.ts index d5e6f033..5db557bb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -16,6 +16,7 @@ import { EDITOR_CREATE_FILE, EDITOR_DELETE_FILE, EDITOR_GIT_STATUS, + EDITOR_LIST_FILES, EDITOR_MOVE_FILE, EDITOR_OPEN, EDITOR_READ_DIR, @@ -200,6 +201,7 @@ import type { EditorFileChangeEvent, GitStatusResult, MoveFileResponse, + QuickOpenFile, ReadDirResult, ReadFileResult, SearchInFilesOptions, @@ -974,6 +976,7 @@ const electronAPI: ElectronAPI = { invokeIpcWithResult(EDITOR_MOVE_FILE, sourcePath, destDir), searchInFiles: (options: SearchInFilesOptions) => invokeIpcWithResult(EDITOR_SEARCH_IN_FILES, options), + listFiles: () => invokeIpcWithResult(EDITOR_LIST_FILES), gitStatus: () => invokeIpcWithResult(EDITOR_GIT_STATUS), watchDir: (enable: boolean) => invokeIpcWithResult(EDITOR_WATCH_DIR, enable), onEditorChange: (callback: (event: EditorFileChangeEvent) => void): (() => void) => { diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index e4d2aae8..db014947 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -947,6 +947,9 @@ export class HttpAPIClient implements ElectronAPI { searchInFiles: async () => { throw new Error('Editor not available in browser mode'); }, + listFiles: async () => { + throw new Error('Editor not available in browser mode'); + }, gitStatus: async () => { throw new Error('Editor not available in browser mode'); }, diff --git a/src/renderer/components/team/editor/CodeMirrorEditor.tsx b/src/renderer/components/team/editor/CodeMirrorEditor.tsx index d9ea64d0..de84e7b1 100644 --- a/src/renderer/components/team/editor/CodeMirrorEditor.tsx +++ b/src/renderer/components/team/editor/CodeMirrorEditor.tsx @@ -470,6 +470,25 @@ export const CodeMirrorEditor = ({ }); }, [lineWrap]); + // Scroll to pending line (from search-in-files result click) + const pendingGoToLine = useStore((s) => s.editorPendingGoToLine); + const setPendingGoToLine = useStore((s) => s.setPendingGoToLine); + useEffect(() => { + const view = viewRef.current; + if (!view || !pendingGoToLine) return; + + const lineCount = view.state.doc.lines; + const targetLine = Math.min(Math.max(1, pendingGoToLine), lineCount); + const lineInfo = view.state.doc.line(targetLine); + + view.dispatch({ + selection: { anchor: lineInfo.from }, + effects: EditorView.scrollIntoView(lineInfo.from, { y: 'center' }), + }); + + setPendingGoToLine(null); + }, [pendingGoToLine, setPendingGoToLine, filePath]); + // Cleanup bridge on full unmount useEffect(() => { return () => { diff --git a/src/renderer/components/team/editor/EditorErrorBoundary.tsx b/src/renderer/components/team/editor/EditorErrorBoundary.tsx index 6f46f3c9..67fd4db2 100644 --- a/src/renderer/components/team/editor/EditorErrorBoundary.tsx +++ b/src/renderer/components/team/editor/EditorErrorBoundary.tsx @@ -39,12 +39,17 @@ export class EditorErrorBoundary extends React.Component { render(): React.ReactElement { if (this.state.hasError) { return ( -
- +
+