- 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
230 lines
7 KiB
TypeScript
230 lines
7 KiB
TypeScript
/**
|
|
* useEditorKeyboardShortcuts — keyboard shortcuts scoped to the project editor overlay.
|
|
*
|
|
* All shortcuts use stopPropagation to prevent conflicts with global useKeyboardShortcuts.
|
|
* CM6-internal shortcuts (Cmd+Z, Cmd+Shift+Z, Cmd+A, Cmd+D) are handled by CodeMirror directly.
|
|
*/
|
|
|
|
import { useCallback, useEffect } from 'react';
|
|
|
|
import { gotoLine, openSearchPanel } from '@codemirror/search';
|
|
import { useStore } from '@renderer/store';
|
|
import { editorBridge } from '@renderer/utils/editorBridge';
|
|
|
|
import type { EditorFileTab } from '@shared/types/editor';
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
interface UseEditorKeyboardShortcutsOptions {
|
|
onToggleQuickOpen: () => void;
|
|
onToggleSearchPanel: () => void;
|
|
onToggleSidebar: () => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
/** Dependencies injected into the key handler for testability. */
|
|
export interface EditorKeyHandlerDeps {
|
|
activeTabId: string | null;
|
|
openTabs: EditorFileTab[];
|
|
setActiveEditorTab: (id: string) => void;
|
|
saveFile: (tabId: string) => Promise<void>;
|
|
saveAllFiles: () => Promise<void>;
|
|
hasUnsavedChanges: () => boolean;
|
|
onToggleQuickOpen: () => void;
|
|
onToggleSearchPanel: () => void;
|
|
onToggleSidebar: () => void;
|
|
onToggleLineWrap: () => void;
|
|
getEditorView: () => { dispatch: unknown } | null;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Pure key handler (exported for testing)
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Create a keyboard event handler for editor shortcuts.
|
|
* Extracted from the hook for unit-testability.
|
|
*/
|
|
export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: KeyboardEvent) => void {
|
|
return (e: KeyboardEvent) => {
|
|
const isMod = e.metaKey || e.ctrlKey;
|
|
if (!isMod) return;
|
|
|
|
// Cmd+P: Quick Open
|
|
if (e.key === 'p' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
deps.onToggleQuickOpen();
|
|
return;
|
|
}
|
|
|
|
// Cmd+Shift+F: Search in files
|
|
if (e.key === 'f' && e.shiftKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
deps.onToggleSearchPanel();
|
|
return;
|
|
}
|
|
|
|
// Cmd+F: Find in current file (CM6)
|
|
if (e.key === 'f' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const view = deps.getEditorView();
|
|
if (view) openSearchPanel(view as Parameters<typeof openSearchPanel>[0]);
|
|
return;
|
|
}
|
|
|
|
// Cmd+G: Go to line
|
|
if (e.key === 'g' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const view = deps.getEditorView();
|
|
if (view) gotoLine(view as Parameters<typeof gotoLine>[0]);
|
|
return;
|
|
}
|
|
|
|
// Cmd+S: Save current file
|
|
if (e.key === 's' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (deps.activeTabId) void deps.saveFile(deps.activeTabId);
|
|
return;
|
|
}
|
|
|
|
// Cmd+Shift+S: Save all files
|
|
if (e.key === 's' && e.shiftKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (deps.hasUnsavedChanges()) void deps.saveAllFiles();
|
|
return;
|
|
}
|
|
|
|
// Cmd+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();
|
|
e.stopPropagation();
|
|
if (deps.activeTabId) {
|
|
// Let overlay handle dirty check via onRequestCloseTab
|
|
const closeEvent = new CustomEvent('editor-close-tab', { detail: deps.activeTabId });
|
|
window.dispatchEvent(closeEvent);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Cmd+B: Toggle sidebar
|
|
if (e.key === 'b') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
deps.onToggleSidebar();
|
|
return;
|
|
}
|
|
|
|
// Cmd+Shift+]: Next tab
|
|
if (e.key === ']' && e.shiftKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const idx = deps.openTabs.findIndex((t) => t.id === deps.activeTabId);
|
|
if (idx !== -1 && idx < deps.openTabs.length - 1) {
|
|
deps.setActiveEditorTab(deps.openTabs[idx + 1].id);
|
|
} else if (deps.openTabs.length > 0) {
|
|
deps.setActiveEditorTab(deps.openTabs[0].id); // wrap
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Cmd+Shift+[: Previous tab
|
|
if (e.key === '[' && e.shiftKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const idx = deps.openTabs.findIndex((t) => t.id === deps.activeTabId);
|
|
if (idx > 0) {
|
|
deps.setActiveEditorTab(deps.openTabs[idx - 1].id);
|
|
} else if (deps.openTabs.length > 0) {
|
|
deps.setActiveEditorTab(deps.openTabs[deps.openTabs.length - 1].id); // wrap
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Ctrl+Tab / Ctrl+Shift+Tab: Tab cycling
|
|
if (e.ctrlKey && e.key === 'Tab') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const idx = deps.openTabs.findIndex((t) => t.id === deps.activeTabId);
|
|
if (e.shiftKey) {
|
|
const prev = idx > 0 ? idx - 1 : deps.openTabs.length - 1;
|
|
if (deps.openTabs[prev]) deps.setActiveEditorTab(deps.openTabs[prev].id);
|
|
} else {
|
|
const next = idx < deps.openTabs.length - 1 ? idx + 1 : 0;
|
|
if (deps.openTabs[next]) deps.setActiveEditorTab(deps.openTabs[next].id);
|
|
}
|
|
}
|
|
|
|
// Escape: Close editor (handled separately in overlay with dialog guards)
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// Hook
|
|
// =============================================================================
|
|
|
|
export function useEditorKeyboardShortcuts({
|
|
onToggleQuickOpen,
|
|
onToggleSearchPanel,
|
|
onToggleSidebar,
|
|
onClose: _onClose,
|
|
}: UseEditorKeyboardShortcutsOptions): void {
|
|
const openTabs = useStore((s) => s.editorOpenTabs);
|
|
const activeTabId = useStore((s) => s.editorActiveTabId);
|
|
const setActiveEditorTab = useStore((s) => s.setActiveEditorTab);
|
|
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) => {
|
|
const handler = createEditorKeyHandler({
|
|
activeTabId,
|
|
openTabs,
|
|
setActiveEditorTab,
|
|
saveFile,
|
|
saveAllFiles,
|
|
hasUnsavedChanges,
|
|
onToggleQuickOpen,
|
|
onToggleSearchPanel,
|
|
onToggleSidebar,
|
|
onToggleLineWrap: toggleLineWrap,
|
|
getEditorView: () => editorBridge.getView(),
|
|
});
|
|
handler(e);
|
|
},
|
|
[
|
|
activeTabId,
|
|
openTabs,
|
|
setActiveEditorTab,
|
|
saveFile,
|
|
saveAllFiles,
|
|
hasUnsavedChanges,
|
|
onToggleQuickOpen,
|
|
onToggleSearchPanel,
|
|
onToggleSidebar,
|
|
toggleLineWrap,
|
|
]
|
|
);
|
|
|
|
useEffect(() => {
|
|
window.addEventListener('keydown', handleKeyDown, true); // capture phase
|
|
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
|
}, [handleKeyDown]);
|
|
}
|