Skip to content

Commit 56f2f5a

Browse files
committed
feat: add tree view mode to changes tab
1 parent fba8bef commit 56f2f5a

13 files changed

Lines changed: 2532 additions & 573 deletions
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { beforeEach, describe, expect, it } from "vitest";
2+
import {
3+
selectChangesExpandedPaths,
4+
selectIsChangesRootExpanded,
5+
useChangesPanelStore,
6+
} from "./changesPanelStore";
7+
8+
describe("changesPanelStore", () => {
9+
beforeEach(() => {
10+
localStorage.clear();
11+
useChangesPanelStore.setState({
12+
preferredViewMode: "list",
13+
viewModeByTask: {},
14+
rootExpandedByTask: {},
15+
expandedPathsByTask: {},
16+
});
17+
});
18+
19+
it("preserves root expanded state per mode on mode switch", () => {
20+
const taskId = "task-1";
21+
const store = useChangesPanelStore.getState();
22+
23+
expect(selectIsChangesRootExpanded(taskId)(store)).toBe(true);
24+
25+
store.setRootExpanded(taskId, false);
26+
expect(
27+
selectIsChangesRootExpanded(taskId)(useChangesPanelStore.getState()),
28+
).toBe(false);
29+
30+
store.setViewMode(taskId, "tree");
31+
expect(
32+
selectIsChangesRootExpanded(taskId)(useChangesPanelStore.getState()),
33+
).toBe(true);
34+
35+
store.setRootExpanded(taskId, false);
36+
expect(
37+
selectIsChangesRootExpanded(taskId)(useChangesPanelStore.getState()),
38+
).toBe(false);
39+
40+
store.setViewMode(taskId, "list");
41+
expect(
42+
selectIsChangesRootExpanded(taskId)(useChangesPanelStore.getState()),
43+
).toBe(false);
44+
});
45+
46+
it("prunes expanded paths that no longer exist", () => {
47+
const taskId = "task-2";
48+
const store = useChangesPanelStore.getState();
49+
50+
store.setExpandedPaths(taskId, ["src", "src/components", "docs"]);
51+
store.pruneExpandedPaths(taskId, ["src", "src/components"]);
52+
53+
const expandedPaths = selectChangesExpandedPaths(taskId)(
54+
useChangesPanelStore.getState(),
55+
);
56+
57+
expect([...expandedPaths]).toEqual(["src", "src/components"]);
58+
});
59+
});
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import { create } from "zustand";
2+
import { persist } from "zustand/middleware";
3+
4+
export type ChangesViewMode = "list" | "tree";
5+
6+
interface ChangesPanelStoreState {
7+
preferredViewMode: ChangesViewMode;
8+
viewModeByTask: Record<string, ChangesViewMode>;
9+
rootExpandedByTask: Record<string, Partial<Record<ChangesViewMode, boolean>>>;
10+
expandedPathsByTask: Record<string, Set<string>>;
11+
}
12+
13+
interface ChangesPanelStoreActions {
14+
setViewMode: (taskId: string, mode: ChangesViewMode) => void;
15+
setRootExpanded: (taskId: string, expanded: boolean) => void;
16+
toggleRoot: (taskId: string) => void;
17+
setPathExpanded: (taskId: string, path: string, expanded: boolean) => void;
18+
togglePath: (taskId: string, path: string) => void;
19+
setExpandedPaths: (taskId: string, paths: string[]) => void;
20+
expandPaths: (taskId: string, paths: string[]) => void;
21+
collapseAll: (taskId: string) => void;
22+
pruneExpandedPaths: (taskId: string, validPaths: string[]) => void;
23+
}
24+
25+
type ChangesPanelStore = ChangesPanelStoreState & ChangesPanelStoreActions;
26+
27+
function areSetsEqual(a: Set<string>, b: Set<string>): boolean {
28+
if (a.size !== b.size) return false;
29+
for (const item of a) {
30+
if (!b.has(item)) return false;
31+
}
32+
return true;
33+
}
34+
35+
export const useChangesPanelStore = create<ChangesPanelStore>()(
36+
persist(
37+
(set) => ({
38+
preferredViewMode: "list",
39+
viewModeByTask: {},
40+
rootExpandedByTask: {},
41+
expandedPathsByTask: {},
42+
setViewMode: (taskId, mode) =>
43+
set((state) => ({
44+
preferredViewMode: mode,
45+
viewModeByTask: {
46+
...state.viewModeByTask,
47+
[taskId]: mode,
48+
},
49+
})),
50+
setRootExpanded: (taskId, expanded) =>
51+
set((state) => {
52+
const mode =
53+
state.viewModeByTask[taskId] ?? state.preferredViewMode ?? "list";
54+
55+
return {
56+
rootExpandedByTask: {
57+
...state.rootExpandedByTask,
58+
[taskId]: {
59+
...(state.rootExpandedByTask[taskId] ?? {}),
60+
[mode]: expanded,
61+
},
62+
},
63+
};
64+
}),
65+
toggleRoot: (taskId) =>
66+
set((state) => {
67+
const mode =
68+
state.viewModeByTask[taskId] ?? state.preferredViewMode ?? "list";
69+
const current = state.rootExpandedByTask[taskId]?.[mode] ?? true;
70+
71+
return {
72+
rootExpandedByTask: {
73+
...state.rootExpandedByTask,
74+
[taskId]: {
75+
...(state.rootExpandedByTask[taskId] ?? {}),
76+
[mode]: !current,
77+
},
78+
},
79+
};
80+
}),
81+
setPathExpanded: (taskId, path, expanded) =>
82+
set((state) => {
83+
const currentPaths =
84+
state.expandedPathsByTask[taskId] ?? new Set<string>();
85+
const nextPaths = new Set(currentPaths);
86+
87+
if (expanded) {
88+
if (nextPaths.has(path)) return state;
89+
nextPaths.add(path);
90+
} else {
91+
if (!nextPaths.has(path)) return state;
92+
nextPaths.delete(path);
93+
}
94+
95+
return {
96+
expandedPathsByTask: {
97+
...state.expandedPathsByTask,
98+
[taskId]: nextPaths,
99+
},
100+
};
101+
}),
102+
togglePath: (taskId, path) =>
103+
set((state) => {
104+
const currentPaths =
105+
state.expandedPathsByTask[taskId] ?? new Set<string>();
106+
const nextPaths = new Set(currentPaths);
107+
108+
if (nextPaths.has(path)) {
109+
nextPaths.delete(path);
110+
} else {
111+
nextPaths.add(path);
112+
}
113+
114+
return {
115+
expandedPathsByTask: {
116+
...state.expandedPathsByTask,
117+
[taskId]: nextPaths,
118+
},
119+
};
120+
}),
121+
setExpandedPaths: (taskId, paths) =>
122+
set((state) => {
123+
const currentPaths =
124+
state.expandedPathsByTask[taskId] ?? new Set<string>();
125+
const nextPaths = new Set(paths);
126+
127+
if (areSetsEqual(currentPaths, nextPaths)) {
128+
return state;
129+
}
130+
131+
return {
132+
expandedPathsByTask: {
133+
...state.expandedPathsByTask,
134+
[taskId]: nextPaths,
135+
},
136+
};
137+
}),
138+
expandPaths: (taskId, paths) =>
139+
set((state) => {
140+
if (paths.length === 0) return state;
141+
142+
const currentPaths =
143+
state.expandedPathsByTask[taskId] ?? new Set<string>();
144+
const nextPaths = new Set(currentPaths);
145+
let changed = false;
146+
147+
for (const path of paths) {
148+
if (!nextPaths.has(path)) {
149+
nextPaths.add(path);
150+
changed = true;
151+
}
152+
}
153+
154+
if (!changed) {
155+
return state;
156+
}
157+
158+
return {
159+
expandedPathsByTask: {
160+
...state.expandedPathsByTask,
161+
[taskId]: nextPaths,
162+
},
163+
};
164+
}),
165+
collapseAll: (taskId) =>
166+
set((state) => ({
167+
expandedPathsByTask: {
168+
...state.expandedPathsByTask,
169+
[taskId]: new Set<string>(),
170+
},
171+
})),
172+
pruneExpandedPaths: (taskId, validPaths) =>
173+
set((state) => {
174+
const currentPaths = state.expandedPathsByTask[taskId];
175+
if (!currentPaths || currentPaths.size === 0) {
176+
return state;
177+
}
178+
179+
const validPathSet = new Set(validPaths);
180+
const nextPaths = new Set<string>();
181+
let changed = false;
182+
183+
for (const path of currentPaths) {
184+
if (validPathSet.has(path)) {
185+
nextPaths.add(path);
186+
} else {
187+
changed = true;
188+
}
189+
}
190+
191+
if (!changed) {
192+
return state;
193+
}
194+
195+
return {
196+
expandedPathsByTask: {
197+
...state.expandedPathsByTask,
198+
[taskId]: nextPaths,
199+
},
200+
};
201+
}),
202+
}),
203+
{
204+
name: "changes-panel-storage",
205+
partialize: (state) => ({
206+
preferredViewMode: state.preferredViewMode,
207+
viewModeByTask: state.viewModeByTask,
208+
}),
209+
},
210+
),
211+
);
212+
213+
export const selectChangesViewMode =
214+
(taskId: string) => (state: ChangesPanelStore) =>
215+
state.viewModeByTask[taskId] ?? state.preferredViewMode ?? "list";
216+
217+
export const selectIsChangesRootExpanded =
218+
(taskId: string) => (state: ChangesPanelStore) => {
219+
const mode =
220+
state.viewModeByTask[taskId] ?? state.preferredViewMode ?? "list";
221+
return state.rootExpandedByTask[taskId]?.[mode] ?? true;
222+
};
223+
224+
const EMPTY_EXPANDED_PATHS = new Set<string>();
225+
226+
export const selectChangesExpandedPaths =
227+
(taskId: string) => (state: ChangesPanelStore) =>
228+
state.expandedPathsByTask[taskId] ?? EMPTY_EXPANDED_PATHS;
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { FileIcon } from "@components/ui/FileIcon";
2+
import { Tooltip } from "@components/ui/Tooltip";
3+
import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore";
4+
import { getStatusIndicator } from "@features/task-detail/components/changesFileUtils";
5+
import { getRowPaddingStyle } from "@features/task-detail/components/changesRowStyles";
6+
import { Badge, Box, Flex, Text } from "@radix-ui/themes";
7+
import type { ChangedFile } from "@shared/types";
8+
9+
interface ChangesCloudFileRowProps {
10+
file: ChangedFile;
11+
taskId: string;
12+
isActive: boolean;
13+
paddingLeft?: number;
14+
showTreeSpacer?: boolean;
15+
}
16+
17+
export function ChangesCloudFileRow({
18+
file,
19+
taskId,
20+
isActive,
21+
paddingLeft,
22+
showTreeSpacer,
23+
}: ChangesCloudFileRowProps) {
24+
const openCloudDiffByMode = usePanelLayoutStore(
25+
(state) => state.openCloudDiffByMode,
26+
);
27+
const fileName = file.path.split("/").pop() || file.path;
28+
const indicator = getStatusIndicator(file.status);
29+
const hasLineStats =
30+
file.linesAdded !== undefined || file.linesRemoved !== undefined;
31+
32+
const handleClick = () => {
33+
openCloudDiffByMode(taskId, file.path, file.status);
34+
};
35+
36+
const handleDoubleClick = () => {
37+
openCloudDiffByMode(taskId, file.path, file.status, false);
38+
};
39+
40+
return (
41+
<Tooltip
42+
content={`${file.path} - ${indicator.fullLabel}`}
43+
side="top"
44+
delayDuration={500}
45+
>
46+
<Flex
47+
align="center"
48+
gap="1"
49+
onClick={handleClick}
50+
onDoubleClick={handleDoubleClick}
51+
className={
52+
isActive
53+
? "h-6 cursor-pointer overflow-hidden whitespace-nowrap border-accent-8 border-y bg-accent-4 pr-2 pl-[var(--changes-row-padding)]"
54+
: "h-6 cursor-pointer overflow-hidden whitespace-nowrap border-transparent border-y pr-2 pl-[var(--changes-row-padding)] hover:bg-gray-3"
55+
}
56+
style={getRowPaddingStyle(paddingLeft ?? 8)}
57+
>
58+
{showTreeSpacer && (
59+
<Box className="flex h-4 w-4 shrink-0 items-center justify-center" />
60+
)}
61+
<FileIcon filename={fileName} size={14} />
62+
<Text size="1" className="ml-0.5 min-w-0 shrink select-none truncate">
63+
{fileName}
64+
</Text>
65+
<Text
66+
size="1"
67+
color="gray"
68+
className="ml-1 min-w-0 flex-1 select-none truncate"
69+
>
70+
{file.originalPath
71+
? `${file.originalPath}${file.path}`
72+
: file.path}
73+
</Text>
74+
75+
{hasLineStats && (
76+
<Flex
77+
align="center"
78+
gap="1"
79+
className="shrink-0 font-mono text-[10px]"
80+
>
81+
{(file.linesAdded ?? 0) > 0 && (
82+
<Text className="text-green-9">+{file.linesAdded}</Text>
83+
)}
84+
{(file.linesRemoved ?? 0) > 0 && (
85+
<Text className="text-red-9">-{file.linesRemoved}</Text>
86+
)}
87+
</Flex>
88+
)}
89+
90+
<Badge
91+
size="1"
92+
color={indicator.color}
93+
className="shrink-0 px-1 text-[10px]"
94+
>
95+
{indicator.label}
96+
</Badge>
97+
</Flex>
98+
</Tooltip>
99+
);
100+
}

0 commit comments

Comments
 (0)