fix: editor improvements — isDir bug, scroll-to-line, Quick Open, a11y

- 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
This commit is contained in:
iliya 2026-03-01 07:55:50 +02:00
parent 8e6fb13e5f
commit ccee484adc
20 changed files with 253 additions and 77 deletions

View file

@ -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<IpcResult<QuickOpenFile[]>> {
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);
}

View file

@ -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<void> {
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.
*/

View file

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

View file

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

View file

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

View file

@ -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<MoveFileResponse>(EDITOR_MOVE_FILE, sourcePath, destDir),
searchInFiles: (options: SearchInFilesOptions) =>
invokeIpcWithResult<SearchInFilesResult>(EDITOR_SEARCH_IN_FILES, options),
listFiles: () => invokeIpcWithResult<QuickOpenFile[]>(EDITOR_LIST_FILES),
gitStatus: () => invokeIpcWithResult<GitStatusResult>(EDITOR_GIT_STATUS),
watchDir: (enable: boolean) => invokeIpcWithResult<void>(EDITOR_WATCH_DIR, enable),
onEditorChange: (callback: (event: EditorFileChangeEvent) => void): (() => void) => {

View file

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

View file

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

View file

@ -39,12 +39,17 @@ export class EditorErrorBoundary extends React.Component<Props, State> {
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" />
<div
role="alert"
aria-live="polite"
className="flex h-full flex-col items-center justify-center gap-3 text-text-muted"
>
<AlertTriangle aria-hidden="true" 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
type="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"
>

View file

@ -16,12 +16,17 @@ export const EditorErrorState = ({
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" />
<div
role="alert"
aria-live="polite"
className="flex h-full flex-col items-center justify-center gap-3 text-text-muted"
>
<AlertTriangle aria-hidden="true" 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
type="button"
onClick={onRetry}
className="rounded border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised"
>
@ -30,6 +35,7 @@ export const EditorErrorState = ({
)}
{onClose && (
<button
type="button"
onClick={onClose}
className="rounded border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised"
>

View file

@ -102,14 +102,21 @@ export const EditorShortcutsHelp = ({ onClose }: EditorShortcutsHelpProps): Reac
);
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div className="fixed inset-0 z-[60] flex items-center justify-center" role="presentation">
{/* 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
role="dialog"
aria-modal="true"
aria-labelledby="shortcuts-dialog-title"
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>
<h2 id="shortcuts-dialog-title" 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"

View file

@ -395,12 +395,14 @@ export const ProjectEditorOverlay = ({
// --- Iter-4: Search result selection ---
const setPendingGoToLine = useStore((s) => s.setPendingGoToLine);
const handleSearchSelectMatch = useCallback(
(filePath: string, _line: number) => {
(filePath: string, line: number) => {
setPendingGoToLine(line);
openFile(filePath);
// Future enhancement: scroll to line in CM6 after file loads
},
[openFile]
[openFile, setPendingGoToLine]
);
// --- Keyboard shortcuts ---

View file

@ -2,17 +2,18 @@
* 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.
* Loads ALL project files via backend API on mount (not limited to expanded dirs).
*/
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useStore } from '@renderer/store';
import { Command } from 'cmdk';
import { Loader2 } from 'lucide-react';
import { getFileIcon } from './fileIcons';
import type { FileTreeEntry } from '@shared/types/editor';
import type { QuickOpenFile } from '@shared/types/editor';
// =============================================================================
// Types
@ -31,17 +32,32 @@ 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);
const [allFiles, setAllFiles] = useState<QuickOpenFile[]>([]);
const [loading, setLoading] = useState(true);
// 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]);
// Load all project files on mount via backend API
useEffect(() => {
let cancelled = false;
setLoading(true);
window.electronAPI.editor
.listFiles()
.then((files) => {
if (!cancelled) {
setAllFiles(files);
setLoading(false);
}
})
.catch(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [projectPath]);
// Escape to close dialog (not overlay)
const handleKeyDown = useCallback(
@ -62,10 +78,24 @@ export const QuickOpenDialog = ({
const handleSelect = useCallback(
(value: string) => {
onSelectFile(value);
onClose();
// value is relativePath from cmdk — look up full path
const file = allFiles.find((f) => f.relativePath === value);
if (file) {
onSelectFile(file.path);
onClose();
}
},
[onSelectFile, onClose]
[allFiles, onSelectFile, onClose]
);
// Memoize file icon lookups
const fileItems = useMemo(
() =>
allFiles.map((file) => ({
...file,
iconInfo: getFileIcon(file.name),
})),
[allFiles]
);
return (
@ -83,6 +113,9 @@ export const QuickOpenDialog = ({
{/* Dialog */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Quick Open"
className="relative z-10 w-[520px] overflow-hidden rounded-lg border border-border-emphasis bg-surface shadow-2xl"
>
<Command label="Quick Open" shouldFilter={true}>
@ -92,20 +125,27 @@ export const QuickOpenDialog = ({
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;
{loading && (
<div className="flex items-center justify-center gap-2 p-6 text-sm text-text-muted">
<Loader2 className="size-4 animate-spin" />
<span>Loading files...</span>
</div>
)}
{!loading && (
<Command.Empty className="p-6 text-center text-sm text-text-muted">
No files found
</Command.Empty>
)}
{fileItems.map((file) => {
const Icon = file.iconInfo.icon;
return (
<Command.Item
key={file.path}
value={file.relativePath}
onSelect={() => handleSelect(file.path)}
onSelect={() => handleSelect(file.relativePath)}
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 }} />
<Icon className="size-4 shrink-0" style={{ color: file.iconInfo.color }} />
<span className="truncate font-medium">{file.name}</span>
<span className="ml-auto truncate text-xs text-text-muted">
{file.relativePath}
@ -119,29 +159,3 @@ export const QuickOpenDialog = ({
</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

@ -35,6 +35,7 @@ export interface EditorKeyHandlerDeps {
onToggleQuickOpen: () => void;
onToggleSearchPanel: () => void;
onToggleSidebar: () => void;
onToggleLineWrap: () => void;
getEditorView: () => { dispatch: unknown } | null;
}
@ -101,6 +102,14 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
return;
}
// Cmd+Shift+W: Toggle line wrap
if (e.key === 'w' && e.shiftKey && !e.altKey) {
e.preventDefault();
e.stopPropagation();
deps.onToggleLineWrap();
return;
}
// Cmd+W: Close current editor tab
if (e.key === 'w' && !e.shiftKey && !e.altKey) {
e.preventDefault();
@ -181,6 +190,7 @@ export function useEditorKeyboardShortcuts({
const saveFile = useStore((s) => s.saveFile);
const saveAllFiles = useStore((s) => s.saveAllFiles);
const hasUnsavedChanges = useStore((s) => s.hasUnsavedChanges);
const toggleLineWrap = useStore((s) => s.toggleLineWrap);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
@ -194,6 +204,7 @@ export function useEditorKeyboardShortcuts({
onToggleQuickOpen,
onToggleSearchPanel,
onToggleSidebar,
onToggleLineWrap: toggleLineWrap,
getEditorView: () => editorBridge.getView(),
});
handler(e);
@ -208,6 +219,7 @@ export function useEditorKeyboardShortcuts({
onToggleQuickOpen,
onToggleSearchPanel,
onToggleSidebar,
toggleLineWrap,
]
);

View file

@ -124,6 +124,10 @@ export interface EditorSlice {
/** File path with active save conflict (null = no conflict) */
editorConflictFile: string | null;
/** Pending line to scroll to after file loads (1-based). Set by search result click. */
editorPendingGoToLine: number | null;
setPendingGoToLine: (line: number | null) => void;
fetchGitStatus: () => Promise<void>;
toggleWatcher: (enable: boolean) => Promise<void>;
toggleLineWrap: () => void;
@ -176,6 +180,9 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
editorExternalChanges: {},
editorFileMtimes: {},
editorConflictFile: null,
editorPendingGoToLine: null,
setPendingGoToLine: (line: number | null) => set({ editorPendingGoToLine: line }),
// ═══════════════════════════════════════════════════════
// Group 1: File tree actions
@ -204,6 +211,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
editorExternalChanges: {},
editorFileMtimes: {},
editorConflictFile: null,
editorPendingGoToLine: null,
});
try {
@ -264,6 +272,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
editorExternalChanges: {},
editorFileMtimes: {},
editorConflictFile: null,
editorPendingGoToLine: null,
});
},
@ -609,16 +618,13 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
try {
const result = await api.editor.moveFile(sourcePath, destDir);
const newPath = result.newPath;
const { newPath, isDirectory } = result;
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) => {
@ -657,8 +663,8 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
editorBridge.remapState(originalPath, tab.filePath);
}
}
// Also remap for single file case
if (!isDir) {
// Also remap for single file case (directories have no direct bridge state)
if (!isDirectory) {
editorBridge.remapState(sourcePath, newPath);
}

View file

@ -75,6 +75,7 @@ export interface DeleteFileResponse {
export interface MoveFileResponse {
newPath: string;
isDirectory: boolean;
}
// =============================================================================
@ -169,6 +170,12 @@ export interface EditorFileChangeEvent {
// Editor API
// =============================================================================
export interface QuickOpenFile {
path: string;
name: string;
relativePath: string;
}
export interface EditorAPI {
open: (projectPath: string) => Promise<void>;
close: () => Promise<void>;
@ -184,6 +191,7 @@ export interface EditorAPI {
deleteFile: (filePath: string) => Promise<DeleteFileResponse>;
moveFile: (sourcePath: string, destDir: string) => Promise<MoveFileResponse>;
searchInFiles: (options: SearchInFilesOptions) => Promise<SearchInFilesResult>;
listFiles: () => Promise<QuickOpenFile[]>;
gitStatus: () => Promise<GitStatusResult>;
watchDir: (enable: boolean) => Promise<void>;
/** Subscribe to file change events (main → renderer). Returns cleanup function. */

View file

@ -39,6 +39,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({
EDITOR_DELETE_FILE: 'editor:deleteFile',
EDITOR_MOVE_FILE: 'editor:moveFile',
EDITOR_SEARCH_IN_FILES: 'editor:searchInFiles',
EDITOR_LIST_FILES: 'editor:listFiles',
EDITOR_GIT_STATUS: 'editor:gitStatus',
EDITOR_WATCH_DIR: 'editor:watchDir',
EDITOR_CHANGE: 'editor:change',
@ -144,8 +145,8 @@ describe('Editor IPC handlers', () => {
});
describe('registration', () => {
it('registers all 12 editor channels', () => {
expect(mockIpc.handle).toHaveBeenCalledTimes(12);
it('registers all 13 editor channels', () => {
expect(mockIpc.handle).toHaveBeenCalledTimes(13);
expect(mockIpc._handlers.has('editor:open')).toBe(true);
expect(mockIpc._handlers.has('editor:close')).toBe(true);
expect(mockIpc._handlers.has('editor:readDir')).toBe(true);
@ -156,13 +157,14 @@ describe('Editor IPC handlers', () => {
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:listFiles')).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);
expect(mockIpc.removeHandler).toHaveBeenCalledTimes(13);
});
});

View file

@ -576,6 +576,7 @@ describe('ProjectFileService.moveFile', () => {
const result = await service.moveFile(PROJECT_ROOT, sourcePath, DEST_DIR);
expect(result.newPath).toBe(path.join(DEST_DIR, 'index.ts'));
expect(result.isDirectory).toBe(false);
expect(mockRename).toHaveBeenCalledWith(
path.resolve(sourcePath),
path.join(DEST_DIR, 'index.ts')
@ -591,6 +592,7 @@ describe('ProjectFileService.moveFile', () => {
const result = await service.moveFile(PROJECT_ROOT, sourceDir, DEST_DIR);
expect(result.newPath).toBe(path.join(DEST_DIR, 'utils'));
expect(result.isDirectory).toBe(true);
expect(mockRename).toHaveBeenCalled();
});

View file

@ -51,6 +51,7 @@ function createMockDeps(overrides: Partial<EditorKeyHandlerDeps> = {}): EditorKe
onToggleQuickOpen: vi.fn(),
onToggleSearchPanel: vi.fn(),
onToggleSidebar: vi.fn(),
onToggleLineWrap: vi.fn(),
getEditorView: vi.fn().mockReturnValue(null),
...overrides,
};
@ -168,6 +169,16 @@ describe('createEditorKeyHandler', () => {
});
});
describe('Cmd+Shift+W — Toggle Line Wrap', () => {
it('calls onToggleLineWrap', () => {
const handler = createEditorKeyHandler(deps);
const event = createKeyEvent('w', { shiftKey: true });
handler(event);
expect(deps.onToggleLineWrap).toHaveBeenCalledOnce();
expect(event.defaultPrevented).toBe(true);
});
});
describe('Cmd+W — Close Tab', () => {
it('dispatches editor-close-tab CustomEvent with active tab id', () => {
const handler = createEditorKeyHandler(deps);

View file

@ -724,7 +724,7 @@ describe('editorSlice', () => {
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.moveFile.mockResolvedValue({ newPath, isDirectory: false });
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
store.getState().openFile(oldPath);
@ -749,7 +749,7 @@ describe('editorSlice', () => {
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.moveFile.mockResolvedValue({ newPath, isDirectory: false });
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
store.getState().openFile(oldPath);
@ -763,7 +763,7 @@ describe('editorSlice', () => {
it('remaps modifiedFiles and fileMtimes', async () => {
const oldPath = SRC_DIR + '/dirty.ts';
const newPath = LIB_DIR + '/dirty.ts';
mockEditorAPI.moveFile.mockResolvedValue({ newPath });
mockEditorAPI.moveFile.mockResolvedValue({ newPath, isDirectory: false });
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
store.setState({
@ -785,7 +785,7 @@ describe('editorSlice', () => {
const newDir = LIB_DIR + '/components';
const oldFilePath = oldDir + '/Button.tsx';
const newFilePath = newDir + '/Button.tsx';
mockEditorAPI.moveFile.mockResolvedValue({ newPath: newDir });
mockEditorAPI.moveFile.mockResolvedValue({ newPath: newDir, isDirectory: true });
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
store.getState().openFile(oldFilePath);
@ -838,7 +838,7 @@ describe('editorSlice', () => {
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.moveFile.mockResolvedValue({ newPath, isDirectory: false });
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
store.getState().openFile(oldPath);