- Added a new file renaming functionality in the editor, allowing users to rename files and directories in place. - Introduced notification settings for team inbox messages and task clarifications, enabling users to receive native OS notifications for important updates. - Updated the README to reflect the new features and provide a clearer overview of the task management capabilities. - Improved the application icon handling for notifications across different platforms.
301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
/**
|
|
* Tests for createEditorKeyHandler — the pure keyboard dispatch logic
|
|
* extracted from useEditorKeyboardShortcuts.
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
// Mock @codemirror/search — handler calls openSearchPanel when view exists
|
|
vi.mock('@codemirror/search', () => ({
|
|
openSearchPanel: vi.fn(),
|
|
}));
|
|
|
|
import { openSearchPanel } from '@codemirror/search';
|
|
import { createEditorKeyHandler } from '@renderer/hooks/useEditorKeyboardShortcuts';
|
|
|
|
import type { EditorKeyHandlerDeps } from '@renderer/hooks/useEditorKeyboardShortcuts';
|
|
import type { EditorFileTab } from '@shared/types/editor';
|
|
|
|
// =============================================================================
|
|
// Helpers
|
|
// =============================================================================
|
|
|
|
function createMockDeps(overrides: Partial<EditorKeyHandlerDeps> = {}): EditorKeyHandlerDeps {
|
|
return {
|
|
activeTabId: '/project/file1.ts',
|
|
openTabs: [
|
|
{
|
|
id: '/project/file1.ts',
|
|
filePath: '/project/file1.ts',
|
|
fileName: 'file1.ts',
|
|
language: 'typescript',
|
|
},
|
|
{
|
|
id: '/project/file2.ts',
|
|
filePath: '/project/file2.ts',
|
|
fileName: 'file2.ts',
|
|
language: 'typescript',
|
|
},
|
|
{
|
|
id: '/project/file3.ts',
|
|
filePath: '/project/file3.ts',
|
|
fileName: 'file3.ts',
|
|
language: 'typescript',
|
|
},
|
|
] as EditorFileTab[],
|
|
setActiveEditorTab: vi.fn(),
|
|
saveFile: vi.fn().mockResolvedValue(undefined),
|
|
saveAllFiles: vi.fn().mockResolvedValue(undefined),
|
|
hasUnsavedChanges: vi.fn().mockReturnValue(false),
|
|
onToggleQuickOpen: vi.fn(),
|
|
onToggleSearchPanel: vi.fn(),
|
|
onToggleGoToLine: vi.fn(),
|
|
onToggleSidebar: vi.fn(),
|
|
onToggleLineWrap: vi.fn(),
|
|
getEditorView: vi.fn().mockReturnValue(null),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** Map a shortcut key to its physical KeyboardEvent.code value. */
|
|
function keyToCode(key: string): string {
|
|
if (key.length === 1 && /[a-z]/i.test(key)) return `Key${key.toUpperCase()}`;
|
|
if (key.length === 1 && /[0-9]/.test(key)) return `Digit${key}`;
|
|
if (key === '[') return 'BracketLeft';
|
|
if (key === ']') return 'BracketRight';
|
|
if (key === '\\') return 'Backslash';
|
|
if (key === ',') return 'Comma';
|
|
if (key === '.') return 'Period';
|
|
if (key === '/') return 'Slash';
|
|
return key; // Tab, Enter, Escape, Arrow*, etc.
|
|
}
|
|
|
|
function createKeyEvent(key: string, opts: Partial<KeyboardEvent> = {}): KeyboardEvent {
|
|
return new KeyboardEvent('keydown', {
|
|
key,
|
|
code: keyToCode(key),
|
|
metaKey: opts.metaKey ?? true,
|
|
ctrlKey: opts.ctrlKey ?? false,
|
|
shiftKey: opts.shiftKey ?? false,
|
|
altKey: opts.altKey ?? false,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
});
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tests
|
|
// =============================================================================
|
|
|
|
describe('createEditorKeyHandler', () => {
|
|
let deps: EditorKeyHandlerDeps;
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
deps = createMockDeps();
|
|
});
|
|
|
|
it('ignores events without modifier key', () => {
|
|
const handler = createEditorKeyHandler(deps);
|
|
const event = new KeyboardEvent('keydown', { key: 'p', bubbles: true, cancelable: true });
|
|
handler(event);
|
|
expect(deps.onToggleQuickOpen).not.toHaveBeenCalled();
|
|
});
|
|
|
|
describe('Cmd+P — Quick Open', () => {
|
|
it('calls onToggleQuickOpen', () => {
|
|
const handler = createEditorKeyHandler(deps);
|
|
const event = createKeyEvent('p');
|
|
handler(event);
|
|
expect(deps.onToggleQuickOpen).toHaveBeenCalledOnce();
|
|
expect(event.defaultPrevented).toBe(true);
|
|
});
|
|
|
|
it('does not trigger with Shift', () => {
|
|
const handler = createEditorKeyHandler(deps);
|
|
handler(createKeyEvent('p', { shiftKey: true }));
|
|
expect(deps.onToggleQuickOpen).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Cmd+Shift+F — Search in Files', () => {
|
|
it('calls onToggleSearchPanel', () => {
|
|
const handler = createEditorKeyHandler(deps);
|
|
const event = createKeyEvent('f', { shiftKey: true });
|
|
handler(event);
|
|
expect(deps.onToggleSearchPanel).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
describe('Cmd+F — Find in File (CM6)', () => {
|
|
it('calls openSearchPanel when editor view exists', () => {
|
|
const mockView = { dispatch: vi.fn() };
|
|
deps = createMockDeps({ getEditorView: vi.fn().mockReturnValue(mockView) });
|
|
const handler = createEditorKeyHandler(deps);
|
|
handler(createKeyEvent('f'));
|
|
expect(openSearchPanel).toHaveBeenCalledWith(mockView);
|
|
});
|
|
|
|
it('does nothing when no editor view', () => {
|
|
const handler = createEditorKeyHandler(deps);
|
|
handler(createKeyEvent('f'));
|
|
expect(openSearchPanel).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Cmd+G — Go to Line', () => {
|
|
it('calls onToggleGoToLine', () => {
|
|
const handler = createEditorKeyHandler(deps);
|
|
handler(createKeyEvent('g'));
|
|
expect(deps.onToggleGoToLine).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Cmd+S — Save', () => {
|
|
it('calls saveFile with active tab id', () => {
|
|
const handler = createEditorKeyHandler(deps);
|
|
handler(createKeyEvent('s'));
|
|
expect(deps.saveFile).toHaveBeenCalledWith('/project/file1.ts');
|
|
});
|
|
|
|
it('does nothing when no active tab', () => {
|
|
deps = createMockDeps({ activeTabId: null });
|
|
const handler = createEditorKeyHandler(deps);
|
|
handler(createKeyEvent('s'));
|
|
expect(deps.saveFile).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Cmd+Shift+S — Save All', () => {
|
|
it('calls saveAllFiles when unsaved changes exist', () => {
|
|
deps = createMockDeps({ hasUnsavedChanges: vi.fn().mockReturnValue(true) });
|
|
const handler = createEditorKeyHandler(deps);
|
|
handler(createKeyEvent('s', { shiftKey: true }));
|
|
expect(deps.saveAllFiles).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('does nothing when no unsaved changes', () => {
|
|
const handler = createEditorKeyHandler(deps);
|
|
handler(createKeyEvent('s', { shiftKey: true }));
|
|
expect(deps.saveAllFiles).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
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);
|
|
const eventSpy = vi.fn();
|
|
window.addEventListener('editor-close-tab', eventSpy);
|
|
|
|
handler(createKeyEvent('w'));
|
|
|
|
expect(eventSpy).toHaveBeenCalledOnce();
|
|
const detail = (eventSpy.mock.calls[0][0] as CustomEvent).detail;
|
|
expect(detail).toBe('/project/file1.ts');
|
|
|
|
window.removeEventListener('editor-close-tab', eventSpy);
|
|
});
|
|
|
|
it('does nothing with Alt modifier', () => {
|
|
const handler = createEditorKeyHandler(deps);
|
|
const eventSpy = vi.fn();
|
|
window.addEventListener('editor-close-tab', eventSpy);
|
|
|
|
handler(createKeyEvent('w', { altKey: true }));
|
|
expect(eventSpy).not.toHaveBeenCalled();
|
|
|
|
window.removeEventListener('editor-close-tab', eventSpy);
|
|
});
|
|
});
|
|
|
|
describe('Cmd+B — Toggle Sidebar', () => {
|
|
it('calls onToggleSidebar', () => {
|
|
const handler = createEditorKeyHandler(deps);
|
|
handler(createKeyEvent('b'));
|
|
expect(deps.onToggleSidebar).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
describe('Cmd+Shift+] / [ — Tab Navigation', () => {
|
|
it('moves to next tab with Cmd+Shift+]', () => {
|
|
const handler = createEditorKeyHandler(deps);
|
|
handler(createKeyEvent(']', { shiftKey: true }));
|
|
expect(deps.setActiveEditorTab).toHaveBeenCalledWith('/project/file2.ts');
|
|
});
|
|
|
|
it('wraps to first tab when on last', () => {
|
|
deps = createMockDeps({ activeTabId: '/project/file3.ts' });
|
|
const handler = createEditorKeyHandler(deps);
|
|
handler(createKeyEvent(']', { shiftKey: true }));
|
|
expect(deps.setActiveEditorTab).toHaveBeenCalledWith('/project/file1.ts');
|
|
});
|
|
|
|
it('moves to previous tab with Cmd+Shift+[', () => {
|
|
deps = createMockDeps({ activeTabId: '/project/file2.ts' });
|
|
const handler = createEditorKeyHandler(deps);
|
|
handler(createKeyEvent('[', { shiftKey: true }));
|
|
expect(deps.setActiveEditorTab).toHaveBeenCalledWith('/project/file1.ts');
|
|
});
|
|
|
|
it('wraps to last tab when on first with Cmd+Shift+[', () => {
|
|
const handler = createEditorKeyHandler(deps);
|
|
handler(createKeyEvent('[', { shiftKey: true }));
|
|
expect(deps.setActiveEditorTab).toHaveBeenCalledWith('/project/file3.ts');
|
|
});
|
|
});
|
|
|
|
describe('Ctrl+Tab — Tab Cycling', () => {
|
|
it('moves to next tab', () => {
|
|
const handler = createEditorKeyHandler(deps);
|
|
handler(createKeyEvent('Tab', { metaKey: false, ctrlKey: true }));
|
|
expect(deps.setActiveEditorTab).toHaveBeenCalledWith('/project/file2.ts');
|
|
});
|
|
|
|
it('moves to previous tab with Shift', () => {
|
|
deps = createMockDeps({ activeTabId: '/project/file2.ts' });
|
|
const handler = createEditorKeyHandler(deps);
|
|
handler(createKeyEvent('Tab', { metaKey: false, ctrlKey: true, shiftKey: true }));
|
|
expect(deps.setActiveEditorTab).toHaveBeenCalledWith('/project/file1.ts');
|
|
});
|
|
|
|
it('wraps forward on last tab', () => {
|
|
deps = createMockDeps({ activeTabId: '/project/file3.ts' });
|
|
const handler = createEditorKeyHandler(deps);
|
|
handler(createKeyEvent('Tab', { metaKey: false, ctrlKey: true }));
|
|
expect(deps.setActiveEditorTab).toHaveBeenCalledWith('/project/file1.ts');
|
|
});
|
|
|
|
it('wraps backward on first tab', () => {
|
|
const handler = createEditorKeyHandler(deps);
|
|
handler(createKeyEvent('Tab', { metaKey: false, ctrlKey: true, shiftKey: true }));
|
|
expect(deps.setActiveEditorTab).toHaveBeenCalledWith('/project/file3.ts');
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('does nothing when openTabs is empty', () => {
|
|
deps = createMockDeps({ openTabs: [], activeTabId: null });
|
|
const handler = createEditorKeyHandler(deps);
|
|
handler(createKeyEvent(']', { shiftKey: true }));
|
|
expect(deps.setActiveEditorTab).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('stopPropagation is called on handled shortcuts', () => {
|
|
const handler = createEditorKeyHandler(deps);
|
|
const event = createKeyEvent('p');
|
|
const spy = vi.spyOn(event, 'stopPropagation');
|
|
handler(event);
|
|
expect(spy).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
});
|