Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
selectChangesExpandedPaths,
selectIsChangesRootExpanded,
useChangesPanelStore,
} from "./changesPanelStore";

describe("changesPanelStore", () => {
beforeEach(() => {
localStorage.clear();
useChangesPanelStore.setState({
preferredViewMode: "list",
viewModeByTask: {},
rootExpandedByTask: {},
expandedPathsByTask: {},
});
});

it("preserves root expanded state per mode on mode switch", () => {
const taskId = "task-1";
const store = useChangesPanelStore.getState();

expect(selectIsChangesRootExpanded(taskId)(store)).toBe(true);

store.setRootExpanded(taskId, false);
expect(
selectIsChangesRootExpanded(taskId)(useChangesPanelStore.getState()),
).toBe(false);

store.setViewMode(taskId, "tree");
expect(
selectIsChangesRootExpanded(taskId)(useChangesPanelStore.getState()),
).toBe(true);

store.setRootExpanded(taskId, false);
expect(
selectIsChangesRootExpanded(taskId)(useChangesPanelStore.getState()),
).toBe(false);

store.setViewMode(taskId, "list");
expect(
selectIsChangesRootExpanded(taskId)(useChangesPanelStore.getState()),
).toBe(false);
});

it("prunes expanded paths that no longer exist", () => {
const taskId = "task-2";
const store = useChangesPanelStore.getState();

store.setExpandedPaths(taskId, ["src", "src/components", "docs"]);
store.pruneExpandedPaths(taskId, ["src", "src/components"]);

const expandedPaths = selectChangesExpandedPaths(taskId)(
useChangesPanelStore.getState(),
);

expect([...expandedPaths]).toEqual(["src", "src/components"]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";

export type ChangesViewMode = "list" | "tree";

interface ChangesPanelStoreState {
preferredViewMode: ChangesViewMode;
viewModeByTask: Record<string, ChangesViewMode>;
rootExpandedByTask: Record<string, Partial<Record<ChangesViewMode, boolean>>>;
expandedPathsByTask: Record<string, Set<string>>;
}

interface ChangesPanelStoreActions {
setViewMode: (taskId: string, mode: ChangesViewMode) => void;
setRootExpanded: (taskId: string, expanded: boolean) => void;
toggleRoot: (taskId: string) => void;
setPathExpanded: (taskId: string, path: string, expanded: boolean) => void;
togglePath: (taskId: string, path: string) => void;
setExpandedPaths: (taskId: string, paths: string[]) => void;
expandPaths: (taskId: string, paths: string[]) => void;
collapseAll: (taskId: string) => void;
pruneExpandedPaths: (taskId: string, validPaths: string[]) => void;
}

type ChangesPanelStore = ChangesPanelStoreState & ChangesPanelStoreActions;

function areSetsEqual(a: Set<string>, b: Set<string>): boolean {
if (a.size !== b.size) return false;
for (const item of a) {
if (!b.has(item)) return false;
}
return true;
}

export const useChangesPanelStore = create<ChangesPanelStore>()(
persist(
(set) => ({
preferredViewMode: "list",
viewModeByTask: {},
rootExpandedByTask: {},
expandedPathsByTask: {},
setViewMode: (taskId, mode) =>
set((state) => ({
preferredViewMode: mode,
viewModeByTask: {
...state.viewModeByTask,
[taskId]: mode,
},
})),
setRootExpanded: (taskId, expanded) =>
set((state) => {
const mode =
state.viewModeByTask[taskId] ?? state.preferredViewMode ?? "list";

return {
rootExpandedByTask: {
...state.rootExpandedByTask,
[taskId]: {
...(state.rootExpandedByTask[taskId] ?? {}),
[mode]: expanded,
},
},
};
}),
toggleRoot: (taskId) =>
set((state) => {
const mode =
state.viewModeByTask[taskId] ?? state.preferredViewMode ?? "list";
const current = state.rootExpandedByTask[taskId]?.[mode] ?? true;

return {
rootExpandedByTask: {
...state.rootExpandedByTask,
[taskId]: {
...(state.rootExpandedByTask[taskId] ?? {}),
[mode]: !current,
},
},
};
}),
setPathExpanded: (taskId, path, expanded) =>
set((state) => {
const currentPaths =
state.expandedPathsByTask[taskId] ?? new Set<string>();
const nextPaths = new Set(currentPaths);

if (expanded) {
if (nextPaths.has(path)) return state;
nextPaths.add(path);
} else {
if (!nextPaths.has(path)) return state;
nextPaths.delete(path);
}

return {
expandedPathsByTask: {
...state.expandedPathsByTask,
[taskId]: nextPaths,
},
};
}),
togglePath: (taskId, path) =>
set((state) => {
const currentPaths =
state.expandedPathsByTask[taskId] ?? new Set<string>();
const nextPaths = new Set(currentPaths);

if (nextPaths.has(path)) {
nextPaths.delete(path);
} else {
nextPaths.add(path);
}

return {
expandedPathsByTask: {
...state.expandedPathsByTask,
[taskId]: nextPaths,
},
};
}),
setExpandedPaths: (taskId, paths) =>
set((state) => {
const currentPaths =
state.expandedPathsByTask[taskId] ?? new Set<string>();
const nextPaths = new Set(paths);

if (areSetsEqual(currentPaths, nextPaths)) {
return state;
}

return {
expandedPathsByTask: {
...state.expandedPathsByTask,
[taskId]: nextPaths,
},
};
}),
expandPaths: (taskId, paths) =>
set((state) => {
if (paths.length === 0) return state;

const currentPaths =
state.expandedPathsByTask[taskId] ?? new Set<string>();
const nextPaths = new Set(currentPaths);
let changed = false;

for (const path of paths) {
if (!nextPaths.has(path)) {
nextPaths.add(path);
changed = true;
}
}

if (!changed) {
return state;
}

return {
expandedPathsByTask: {
...state.expandedPathsByTask,
[taskId]: nextPaths,
},
};
}),
collapseAll: (taskId) =>
set((state) => ({
expandedPathsByTask: {
...state.expandedPathsByTask,
[taskId]: new Set<string>(),
},
})),
pruneExpandedPaths: (taskId, validPaths) =>
set((state) => {
const currentPaths = state.expandedPathsByTask[taskId];
if (!currentPaths || currentPaths.size === 0) {
return state;
}

const validPathSet = new Set(validPaths);
const nextPaths = new Set<string>();
let changed = false;

for (const path of currentPaths) {
if (validPathSet.has(path)) {
nextPaths.add(path);
} else {
changed = true;
}
}

if (!changed) {
return state;
}

return {
expandedPathsByTask: {
...state.expandedPathsByTask,
[taskId]: nextPaths,
},
};
}),
}),
{
name: "changes-panel-storage",
partialize: (state) => ({
preferredViewMode: state.preferredViewMode,
viewModeByTask: state.viewModeByTask,
}),
},
),
);

export const selectChangesViewMode =
(taskId: string) => (state: ChangesPanelStore) =>
state.viewModeByTask[taskId] ?? state.preferredViewMode ?? "list";

export const selectIsChangesRootExpanded =
(taskId: string) => (state: ChangesPanelStore) => {
const mode =
state.viewModeByTask[taskId] ?? state.preferredViewMode ?? "list";
return state.rootExpandedByTask[taskId]?.[mode] ?? true;
};

const EMPTY_EXPANDED_PATHS = new Set<string>();

export const selectChangesExpandedPaths =
(taskId: string) => (state: ChangesPanelStore) =>
state.expandedPathsByTask[taskId] ?? EMPTY_EXPANDED_PATHS;
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { FileIcon } from "@components/ui/FileIcon";
import { Tooltip } from "@components/ui/Tooltip";
import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore";
import { getStatusIndicator } from "@features/task-detail/components/changesFileUtils";
import { getRowPaddingStyle } from "@features/task-detail/components/changesRowStyles";
import { Badge, Box, Flex, Text } from "@radix-ui/themes";
import type { ChangedFile } from "@shared/types";

interface ChangesCloudFileRowProps {
file: ChangedFile;
taskId: string;
isActive: boolean;
paddingLeft?: number;
showTreeSpacer?: boolean;
}

export function ChangesCloudFileRow({
file,
taskId,
isActive,
paddingLeft,
showTreeSpacer,
}: ChangesCloudFileRowProps) {
const openCloudDiffByMode = usePanelLayoutStore(
(state) => state.openCloudDiffByMode,
);
const fileName = file.path.split("/").pop() || file.path;
const indicator = getStatusIndicator(file.status);
const hasLineStats =
file.linesAdded !== undefined || file.linesRemoved !== undefined;

const handleClick = () => {
openCloudDiffByMode(taskId, file.path, file.status);
};

const handleDoubleClick = () => {
openCloudDiffByMode(taskId, file.path, file.status, false);
};

return (
<Tooltip
content={`${file.path} - ${indicator.fullLabel}`}
side="top"
delayDuration={500}
>
<Flex
align="center"
gap="1"
onClick={handleClick}
onDoubleClick={handleDoubleClick}
className={
isActive
? "h-6 cursor-pointer overflow-hidden whitespace-nowrap border-accent-8 border-y bg-accent-4 pr-2 pl-[var(--changes-row-padding)]"
: "h-6 cursor-pointer overflow-hidden whitespace-nowrap border-transparent border-y pr-2 pl-[var(--changes-row-padding)] hover:bg-gray-3"
}
style={getRowPaddingStyle(paddingLeft ?? 8)}
>
{showTreeSpacer && (
<Box className="flex h-4 w-4 shrink-0 items-center justify-center" />
)}
<FileIcon filename={fileName} size={14} />
<Text size="1" className="ml-0.5 min-w-0 shrink select-none truncate">
{fileName}
</Text>
<Text
size="1"
color="gray"
className="ml-1 min-w-0 flex-1 select-none truncate"
>
{file.originalPath
? `${file.originalPath} → ${file.path}`
: file.path}
</Text>

{hasLineStats && (
<Flex
align="center"
gap="1"
className="shrink-0 font-mono text-[10px]"
>
{(file.linesAdded ?? 0) > 0 && (
<Text className="text-green-9">+{file.linesAdded}</Text>
)}
{(file.linesRemoved ?? 0) > 0 && (
<Text className="text-red-9">-{file.linesRemoved}</Text>
)}
</Flex>
)}

<Badge
size="1"
color={indicator.color}
className="shrink-0 px-1 text-[10px]"
>
{indicator.label}
</Badge>
</Flex>
</Tooltip>
);
}
Loading