mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
Added isTabSwitcherShortcut to the overlay allowlist so users can open the tab switcher modal while viewing a file preview tab.
1168 lines
33 KiB
TypeScript
1168 lines
33 KiB
TypeScript
import { renderHook, act } from '@testing-library/react';
|
|
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { useMainKeyboardHandler } from '../../../renderer/hooks';
|
|
|
|
/**
|
|
* Creates a minimal mock context with all required handler functions.
|
|
* The keyboard handler requires these functions to be present to avoid
|
|
* "is not a function" errors when processing keyboard events.
|
|
*/
|
|
function createMockContext(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
hasOpenLayers: () => false,
|
|
hasOpenModal: () => false,
|
|
editingSessionId: null,
|
|
editingGroupId: null,
|
|
handleSidebarNavigation: vi.fn().mockReturnValue(false),
|
|
handleEnterToActivate: vi.fn().mockReturnValue(false),
|
|
handleTabNavigation: vi.fn().mockReturnValue(false),
|
|
handleEscapeInMain: vi.fn().mockReturnValue(false),
|
|
isShortcut: () => false,
|
|
isTabShortcut: () => false,
|
|
sessions: [],
|
|
activeSession: null,
|
|
activeSessionId: null,
|
|
activeGroupChatId: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('useMainKeyboardHandler', () => {
|
|
// Track event listeners for cleanup
|
|
let addedListeners: { type: string; handler: EventListener }[] = [];
|
|
const originalAddEventListener = window.addEventListener;
|
|
const originalRemoveEventListener = window.removeEventListener;
|
|
|
|
beforeEach(() => {
|
|
addedListeners = [];
|
|
window.addEventListener = vi.fn((type, handler) => {
|
|
addedListeners.push({ type, handler: handler as EventListener });
|
|
originalAddEventListener.call(window, type, handler as EventListener);
|
|
});
|
|
window.removeEventListener = vi.fn((type, handler) => {
|
|
addedListeners = addedListeners.filter((l) => !(l.type === type && l.handler === handler));
|
|
originalRemoveEventListener.call(window, type, handler as EventListener);
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
window.addEventListener = originalAddEventListener;
|
|
window.removeEventListener = originalRemoveEventListener;
|
|
});
|
|
|
|
describe('hook initialization', () => {
|
|
it('should return keyboardHandlerRef and showSessionJumpNumbers', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
expect(result.current.keyboardHandlerRef).toBeDefined();
|
|
expect(result.current.keyboardHandlerRef.current).toBeNull();
|
|
expect(result.current.showSessionJumpNumbers).toBe(false);
|
|
});
|
|
|
|
it('should attach keydown, keyup, and blur listeners', () => {
|
|
renderHook(() => useMainKeyboardHandler());
|
|
|
|
const listenerTypes = addedListeners.map((l) => l.type);
|
|
expect(listenerTypes).toContain('keydown');
|
|
expect(listenerTypes).toContain('keyup');
|
|
expect(listenerTypes).toContain('blur');
|
|
});
|
|
|
|
it('should remove listeners on unmount', () => {
|
|
const { unmount } = renderHook(() => useMainKeyboardHandler());
|
|
unmount();
|
|
|
|
// After unmount, window.removeEventListener should have been called
|
|
expect(window.removeEventListener).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('browser refresh blocking', () => {
|
|
it('should prevent Cmd+R', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
// Set up context with all required handlers
|
|
result.current.keyboardHandlerRef.current = createMockContext();
|
|
|
|
const event = new KeyboardEvent('keydown', {
|
|
key: 'r',
|
|
metaKey: true,
|
|
bubbles: true,
|
|
});
|
|
const preventDefaultSpy = vi.spyOn(event, 'preventDefault');
|
|
|
|
act(() => {
|
|
window.dispatchEvent(event);
|
|
});
|
|
|
|
expect(preventDefaultSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should prevent Ctrl+R', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
result.current.keyboardHandlerRef.current = createMockContext();
|
|
|
|
const event = new KeyboardEvent('keydown', {
|
|
key: 'R',
|
|
ctrlKey: true,
|
|
bubbles: true,
|
|
});
|
|
const preventDefaultSpy = vi.spyOn(event, 'preventDefault');
|
|
|
|
act(() => {
|
|
window.dispatchEvent(event);
|
|
});
|
|
|
|
expect(preventDefaultSpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('showSessionJumpNumbers state', () => {
|
|
it('should show badges when Alt+Cmd are pressed together', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
expect(result.current.showSessionJumpNumbers).toBe(false);
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'Alt',
|
|
altKey: true,
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
expect(result.current.showSessionJumpNumbers).toBe(true);
|
|
});
|
|
|
|
it('should hide badges when Alt is released', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
// First, show the badges
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'Alt',
|
|
altKey: true,
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
expect(result.current.showSessionJumpNumbers).toBe(true);
|
|
|
|
// Release Alt key
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keyup', {
|
|
key: 'Alt',
|
|
altKey: false,
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
expect(result.current.showSessionJumpNumbers).toBe(false);
|
|
});
|
|
|
|
it('should hide badges when Cmd is released', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
// First, show the badges
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'Alt',
|
|
altKey: true,
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
expect(result.current.showSessionJumpNumbers).toBe(true);
|
|
|
|
// Release Meta key
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keyup', {
|
|
key: 'Meta',
|
|
altKey: true,
|
|
metaKey: false,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
expect(result.current.showSessionJumpNumbers).toBe(false);
|
|
});
|
|
|
|
it('should hide badges on window blur', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
// First, show the badges
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'Alt',
|
|
altKey: true,
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
expect(result.current.showSessionJumpNumbers).toBe(true);
|
|
|
|
// Blur window
|
|
act(() => {
|
|
window.dispatchEvent(new FocusEvent('blur'));
|
|
});
|
|
|
|
expect(result.current.showSessionJumpNumbers).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('modal/layer interaction', () => {
|
|
it('should skip shortcut handling when editing session name', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockToggleSidebar = vi.fn();
|
|
result.current.keyboardHandlerRef.current = createMockContext({
|
|
editingSessionId: 'session-123',
|
|
isShortcut: () => true,
|
|
setLeftSidebarOpen: mockToggleSidebar,
|
|
sessions: [{ id: 'test' }],
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'b',
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
// Should not have called any shortcut handlers
|
|
expect(mockToggleSidebar).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should skip shortcut handling when editing group name', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockToggleSidebar = vi.fn();
|
|
result.current.keyboardHandlerRef.current = createMockContext({
|
|
editingGroupId: 'group-123',
|
|
isShortcut: () => true,
|
|
setLeftSidebarOpen: mockToggleSidebar,
|
|
sessions: [{ id: 'test' }],
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'b',
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
// Should not have called any shortcut handlers
|
|
expect(mockToggleSidebar).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should allow Tab when layers are open for accessibility', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockTabNav = vi.fn().mockReturnValue(true);
|
|
result.current.keyboardHandlerRef.current = createMockContext({
|
|
hasOpenLayers: () => true,
|
|
hasOpenModal: () => true,
|
|
handleTabNavigation: mockTabNav,
|
|
});
|
|
|
|
const event = new KeyboardEvent('keydown', {
|
|
key: 'Tab',
|
|
bubbles: true,
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(event);
|
|
});
|
|
|
|
// Tab should be allowed through (early return, not handled by modal logic)
|
|
// The event should NOT be prevented when Tab is pressed with layers open
|
|
});
|
|
|
|
it('should allow layout shortcuts (Alt+Cmd+Arrow) when modals are open', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockSetLeftSidebar = vi.fn();
|
|
result.current.keyboardHandlerRef.current = createMockContext({
|
|
hasOpenLayers: () => true,
|
|
hasOpenModal: () => true,
|
|
isShortcut: (e: KeyboardEvent, actionId: string) => {
|
|
if (actionId === 'toggleSidebar') {
|
|
return e.altKey && e.metaKey && e.key === 'ArrowLeft';
|
|
}
|
|
return false;
|
|
},
|
|
sessions: [{ id: 'test' }],
|
|
leftSidebarOpen: true,
|
|
setLeftSidebarOpen: mockSetLeftSidebar,
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'ArrowLeft',
|
|
altKey: true,
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
// Layout shortcuts should work even when modal is open
|
|
expect(mockSetLeftSidebar).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should allow tab management shortcuts (Cmd+T) when only overlays are open', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockSetSessions = vi.fn();
|
|
const mockSetActiveFocus = vi.fn();
|
|
const mockInputRef = { current: { focus: vi.fn() } };
|
|
const mockActiveSession = {
|
|
id: 'test-session',
|
|
name: 'Test',
|
|
aiTabs: [],
|
|
activeTabId: 'tab-1',
|
|
unifiedTabOrder: [],
|
|
};
|
|
|
|
result.current.keyboardHandlerRef.current = createMockContext({
|
|
hasOpenLayers: () => true, // Overlay is open (e.g., file preview)
|
|
hasOpenModal: () => false, // But no true modal
|
|
isTabShortcut: (_e: KeyboardEvent, actionId: string) => actionId === 'newTab',
|
|
activeSession: mockActiveSession,
|
|
createTab: vi.fn().mockReturnValue({
|
|
session: { ...mockActiveSession, aiTabs: [{ id: 'new-tab' }] },
|
|
}),
|
|
setSessions: mockSetSessions,
|
|
setActiveFocus: mockSetActiveFocus,
|
|
inputRef: mockInputRef,
|
|
defaultSaveToHistory: true,
|
|
defaultShowThinking: 'on',
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 't',
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
// Cmd+T should create a new tab even when file preview overlay is open
|
|
expect(mockSetSessions).toHaveBeenCalled();
|
|
expect(mockSetActiveFocus).toHaveBeenCalledWith('main');
|
|
});
|
|
|
|
it('should allow tab switcher shortcut (Alt+Cmd+T) when only overlays are open', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockSetTabSwitcherOpen = vi.fn();
|
|
result.current.keyboardHandlerRef.current = createMockContext({
|
|
hasOpenLayers: () => true, // Overlay is open (e.g., file preview)
|
|
hasOpenModal: () => false, // But no true modal
|
|
isTabShortcut: (_e: KeyboardEvent, actionId: string) => actionId === 'tabSwitcher',
|
|
setTabSwitcherOpen: mockSetTabSwitcherOpen,
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 't', // Alt key changes the key on macOS, but we use code
|
|
code: 'KeyT',
|
|
altKey: true,
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
// Alt+Cmd+T should open tab switcher even when file preview overlay is open
|
|
expect(mockSetTabSwitcherOpen).toHaveBeenCalledWith(true);
|
|
});
|
|
});
|
|
|
|
describe('navigation handlers delegation', () => {
|
|
it('should delegate to handleSidebarNavigation', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockSidebarNav = vi.fn().mockReturnValue(true);
|
|
result.current.keyboardHandlerRef.current = createMockContext({
|
|
handleSidebarNavigation: mockSidebarNav,
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'ArrowDown',
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
expect(mockSidebarNav).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should delegate to handleEnterToActivate', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockEnterActivate = vi.fn().mockReturnValue(true);
|
|
result.current.keyboardHandlerRef.current = createMockContext({
|
|
handleEnterToActivate: mockEnterActivate,
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'Enter',
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
expect(mockEnterActivate).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('session jump shortcuts', () => {
|
|
it('should jump to session by number (Alt+Cmd+1)', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockSetActiveSessionId = vi.fn();
|
|
const mockSetLeftSidebarOpen = vi.fn();
|
|
const visibleSessions = [{ id: 'session-1' }, { id: 'session-2' }, { id: 'session-3' }];
|
|
|
|
result.current.keyboardHandlerRef.current = createMockContext({
|
|
visibleSessions,
|
|
setActiveSessionId: mockSetActiveSessionId,
|
|
leftSidebarOpen: true,
|
|
setLeftSidebarOpen: mockSetLeftSidebarOpen,
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: '1',
|
|
code: 'Digit1',
|
|
altKey: true,
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
expect(mockSetActiveSessionId).toHaveBeenCalledWith('session-1');
|
|
});
|
|
|
|
it('should expand sidebar when jumping to session', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockSetActiveSessionId = vi.fn();
|
|
const mockSetLeftSidebarOpen = vi.fn();
|
|
const visibleSessions = [{ id: 'session-1' }];
|
|
|
|
result.current.keyboardHandlerRef.current = createMockContext({
|
|
visibleSessions,
|
|
setActiveSessionId: mockSetActiveSessionId,
|
|
leftSidebarOpen: false, // Sidebar is closed
|
|
setLeftSidebarOpen: mockSetLeftSidebarOpen,
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: '1',
|
|
code: 'Digit1',
|
|
altKey: true,
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
expect(mockSetLeftSidebarOpen).toHaveBeenCalledWith(true);
|
|
});
|
|
|
|
it('should use 0 as 10th session', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockSetActiveSessionId = vi.fn();
|
|
const visibleSessions = Array.from({ length: 10 }, (_, i) => ({
|
|
id: `session-${i + 1}`,
|
|
}));
|
|
|
|
result.current.keyboardHandlerRef.current = createMockContext({
|
|
visibleSessions,
|
|
setActiveSessionId: mockSetActiveSessionId,
|
|
leftSidebarOpen: true,
|
|
setLeftSidebarOpen: vi.fn(),
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: '0',
|
|
code: 'Digit0',
|
|
altKey: true,
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
expect(mockSetActiveSessionId).toHaveBeenCalledWith('session-10');
|
|
});
|
|
});
|
|
|
|
describe('wizard tab restrictions', () => {
|
|
it('should disable toggleMode (Cmd+J) for wizard tabs', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockToggleInputMode = vi.fn();
|
|
const wizardTab = {
|
|
id: 'tab-1',
|
|
name: 'Wizard',
|
|
wizardState: { isActive: true },
|
|
logs: [],
|
|
};
|
|
|
|
result.current.keyboardHandlerRef.current = createMockContext({
|
|
isShortcut: (_e: KeyboardEvent, actionId: string) => actionId === 'toggleMode',
|
|
activeSession: {
|
|
id: 'session-1',
|
|
aiTabs: [wizardTab],
|
|
activeTabId: 'tab-1',
|
|
inputMode: 'ai',
|
|
},
|
|
activeSessionId: 'session-1',
|
|
toggleInputMode: mockToggleInputMode,
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'j',
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
// toggleInputMode should NOT be called for wizard tabs
|
|
expect(mockToggleInputMode).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should allow toggleMode (Cmd+J) for regular tabs', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockToggleInputMode = vi.fn();
|
|
const regularTab = {
|
|
id: 'tab-1',
|
|
name: 'Regular Tab',
|
|
logs: [],
|
|
// No wizardState
|
|
};
|
|
|
|
result.current.keyboardHandlerRef.current = createMockContext({
|
|
isShortcut: (_e: KeyboardEvent, actionId: string) => actionId === 'toggleMode',
|
|
activeSession: {
|
|
id: 'session-1',
|
|
aiTabs: [regularTab],
|
|
activeTabId: 'tab-1',
|
|
inputMode: 'ai',
|
|
},
|
|
activeSessionId: 'session-1',
|
|
toggleInputMode: mockToggleInputMode,
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'j',
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
// toggleInputMode SHOULD be called for regular tabs
|
|
expect(mockToggleInputMode).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should allow toggleMode when wizardState exists but isActive is false', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockToggleInputMode = vi.fn();
|
|
const completedWizardTab = {
|
|
id: 'tab-1',
|
|
name: 'Completed Wizard',
|
|
wizardState: { isActive: false }, // Wizard completed
|
|
logs: [],
|
|
};
|
|
|
|
result.current.keyboardHandlerRef.current = createMockContext({
|
|
isShortcut: (_e: KeyboardEvent, actionId: string) => actionId === 'toggleMode',
|
|
activeSession: {
|
|
id: 'session-1',
|
|
aiTabs: [completedWizardTab],
|
|
activeTabId: 'tab-1',
|
|
inputMode: 'ai',
|
|
},
|
|
activeSessionId: 'session-1',
|
|
toggleInputMode: mockToggleInputMode,
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'j',
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
// toggleInputMode SHOULD be called when wizard is not active
|
|
expect(mockToggleInputMode).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('unified tab shortcuts - file tab vs AI tab context', () => {
|
|
/**
|
|
* Helper to create a session context with both AI tabs and file tabs.
|
|
* Uses unifiedTabOrder to establish combined ordering.
|
|
*/
|
|
function createUnifiedTabContext(overrides: Record<string, unknown> = {}) {
|
|
const aiTab1 = { id: 'ai-tab-1', name: 'AI Tab 1', logs: [] };
|
|
const aiTab2 = { id: 'ai-tab-2', name: 'AI Tab 2', logs: [] };
|
|
const fileTab1 = { id: 'file-tab-1', path: '/test/file1.ts', name: 'file1', extension: '.ts' };
|
|
const fileTab2 = { id: 'file-tab-2', path: '/test/file2.ts', name: 'file2', extension: '.ts' };
|
|
|
|
return createMockContext({
|
|
activeSession: {
|
|
id: 'session-1',
|
|
aiTabs: [aiTab1, aiTab2],
|
|
activeTabId: 'ai-tab-1',
|
|
filePreviewTabs: [fileTab1, fileTab2],
|
|
activeFileTabId: null,
|
|
unifiedTabOrder: ['ai-tab-1', 'file-tab-1', 'ai-tab-2', 'file-tab-2'],
|
|
unifiedClosedTabHistory: [],
|
|
inputMode: 'ai',
|
|
},
|
|
activeSessionId: 'session-1',
|
|
showUnreadOnly: false,
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
describe('Cmd+W (closeTab)', () => {
|
|
it('should close file tab when a file tab is active', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockHandleCloseCurrentTab = vi.fn().mockReturnValue({ type: 'file' });
|
|
const mockSetSessions = vi.fn();
|
|
|
|
result.current.keyboardHandlerRef.current = createUnifiedTabContext({
|
|
isTabShortcut: (_e: KeyboardEvent, actionId: string) => actionId === 'closeTab',
|
|
handleCloseCurrentTab: mockHandleCloseCurrentTab,
|
|
setSessions: mockSetSessions,
|
|
activeSession: {
|
|
id: 'session-1',
|
|
aiTabs: [{ id: 'ai-tab-1', name: 'AI Tab 1', logs: [] }],
|
|
activeTabId: 'ai-tab-1',
|
|
filePreviewTabs: [{ id: 'file-tab-1', path: '/test/file.ts', name: 'file', extension: '.ts' }],
|
|
activeFileTabId: 'file-tab-1', // File tab is active
|
|
unifiedTabOrder: ['ai-tab-1', 'file-tab-1'],
|
|
inputMode: 'ai',
|
|
},
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'w',
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
expect(mockHandleCloseCurrentTab).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should close AI tab when no file tab is active', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockHandleCloseCurrentTab = vi.fn().mockReturnValue({
|
|
type: 'ai',
|
|
tabId: 'ai-tab-2',
|
|
isWizardTab: false,
|
|
});
|
|
const mockCloseTab = vi.fn().mockReturnValue({ session: { id: 'session-1' } });
|
|
const mockSetSessions = vi.fn();
|
|
|
|
result.current.keyboardHandlerRef.current = createUnifiedTabContext({
|
|
isTabShortcut: (_e: KeyboardEvent, actionId: string) => actionId === 'closeTab',
|
|
handleCloseCurrentTab: mockHandleCloseCurrentTab,
|
|
closeTab: mockCloseTab,
|
|
setSessions: mockSetSessions,
|
|
activeSession: {
|
|
id: 'session-1',
|
|
aiTabs: [
|
|
{ id: 'ai-tab-1', name: 'AI Tab 1', logs: [] },
|
|
{ id: 'ai-tab-2', name: 'AI Tab 2', logs: [] },
|
|
],
|
|
activeTabId: 'ai-tab-2',
|
|
filePreviewTabs: [],
|
|
activeFileTabId: null, // No file tab active
|
|
unifiedTabOrder: ['ai-tab-1', 'ai-tab-2'],
|
|
inputMode: 'ai',
|
|
},
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'w',
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
expect(mockHandleCloseCurrentTab).toHaveBeenCalled();
|
|
expect(mockCloseTab).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: 'session-1' }),
|
|
'ai-tab-2',
|
|
false,
|
|
{ skipHistory: false }
|
|
);
|
|
});
|
|
|
|
it('should prevent closing when it is the last AI tab', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockHandleCloseCurrentTab = vi.fn().mockReturnValue({ type: 'prevented' });
|
|
const mockCloseTab = vi.fn();
|
|
const mockSetSessions = vi.fn();
|
|
|
|
result.current.keyboardHandlerRef.current = createUnifiedTabContext({
|
|
isTabShortcut: (_e: KeyboardEvent, actionId: string) => actionId === 'closeTab',
|
|
handleCloseCurrentTab: mockHandleCloseCurrentTab,
|
|
closeTab: mockCloseTab,
|
|
setSessions: mockSetSessions,
|
|
activeSession: {
|
|
id: 'session-1',
|
|
aiTabs: [{ id: 'ai-tab-1', name: 'AI Tab 1', logs: [] }],
|
|
activeTabId: 'ai-tab-1',
|
|
filePreviewTabs: [],
|
|
activeFileTabId: null,
|
|
unifiedTabOrder: ['ai-tab-1'],
|
|
inputMode: 'ai',
|
|
},
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'w',
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
// closeTab should NOT be called when it's the last AI tab
|
|
expect(mockCloseTab).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Cmd+Shift+[ and Cmd+Shift+] (tab cycling)', () => {
|
|
it('should navigate to next tab in unified order (Cmd+Shift+])', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockNavigateToNextUnifiedTab = vi.fn().mockReturnValue({
|
|
session: { id: 'session-1', activeFileTabId: 'file-tab-1' },
|
|
});
|
|
const mockSetSessions = vi.fn();
|
|
|
|
result.current.keyboardHandlerRef.current = createUnifiedTabContext({
|
|
isTabShortcut: (_e: KeyboardEvent, actionId: string) => actionId === 'nextTab',
|
|
navigateToNextUnifiedTab: mockNavigateToNextUnifiedTab,
|
|
setSessions: mockSetSessions,
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: ']',
|
|
metaKey: true,
|
|
shiftKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
expect(mockNavigateToNextUnifiedTab).toHaveBeenCalled();
|
|
expect(mockSetSessions).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should navigate to previous tab in unified order (Cmd+Shift+[)', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockNavigateToPrevUnifiedTab = vi.fn().mockReturnValue({
|
|
session: { id: 'session-1', activeFileTabId: 'file-tab-2' },
|
|
});
|
|
const mockSetSessions = vi.fn();
|
|
|
|
result.current.keyboardHandlerRef.current = createUnifiedTabContext({
|
|
isTabShortcut: (_e: KeyboardEvent, actionId: string) => actionId === 'prevTab',
|
|
navigateToPrevUnifiedTab: mockNavigateToPrevUnifiedTab,
|
|
setSessions: mockSetSessions,
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: '[',
|
|
metaKey: true,
|
|
shiftKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
expect(mockNavigateToPrevUnifiedTab).toHaveBeenCalled();
|
|
expect(mockSetSessions).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should pass showUnreadOnly filter to navigation', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockNavigateToNextUnifiedTab = vi.fn().mockReturnValue({
|
|
session: { id: 'session-1' },
|
|
});
|
|
const mockSetSessions = vi.fn();
|
|
|
|
result.current.keyboardHandlerRef.current = createUnifiedTabContext({
|
|
isTabShortcut: (_e: KeyboardEvent, actionId: string) => actionId === 'nextTab',
|
|
navigateToNextUnifiedTab: mockNavigateToNextUnifiedTab,
|
|
setSessions: mockSetSessions,
|
|
showUnreadOnly: true, // Filter is active
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: ']',
|
|
metaKey: true,
|
|
shiftKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
expect(mockNavigateToNextUnifiedTab).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
true // showUnreadOnly passed
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Cmd+1-9 (tab jumping by index)', () => {
|
|
it('should jump to AI tab at index 0 with Cmd+1', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockNavigateToUnifiedTabByIndex = vi.fn().mockReturnValue({
|
|
session: { id: 'session-1', activeTabId: 'ai-tab-1', activeFileTabId: null },
|
|
});
|
|
const mockSetSessions = vi.fn();
|
|
|
|
result.current.keyboardHandlerRef.current = createUnifiedTabContext({
|
|
isTabShortcut: (_e: KeyboardEvent, actionId: string) => actionId === 'goToTab1',
|
|
navigateToUnifiedTabByIndex: mockNavigateToUnifiedTabByIndex,
|
|
setSessions: mockSetSessions,
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: '1',
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
expect(mockNavigateToUnifiedTabByIndex).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
0 // index 0 for Cmd+1
|
|
);
|
|
});
|
|
|
|
it('should jump to file tab at index 1 with Cmd+2', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockNavigateToUnifiedTabByIndex = vi.fn().mockReturnValue({
|
|
session: { id: 'session-1', activeTabId: 'ai-tab-1', activeFileTabId: 'file-tab-1' },
|
|
});
|
|
const mockSetSessions = vi.fn();
|
|
|
|
result.current.keyboardHandlerRef.current = createUnifiedTabContext({
|
|
isTabShortcut: (_e: KeyboardEvent, actionId: string) => actionId === 'goToTab2',
|
|
navigateToUnifiedTabByIndex: mockNavigateToUnifiedTabByIndex,
|
|
setSessions: mockSetSessions,
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: '2',
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
expect(mockNavigateToUnifiedTabByIndex).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
1 // index 1 for Cmd+2
|
|
);
|
|
});
|
|
|
|
it('should not execute tab jump when showUnreadOnly is active', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockNavigateToUnifiedTabByIndex = vi.fn();
|
|
const mockSetSessions = vi.fn();
|
|
|
|
result.current.keyboardHandlerRef.current = createUnifiedTabContext({
|
|
isTabShortcut: (_e: KeyboardEvent, actionId: string) => actionId === 'goToTab1',
|
|
navigateToUnifiedTabByIndex: mockNavigateToUnifiedTabByIndex,
|
|
setSessions: mockSetSessions,
|
|
showUnreadOnly: true, // Filter is active - disables Cmd+1-9
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: '1',
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
// Should NOT be called when showUnreadOnly is active
|
|
expect(mockNavigateToUnifiedTabByIndex).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Cmd+0 (jump to last tab)', () => {
|
|
it('should jump to last tab in unified order', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockNavigateToLastUnifiedTab = vi.fn().mockReturnValue({
|
|
session: { id: 'session-1', activeFileTabId: 'file-tab-2' },
|
|
});
|
|
const mockSetSessions = vi.fn();
|
|
|
|
result.current.keyboardHandlerRef.current = createUnifiedTabContext({
|
|
isTabShortcut: (_e: KeyboardEvent, actionId: string) => actionId === 'goToLastTab',
|
|
navigateToLastUnifiedTab: mockNavigateToLastUnifiedTab,
|
|
setSessions: mockSetSessions,
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: '0',
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
expect(mockNavigateToLastUnifiedTab).toHaveBeenCalled();
|
|
expect(mockSetSessions).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not execute when showUnreadOnly is active', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockNavigateToLastUnifiedTab = vi.fn();
|
|
|
|
result.current.keyboardHandlerRef.current = createUnifiedTabContext({
|
|
isTabShortcut: (_e: KeyboardEvent, actionId: string) => actionId === 'goToLastTab',
|
|
navigateToLastUnifiedTab: mockNavigateToLastUnifiedTab,
|
|
showUnreadOnly: true,
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: '0',
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
// Should NOT be called when showUnreadOnly is active
|
|
expect(mockNavigateToLastUnifiedTab).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Cmd+Shift+T (reopen closed tab)', () => {
|
|
it('should reopen from unified closed tab history', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockReopenUnifiedClosedTab = vi.fn().mockReturnValue({
|
|
session: { id: 'session-1' },
|
|
tab: { id: 'reopened-tab' },
|
|
wasFile: true,
|
|
});
|
|
const mockSetSessions = vi.fn();
|
|
|
|
result.current.keyboardHandlerRef.current = createUnifiedTabContext({
|
|
isTabShortcut: (_e: KeyboardEvent, actionId: string) => actionId === 'reopenClosedTab',
|
|
reopenUnifiedClosedTab: mockReopenUnifiedClosedTab,
|
|
setSessions: mockSetSessions,
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 't',
|
|
metaKey: true,
|
|
shiftKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
expect(mockReopenUnifiedClosedTab).toHaveBeenCalled();
|
|
expect(mockSetSessions).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not update sessions when no closed tab to reopen', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockReopenUnifiedClosedTab = vi.fn().mockReturnValue(null);
|
|
const mockSetSessions = vi.fn();
|
|
|
|
result.current.keyboardHandlerRef.current = createUnifiedTabContext({
|
|
isTabShortcut: (_e: KeyboardEvent, actionId: string) => actionId === 'reopenClosedTab',
|
|
reopenUnifiedClosedTab: mockReopenUnifiedClosedTab,
|
|
setSessions: mockSetSessions,
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 't',
|
|
metaKey: true,
|
|
shiftKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
expect(mockReopenUnifiedClosedTab).toHaveBeenCalled();
|
|
expect(mockSetSessions).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('tab shortcuts disabled in group chat', () => {
|
|
it('should not execute tab shortcuts when group chat is active', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockCreateTab = vi.fn();
|
|
const mockSetSessions = vi.fn();
|
|
|
|
result.current.keyboardHandlerRef.current = createUnifiedTabContext({
|
|
isTabShortcut: (_e: KeyboardEvent, actionId: string) => actionId === 'newTab',
|
|
createTab: mockCreateTab,
|
|
setSessions: mockSetSessions,
|
|
activeGroupChatId: 'group-chat-123', // Group chat is active
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 't',
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
// Tab shortcuts should be disabled in group chat mode
|
|
expect(mockCreateTab).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('tab shortcuts disabled in terminal mode', () => {
|
|
it('should not execute tab shortcuts when in terminal/shell mode', () => {
|
|
const { result } = renderHook(() => useMainKeyboardHandler());
|
|
|
|
const mockCreateTab = vi.fn();
|
|
const mockSetSessions = vi.fn();
|
|
|
|
result.current.keyboardHandlerRef.current = createUnifiedTabContext({
|
|
isTabShortcut: (_e: KeyboardEvent, actionId: string) => actionId === 'newTab',
|
|
createTab: mockCreateTab,
|
|
setSessions: mockSetSessions,
|
|
activeSession: {
|
|
id: 'session-1',
|
|
aiTabs: [{ id: 'ai-tab-1', name: 'AI Tab 1', logs: [] }],
|
|
activeTabId: 'ai-tab-1',
|
|
filePreviewTabs: [],
|
|
activeFileTabId: null,
|
|
unifiedTabOrder: ['ai-tab-1'],
|
|
inputMode: 'terminal', // Terminal mode - tabs not applicable
|
|
},
|
|
});
|
|
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 't',
|
|
metaKey: true,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
// Tab shortcuts should be disabled in terminal mode
|
|
expect(mockCreateTab).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
});
|