diff --git a/src/__tests__/renderer/utils/tabHelpers.test.ts b/src/__tests__/renderer/utils/tabHelpers.test.ts index deab62eb..abe0074b 100644 --- a/src/__tests__/renderer/utils/tabHelpers.test.ts +++ b/src/__tests__/renderer/utils/tabHelpers.test.ts @@ -14,6 +14,8 @@ * - navigateToPrevTab * - navigateToTabByIndex * - navigateToLastTab + * - navigateToUnifiedTabByIndex + * - navigateToLastUnifiedTab * - createMergedSession * - hasActiveWizard */ @@ -32,11 +34,13 @@ import { navigateToPrevTab, navigateToTabByIndex, navigateToLastTab, + navigateToUnifiedTabByIndex, + navigateToLastUnifiedTab, createMergedSession, hasActiveWizard, } from '../../../renderer/utils/tabHelpers'; import type { LogEntry } from '../../../renderer/types'; -import type { Session, AITab, ClosedTab } from '../../../renderer/types'; +import type { Session, AITab, ClosedTab, FilePreviewTab } from '../../../renderer/types'; // Mock the generateId function to return predictable IDs vi.mock('../../../renderer/utils/ids', () => ({ @@ -95,6 +99,24 @@ function createMockTab(overrides: Partial = {}): AITab { }; } +// Helper to create a minimal FilePreviewTab for testing +function createMockFileTab(overrides: Partial = {}): FilePreviewTab { + return { + id: 'file-tab-1', + path: '/test/file.ts', + name: 'file', + extension: '.ts', + content: '// test content', + scrollTop: 0, + searchQuery: '', + editMode: false, + editContent: undefined, + createdAt: Date.now(), + lastModified: Date.now(), + ...overrides, + }; +} + describe('tabHelpers', () => { beforeEach(() => { vi.clearAllMocks(); @@ -1136,6 +1158,262 @@ describe('tabHelpers', () => { }); }); + describe('navigateToUnifiedTabByIndex', () => { + it('returns null for session with no unifiedTabOrder', () => { + const session = createMockSession({ unifiedTabOrder: [] }); + expect(navigateToUnifiedTabByIndex(session, 0)).toBeNull(); + }); + + it('returns null for session with undefined unifiedTabOrder', () => { + const session = createMockSession(); + (session as any).unifiedTabOrder = undefined; + expect(navigateToUnifiedTabByIndex(session, 0)).toBeNull(); + }); + + it('returns null for negative index', () => { + const tab = createMockTab({ id: 'tab-1' }); + const session = createMockSession({ + aiTabs: [tab], + unifiedTabOrder: [{ type: 'ai', id: 'tab-1' }], + }); + expect(navigateToUnifiedTabByIndex(session, -1)).toBeNull(); + }); + + it('returns null for out of bounds index', () => { + const tab = createMockTab({ id: 'tab-1' }); + const session = createMockSession({ + aiTabs: [tab], + unifiedTabOrder: [{ type: 'ai', id: 'tab-1' }], + }); + expect(navigateToUnifiedTabByIndex(session, 5)).toBeNull(); + }); + + it('navigates to AI tab by unified index', () => { + const tab1 = createMockTab({ id: 'tab-1' }); + const tab2 = createMockTab({ id: 'tab-2' }); + const session = createMockSession({ + aiTabs: [tab1, tab2], + activeTabId: 'tab-1', + activeFileTabId: null, + unifiedTabOrder: [ + { type: 'ai', id: 'tab-1' }, + { type: 'ai', id: 'tab-2' }, + ], + }); + + const result = navigateToUnifiedTabByIndex(session, 1); + + expect(result!.type).toBe('ai'); + expect(result!.id).toBe('tab-2'); + expect(result!.session.activeTabId).toBe('tab-2'); + expect(result!.session.activeFileTabId).toBeNull(); + }); + + it('navigates to file tab by unified index', () => { + const aiTab = createMockTab({ id: 'ai-tab-1' }); + const fileTab = createMockFileTab({ id: 'file-tab-1' }); + const session = createMockSession({ + aiTabs: [aiTab], + filePreviewTabs: [fileTab], + activeTabId: 'ai-tab-1', + activeFileTabId: null, + unifiedTabOrder: [ + { type: 'ai', id: 'ai-tab-1' }, + { type: 'file', id: 'file-tab-1' }, + ], + }); + + const result = navigateToUnifiedTabByIndex(session, 1); + + expect(result!.type).toBe('file'); + expect(result!.id).toBe('file-tab-1'); + expect(result!.session.activeFileTabId).toBe('file-tab-1'); + // activeTabId is preserved for switching back + expect(result!.session.activeTabId).toBe('ai-tab-1'); + }); + + it('clears activeFileTabId when selecting AI tab', () => { + const aiTab = createMockTab({ id: 'ai-tab-1' }); + const fileTab = createMockFileTab({ id: 'file-tab-1' }); + const session = createMockSession({ + aiTabs: [aiTab], + filePreviewTabs: [fileTab], + activeTabId: 'ai-tab-1', + activeFileTabId: 'file-tab-1', // Currently on a file tab + unifiedTabOrder: [ + { type: 'ai', id: 'ai-tab-1' }, + { type: 'file', id: 'file-tab-1' }, + ], + }); + + const result = navigateToUnifiedTabByIndex(session, 0); // Navigate to AI tab + + expect(result!.type).toBe('ai'); + expect(result!.session.activeTabId).toBe('ai-tab-1'); + expect(result!.session.activeFileTabId).toBeNull(); + }); + + it('returns same session when already on target AI tab', () => { + const tab = createMockTab({ id: 'tab-1' }); + const session = createMockSession({ + aiTabs: [tab], + activeTabId: 'tab-1', + activeFileTabId: null, + unifiedTabOrder: [{ type: 'ai', id: 'tab-1' }], + }); + + const result = navigateToUnifiedTabByIndex(session, 0); + + expect(result!.session).toBe(session); + }); + + it('returns same session when already on target file tab', () => { + const fileTab = createMockFileTab({ id: 'file-tab-1' }); + const session = createMockSession({ + aiTabs: [], + filePreviewTabs: [fileTab], + activeTabId: '', + activeFileTabId: 'file-tab-1', + unifiedTabOrder: [{ type: 'file', id: 'file-tab-1' }], + }); + + const result = navigateToUnifiedTabByIndex(session, 0); + + expect(result!.session).toBe(session); + }); + + it('returns null if AI tab reference does not exist in aiTabs', () => { + const tab = createMockTab({ id: 'tab-1' }); + const session = createMockSession({ + aiTabs: [tab], + unifiedTabOrder: [{ type: 'ai', id: 'non-existent' }], + }); + + expect(navigateToUnifiedTabByIndex(session, 0)).toBeNull(); + }); + + it('returns null if file tab reference does not exist in filePreviewTabs', () => { + const session = createMockSession({ + aiTabs: [], + filePreviewTabs: [], + unifiedTabOrder: [{ type: 'file', id: 'non-existent' }], + }); + + expect(navigateToUnifiedTabByIndex(session, 0)).toBeNull(); + }); + + it('handles mixed AI and file tabs correctly', () => { + const aiTab1 = createMockTab({ id: 'ai-1' }); + const aiTab2 = createMockTab({ id: 'ai-2' }); + const fileTab1 = createMockFileTab({ id: 'file-1' }); + const fileTab2 = createMockFileTab({ id: 'file-2' }); + const session = createMockSession({ + aiTabs: [aiTab1, aiTab2], + filePreviewTabs: [fileTab1, fileTab2], + activeTabId: 'ai-1', + activeFileTabId: null, + unifiedTabOrder: [ + { type: 'ai', id: 'ai-1' }, + { type: 'file', id: 'file-1' }, + { type: 'ai', id: 'ai-2' }, + { type: 'file', id: 'file-2' }, + ], + }); + + // Index 0: AI tab + const result0 = navigateToUnifiedTabByIndex(session, 0); + expect(result0!.type).toBe('ai'); + expect(result0!.id).toBe('ai-1'); + + // Index 1: File tab + const result1 = navigateToUnifiedTabByIndex(session, 1); + expect(result1!.type).toBe('file'); + expect(result1!.id).toBe('file-1'); + + // Index 2: AI tab + const result2 = navigateToUnifiedTabByIndex(session, 2); + expect(result2!.type).toBe('ai'); + expect(result2!.id).toBe('ai-2'); + + // Index 3: File tab + const result3 = navigateToUnifiedTabByIndex(session, 3); + expect(result3!.type).toBe('file'); + expect(result3!.id).toBe('file-2'); + }); + }); + + describe('navigateToLastUnifiedTab', () => { + it('returns null for session with no unifiedTabOrder', () => { + const session = createMockSession({ unifiedTabOrder: [] }); + expect(navigateToLastUnifiedTab(session)).toBeNull(); + }); + + it('returns null for session with undefined unifiedTabOrder', () => { + const session = createMockSession(); + (session as any).unifiedTabOrder = undefined; + expect(navigateToLastUnifiedTab(session)).toBeNull(); + }); + + it('navigates to last AI tab', () => { + const tab1 = createMockTab({ id: 'tab-1' }); + const tab2 = createMockTab({ id: 'tab-2' }); + const tab3 = createMockTab({ id: 'tab-3' }); + const session = createMockSession({ + aiTabs: [tab1, tab2, tab3], + activeTabId: 'tab-1', + activeFileTabId: null, + unifiedTabOrder: [ + { type: 'ai', id: 'tab-1' }, + { type: 'ai', id: 'tab-2' }, + { type: 'ai', id: 'tab-3' }, + ], + }); + + const result = navigateToLastUnifiedTab(session); + + expect(result!.type).toBe('ai'); + expect(result!.id).toBe('tab-3'); + expect(result!.session.activeTabId).toBe('tab-3'); + }); + + it('navigates to last file tab when file is last in unified order', () => { + const aiTab = createMockTab({ id: 'ai-1' }); + const fileTab = createMockFileTab({ id: 'file-1' }); + const session = createMockSession({ + aiTabs: [aiTab], + filePreviewTabs: [fileTab], + activeTabId: 'ai-1', + activeFileTabId: null, + unifiedTabOrder: [ + { type: 'ai', id: 'ai-1' }, + { type: 'file', id: 'file-1' }, + ], + }); + + const result = navigateToLastUnifiedTab(session); + + expect(result!.type).toBe('file'); + expect(result!.id).toBe('file-1'); + expect(result!.session.activeFileTabId).toBe('file-1'); + }); + + it('returns single tab when only one exists', () => { + const tab = createMockTab({ id: 'only-tab' }); + const session = createMockSession({ + aiTabs: [tab], + activeTabId: 'only-tab', + activeFileTabId: null, + unifiedTabOrder: [{ type: 'ai', id: 'only-tab' }], + }); + + const result = navigateToLastUnifiedTab(session); + + expect(result!.type).toBe('ai'); + expect(result!.id).toBe('only-tab'); + expect(result!.session).toBe(session); // Same session since already active + }); + }); + describe('createMergedSession', () => { it('creates a session with basic options', () => { const { session, tabId } = createMergedSession({ diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index c41d8dcb..456cfcf3 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -172,6 +172,8 @@ import { navigateToPrevTab, navigateToTabByIndex, navigateToLastTab, + navigateToUnifiedTabByIndex, + navigateToLastUnifiedTab, getInitialRenameValue, hasActiveWizard, } from './utils/tabHelpers'; @@ -12609,6 +12611,8 @@ You are taking over this conversation. Based on the context above, provide a bri navigateToPrevTab, navigateToTabByIndex, navigateToLastTab, + navigateToUnifiedTabByIndex, + navigateToLastUnifiedTab, setFileTreeFilterOpen, isShortcut, isTabShortcut, diff --git a/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts b/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts index 31574176..adc2593d 100644 --- a/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts +++ b/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts @@ -661,12 +661,14 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn { trackShortcut('prevTab'); } } - // Cmd+1 through Cmd+9: Jump to specific tab by index (disabled in unread-only mode) + // Cmd+1 through Cmd+9: Jump to specific tab by index in unified tab order + // Works with both AI tabs and file preview tabs + // Disabled in unread-only mode (unread filter only applies to AI tabs) if (!ctx.showUnreadOnly) { for (let i = 1; i <= 9; i++) { if (ctx.isTabShortcut(e, `goToTab${i}`)) { e.preventDefault(); - const result = ctx.navigateToTabByIndex(ctx.activeSession, i - 1); + const result = ctx.navigateToUnifiedTabByIndex(ctx.activeSession, i - 1); if (result) { ctx.setSessions((prev: Session[]) => prev.map((s: Session) => (s.id === ctx.activeSession!.id ? result.session : s)) @@ -676,10 +678,10 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn { break; } } - // Cmd+0: Jump to last tab + // Cmd+0: Jump to last tab in unified tab order if (ctx.isTabShortcut(e, 'goToLastTab')) { e.preventDefault(); - const result = ctx.navigateToLastTab(ctx.activeSession); + const result = ctx.navigateToLastUnifiedTab(ctx.activeSession); if (result) { ctx.setSessions((prev: Session[]) => prev.map((s: Session) => (s.id === ctx.activeSession!.id ? result.session : s)) diff --git a/src/renderer/utils/tabHelpers.ts b/src/renderer/utils/tabHelpers.ts index 07dbbeed..7c6ff381 100644 --- a/src/renderer/utils/tabHelpers.ts +++ b/src/renderer/utils/tabHelpers.ts @@ -716,6 +716,117 @@ export function navigateToLastTab( return navigateToTabByIndex(session, lastIndex, showUnreadOnly); } +/** + * Result of navigating to a unified tab (can be AI or file tab). + */ +export interface NavigateToUnifiedTabResult { + type: 'ai' | 'file'; + id: string; + session: Session; +} + +/** + * Navigate to a tab by its index in the unified tab order. + * Used for Cmd+1 through Cmd+9 shortcuts to jump to tabs by position. + * Works with both AI tabs and file preview tabs in the unified tab system. + * + * @param session - The Maestro session + * @param index - The 0-based index in unifiedTabOrder + * @returns Object with the tab type, id, and updated session, or null if index out of bounds + * + * @example + * // Navigate to the first tab (Cmd+1) + * const result = navigateToUnifiedTabByIndex(session, 0); + * if (result) { + * if (result.type === 'ai') { + * // AI tab - activeTabId is updated, activeFileTabId is cleared + * } else { + * // File tab - activeFileTabId is updated, activeTabId preserved + * } + * setSessions(prev => prev.map(s => s.id === session.id ? result.session : s)); + * } + */ +export function navigateToUnifiedTabByIndex( + session: Session, + index: number +): NavigateToUnifiedTabResult | null { + if (!session || !session.unifiedTabOrder || session.unifiedTabOrder.length === 0) { + return null; + } + + // Check if index is within bounds + if (index < 0 || index >= session.unifiedTabOrder.length) { + return null; + } + + const targetTabRef = session.unifiedTabOrder[index]; + + if (targetTabRef.type === 'ai') { + // Navigate to AI tab - verify it exists + const aiTab = session.aiTabs.find((tab) => tab.id === targetTabRef.id); + if (!aiTab) return null; + + // If already active, return current state + if (session.activeTabId === targetTabRef.id && session.activeFileTabId === null) { + return { + type: 'ai', + id: targetTabRef.id, + session, + }; + } + + // Set the AI tab as active and clear file tab selection + return { + type: 'ai', + id: targetTabRef.id, + session: { + ...session, + activeTabId: targetTabRef.id, + activeFileTabId: null, + }, + }; + } else { + // Navigate to file tab - verify it exists + const fileTab = session.filePreviewTabs.find((tab) => tab.id === targetTabRef.id); + if (!fileTab) return null; + + // If already active, return current state + if (session.activeFileTabId === targetTabRef.id) { + return { + type: 'file', + id: targetTabRef.id, + session, + }; + } + + // Set the file tab as active (preserve activeTabId for switching back) + return { + type: 'file', + id: targetTabRef.id, + session: { + ...session, + activeFileTabId: targetTabRef.id, + }, + }; + } +} + +/** + * Navigate to the last tab in the unified tab order. + * Used for Cmd+0 shortcut. + * + * @param session - The Maestro session + * @returns Object with the tab type, id, and updated session, or null if no tabs + */ +export function navigateToLastUnifiedTab(session: Session): NavigateToUnifiedTabResult | null { + if (!session || !session.unifiedTabOrder || session.unifiedTabOrder.length === 0) { + return null; + } + + const lastIndex = session.unifiedTabOrder.length - 1; + return navigateToUnifiedTabByIndex(session, lastIndex); +} + /** * Options for creating a new AI tab at a specific position. */