diff --git a/apps/array/src/renderer/features/panels/components/DraggableTab.tsx b/apps/array/src/renderer/features/panels/components/DraggableTab.tsx index d3cc7a66..10f620c6 100644 --- a/apps/array/src/renderer/features/panels/components/DraggableTab.tsx +++ b/apps/array/src/renderer/features/panels/components/DraggableTab.tsx @@ -14,10 +14,12 @@ interface DraggableTabProps { isActive: boolean; index: number; closeable?: boolean; + isPreview?: boolean; onSelect: () => void; onClose?: () => void; onCloseOthers?: () => void; onCloseToRight?: () => void; + onKeep?: () => void; icon?: React.ReactNode; badge?: React.ReactNode; hasUnsavedChanges?: boolean; @@ -31,10 +33,12 @@ export const DraggableTab: React.FC = ({ isActive, index, closeable = true, + isPreview, onSelect, onClose, onCloseOthers, onCloseToRight, + onKeep, icon, badge, hasUnsavedChanges, @@ -50,6 +54,12 @@ export const DraggableTab: React.FC = ({ data: { tabId, panelId, type: "tab" }, }); + const handleDoubleClick = useCallback(() => { + if (isPreview) { + onKeep?.(); + } + }, [isPreview, onKeep]); + const handleContextMenu = useCallback( async (e: React.MouseEvent) => { e.preventDefault(); @@ -112,6 +122,7 @@ export const DraggableTab: React.FC = ({ minWidth: "60px", }} onClick={onSelect} + onDoubleClick={handleDoubleClick} onContextMenu={handleContextMenu} onMouseEnter={(e) => { if (!isActive) { @@ -130,6 +141,10 @@ export const DraggableTab: React.FC = ({ {label} diff --git a/apps/array/src/renderer/features/panels/components/LeafNodeRenderer.tsx b/apps/array/src/renderer/features/panels/components/LeafNodeRenderer.tsx index 79865d36..e55c4e7f 100644 --- a/apps/array/src/renderer/features/panels/components/LeafNodeRenderer.tsx +++ b/apps/array/src/renderer/features/panels/components/LeafNodeRenderer.tsx @@ -12,6 +12,7 @@ interface LeafNodeRendererProps { closeTab: (taskId: string, panelId: string, tabId: string) => void; closeOtherTabs: (panelId: string, tabId: string) => void; closeTabsToRight: (panelId: string, tabId: string) => void; + keepTab: (panelId: string, tabId: string) => void; draggingTabId: string | null; draggingTabPanelId: string | null; onActiveTabChange: (panelId: string, tabId: string) => void; @@ -28,6 +29,7 @@ export const LeafNodeRenderer: React.FC = ({ closeTab, closeOtherTabs, closeTabsToRight, + keepTab, draggingTabId, draggingTabPanelId, onActiveTabChange, @@ -56,6 +58,7 @@ export const LeafNodeRenderer: React.FC = ({ onActiveTabChange={onActiveTabChange} onCloseOtherTabs={closeOtherTabs} onCloseTabsToRight={closeTabsToRight} + onKeepTab={keepTab} onPanelFocus={onPanelFocus} draggingTabId={draggingTabId} draggingTabPanelId={draggingTabPanelId} diff --git a/apps/array/src/renderer/features/panels/components/PanelLayout.tsx b/apps/array/src/renderer/features/panels/components/PanelLayout.tsx index 29e0876e..fba7761b 100644 --- a/apps/array/src/renderer/features/panels/components/PanelLayout.tsx +++ b/apps/array/src/renderer/features/panels/components/PanelLayout.tsx @@ -52,6 +52,13 @@ const PanelLayoutRenderer: React.FC<{ [layoutState, taskId], ); + const handleKeepTab = useCallback( + (panelId: string, tabId: string) => { + layoutState.keepTab(taskId, panelId, tabId); + }, + [layoutState, taskId], + ); + const handlePanelFocus = useCallback( (panelId: string) => { layoutState.setFocusedPanel(taskId, panelId); @@ -116,6 +123,7 @@ const PanelLayoutRenderer: React.FC<{ closeTab={layoutState.closeTab} closeOtherTabs={handleCloseOtherTabs} closeTabsToRight={handleCloseTabsToRight} + keepTab={handleKeepTab} draggingTabId={layoutState.draggingTabId} draggingTabPanelId={layoutState.draggingTabPanelId} onActiveTabChange={handleSetActiveTab} @@ -147,6 +155,7 @@ const PanelLayoutRenderer: React.FC<{ handleSetActiveTab, handleCloseOtherTabs, handleCloseTabsToRight, + handleKeepTab, handlePanelFocus, handleAddTerminal, handleSplitPanel, diff --git a/apps/array/src/renderer/features/panels/components/PanelTab.tsx b/apps/array/src/renderer/features/panels/components/PanelTab.tsx index 919a0300..35db7507 100644 --- a/apps/array/src/renderer/features/panels/components/PanelTab.tsx +++ b/apps/array/src/renderer/features/panels/components/PanelTab.tsx @@ -12,10 +12,12 @@ interface PanelTabProps { index: number; draggable?: boolean; closeable?: boolean; + isPreview?: boolean; onSelect: () => void; onClose?: () => void; onCloseOthers?: () => void; onCloseToRight?: () => void; + onKeep?: () => void; icon?: React.ReactNode; badge?: React.ReactNode; hasUnsavedChanges?: boolean; @@ -30,10 +32,12 @@ export const PanelTab: React.FC = ({ index, draggable = true, closeable = true, + isPreview, onSelect, onClose, onCloseOthers, onCloseToRight, + onKeep, icon, badge, hasUnsavedChanges, @@ -60,10 +64,12 @@ export const PanelTab: React.FC = ({ isActive={isActive} index={index} closeable={closeable} + isPreview={isPreview} onSelect={onSelect} onClose={onClose} onCloseOthers={onCloseOthers} onCloseToRight={onCloseToRight} + onKeep={onKeep} icon={icon} badge={badge} hasUnsavedChanges={hasUnsavedChanges} diff --git a/apps/array/src/renderer/features/panels/components/TabbedPanel.tsx b/apps/array/src/renderer/features/panels/components/TabbedPanel.tsx index af2db3e4..a55c8464 100644 --- a/apps/array/src/renderer/features/panels/components/TabbedPanel.tsx +++ b/apps/array/src/renderer/features/panels/components/TabbedPanel.tsx @@ -48,6 +48,7 @@ interface TabbedPanelProps { onActiveTabChange?: (panelId: string, tabId: string) => void; onCloseOtherTabs?: (panelId: string, tabId: string) => void; onCloseTabsToRight?: (panelId: string, tabId: string) => void; + onKeepTab?: (panelId: string, tabId: string) => void; onPanelFocus?: (panelId: string) => void; draggingTabId?: string | null; draggingTabPanelId?: string | null; @@ -62,6 +63,7 @@ export const TabbedPanel: React.FC = ({ onActiveTabChange, onCloseOtherTabs, onCloseTabsToRight, + onKeepTab, onPanelFocus, draggingTabId = null, draggingTabPanelId = null, @@ -163,6 +165,7 @@ export const TabbedPanel: React.FC = ({ index={index} draggable={tab.draggable} closeable={tab.closeable !== false} + isPreview={tab.isPreview} onSelect={() => { onActiveTabChange?.(panelId, tab.id); onPanelFocus?.(panelId); @@ -175,6 +178,7 @@ export const TabbedPanel: React.FC = ({ } onCloseOthers={() => onCloseOtherTabs?.(panelId, tab.id)} onCloseToRight={() => onCloseTabsToRight?.(panelId, tab.id)} + onKeep={() => onKeepTab?.(panelId, tab.id)} icon={tab.icon} hasUnsavedChanges={tab.hasUnsavedChanges} badge={tab.badge} diff --git a/apps/array/src/renderer/features/panels/hooks/usePanelLayoutHooks.tsx b/apps/array/src/renderer/features/panels/hooks/usePanelLayoutHooks.tsx index 46536929..3ad50733 100644 --- a/apps/array/src/renderer/features/panels/hooks/usePanelLayoutHooks.tsx +++ b/apps/array/src/renderer/features/panels/hooks/usePanelLayoutHooks.tsx @@ -18,6 +18,7 @@ export interface PanelLayoutState { closeTab: (taskId: string, panelId: string, tabId: string) => void; closeOtherTabs: (taskId: string, panelId: string, tabId: string) => void; closeTabsToRight: (taskId: string, panelId: string, tabId: string) => void; + keepTab: (taskId: string, panelId: string, tabId: string) => void; setFocusedPanel: (taskId: string, panelId: string) => void; addTerminalTab: (taskId: string, panelId: string) => void; splitPanel: ( @@ -41,6 +42,7 @@ export function usePanelLayoutState(taskId: string): PanelLayoutState { closeTab: state.closeTab, closeOtherTabs: state.closeOtherTabs, closeTabsToRight: state.closeTabsToRight, + keepTab: state.keepTab, setFocusedPanel: state.setFocusedPanel, addTerminalTab: state.addTerminalTab, splitPanel: state.splitPanel, diff --git a/apps/array/src/renderer/features/panels/store/panelLayoutStore.test.ts b/apps/array/src/renderer/features/panels/store/panelLayoutStore.test.ts index 1d1d1680..153baa57 100644 --- a/apps/array/src/renderer/features/panels/store/panelLayoutStore.test.ts +++ b/apps/array/src/renderer/features/panels/store/panelLayoutStore.test.ts @@ -513,4 +513,119 @@ describe("panelLayoutStore", () => { expect(updatedMainPanel.type).toBe("leaf"); }); }); + + describe("preview tabs", () => { + beforeEach(() => { + usePanelLayoutStore.getState().initializeTask("task-1"); + }); + + it("creates preview tab by default when opening a file", () => { + usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx"); + + const panel = findPanelById(getPanelTree("task-1"), "main-panel"); + const fileTab = panel?.content.tabs.find( + (t: { id: string }) => t.id === "file-src/App.tsx", + ); + expect(fileTab?.isPreview).toBe(true); + }); + + it("replaces existing preview tab when opening another file", () => { + usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx"); + usePanelLayoutStore.getState().openFile("task-1", "src/Other.tsx"); + + const panel = findPanelById(getPanelTree("task-1"), "main-panel"); + const fileTabs = panel?.content.tabs.filter((t: { id: string }) => + t.id.startsWith("file-"), + ); + expect(fileTabs).toHaveLength(1); + expect(fileTabs?.[0].id).toBe("file-src/Other.tsx"); + expect(fileTabs?.[0].isPreview).toBe(true); + }); + + it("creates permanent tab when asPreview is false", () => { + usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx", false); + + const panel = findPanelById(getPanelTree("task-1"), "main-panel"); + const fileTab = panel?.content.tabs.find( + (t: { id: string }) => t.id === "file-src/App.tsx", + ); + expect(fileTab?.isPreview).toBe(false); + }); + + it("keeps preview tab as preview when re-clicking same file", () => { + usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx"); + usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx"); + + const panel = findPanelById(getPanelTree("task-1"), "main-panel"); + const fileTab = panel?.content.tabs.find( + (t: { id: string }) => t.id === "file-src/App.tsx", + ); + expect(fileTab?.isPreview).toBe(true); + }); + + it("pins preview tab when double-clicking (asPreview=false)", () => { + usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx"); + usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx", false); + + const panel = findPanelById(getPanelTree("task-1"), "main-panel"); + const fileTab = panel?.content.tabs.find( + (t: { id: string }) => t.id === "file-src/App.tsx", + ); + expect(fileTab?.isPreview).toBe(false); + }); + + it("keepTab sets isPreview to false", () => { + usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx"); + usePanelLayoutStore + .getState() + .keepTab("task-1", "main-panel", "file-src/App.tsx"); + + const panel = findPanelById(getPanelTree("task-1"), "main-panel"); + const fileTab = panel?.content.tabs.find( + (t: { id: string }) => t.id === "file-src/App.tsx", + ); + expect(fileTab?.isPreview).toBe(false); + }); + + it("does not replace non-preview tabs when opening preview", () => { + usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx", false); + usePanelLayoutStore.getState().openFile("task-1", "src/Other.tsx"); + + const panel = findPanelById(getPanelTree("task-1"), "main-panel"); + const fileTabs = panel?.content.tabs.filter((t: { id: string }) => + t.id.startsWith("file-"), + ); + expect(fileTabs).toHaveLength(2); + expect( + fileTabs?.find((t) => t.id === "file-src/App.tsx")?.isPreview, + ).toBe(false); + expect( + fileTabs?.find((t) => t.id === "file-src/Other.tsx")?.isPreview, + ).toBe(true); + }); + + it("openDiff creates preview tab by default", () => { + usePanelLayoutStore + .getState() + .openDiff("task-1", "src/App.tsx", "modified"); + + const panel = findPanelById(getPanelTree("task-1"), "main-panel"); + const diffTab = panel?.content.tabs.find((t: { id: string }) => + t.id.startsWith("diff-"), + ); + expect(diffTab?.isPreview).toBe(true); + }); + + it("openDiff creates permanent tab when asPreview is false", () => { + usePanelLayoutStore + .getState() + .openDiff("task-1", "src/App.tsx", "modified", false); + + const panel = findPanelById(getPanelTree("task-1"), "main-panel"); + const diffTab = panel?.content.tabs.find((t: { id: string }) => + t.id.startsWith("diff-"), + ); + expect(diffTab?.isPreview).toBe(false); + }); + }); }); diff --git a/apps/array/src/renderer/features/panels/store/panelLayoutStore.ts b/apps/array/src/renderer/features/panels/store/panelLayoutStore.ts index 3d834996..3bbe237a 100644 --- a/apps/array/src/renderer/features/panels/store/panelLayoutStore.ts +++ b/apps/array/src/renderer/features/panels/store/panelLayoutStore.ts @@ -48,9 +48,15 @@ export interface PanelLayoutStore { taskId: string, terminalLayoutMode?: "split" | "tabbed", ) => void; - openFile: (taskId: string, filePath: string) => void; + openFile: (taskId: string, filePath: string, asPreview?: boolean) => void; openArtifact: (taskId: string, fileName: string) => void; - openDiff: (taskId: string, filePath: string, status?: string) => void; + openDiff: ( + taskId: string, + filePath: string, + status?: string, + asPreview?: boolean, + ) => void; + keepTab: (taskId: string, panelId: string, tabId: string) => void; closeTab: (taskId: string, panelId: string, tabId: string) => void; closeOtherTabs: (taskId: string, panelId: string, tabId: string) => void; closeTabsToRight: (taskId: string, panelId: string, tabId: string) => void; @@ -198,17 +204,32 @@ function openTab( state: { taskLayouts: Record }, taskId: string, tabId: string, + asPreview = true, ): { taskLayouts: Record } { return updateTaskLayout(state, taskId, (layout) => { // Check if tab already exists in tree const existingTab = findTabInTree(layout.panelTree, tabId); if (existingTab) { - // Tab exists, just activate it + // Tab exists - activate it, only pin if explicitly requested (asPreview=false) const updatedTree = updateTreeNode( layout.panelTree, existingTab.panelId, - (panel) => setActiveTabInPanel(panel, tabId), + (panel) => { + if (panel.type !== "leaf") return panel; + return { + ...panel, + content: { + ...panel.content, + tabs: asPreview + ? panel.content.tabs + : panel.content.tabs.map((tab) => + tab.id === tabId ? { ...tab, isPreview: false } : tab, + ), + activeTabId: tabId, + }, + }; + }, ); return { panelTree: updatedTree }; @@ -224,7 +245,7 @@ function openTab( const updatedTree = updateTreeNode( layout.panelTree, DEFAULT_PANEL_IDS.MAIN_PANEL, - (panel) => addNewTabToPanel(panel, tabId, true), + (panel) => addNewTabToPanel(panel, tabId, true, asPreview), ); const metadata = updateMetadataForTab(layout, tabId, "add"); @@ -261,19 +282,43 @@ export const usePanelLayoutStore = createWithEqualityFn()( })); }, - openFile: (taskId, filePath) => { + openFile: (taskId, filePath, asPreview = true) => { const tabId = createFileTabId(filePath); - set((state) => openTab(state, taskId, tabId)); + set((state) => openTab(state, taskId, tabId, asPreview)); }, openArtifact: (taskId, fileName) => { const tabId = createArtifactTabId(fileName); - set((state) => openTab(state, taskId, tabId)); + set((state) => openTab(state, taskId, tabId, false)); }, - openDiff: (taskId, filePath, status) => { + openDiff: (taskId, filePath, status, asPreview = true) => { const tabId = createDiffTabId(filePath, status); - set((state) => openTab(state, taskId, tabId)); + set((state) => openTab(state, taskId, tabId, asPreview)); + }, + + keepTab: (taskId, panelId, tabId) => { + set((state) => + updateTaskLayout(state, taskId, (layout) => { + const updatedTree = updateTreeNode( + layout.panelTree, + panelId, + (panel) => { + if (panel.type !== "leaf") return panel; + return { + ...panel, + content: { + ...panel.content, + tabs: panel.content.tabs.map((tab) => + tab.id === tabId ? { ...tab, isPreview: false } : tab, + ), + }, + }; + }, + ); + return { panelTree: updatedTree }; + }), + ); }, closeTab: (taskId, panelId, tabId) => { diff --git a/apps/array/src/renderer/features/panels/store/panelStoreHelpers.ts b/apps/array/src/renderer/features/panels/store/panelStoreHelpers.ts index d78f56c9..5da7ddaa 100644 --- a/apps/array/src/renderer/features/panels/store/panelStoreHelpers.ts +++ b/apps/array/src/renderer/features/panels/store/panelStoreHelpers.ts @@ -157,7 +157,11 @@ export function updateTaskLayout( } // Tree update helpers -export function createNewTab(tabId: string, closeable = true): Tab { +export function createNewTab( + tabId: string, + closeable = true, + isPreview = false, +): Tab { const parsed = parseTabId(tabId); let data: Tab["data"]; @@ -210,6 +214,7 @@ export function createNewTab(tabId: string, closeable = true): Tab { component: null, closeable, draggable: true, + isPreview, }; } @@ -217,14 +222,20 @@ export function addNewTabToPanel( panel: PanelNode, tabId: string, closeable = true, + isPreview = false, ): PanelNode { if (panel.type !== "leaf") return panel; + // If opening as preview, remove any existing preview tab first + const tabs = isPreview + ? panel.content.tabs.filter((tab) => !tab.isPreview) + : panel.content.tabs; + return { ...panel, content: { ...panel.content, - tabs: [...panel.content.tabs, createNewTab(tabId, closeable)], + tabs: [...tabs, createNewTab(tabId, closeable, isPreview)], activeTabId: tabId, }, }; diff --git a/apps/array/src/renderer/features/panels/store/panelTypes.ts b/apps/array/src/renderer/features/panels/store/panelTypes.ts index 525583fe..84a1b4bd 100644 --- a/apps/array/src/renderer/features/panels/store/panelTypes.ts +++ b/apps/array/src/renderer/features/panels/store/panelTypes.ts @@ -57,6 +57,7 @@ export type Tab = { icon?: React.ReactNode; hasUnsavedChanges?: boolean; badge?: React.ReactNode; + isPreview?: boolean; }; export type PanelContent = { diff --git a/apps/array/src/renderer/features/task-detail/components/ChangesPanel.tsx b/apps/array/src/renderer/features/task-detail/components/ChangesPanel.tsx index bdbc220d..16ed2310 100644 --- a/apps/array/src/renderer/features/task-detail/components/ChangesPanel.tsx +++ b/apps/array/src/renderer/features/task-detail/components/ChangesPanel.tsx @@ -127,6 +127,10 @@ function ChangedFileItem({ openDiff(taskId, file.path, file.status); }; + const handleDoubleClick = () => { + openDiff(taskId, file.path, file.status, false); + }; + const handleContextMenu = async (e: React.MouseEvent) => { e.preventDefault(); const result = await window.electronAPI.showFileContextMenu(fullPath); @@ -190,6 +194,7 @@ function ChangedFileItem({ align="center" gap="1" onClick={handleClick} + onDoubleClick={handleDoubleClick} onContextMenu={handleContextMenu} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} diff --git a/apps/array/src/renderer/features/task-detail/components/FileTreePanel.tsx b/apps/array/src/renderer/features/task-detail/components/FileTreePanel.tsx index 94e376cc..e1897140 100644 --- a/apps/array/src/renderer/features/task-detail/components/FileTreePanel.tsx +++ b/apps/array/src/renderer/features/task-detail/components/FileTreePanel.tsx @@ -69,6 +69,12 @@ function LazyTreeItem({ } }; + const handleDoubleClick = () => { + if (entry.type === "file") { + openFile(taskId, relativePath, false); + } + }; + const handleContextMenu = async (e: React.MouseEvent) => { e.preventDefault(); const result = await window.electronAPI.showFileContextMenu(entry.path, { @@ -104,6 +110,7 @@ function LazyTreeItem({ : "border-transparent border-y hover:bg-gray-3" } onClick={handleClick} + onDoubleClick={handleDoubleClick} onContextMenu={handleContextMenu} >