Skip to content

Commit 77f297d

Browse files
authored
feat: temporary tabs (#253)
Now when you click a tab in the editor, it'll open as a temporary tab. Double clicking the file again (either in the changelist, filetree, or the tab itself), makes it a permanent one. Also fixed an issue where navigating through the ChangesPanel would open a new tab every time. <img width="416" height="67" alt="image" src="https://github.com/user-attachments/assets/2b9b1d95-530b-4a0e-b62e-f0a0f559474f" />
1 parent 8075324 commit 77f297d

File tree

13 files changed

+236
-13
lines changed

13 files changed

+236
-13
lines changed

apps/array/src/renderer/features/panels/components/DraggableTab.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ interface DraggableTabProps {
1414
isActive: boolean;
1515
index: number;
1616
closeable?: boolean;
17+
isPreview?: boolean;
1718
onSelect: () => void;
1819
onClose?: () => void;
1920
onCloseOthers?: () => void;
2021
onCloseToRight?: () => void;
22+
onKeep?: () => void;
2123
icon?: React.ReactNode;
2224
badge?: React.ReactNode;
2325
hasUnsavedChanges?: boolean;
@@ -31,10 +33,12 @@ export const DraggableTab: React.FC<DraggableTabProps> = ({
3133
isActive,
3234
index,
3335
closeable = true,
36+
isPreview,
3437
onSelect,
3538
onClose,
3639
onCloseOthers,
3740
onCloseToRight,
41+
onKeep,
3842
icon,
3943
badge,
4044
hasUnsavedChanges,
@@ -50,6 +54,12 @@ export const DraggableTab: React.FC<DraggableTabProps> = ({
5054
data: { tabId, panelId, type: "tab" },
5155
});
5256

57+
const handleDoubleClick = useCallback(() => {
58+
if (isPreview) {
59+
onKeep?.();
60+
}
61+
}, [isPreview, onKeep]);
62+
5363
const handleContextMenu = useCallback(
5464
async (e: React.MouseEvent) => {
5565
e.preventDefault();
@@ -112,6 +122,7 @@ export const DraggableTab: React.FC<DraggableTabProps> = ({
112122
minWidth: "60px",
113123
}}
114124
onClick={onSelect}
125+
onDoubleClick={handleDoubleClick}
115126
onContextMenu={handleContextMenu}
116127
onMouseEnter={(e) => {
117128
if (!isActive) {
@@ -130,6 +141,10 @@ export const DraggableTab: React.FC<DraggableTabProps> = ({
130141
<Text
131142
size="1"
132143
className="max-w-[200px] select-none overflow-hidden text-ellipsis whitespace-nowrap"
144+
style={{
145+
fontStyle: isPreview ? "italic" : "normal",
146+
opacity: isPreview ? 0.7 : 1,
147+
}}
133148
>
134149
{label}
135150
</Text>

apps/array/src/renderer/features/panels/components/LeafNodeRenderer.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ interface LeafNodeRendererProps {
1212
closeTab: (taskId: string, panelId: string, tabId: string) => void;
1313
closeOtherTabs: (panelId: string, tabId: string) => void;
1414
closeTabsToRight: (panelId: string, tabId: string) => void;
15+
keepTab: (panelId: string, tabId: string) => void;
1516
draggingTabId: string | null;
1617
draggingTabPanelId: string | null;
1718
onActiveTabChange: (panelId: string, tabId: string) => void;
@@ -28,6 +29,7 @@ export const LeafNodeRenderer: React.FC<LeafNodeRendererProps> = ({
2829
closeTab,
2930
closeOtherTabs,
3031
closeTabsToRight,
32+
keepTab,
3133
draggingTabId,
3234
draggingTabPanelId,
3335
onActiveTabChange,
@@ -56,6 +58,7 @@ export const LeafNodeRenderer: React.FC<LeafNodeRendererProps> = ({
5658
onActiveTabChange={onActiveTabChange}
5759
onCloseOtherTabs={closeOtherTabs}
5860
onCloseTabsToRight={closeTabsToRight}
61+
onKeepTab={keepTab}
5962
onPanelFocus={onPanelFocus}
6063
draggingTabId={draggingTabId}
6164
draggingTabPanelId={draggingTabPanelId}

apps/array/src/renderer/features/panels/components/PanelLayout.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ const PanelLayoutRenderer: React.FC<{
5252
[layoutState, taskId],
5353
);
5454

55+
const handleKeepTab = useCallback(
56+
(panelId: string, tabId: string) => {
57+
layoutState.keepTab(taskId, panelId, tabId);
58+
},
59+
[layoutState, taskId],
60+
);
61+
5562
const handlePanelFocus = useCallback(
5663
(panelId: string) => {
5764
layoutState.setFocusedPanel(taskId, panelId);
@@ -116,6 +123,7 @@ const PanelLayoutRenderer: React.FC<{
116123
closeTab={layoutState.closeTab}
117124
closeOtherTabs={handleCloseOtherTabs}
118125
closeTabsToRight={handleCloseTabsToRight}
126+
keepTab={handleKeepTab}
119127
draggingTabId={layoutState.draggingTabId}
120128
draggingTabPanelId={layoutState.draggingTabPanelId}
121129
onActiveTabChange={handleSetActiveTab}
@@ -147,6 +155,7 @@ const PanelLayoutRenderer: React.FC<{
147155
handleSetActiveTab,
148156
handleCloseOtherTabs,
149157
handleCloseTabsToRight,
158+
handleKeepTab,
150159
handlePanelFocus,
151160
handleAddTerminal,
152161
handleSplitPanel,

apps/array/src/renderer/features/panels/components/PanelTab.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ interface PanelTabProps {
1212
index: number;
1313
draggable?: boolean;
1414
closeable?: boolean;
15+
isPreview?: boolean;
1516
onSelect: () => void;
1617
onClose?: () => void;
1718
onCloseOthers?: () => void;
1819
onCloseToRight?: () => void;
20+
onKeep?: () => void;
1921
icon?: React.ReactNode;
2022
badge?: React.ReactNode;
2123
hasUnsavedChanges?: boolean;
@@ -30,10 +32,12 @@ export const PanelTab: React.FC<PanelTabProps> = ({
3032
index,
3133
draggable = true,
3234
closeable = true,
35+
isPreview,
3336
onSelect,
3437
onClose,
3538
onCloseOthers,
3639
onCloseToRight,
40+
onKeep,
3741
icon,
3842
badge,
3943
hasUnsavedChanges,
@@ -60,10 +64,12 @@ export const PanelTab: React.FC<PanelTabProps> = ({
6064
isActive={isActive}
6165
index={index}
6266
closeable={closeable}
67+
isPreview={isPreview}
6368
onSelect={onSelect}
6469
onClose={onClose}
6570
onCloseOthers={onCloseOthers}
6671
onCloseToRight={onCloseToRight}
72+
onKeep={onKeep}
6773
icon={icon}
6874
badge={badge}
6975
hasUnsavedChanges={hasUnsavedChanges}

apps/array/src/renderer/features/panels/components/TabbedPanel.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ interface TabbedPanelProps {
4848
onActiveTabChange?: (panelId: string, tabId: string) => void;
4949
onCloseOtherTabs?: (panelId: string, tabId: string) => void;
5050
onCloseTabsToRight?: (panelId: string, tabId: string) => void;
51+
onKeepTab?: (panelId: string, tabId: string) => void;
5152
onPanelFocus?: (panelId: string) => void;
5253
draggingTabId?: string | null;
5354
draggingTabPanelId?: string | null;
@@ -62,6 +63,7 @@ export const TabbedPanel: React.FC<TabbedPanelProps> = ({
6263
onActiveTabChange,
6364
onCloseOtherTabs,
6465
onCloseTabsToRight,
66+
onKeepTab,
6567
onPanelFocus,
6668
draggingTabId = null,
6769
draggingTabPanelId = null,
@@ -163,6 +165,7 @@ export const TabbedPanel: React.FC<TabbedPanelProps> = ({
163165
index={index}
164166
draggable={tab.draggable}
165167
closeable={tab.closeable !== false}
168+
isPreview={tab.isPreview}
166169
onSelect={() => {
167170
onActiveTabChange?.(panelId, tab.id);
168171
onPanelFocus?.(panelId);
@@ -175,6 +178,7 @@ export const TabbedPanel: React.FC<TabbedPanelProps> = ({
175178
}
176179
onCloseOthers={() => onCloseOtherTabs?.(panelId, tab.id)}
177180
onCloseToRight={() => onCloseTabsToRight?.(panelId, tab.id)}
181+
onKeep={() => onKeepTab?.(panelId, tab.id)}
178182
icon={tab.icon}
179183
hasUnsavedChanges={tab.hasUnsavedChanges}
180184
badge={tab.badge}

apps/array/src/renderer/features/panels/hooks/usePanelLayoutHooks.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface PanelLayoutState {
1818
closeTab: (taskId: string, panelId: string, tabId: string) => void;
1919
closeOtherTabs: (taskId: string, panelId: string, tabId: string) => void;
2020
closeTabsToRight: (taskId: string, panelId: string, tabId: string) => void;
21+
keepTab: (taskId: string, panelId: string, tabId: string) => void;
2122
setFocusedPanel: (taskId: string, panelId: string) => void;
2223
addTerminalTab: (taskId: string, panelId: string) => void;
2324
splitPanel: (
@@ -41,6 +42,7 @@ export function usePanelLayoutState(taskId: string): PanelLayoutState {
4142
closeTab: state.closeTab,
4243
closeOtherTabs: state.closeOtherTabs,
4344
closeTabsToRight: state.closeTabsToRight,
45+
keepTab: state.keepTab,
4446
setFocusedPanel: state.setFocusedPanel,
4547
addTerminalTab: state.addTerminalTab,
4648
splitPanel: state.splitPanel,

apps/array/src/renderer/features/panels/store/panelLayoutStore.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,4 +513,119 @@ describe("panelLayoutStore", () => {
513513
expect(updatedMainPanel.type).toBe("leaf");
514514
});
515515
});
516+
517+
describe("preview tabs", () => {
518+
beforeEach(() => {
519+
usePanelLayoutStore.getState().initializeTask("task-1");
520+
});
521+
522+
it("creates preview tab by default when opening a file", () => {
523+
usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx");
524+
525+
const panel = findPanelById(getPanelTree("task-1"), "main-panel");
526+
const fileTab = panel?.content.tabs.find(
527+
(t: { id: string }) => t.id === "file-src/App.tsx",
528+
);
529+
expect(fileTab?.isPreview).toBe(true);
530+
});
531+
532+
it("replaces existing preview tab when opening another file", () => {
533+
usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx");
534+
usePanelLayoutStore.getState().openFile("task-1", "src/Other.tsx");
535+
536+
const panel = findPanelById(getPanelTree("task-1"), "main-panel");
537+
const fileTabs = panel?.content.tabs.filter((t: { id: string }) =>
538+
t.id.startsWith("file-"),
539+
);
540+
expect(fileTabs).toHaveLength(1);
541+
expect(fileTabs?.[0].id).toBe("file-src/Other.tsx");
542+
expect(fileTabs?.[0].isPreview).toBe(true);
543+
});
544+
545+
it("creates permanent tab when asPreview is false", () => {
546+
usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx", false);
547+
548+
const panel = findPanelById(getPanelTree("task-1"), "main-panel");
549+
const fileTab = panel?.content.tabs.find(
550+
(t: { id: string }) => t.id === "file-src/App.tsx",
551+
);
552+
expect(fileTab?.isPreview).toBe(false);
553+
});
554+
555+
it("keeps preview tab as preview when re-clicking same file", () => {
556+
usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx");
557+
usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx");
558+
559+
const panel = findPanelById(getPanelTree("task-1"), "main-panel");
560+
const fileTab = panel?.content.tabs.find(
561+
(t: { id: string }) => t.id === "file-src/App.tsx",
562+
);
563+
expect(fileTab?.isPreview).toBe(true);
564+
});
565+
566+
it("pins preview tab when double-clicking (asPreview=false)", () => {
567+
usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx");
568+
usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx", false);
569+
570+
const panel = findPanelById(getPanelTree("task-1"), "main-panel");
571+
const fileTab = panel?.content.tabs.find(
572+
(t: { id: string }) => t.id === "file-src/App.tsx",
573+
);
574+
expect(fileTab?.isPreview).toBe(false);
575+
});
576+
577+
it("keepTab sets isPreview to false", () => {
578+
usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx");
579+
usePanelLayoutStore
580+
.getState()
581+
.keepTab("task-1", "main-panel", "file-src/App.tsx");
582+
583+
const panel = findPanelById(getPanelTree("task-1"), "main-panel");
584+
const fileTab = panel?.content.tabs.find(
585+
(t: { id: string }) => t.id === "file-src/App.tsx",
586+
);
587+
expect(fileTab?.isPreview).toBe(false);
588+
});
589+
590+
it("does not replace non-preview tabs when opening preview", () => {
591+
usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx", false);
592+
usePanelLayoutStore.getState().openFile("task-1", "src/Other.tsx");
593+
594+
const panel = findPanelById(getPanelTree("task-1"), "main-panel");
595+
const fileTabs = panel?.content.tabs.filter((t: { id: string }) =>
596+
t.id.startsWith("file-"),
597+
);
598+
expect(fileTabs).toHaveLength(2);
599+
expect(
600+
fileTabs?.find((t) => t.id === "file-src/App.tsx")?.isPreview,
601+
).toBe(false);
602+
expect(
603+
fileTabs?.find((t) => t.id === "file-src/Other.tsx")?.isPreview,
604+
).toBe(true);
605+
});
606+
607+
it("openDiff creates preview tab by default", () => {
608+
usePanelLayoutStore
609+
.getState()
610+
.openDiff("task-1", "src/App.tsx", "modified");
611+
612+
const panel = findPanelById(getPanelTree("task-1"), "main-panel");
613+
const diffTab = panel?.content.tabs.find((t: { id: string }) =>
614+
t.id.startsWith("diff-"),
615+
);
616+
expect(diffTab?.isPreview).toBe(true);
617+
});
618+
619+
it("openDiff creates permanent tab when asPreview is false", () => {
620+
usePanelLayoutStore
621+
.getState()
622+
.openDiff("task-1", "src/App.tsx", "modified", false);
623+
624+
const panel = findPanelById(getPanelTree("task-1"), "main-panel");
625+
const diffTab = panel?.content.tabs.find((t: { id: string }) =>
626+
t.id.startsWith("diff-"),
627+
);
628+
expect(diffTab?.isPreview).toBe(false);
629+
});
630+
});
516631
});

0 commit comments

Comments
 (0)