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:
parent
8e6fb13e5f
commit
ccee484adc
20 changed files with 253 additions and 77 deletions
|
|
@ -14,6 +14,7 @@ import {
|
||||||
EDITOR_CREATE_FILE,
|
EDITOR_CREATE_FILE,
|
||||||
EDITOR_DELETE_FILE,
|
EDITOR_DELETE_FILE,
|
||||||
EDITOR_GIT_STATUS,
|
EDITOR_GIT_STATUS,
|
||||||
|
EDITOR_LIST_FILES,
|
||||||
EDITOR_MOVE_FILE,
|
EDITOR_MOVE_FILE,
|
||||||
EDITOR_OPEN,
|
EDITOR_OPEN,
|
||||||
EDITOR_READ_DIR,
|
EDITOR_READ_DIR,
|
||||||
|
|
@ -44,6 +45,7 @@ import type {
|
||||||
DeleteFileResponse,
|
DeleteFileResponse,
|
||||||
GitStatusResult,
|
GitStatusResult,
|
||||||
MoveFileResponse,
|
MoveFileResponse,
|
||||||
|
QuickOpenFile,
|
||||||
ReadDirResult,
|
ReadDirResult,
|
||||||
ReadFileResult,
|
ReadFileResult,
|
||||||
SearchInFilesOptions,
|
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).
|
* 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_DELETE_FILE, handleEditorDeleteFile);
|
||||||
ipcMain.handle(EDITOR_MOVE_FILE, handleEditorMoveFile);
|
ipcMain.handle(EDITOR_MOVE_FILE, handleEditorMoveFile);
|
||||||
ipcMain.handle(EDITOR_SEARCH_IN_FILES, handleEditorSearchInFiles);
|
ipcMain.handle(EDITOR_SEARCH_IN_FILES, handleEditorSearchInFiles);
|
||||||
|
ipcMain.handle(EDITOR_LIST_FILES, handleEditorListFiles);
|
||||||
ipcMain.handle(EDITOR_GIT_STATUS, handleEditorGitStatus);
|
ipcMain.handle(EDITOR_GIT_STATUS, handleEditorGitStatus);
|
||||||
ipcMain.handle(EDITOR_WATCH_DIR, handleEditorWatchDir);
|
ipcMain.handle(EDITOR_WATCH_DIR, handleEditorWatchDir);
|
||||||
}
|
}
|
||||||
|
|
@ -348,6 +361,7 @@ export function removeEditorHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(EDITOR_DELETE_FILE);
|
ipcMain.removeHandler(EDITOR_DELETE_FILE);
|
||||||
ipcMain.removeHandler(EDITOR_MOVE_FILE);
|
ipcMain.removeHandler(EDITOR_MOVE_FILE);
|
||||||
ipcMain.removeHandler(EDITOR_SEARCH_IN_FILES);
|
ipcMain.removeHandler(EDITOR_SEARCH_IN_FILES);
|
||||||
|
ipcMain.removeHandler(EDITOR_LIST_FILES);
|
||||||
ipcMain.removeHandler(EDITOR_GIT_STATUS);
|
ipcMain.removeHandler(EDITOR_GIT_STATUS);
|
||||||
ipcMain.removeHandler(EDITOR_WATCH_DIR);
|
ipcMain.removeHandler(EDITOR_WATCH_DIR);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,20 @@ const log = createLogger('FileSearchService');
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export class 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.
|
* Search for a literal string across project files.
|
||||||
*
|
*
|
||||||
|
|
@ -113,6 +127,48 @@ export class FileSearchService {
|
||||||
return { results, totalMatches, truncated };
|
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.
|
* Recursively collect all searchable files.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -491,8 +491,9 @@ export class ProjectFileService {
|
||||||
throw new Error('Cannot move files into .git/ directory');
|
throw new Error('Cannot move files into .git/ directory');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Verify source exists
|
// 5. Verify source exists and determine type
|
||||||
await fs.lstat(normalizedSrc);
|
const srcStat = await fs.lstat(normalizedSrc);
|
||||||
|
const isDirectory = srcStat.isDirectory();
|
||||||
|
|
||||||
// 6. Verify destination is a directory
|
// 6. Verify destination is a directory
|
||||||
const destStat = await fs.lstat(normalizedDest);
|
const destStat = await fs.lstat(normalizedDest);
|
||||||
|
|
@ -541,7 +542,7 @@ export class ProjectFileService {
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('File moved:', normalizedSrc, '→', newPath);
|
log.info('File moved:', normalizedSrc, '→', newPath);
|
||||||
return { newPath };
|
return { newPath, isDirectory };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,14 @@ export async function atomicWriteAsync(targetPath: string, data: string): Promis
|
||||||
await fs.promises.mkdir(dir, { recursive: true });
|
await fs.promises.mkdir(dir, { recursive: true });
|
||||||
await fs.promises.writeFile(tmpPath, data, 'utf8');
|
await fs.promises.writeFile(tmpPath, data, 'utf8');
|
||||||
|
|
||||||
|
let fd: fs.promises.FileHandle | null = null;
|
||||||
try {
|
try {
|
||||||
const fd = await fs.promises.open(tmpPath, 'r+');
|
fd = await fs.promises.open(tmpPath, 'r+');
|
||||||
await fd.sync();
|
await fd.sync();
|
||||||
await fd.close();
|
|
||||||
} catch {
|
} catch {
|
||||||
// fsync is best-effort.
|
// fsync is best-effort.
|
||||||
|
} finally {
|
||||||
|
await fd?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -434,6 +434,9 @@ export const EDITOR_MOVE_FILE = 'editor:moveFile';
|
||||||
/** Search in files (literal string search) */
|
/** Search in files (literal string search) */
|
||||||
export const EDITOR_SEARCH_IN_FILES = 'editor:searchInFiles';
|
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 */
|
/** Get git status for current project */
|
||||||
export const EDITOR_GIT_STATUS = 'editor:gitStatus';
|
export const EDITOR_GIT_STATUS = 'editor:gitStatus';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
EDITOR_CREATE_FILE,
|
EDITOR_CREATE_FILE,
|
||||||
EDITOR_DELETE_FILE,
|
EDITOR_DELETE_FILE,
|
||||||
EDITOR_GIT_STATUS,
|
EDITOR_GIT_STATUS,
|
||||||
|
EDITOR_LIST_FILES,
|
||||||
EDITOR_MOVE_FILE,
|
EDITOR_MOVE_FILE,
|
||||||
EDITOR_OPEN,
|
EDITOR_OPEN,
|
||||||
EDITOR_READ_DIR,
|
EDITOR_READ_DIR,
|
||||||
|
|
@ -200,6 +201,7 @@ import type {
|
||||||
EditorFileChangeEvent,
|
EditorFileChangeEvent,
|
||||||
GitStatusResult,
|
GitStatusResult,
|
||||||
MoveFileResponse,
|
MoveFileResponse,
|
||||||
|
QuickOpenFile,
|
||||||
ReadDirResult,
|
ReadDirResult,
|
||||||
ReadFileResult,
|
ReadFileResult,
|
||||||
SearchInFilesOptions,
|
SearchInFilesOptions,
|
||||||
|
|
@ -974,6 +976,7 @@ const electronAPI: ElectronAPI = {
|
||||||
invokeIpcWithResult<MoveFileResponse>(EDITOR_MOVE_FILE, sourcePath, destDir),
|
invokeIpcWithResult<MoveFileResponse>(EDITOR_MOVE_FILE, sourcePath, destDir),
|
||||||
searchInFiles: (options: SearchInFilesOptions) =>
|
searchInFiles: (options: SearchInFilesOptions) =>
|
||||||
invokeIpcWithResult<SearchInFilesResult>(EDITOR_SEARCH_IN_FILES, options),
|
invokeIpcWithResult<SearchInFilesResult>(EDITOR_SEARCH_IN_FILES, options),
|
||||||
|
listFiles: () => invokeIpcWithResult<QuickOpenFile[]>(EDITOR_LIST_FILES),
|
||||||
gitStatus: () => invokeIpcWithResult<GitStatusResult>(EDITOR_GIT_STATUS),
|
gitStatus: () => invokeIpcWithResult<GitStatusResult>(EDITOR_GIT_STATUS),
|
||||||
watchDir: (enable: boolean) => invokeIpcWithResult<void>(EDITOR_WATCH_DIR, enable),
|
watchDir: (enable: boolean) => invokeIpcWithResult<void>(EDITOR_WATCH_DIR, enable),
|
||||||
onEditorChange: (callback: (event: EditorFileChangeEvent) => void): (() => void) => {
|
onEditorChange: (callback: (event: EditorFileChangeEvent) => void): (() => void) => {
|
||||||
|
|
|
||||||
|
|
@ -947,6 +947,9 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
searchInFiles: async () => {
|
searchInFiles: async () => {
|
||||||
throw new Error('Editor not available in browser mode');
|
throw new Error('Editor not available in browser mode');
|
||||||
},
|
},
|
||||||
|
listFiles: async () => {
|
||||||
|
throw new Error('Editor not available in browser mode');
|
||||||
|
},
|
||||||
gitStatus: async () => {
|
gitStatus: async () => {
|
||||||
throw new Error('Editor not available in browser mode');
|
throw new Error('Editor not available in browser mode');
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -470,6 +470,25 @@ export const CodeMirrorEditor = ({
|
||||||
});
|
});
|
||||||
}, [lineWrap]);
|
}, [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
|
// Cleanup bridge on full unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|
|
||||||
|
|
@ -39,12 +39,17 @@ export class EditorErrorBoundary extends React.Component<Props, State> {
|
||||||
render(): React.ReactElement {
|
render(): React.ReactElement {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
|
<div
|
||||||
<AlertTriangle className="size-12 text-red-400 opacity-50" />
|
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">
|
<p className="max-w-md text-center text-sm text-text-secondary">
|
||||||
Editor crashed: {this.state.error ?? 'Unknown error'}
|
Editor crashed: {this.state.error ?? 'Unknown error'}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={this.handleRetry}
|
onClick={this.handleRetry}
|
||||||
className="rounded border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised"
|
className="rounded border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,17 @@ export const EditorErrorState = ({
|
||||||
onClose,
|
onClose,
|
||||||
}: EditorErrorStateProps): React.ReactElement => {
|
}: EditorErrorStateProps): React.ReactElement => {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
|
<div
|
||||||
<AlertTriangle className="size-12 text-yellow-500 opacity-50" />
|
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>
|
<p className="max-w-md text-center text-sm text-text-secondary">{error}</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onRetry}
|
onClick={onRetry}
|
||||||
className="rounded border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised"
|
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 && (
|
{onClose && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="rounded border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised"
|
className="rounded border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -102,14 +102,21 @@ export const EditorShortcutsHelp = ({ onClose }: EditorShortcutsHelpProps): Reac
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Backdrop */}
|
||||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
|
||||||
{/* Dialog */}
|
{/* 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">
|
<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
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||||
|
|
|
||||||
|
|
@ -395,12 +395,14 @@ export const ProjectEditorOverlay = ({
|
||||||
|
|
||||||
// --- Iter-4: Search result selection ---
|
// --- Iter-4: Search result selection ---
|
||||||
|
|
||||||
|
const setPendingGoToLine = useStore((s) => s.setPendingGoToLine);
|
||||||
|
|
||||||
const handleSearchSelectMatch = useCallback(
|
const handleSearchSelectMatch = useCallback(
|
||||||
(filePath: string, _line: number) => {
|
(filePath: string, line: number) => {
|
||||||
|
setPendingGoToLine(line);
|
||||||
openFile(filePath);
|
openFile(filePath);
|
||||||
// Future enhancement: scroll to line in CM6 after file loads
|
|
||||||
},
|
},
|
||||||
[openFile]
|
[openFile, setPendingGoToLine]
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Keyboard shortcuts ---
|
// --- Keyboard shortcuts ---
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,18 @@
|
||||||
* Quick Open dialog (Cmd+P) — fuzzy file search using cmdk.
|
* Quick Open dialog (Cmd+P) — fuzzy file search using cmdk.
|
||||||
*
|
*
|
||||||
* Escape closes dialog (not the editor overlay).
|
* 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 { useStore } from '@renderer/store';
|
||||||
import { Command } from 'cmdk';
|
import { Command } from 'cmdk';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import { getFileIcon } from './fileIcons';
|
import { getFileIcon } from './fileIcons';
|
||||||
|
|
||||||
import type { FileTreeEntry } from '@shared/types/editor';
|
import type { QuickOpenFile } from '@shared/types/editor';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
|
|
@ -31,17 +32,32 @@ export const QuickOpenDialog = ({
|
||||||
onClose,
|
onClose,
|
||||||
onSelectFile,
|
onSelectFile,
|
||||||
}: QuickOpenDialogProps): React.ReactElement => {
|
}: QuickOpenDialogProps): React.ReactElement => {
|
||||||
const fileTree = useStore((s) => s.editorFileTree);
|
|
||||||
const projectPath = useStore((s) => s.editorProjectPath);
|
const projectPath = useStore((s) => s.editorProjectPath);
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [allFiles, setAllFiles] = useState<QuickOpenFile[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Flatten file tree into searchable list
|
// Load all project files on mount via backend API
|
||||||
const flatFiles = useMemo(() => {
|
useEffect(() => {
|
||||||
if (!fileTree) return [];
|
let cancelled = false;
|
||||||
const files: { path: string; name: string; relativePath: string }[] = [];
|
setLoading(true);
|
||||||
flattenTree(fileTree, files, projectPath ?? '');
|
|
||||||
return files;
|
window.electronAPI.editor
|
||||||
}, [fileTree, projectPath]);
|
.listFiles()
|
||||||
|
.then((files) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setAllFiles(files);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [projectPath]);
|
||||||
|
|
||||||
// Escape to close dialog (not overlay)
|
// Escape to close dialog (not overlay)
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
|
|
@ -62,10 +78,24 @@ export const QuickOpenDialog = ({
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
onSelectFile(value);
|
// value is relativePath from cmdk — look up full path
|
||||||
onClose();
|
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 (
|
return (
|
||||||
|
|
@ -83,6 +113,9 @@ export const QuickOpenDialog = ({
|
||||||
{/* Dialog */}
|
{/* Dialog */}
|
||||||
<div
|
<div
|
||||||
ref={dialogRef}
|
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"
|
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 label="Quick Open" shouldFilter={true}>
|
||||||
|
|
@ -92,20 +125,27 @@ export const QuickOpenDialog = ({
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Command.List className="max-h-80 overflow-y-auto p-1">
|
<Command.List className="max-h-80 overflow-y-auto p-1">
|
||||||
<Command.Empty className="p-6 text-center text-sm text-text-muted">
|
{loading && (
|
||||||
No files found
|
<div className="flex items-center justify-center gap-2 p-6 text-sm text-text-muted">
|
||||||
</Command.Empty>
|
<Loader2 className="size-4 animate-spin" />
|
||||||
{flatFiles.map((file) => {
|
<span>Loading files...</span>
|
||||||
const iconInfo = getFileIcon(file.name);
|
</div>
|
||||||
const Icon = iconInfo.icon;
|
)}
|
||||||
|
{!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 (
|
return (
|
||||||
<Command.Item
|
<Command.Item
|
||||||
key={file.path}
|
key={file.path}
|
||||||
value={file.relativePath}
|
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"
|
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="truncate font-medium">{file.name}</span>
|
||||||
<span className="ml-auto truncate text-xs text-text-muted">
|
<span className="ml-auto truncate text-xs text-text-muted">
|
||||||
{file.relativePath}
|
{file.relativePath}
|
||||||
|
|
@ -119,29 +159,3 @@ export const QuickOpenDialog = ({
|
||||||
</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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ export interface EditorKeyHandlerDeps {
|
||||||
onToggleQuickOpen: () => void;
|
onToggleQuickOpen: () => void;
|
||||||
onToggleSearchPanel: () => void;
|
onToggleSearchPanel: () => void;
|
||||||
onToggleSidebar: () => void;
|
onToggleSidebar: () => void;
|
||||||
|
onToggleLineWrap: () => void;
|
||||||
getEditorView: () => { dispatch: unknown } | null;
|
getEditorView: () => { dispatch: unknown } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,6 +102,14 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
|
||||||
return;
|
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
|
// Cmd+W: Close current editor tab
|
||||||
if (e.key === 'w' && !e.shiftKey && !e.altKey) {
|
if (e.key === 'w' && !e.shiftKey && !e.altKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -181,6 +190,7 @@ export function useEditorKeyboardShortcuts({
|
||||||
const saveFile = useStore((s) => s.saveFile);
|
const saveFile = useStore((s) => s.saveFile);
|
||||||
const saveAllFiles = useStore((s) => s.saveAllFiles);
|
const saveAllFiles = useStore((s) => s.saveAllFiles);
|
||||||
const hasUnsavedChanges = useStore((s) => s.hasUnsavedChanges);
|
const hasUnsavedChanges = useStore((s) => s.hasUnsavedChanges);
|
||||||
|
const toggleLineWrap = useStore((s) => s.toggleLineWrap);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: KeyboardEvent) => {
|
(e: KeyboardEvent) => {
|
||||||
|
|
@ -194,6 +204,7 @@ export function useEditorKeyboardShortcuts({
|
||||||
onToggleQuickOpen,
|
onToggleQuickOpen,
|
||||||
onToggleSearchPanel,
|
onToggleSearchPanel,
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
|
onToggleLineWrap: toggleLineWrap,
|
||||||
getEditorView: () => editorBridge.getView(),
|
getEditorView: () => editorBridge.getView(),
|
||||||
});
|
});
|
||||||
handler(e);
|
handler(e);
|
||||||
|
|
@ -208,6 +219,7 @@ export function useEditorKeyboardShortcuts({
|
||||||
onToggleQuickOpen,
|
onToggleQuickOpen,
|
||||||
onToggleSearchPanel,
|
onToggleSearchPanel,
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
|
toggleLineWrap,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,10 @@ export interface EditorSlice {
|
||||||
/** File path with active save conflict (null = no conflict) */
|
/** File path with active save conflict (null = no conflict) */
|
||||||
editorConflictFile: string | null;
|
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>;
|
fetchGitStatus: () => Promise<void>;
|
||||||
toggleWatcher: (enable: boolean) => Promise<void>;
|
toggleWatcher: (enable: boolean) => Promise<void>;
|
||||||
toggleLineWrap: () => void;
|
toggleLineWrap: () => void;
|
||||||
|
|
@ -176,6 +180,9 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
editorExternalChanges: {},
|
editorExternalChanges: {},
|
||||||
editorFileMtimes: {},
|
editorFileMtimes: {},
|
||||||
editorConflictFile: null,
|
editorConflictFile: null,
|
||||||
|
editorPendingGoToLine: null,
|
||||||
|
|
||||||
|
setPendingGoToLine: (line: number | null) => set({ editorPendingGoToLine: line }),
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════
|
||||||
// Group 1: File tree actions
|
// Group 1: File tree actions
|
||||||
|
|
@ -204,6 +211,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
editorExternalChanges: {},
|
editorExternalChanges: {},
|
||||||
editorFileMtimes: {},
|
editorFileMtimes: {},
|
||||||
editorConflictFile: null,
|
editorConflictFile: null,
|
||||||
|
editorPendingGoToLine: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -264,6 +272,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
editorExternalChanges: {},
|
editorExternalChanges: {},
|
||||||
editorFileMtimes: {},
|
editorFileMtimes: {},
|
||||||
editorConflictFile: null,
|
editorConflictFile: null,
|
||||||
|
editorPendingGoToLine: null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -609,16 +618,13 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.editor.moveFile(sourcePath, destDir);
|
const result = await api.editor.moveFile(sourcePath, destDir);
|
||||||
const newPath = result.newPath;
|
const { newPath, isDirectory } = result;
|
||||||
const oldParent = sourcePath.substring(0, sourcePath.lastIndexOf('/'));
|
const oldParent = sourcePath.substring(0, sourcePath.lastIndexOf('/'));
|
||||||
|
|
||||||
// Record move timestamps for watcher cooldown
|
// Record move timestamps for watcher cooldown
|
||||||
recentMoveTimestamps.set(sourcePath, Date.now());
|
recentMoveTimestamps.set(sourcePath, Date.now());
|
||||||
recentMoveTimestamps.set(newPath, 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
|
// Atomic remap of all path-keyed state
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const tabs = s.editorOpenTabs.map((tab) => {
|
const tabs = s.editorOpenTabs.map((tab) => {
|
||||||
|
|
@ -657,8 +663,8 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
editorBridge.remapState(originalPath, tab.filePath);
|
editorBridge.remapState(originalPath, tab.filePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Also remap for single file case
|
// Also remap for single file case (directories have no direct bridge state)
|
||||||
if (!isDir) {
|
if (!isDirectory) {
|
||||||
editorBridge.remapState(sourcePath, newPath);
|
editorBridge.remapState(sourcePath, newPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ export interface DeleteFileResponse {
|
||||||
|
|
||||||
export interface MoveFileResponse {
|
export interface MoveFileResponse {
|
||||||
newPath: string;
|
newPath: string;
|
||||||
|
isDirectory: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -169,6 +170,12 @@ export interface EditorFileChangeEvent {
|
||||||
// Editor API
|
// Editor API
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface QuickOpenFile {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
relativePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EditorAPI {
|
export interface EditorAPI {
|
||||||
open: (projectPath: string) => Promise<void>;
|
open: (projectPath: string) => Promise<void>;
|
||||||
close: () => Promise<void>;
|
close: () => Promise<void>;
|
||||||
|
|
@ -184,6 +191,7 @@ export interface EditorAPI {
|
||||||
deleteFile: (filePath: string) => Promise<DeleteFileResponse>;
|
deleteFile: (filePath: string) => Promise<DeleteFileResponse>;
|
||||||
moveFile: (sourcePath: string, destDir: string) => Promise<MoveFileResponse>;
|
moveFile: (sourcePath: string, destDir: string) => Promise<MoveFileResponse>;
|
||||||
searchInFiles: (options: SearchInFilesOptions) => Promise<SearchInFilesResult>;
|
searchInFiles: (options: SearchInFilesOptions) => Promise<SearchInFilesResult>;
|
||||||
|
listFiles: () => Promise<QuickOpenFile[]>;
|
||||||
gitStatus: () => Promise<GitStatusResult>;
|
gitStatus: () => Promise<GitStatusResult>;
|
||||||
watchDir: (enable: boolean) => Promise<void>;
|
watchDir: (enable: boolean) => Promise<void>;
|
||||||
/** Subscribe to file change events (main → renderer). Returns cleanup function. */
|
/** Subscribe to file change events (main → renderer). Returns cleanup function. */
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({
|
||||||
EDITOR_DELETE_FILE: 'editor:deleteFile',
|
EDITOR_DELETE_FILE: 'editor:deleteFile',
|
||||||
EDITOR_MOVE_FILE: 'editor:moveFile',
|
EDITOR_MOVE_FILE: 'editor:moveFile',
|
||||||
EDITOR_SEARCH_IN_FILES: 'editor:searchInFiles',
|
EDITOR_SEARCH_IN_FILES: 'editor:searchInFiles',
|
||||||
|
EDITOR_LIST_FILES: 'editor:listFiles',
|
||||||
EDITOR_GIT_STATUS: 'editor:gitStatus',
|
EDITOR_GIT_STATUS: 'editor:gitStatus',
|
||||||
EDITOR_WATCH_DIR: 'editor:watchDir',
|
EDITOR_WATCH_DIR: 'editor:watchDir',
|
||||||
EDITOR_CHANGE: 'editor:change',
|
EDITOR_CHANGE: 'editor:change',
|
||||||
|
|
@ -144,8 +145,8 @@ describe('Editor IPC handlers', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('registration', () => {
|
describe('registration', () => {
|
||||||
it('registers all 12 editor channels', () => {
|
it('registers all 13 editor channels', () => {
|
||||||
expect(mockIpc.handle).toHaveBeenCalledTimes(12);
|
expect(mockIpc.handle).toHaveBeenCalledTimes(13);
|
||||||
expect(mockIpc._handlers.has('editor:open')).toBe(true);
|
expect(mockIpc._handlers.has('editor:open')).toBe(true);
|
||||||
expect(mockIpc._handlers.has('editor:close')).toBe(true);
|
expect(mockIpc._handlers.has('editor:close')).toBe(true);
|
||||||
expect(mockIpc._handlers.has('editor:readDir')).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:deleteFile')).toBe(true);
|
||||||
expect(mockIpc._handlers.has('editor:moveFile')).toBe(true);
|
expect(mockIpc._handlers.has('editor:moveFile')).toBe(true);
|
||||||
expect(mockIpc._handlers.has('editor:searchInFiles')).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:gitStatus')).toBe(true);
|
||||||
expect(mockIpc._handlers.has('editor:watchDir')).toBe(true);
|
expect(mockIpc._handlers.has('editor:watchDir')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removeEditorHandlers clears all channels', () => {
|
it('removeEditorHandlers clears all channels', () => {
|
||||||
removeEditorHandlers(mockIpc as unknown as IpcMain);
|
removeEditorHandlers(mockIpc as unknown as IpcMain);
|
||||||
expect(mockIpc.removeHandler).toHaveBeenCalledTimes(12);
|
expect(mockIpc.removeHandler).toHaveBeenCalledTimes(13);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -576,6 +576,7 @@ describe('ProjectFileService.moveFile', () => {
|
||||||
const result = await service.moveFile(PROJECT_ROOT, sourcePath, DEST_DIR);
|
const result = await service.moveFile(PROJECT_ROOT, sourcePath, DEST_DIR);
|
||||||
|
|
||||||
expect(result.newPath).toBe(path.join(DEST_DIR, 'index.ts'));
|
expect(result.newPath).toBe(path.join(DEST_DIR, 'index.ts'));
|
||||||
|
expect(result.isDirectory).toBe(false);
|
||||||
expect(mockRename).toHaveBeenCalledWith(
|
expect(mockRename).toHaveBeenCalledWith(
|
||||||
path.resolve(sourcePath),
|
path.resolve(sourcePath),
|
||||||
path.join(DEST_DIR, 'index.ts')
|
path.join(DEST_DIR, 'index.ts')
|
||||||
|
|
@ -591,6 +592,7 @@ describe('ProjectFileService.moveFile', () => {
|
||||||
const result = await service.moveFile(PROJECT_ROOT, sourceDir, DEST_DIR);
|
const result = await service.moveFile(PROJECT_ROOT, sourceDir, DEST_DIR);
|
||||||
|
|
||||||
expect(result.newPath).toBe(path.join(DEST_DIR, 'utils'));
|
expect(result.newPath).toBe(path.join(DEST_DIR, 'utils'));
|
||||||
|
expect(result.isDirectory).toBe(true);
|
||||||
expect(mockRename).toHaveBeenCalled();
|
expect(mockRename).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ function createMockDeps(overrides: Partial<EditorKeyHandlerDeps> = {}): EditorKe
|
||||||
onToggleQuickOpen: vi.fn(),
|
onToggleQuickOpen: vi.fn(),
|
||||||
onToggleSearchPanel: vi.fn(),
|
onToggleSearchPanel: vi.fn(),
|
||||||
onToggleSidebar: vi.fn(),
|
onToggleSidebar: vi.fn(),
|
||||||
|
onToggleLineWrap: vi.fn(),
|
||||||
getEditorView: vi.fn().mockReturnValue(null),
|
getEditorView: vi.fn().mockReturnValue(null),
|
||||||
...overrides,
|
...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', () => {
|
describe('Cmd+W — Close Tab', () => {
|
||||||
it('dispatches editor-close-tab CustomEvent with active tab id', () => {
|
it('dispatches editor-close-tab CustomEvent with active tab id', () => {
|
||||||
const handler = createEditorKeyHandler(deps);
|
const handler = createEditorKeyHandler(deps);
|
||||||
|
|
|
||||||
|
|
@ -724,7 +724,7 @@ describe('editorSlice', () => {
|
||||||
it('moves file, updates tabs, and returns true', async () => {
|
it('moves file, updates tabs, and returns true', async () => {
|
||||||
const oldPath = SRC_DIR + '/utils.ts';
|
const oldPath = SRC_DIR + '/utils.ts';
|
||||||
const newPath = LIB_DIR + '/utils.ts';
|
const newPath = LIB_DIR + '/utils.ts';
|
||||||
mockEditorAPI.moveFile.mockResolvedValue({ newPath });
|
mockEditorAPI.moveFile.mockResolvedValue({ newPath, isDirectory: false });
|
||||||
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
||||||
|
|
||||||
store.getState().openFile(oldPath);
|
store.getState().openFile(oldPath);
|
||||||
|
|
@ -749,7 +749,7 @@ describe('editorSlice', () => {
|
||||||
it('remaps activeTabId when moved file is active', async () => {
|
it('remaps activeTabId when moved file is active', async () => {
|
||||||
const oldPath = SRC_DIR + '/index.ts';
|
const oldPath = SRC_DIR + '/index.ts';
|
||||||
const newPath = LIB_DIR + '/index.ts';
|
const newPath = LIB_DIR + '/index.ts';
|
||||||
mockEditorAPI.moveFile.mockResolvedValue({ newPath });
|
mockEditorAPI.moveFile.mockResolvedValue({ newPath, isDirectory: false });
|
||||||
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
||||||
|
|
||||||
store.getState().openFile(oldPath);
|
store.getState().openFile(oldPath);
|
||||||
|
|
@ -763,7 +763,7 @@ describe('editorSlice', () => {
|
||||||
it('remaps modifiedFiles and fileMtimes', async () => {
|
it('remaps modifiedFiles and fileMtimes', async () => {
|
||||||
const oldPath = SRC_DIR + '/dirty.ts';
|
const oldPath = SRC_DIR + '/dirty.ts';
|
||||||
const newPath = LIB_DIR + '/dirty.ts';
|
const newPath = LIB_DIR + '/dirty.ts';
|
||||||
mockEditorAPI.moveFile.mockResolvedValue({ newPath });
|
mockEditorAPI.moveFile.mockResolvedValue({ newPath, isDirectory: false });
|
||||||
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
||||||
|
|
||||||
store.setState({
|
store.setState({
|
||||||
|
|
@ -785,7 +785,7 @@ describe('editorSlice', () => {
|
||||||
const newDir = LIB_DIR + '/components';
|
const newDir = LIB_DIR + '/components';
|
||||||
const oldFilePath = oldDir + '/Button.tsx';
|
const oldFilePath = oldDir + '/Button.tsx';
|
||||||
const newFilePath = newDir + '/Button.tsx';
|
const newFilePath = newDir + '/Button.tsx';
|
||||||
mockEditorAPI.moveFile.mockResolvedValue({ newPath: newDir });
|
mockEditorAPI.moveFile.mockResolvedValue({ newPath: newDir, isDirectory: true });
|
||||||
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
||||||
|
|
||||||
store.getState().openFile(oldFilePath);
|
store.getState().openFile(oldFilePath);
|
||||||
|
|
@ -838,7 +838,7 @@ describe('editorSlice', () => {
|
||||||
it('calls editorBridge.remapState for affected files', async () => {
|
it('calls editorBridge.remapState for affected files', async () => {
|
||||||
const oldPath = SRC_DIR + '/bridge.ts';
|
const oldPath = SRC_DIR + '/bridge.ts';
|
||||||
const newPath = LIB_DIR + '/bridge.ts';
|
const newPath = LIB_DIR + '/bridge.ts';
|
||||||
mockEditorAPI.moveFile.mockResolvedValue({ newPath });
|
mockEditorAPI.moveFile.mockResolvedValue({ newPath, isDirectory: false });
|
||||||
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
||||||
|
|
||||||
store.getState().openFile(oldPath);
|
store.getState().openFile(oldPath);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue