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_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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue