diff --git a/apps/code/src/renderer/features/right-sidebar/stores/changesPanelStore.test.ts b/apps/code/src/renderer/features/right-sidebar/stores/changesPanelStore.test.ts new file mode 100644 index 000000000..2aa235297 --- /dev/null +++ b/apps/code/src/renderer/features/right-sidebar/stores/changesPanelStore.test.ts @@ -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"]); + }); +}); diff --git a/apps/code/src/renderer/features/right-sidebar/stores/changesPanelStore.ts b/apps/code/src/renderer/features/right-sidebar/stores/changesPanelStore.ts new file mode 100644 index 000000000..1345d715a --- /dev/null +++ b/apps/code/src/renderer/features/right-sidebar/stores/changesPanelStore.ts @@ -0,0 +1,228 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +export type ChangesViewMode = "list" | "tree"; + +interface ChangesPanelStoreState { + preferredViewMode: ChangesViewMode; + viewModeByTask: Record; + rootExpandedByTask: Record>>; + expandedPathsByTask: Record>; +} + +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, b: Set): 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()( + 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(); + 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(); + 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(); + 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(); + 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(), + }, + })), + 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(); + 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(); + +export const selectChangesExpandedPaths = + (taskId: string) => (state: ChangesPanelStore) => + state.expandedPathsByTask[taskId] ?? EMPTY_EXPANDED_PATHS; diff --git a/apps/code/src/renderer/features/task-detail/components/ChangesCloudFileRow.tsx b/apps/code/src/renderer/features/task-detail/components/ChangesCloudFileRow.tsx new file mode 100644 index 000000000..d059f5df3 --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/components/ChangesCloudFileRow.tsx @@ -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 ( + + + {showTreeSpacer && ( + + )} + + + {fileName} + + + {file.originalPath + ? `${file.originalPath} → ${file.path}` + : file.path} + + + {hasLineStats && ( + + {(file.linesAdded ?? 0) > 0 && ( + +{file.linesAdded} + )} + {(file.linesRemoved ?? 0) > 0 && ( + -{file.linesRemoved} + )} + + )} + + + {indicator.label} + + + + ); +} diff --git a/apps/code/src/renderer/features/task-detail/components/ChangesFilesView.tsx b/apps/code/src/renderer/features/task-detail/components/ChangesFilesView.tsx new file mode 100644 index 000000000..95f20a299 --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/components/ChangesFilesView.tsx @@ -0,0 +1,241 @@ +import type { ChangesViewMode } from "@features/right-sidebar/stores/changesPanelStore"; +import { ChangesRootRow } from "@features/task-detail/components/ChangesRootRow"; +import { + getRowPaddingStyle, + getTreePadding, + ROOT_CHILD_PADDING, +} from "@features/task-detail/components/changesRowStyles"; +import { + type ChangesDirectoryNode, + type ChangesTreeModel, + getChangedFileId, + getNodeById, +} from "@features/task-detail/utils/changesTreeModel"; +import { CaretRight, FolderIcon, FolderOpenIcon } from "@phosphor-icons/react"; +import { Box, Flex, Text } from "@radix-ui/themes"; +import type { ChangedFile } from "@shared/types"; +import { type ReactNode, useLayoutEffect, useMemo, useRef } from "react"; +import { VList, type VListHandle } from "virtua"; + +const KEYBOARD_SCROLL_PADDING_ROWS = 6; + +interface ChangesDirectoryRowProps { + node: ChangesDirectoryNode; + isExpanded: boolean; + selectedDirectoryPath: string | null; + onToggle: () => void; +} + +function ChangesDirectoryRow({ + node, + isExpanded, + selectedDirectoryPath, + onToggle, +}: ChangesDirectoryRowProps) { + const isSelected = selectedDirectoryPath === node.path; + + return ( + + + + + {isExpanded ? ( + + ) : ( + + )} + + {node.name} + + + ); +} + +export interface ChangesFilesViewProps { + rootLabel: string; + files: ChangedFile[]; + viewMode: ChangesViewMode; + isRootExpanded: boolean; + isViewOptionsMenuOpen: boolean; + selectedEntryId?: string | null; + treeModel: ChangesTreeModel; + visibleTreeRowIds: string[]; + expandedPaths: Set; + selectedDirectoryPath?: string | null; + allDirectoryPaths: string[]; + onToggleRoot: () => void; + onViewOptionsMenuOpenChange: (open: boolean) => void; + onSetViewMode: (mode: ChangesViewMode) => void; + onToggleDirectory: (directoryPath: string, directoryId: string) => void; + onExpandAllFolders: () => void; + onCollapseAllFolders: () => void; + renderFileRow: ( + file: ChangedFile, + options: { paddingLeft: number; showTreeSpacer: boolean }, + ) => ReactNode; + footer?: ReactNode; +} + +export function ChangesFilesView({ + rootLabel, + files, + viewMode, + isRootExpanded, + isViewOptionsMenuOpen, + selectedEntryId, + treeModel, + visibleTreeRowIds, + expandedPaths, + selectedDirectoryPath, + allDirectoryPaths, + onToggleRoot, + onViewOptionsMenuOpenChange, + onSetViewMode, + onToggleDirectory, + onExpandAllFolders, + onCollapseAllFolders, + renderFileRow, + footer, +}: ChangesFilesViewProps) { + const listRef = useRef(null); + + const renderedRowIds = useMemo( + () => + viewMode === "list" + ? files.map((file) => getChangedFileId(file)) + : visibleTreeRowIds, + [files, viewMode, visibleTreeRowIds], + ); + + useLayoutEffect(() => { + if (!isRootExpanded || !selectedEntryId) { + return; + } + + const selectedIndex = renderedRowIds.indexOf(selectedEntryId); + if (selectedIndex === -1) { + return; + } + + const handle = listRef.current; + if (!handle) { + return; + } + + const animationFrame = requestAnimationFrame(() => { + const itemSize = handle.getItemSize(selectedIndex); + const viewportSize = handle.viewportSize; + + if (itemSize <= 0 || viewportSize <= 0) { + handle.scrollToIndex(selectedIndex, { align: "nearest" }); + return; + } + + const paddingPx = itemSize * KEYBOARD_SCROLL_PADDING_ROWS; + const itemStart = handle.getItemOffset(selectedIndex); + const itemEnd = itemStart + itemSize; + const viewStart = handle.scrollOffset; + const viewEnd = viewStart + viewportSize; + const topThreshold = viewStart + paddingPx; + const bottomThreshold = viewEnd - paddingPx; + + if (bottomThreshold <= topThreshold) { + handle.scrollToIndex(selectedIndex, { align: "nearest" }); + return; + } + + if (itemStart < topThreshold) { + handle.scrollTo(Math.max(0, itemStart - paddingPx)); + return; + } + + if (itemEnd > bottomThreshold) { + const maxOffset = Math.max(0, handle.scrollSize - viewportSize); + handle.scrollTo( + Math.min(maxOffset, itemEnd + paddingPx - viewportSize), + ); + } + }); + + return () => { + cancelAnimationFrame(animationFrame); + }; + }, [isRootExpanded, renderedRowIds, selectedEntryId]); + + return ( + + 0} + /> + + {isRootExpanded && ( + + + {viewMode === "list" + ? files.map((file) => ( + + {renderFileRow(file, { + paddingLeft: ROOT_CHILD_PADDING, + showTreeSpacer: false, + })} + + )) + : visibleTreeRowIds.map((rowId) => { + const node = getNodeById(treeModel, rowId); + if (!node) { + return null; + } + + if (node.kind === "directory") { + return ( + onToggleDirectory(node.path, node.id)} + /> + ); + } + + return ( + + {renderFileRow(node.file, { + paddingLeft: getTreePadding(node.depth), + showTreeSpacer: true, + })} + + ); + })} + {footer &&
{footer}
} +
+
+ )} +
+ ); +} diff --git a/apps/code/src/renderer/features/task-detail/components/ChangesLocalFileRow.tsx b/apps/code/src/renderer/features/task-detail/components/ChangesLocalFileRow.tsx new file mode 100644 index 000000000..204e97c74 --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/components/ChangesLocalFileRow.tsx @@ -0,0 +1,280 @@ +import { FileIcon } from "@components/ui/FileIcon"; +import { Tooltip } from "@components/ui/Tooltip"; +import { useExternalApps } from "@features/external-apps/hooks/useExternalApps"; +import { updateGitCacheFromSnapshot } from "@features/git-interaction/utils/updateGitCache"; +import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; +import { + getDiscardInfo, + getStatusIndicator, +} from "@features/task-detail/components/changesFileUtils"; +import { getRowPaddingStyle } from "@features/task-detail/components/changesRowStyles"; +import { + ArrowCounterClockwiseIcon, + CodeIcon, + CopyIcon, + FilePlus, +} from "@phosphor-icons/react"; +import { + Badge, + Box, + DropdownMenu, + Flex, + IconButton, + Text, +} from "@radix-ui/themes"; +import { useWorkspace } from "@renderer/features/workspace/hooks/useWorkspace"; +import { trpcClient } from "@renderer/trpc/client"; +import type { ChangedFile } from "@shared/types"; +import { useQueryClient } from "@tanstack/react-query"; +import { showMessageBox } from "@utils/dialog"; +import { handleExternalAppAction } from "@utils/handleExternalAppAction"; +import { useState } from "react"; + +interface ChangesLocalFileRowProps { + file: ChangedFile; + taskId: string; + repoPath: string; + isActive: boolean; + isKeyboardSelected?: boolean; + mainRepoPath?: string; + paddingLeft?: number; + showTreeSpacer?: boolean; +} + +export function ChangesLocalFileRow({ + file, + taskId, + repoPath, + isActive, + isKeyboardSelected, + mainRepoPath, + paddingLeft, + showTreeSpacer, +}: ChangesLocalFileRowProps) { + const openDiffByMode = usePanelLayoutStore((state) => state.openDiffByMode); + const closeDiffTabsForFile = usePanelLayoutStore( + (state) => state.closeDiffTabsForFile, + ); + const queryClient = useQueryClient(); + const { detectedApps } = useExternalApps(); + const workspace = useWorkspace(taskId); + + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [isHovered, setIsHovered] = useState(false); + + const isToolbarVisible = isHovered || isDropdownOpen; + const fileName = file.path.split("/").pop() || file.path; + const fullPath = `${repoPath}/${file.path}`; + const indicator = getStatusIndicator(file.status); + + const handleClick = () => { + openDiffByMode(taskId, file.path, file.status); + }; + + const handleDoubleClick = () => { + openDiffByMode(taskId, file.path, file.status, false); + }; + + const workspaceContext = { + workspace, + mainRepoPath, + }; + + const handleContextMenu = async (e: React.MouseEvent) => { + e.preventDefault(); + const result = await trpcClient.contextMenu.showFileContextMenu.mutate({ + filePath: fullPath, + }); + + if (!result.action) return; + + if (result.action.type === "external-app") { + await handleExternalAppAction( + result.action.action, + fullPath, + fileName, + workspaceContext, + ); + } + }; + + const handleOpenWith = async (appId: string) => { + await handleExternalAppAction( + { type: "open-in-app", appId }, + fullPath, + fileName, + workspaceContext, + ); + + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + }; + + const handleCopyPath = async () => { + await handleExternalAppAction({ type: "copy-path" }, fullPath, fileName); + }; + + const handleDiscard = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const { message, action } = getDiscardInfo(file, fileName); + + const dialogResult = await showMessageBox({ + type: "warning", + title: "Discard changes", + message, + buttons: ["Cancel", action], + defaultId: 1, + cancelId: 0, + }); + + if (dialogResult.response !== 1) return; + + const discardResult = await trpcClient.git.discardFileChanges.mutate({ + directoryPath: repoPath, + filePath: file.originalPath ?? file.path, + fileStatus: file.status, + }); + + closeDiffTabsForFile(taskId, file.path); + + if (discardResult.state) { + updateGitCacheFromSnapshot(queryClient, repoPath, discardResult.state); + } + }; + + const hasLineStats = + file.linesAdded !== undefined || file.linesRemoved !== undefined; + + const tooltipContent = `${file.path} - ${indicator.fullLabel}`; + const isRowActive = isKeyboardSelected ?? isActive; + + return ( + + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + className={ + isRowActive + ? "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 && ( + + )} + + + {fileName} + + + {file.originalPath + ? `${file.originalPath} → ${file.path}` + : file.path} + + + {hasLineStats && !isToolbarVisible && ( + + {(file.linesAdded ?? 0) > 0 && ( + +{file.linesAdded} + )} + {(file.linesRemoved ?? 0) > 0 && ( + -{file.linesRemoved} + )} + + )} + + {isToolbarVisible && ( + + + + + + + + + + + e.stopPropagation()} + className="h-5 w-5 shrink-0 p-0" + > + + + + + + {detectedApps + .filter((app) => app.type !== "terminal") + .map((app) => ( + handleOpenWith(app.id)} + > + + {app.icon ? ( + + ) : ( + + )} + {app.name} + + + ))} + + + + + Copy Path + + + + + + )} + + + {indicator.label} + + + + ); +} diff --git a/apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx b/apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx index 10d3669eb..078a44277 100644 --- a/apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx +++ b/apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx @@ -1,503 +1,253 @@ -import { FileIcon } from "@components/ui/FileIcon"; import { PanelMessage } from "@components/ui/PanelMessage"; -import { Tooltip } from "@components/ui/Tooltip"; -import { useExternalApps } from "@features/external-apps/hooks/useExternalApps"; import { useCloudBranchChangedFiles, useCloudPrChangedFiles, useGitQueries, } from "@features/git-interaction/hooks/useGitQueries"; -import { updateGitCacheFromSnapshot } from "@features/git-interaction/utils/updateGitCache"; import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; import { isCloudDiffTabActiveInTree, isDiffTabActiveInTree, } from "@features/panels/store/panelStoreHelpers"; +import { + type ChangesViewMode, + selectChangesExpandedPaths, + selectChangesViewMode, + selectIsChangesRootExpanded, + useChangesPanelStore, +} from "@features/right-sidebar/stores/changesPanelStore"; import { usePendingPermissionsForTask } from "@features/sessions/stores/sessionStore"; import { useCwd } from "@features/sidebar/hooks/useCwd"; +import { ChangesCloudFileRow } from "@features/task-detail/components/ChangesCloudFileRow"; +import { ChangesFilesView } from "@features/task-detail/components/ChangesFilesView"; +import { ChangesLocalFileRow } from "@features/task-detail/components/ChangesLocalFileRow"; +import { useChangesKeyboardNavigation } from "@features/task-detail/hooks/useChangesKeyboardNavigation"; import { useCloudRunState } from "@features/task-detail/hooks/useCloudRunState"; import { - ArrowCounterClockwiseIcon, + buildChangesTreeModel, + buildVisibleTreeRowIds, + type ChangesTreeModel, + collapseDirectoryInVisibleRows, + collectDirectoryPaths, + collectInitialExpandedPaths, + expandDirectoryInVisibleRows, + getChangedFileId, +} from "@features/task-detail/utils/changesTreeModel"; +import { getCloudChangesState } from "@features/task-detail/utils/getCloudChangesState"; +import { CaretDownIcon, + CaretLeftIcon, + CaretRightIcon, CaretUpIcon, - CodeIcon, - CopyIcon, - FilePlus, } from "@phosphor-icons/react"; -import { - Badge, - Box, - Button, - DropdownMenu, - Flex, - IconButton, - Spinner, - Text, -} from "@radix-ui/themes"; +import { Box, Button, Flex, Spinner, Text } from "@radix-ui/themes"; import { useWorkspace } from "@renderer/features/workspace/hooks/useWorkspace"; -import { trpcClient } from "@renderer/trpc/client"; -import type { ChangedFile, GitFileStatus, Task } from "@shared/types"; -import { useQueryClient } from "@tanstack/react-query"; -import { showMessageBox } from "@utils/dialog"; -import { handleExternalAppAction } from "@utils/handleExternalAppAction"; -import { useCallback, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; +import type { ChangedFile, Task } from "@shared/types"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; interface ChangesPanelProps { taskId: string; task: Task; } -interface ChangedFileItemProps { - file: ChangedFile; - taskId: string; - repoPath: string; - isActive: boolean; - mainRepoPath?: string; +function getBaseName(path: string | null | undefined): string | null { + if (!path) return null; + const normalized = path.replaceAll("\\", "/").replace(/\/+$/, ""); + if (!normalized) return null; + return normalized.split("/").pop() || null; } -function getStatusIndicator(status: GitFileStatus): { - label: string; - fullLabel: string; - color: "green" | "orange" | "red" | "blue" | "gray"; -} { - switch (status) { - case "added": - case "untracked": - return { label: "A", fullLabel: "Added", color: "green" }; - case "deleted": - return { label: "D", fullLabel: "Deleted", color: "red" }; - case "modified": - return { label: "M", fullLabel: "Modified", color: "orange" }; - case "renamed": - return { label: "R", fullLabel: "Renamed", color: "blue" }; - default: - return { label: "?", fullLabel: "Unknown", color: "gray" }; - } +function getRepositoryName( + repository: string | null | undefined, +): string | null { + if (!repository) return null; + return repository.split("/").pop() || null; } -function getDiscardInfo( - file: ChangedFile, - fileName: string, -): { message: string; action: string } { - switch (file.status) { - case "modified": - return { - message: `Are you sure you want to discard changes in '${fileName}'?`, - action: "Discard File", - }; - case "deleted": - return { - message: `Are you sure you want to restore '${fileName}'?`, - action: "Restore File", - }; - case "added": - return { - message: `Are you sure you want to remove '${fileName}'?`, - action: "Remove File", - }; - case "untracked": - return { - message: `Are you sure you want to delete '${fileName}'?`, - action: "Delete File", - }; - case "renamed": - return { - message: `Are you sure you want to undo the rename of '${fileName}'?`, - action: "Undo Rename File", - }; - default: - return { - message: `Are you sure you want to discard changes in '${fileName}'?`, - action: "Discard File", - }; - } +interface UseChangesTreeViewStateResult { + treeModel: ChangesTreeModel; + allDirectoryPaths: string[]; + visibleTreeRowIds: string[]; + expandedPaths: Set; + setDirectoryExpanded: ( + directoryPath: string, + directoryId: string, + expanded: boolean, + ) => void; + toggleDirectory: (directoryPath: string, directoryId: string) => void; + expandAllDirectories: () => void; + collapseAllDirectories: () => void; } -function ChangedFileItem({ - file, - taskId, - repoPath, - isActive, - mainRepoPath, -}: ChangedFileItemProps) { - const openDiffByMode = usePanelLayoutStore((state) => state.openDiffByMode); - const closeDiffTabsForFile = usePanelLayoutStore( - (state) => state.closeDiffTabsForFile, +function useChangesTreeViewState( + taskId: string, + files: ChangedFile[], + viewMode: ChangesViewMode, +): UseChangesTreeViewStateResult { + const expandedPaths = useChangesPanelStore( + selectChangesExpandedPaths(taskId), + ); + const expandPaths = useChangesPanelStore((state) => state.expandPaths); + const setPathExpanded = useChangesPanelStore( + (state) => state.setPathExpanded, + ); + const setExpandedPaths = useChangesPanelStore( + (state) => state.setExpandedPaths, + ); + const collapseAll = useChangesPanelStore((state) => state.collapseAll); + const pruneExpandedPaths = useChangesPanelStore( + (state) => state.pruneExpandedPaths, ); - const queryClient = useQueryClient(); - const { detectedApps } = useExternalApps(); - const workspace = useWorkspace(taskId); - - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [isHovered, setIsHovered] = useState(false); - - // show toolbar when hovered OR when dropdown is open - const isToolbarVisible = isHovered || isDropdownOpen; - - const fileName = file.path.split("/").pop() || file.path; - const fullPath = `${repoPath}/${file.path}`; - const indicator = getStatusIndicator(file.status); - const handleClick = () => { - openDiffByMode(taskId, file.path, file.status); - }; + const treeModel = useMemo(() => buildChangesTreeModel(files), [files]); + const allDirectoryPaths = useMemo( + () => collectDirectoryPaths(treeModel), + [treeModel], + ); - const handleDoubleClick = () => { - openDiffByMode(taskId, file.path, file.status, false); - }; + const [visibleTreeRowIds, setVisibleTreeRowIds] = useState(() => + buildVisibleTreeRowIds(treeModel, expandedPaths), + ); - const workspaceContext = { - workspace, - mainRepoPath, - }; + const skipExpandedSyncRef = useRef(false); + const previousTreeModelRef = useRef(treeModel); + const previousViewModeRef = useRef(null); - const handleContextMenu = async (e: React.MouseEvent) => { - e.preventDefault(); - const result = await trpcClient.contextMenu.showFileContextMenu.mutate({ - filePath: fullPath, - }); + useEffect(() => { + pruneExpandedPaths(taskId, allDirectoryPaths); + }, [allDirectoryPaths, pruneExpandedPaths, taskId]); - if (!result.action) return; + useEffect(() => { + const treeChanged = previousTreeModelRef.current !== treeModel; + previousTreeModelRef.current = treeModel; - if (result.action.type === "external-app") { - await handleExternalAppAction( - result.action.action, - fullPath, - fileName, - workspaceContext, - ); + if (treeChanged) { + skipExpandedSyncRef.current = false; + setVisibleTreeRowIds(buildVisibleTreeRowIds(treeModel, expandedPaths)); + return; } - }; - - const handleOpenWith = async (appId: string) => { - await handleExternalAppAction( - { type: "open-in-app", appId }, - fullPath, - fileName, - workspaceContext, - ); - // blur active element to dismiss any open tooltip - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); + if (skipExpandedSyncRef.current) { + skipExpandedSyncRef.current = false; + return; } - }; - const handleCopyPath = async () => { - await handleExternalAppAction({ type: "copy-path" }, fullPath, fileName); - }; + setVisibleTreeRowIds(buildVisibleTreeRowIds(treeModel, expandedPaths)); + }, [expandedPaths, treeModel]); - const handleDiscard = async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); + useEffect(() => { + const previousViewMode = previousViewModeRef.current; + previousViewModeRef.current = viewMode; - const { message, action } = getDiscardInfo(file, fileName); + const isInitialTreeLoad = previousViewMode === null && viewMode === "tree"; + const isEnteringTree = + previousViewMode !== null && + previousViewMode !== "tree" && + viewMode === "tree"; - const dialogResult = await showMessageBox({ - type: "warning", - title: "Discard changes", - message, - buttons: ["Cancel", action], - defaultId: 1, - cancelId: 0, - }); - - if (dialogResult.response !== 1) return; + if (!isInitialTreeLoad && !isEnteringTree) { + return; + } - const discardResult = await trpcClient.git.discardFileChanges.mutate({ - directoryPath: repoPath, - filePath: file.originalPath ?? file.path, - fileStatus: file.status, - }); + const hasExpandedVisiblePath = allDirectoryPaths.some((path) => + expandedPaths.has(path), + ); + if (hasExpandedVisiblePath) { + return; + } - closeDiffTabsForFile(taskId, file.path); + const initialExpandedPaths = collectInitialExpandedPaths(treeModel); + if (initialExpandedPaths.length === 0) { + return; + } - if (discardResult.state) { - updateGitCacheFromSnapshot(queryClient, repoPath, discardResult.state); + const nextExpandedPaths = new Set(expandedPaths); + for (const path of initialExpandedPaths) { + nextExpandedPaths.add(path); } - }; - const hasLineStats = - file.linesAdded !== undefined || file.linesRemoved !== undefined; + skipExpandedSyncRef.current = true; + expandPaths(taskId, initialExpandedPaths); + setVisibleTreeRowIds(buildVisibleTreeRowIds(treeModel, nextExpandedPaths)); + }, [ + allDirectoryPaths, + expandedPaths, + expandPaths, + taskId, + treeModel, + viewMode, + ]); + + const setDirectoryExpanded = useCallback( + (directoryPath: string, directoryId: string, expanded: boolean) => { + const isExpanded = expandedPaths.has(directoryPath); + if (isExpanded === expanded) { + return; + } - const tooltipContent = `${file.path} - ${indicator.fullLabel}`; + skipExpandedSyncRef.current = true; + setPathExpanded(taskId, directoryPath, expanded); + + if (expanded) { + const nextExpandedPaths = new Set(expandedPaths); + nextExpandedPaths.add(directoryPath); + setVisibleTreeRowIds((currentRows) => + expandDirectoryInVisibleRows( + currentRows, + treeModel, + directoryId, + nextExpandedPaths, + ), + ); + return; + } - return ( - - setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - className={ - isActive - ? "border-accent-8 border-y bg-accent-4" - : "border-transparent border-y hover:bg-gray-3" - } - style={{ - cursor: "pointer", - whiteSpace: "nowrap", - overflow: "hidden", - height: "26px", - paddingLeft: "8px", - paddingRight: "8px", - }} - > - - - {fileName} - - - {file.originalPath - ? `${file.originalPath} → ${file.path}` - : file.path} - - - {hasLineStats && !isToolbarVisible && ( - - {(file.linesAdded ?? 0) > 0 && ( - - +{file.linesAdded} - - )} - {(file.linesRemoved ?? 0) > 0 && ( - - -{file.linesRemoved} - - )} - - )} - - {isToolbarVisible && ( - - - - - - - - - - - e.stopPropagation()} - style={{ - flexShrink: 0, - width: "18px", - height: "18px", - padding: 0, - }} - > - - - - - - {detectedApps - .filter((app) => app.type !== "terminal") - .map((app) => ( - handleOpenWith(app.id)} - > - - {app.icon ? ( - - ) : ( - - )} - {app.name} - - - ))} - - - - - Copy Path - - - - - - )} - - - {indicator.label} - - - + setVisibleTreeRowIds((currentRows) => + collapseDirectoryInVisibleRows(currentRows, treeModel, directoryId), + ); + }, + [expandedPaths, setPathExpanded, taskId, treeModel], ); -} -function CloudChangedFileItem({ - file, - taskId, - isActive, -}: { - file: ChangedFile; - taskId: string; - isActive: boolean; -}) { - const openCloudDiffByMode = usePanelLayoutStore( - (state) => state.openCloudDiffByMode, + const toggleDirectory = useCallback( + (directoryPath: string, directoryId: string) => { + setDirectoryExpanded( + directoryPath, + directoryId, + !expandedPaths.has(directoryPath), + ); + }, + [expandedPaths, setDirectoryExpanded], ); - 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 expandAllDirectories = useCallback(() => { + if (allDirectoryPaths.length === 0) { + return; + } - const handleDoubleClick = () => { - openCloudDiffByMode(taskId, file.path, file.status, false); + skipExpandedSyncRef.current = true; + setExpandedPaths(taskId, allDirectoryPaths); + setVisibleTreeRowIds( + buildVisibleTreeRowIds(treeModel, new Set(allDirectoryPaths)), + ); + }, [allDirectoryPaths, setExpandedPaths, taskId, treeModel]); + + const collapseAllDirectories = useCallback(() => { + skipExpandedSyncRef.current = true; + collapseAll(taskId); + setVisibleTreeRowIds(buildVisibleTreeRowIds(treeModel, new Set())); + }, [collapseAll, taskId, treeModel]); + + return { + treeModel, + allDirectoryPaths, + visibleTreeRowIds, + expandedPaths, + setDirectoryExpanded, + toggleDirectory, + expandAllDirectories, + collapseAllDirectories, }; - - return ( - - - - - {fileName} - - - {file.originalPath - ? `${file.originalPath} → ${file.path}` - : file.path} - - - {hasLineStats && ( - - {(file.linesAdded ?? 0) > 0 && ( - - +{file.linesAdded} - - )} - {(file.linesRemoved ?? 0) > 0 && ( - - -{file.linesRemoved} - - )} - - )} - - - {indicator.label} - - - - ); } function CloudChangesPanel({ taskId, task }: ChangesPanelProps) { @@ -505,20 +255,28 @@ function CloudChangesPanel({ taskId, task }: ChangesPanelProps) { useCloudRunState(taskId, task); const layout = usePanelLayoutStore((state) => state.getLayout(taskId)); + const viewMode = useChangesPanelStore(selectChangesViewMode(taskId)); + const isRootExpanded = useChangesPanelStore( + selectIsChangesRootExpanded(taskId), + ); + const setViewMode = useChangesPanelStore((state) => state.setViewMode); + const toggleRoot = useChangesPanelStore((state) => state.toggleRoot); + const setRootExpanded = useChangesPanelStore( + (state) => state.setRootExpanded, + ); + const [isViewOptionsMenuOpen, setIsViewOptionsMenuOpen] = useState(false); const isFileActive = (file: ChangedFile): boolean => { if (!layout) return false; return isCloudDiffTabActiveInTree(layout.panelTree, file.path, file.status); }; - // PR-based files (preferred when PR exists, to avoid possible state weirdness) const { data: prFiles, isPending: prPending, isError: prError, } = useCloudPrChangedFiles(prUrl); - // Branch-based files — use effectiveBranch (includes live cloudBranch) const { data: branchFiles, isPending: branchPending, @@ -531,80 +289,111 @@ function CloudChangesPanel({ taskId, task }: ChangesPanelProps) { const changedFiles = prUrl ? (prFiles ?? []) : (branchFiles ?? []); const isLoading = prUrl ? prPending : effectiveBranch ? branchPending : false; const hasError = prUrl ? prError : effectiveBranch ? branchError : false; - const effectiveFiles = changedFiles.length > 0 ? changedFiles : fallbackFiles; - // No branch/PR yet and run is active — show waiting state - if (!prUrl && !effectiveBranch && effectiveFiles.length === 0) { - if (isRunActive) { - return ( - - - - Waiting for changes... - - - ); - } - return No file changes yet; + const { + treeModel, + allDirectoryPaths, + visibleTreeRowIds, + expandedPaths, + toggleDirectory, + expandAllDirectories, + collapseAllDirectories, + } = useChangesTreeViewState(taskId, effectiveFiles, viewMode); + + const handleExpandAllFolders = useCallback(() => { + setRootExpanded(taskId, true); + expandAllDirectories(); + }, [expandAllDirectories, setRootExpanded, taskId]); + + const cloudChangesState = getCloudChangesState({ + prUrl, + effectiveBranch, + isRunActive, + effectiveFiles, + isLoading, + hasError, + }); + + if (cloudChangesState.kind === "waiting") { + return ( + + + + Waiting for changes... + + + ); } - if (isLoading && effectiveFiles.length === 0) { + if (cloudChangesState.kind === "loading") { return Loading changes...; } - if (effectiveFiles.length === 0) { - if (hasError && prUrl) { - return ( - - - Could not load file changes - - - - ); - } - if (prUrl) { - return No file changes in pull request; - } - if (isRunActive) { - return ( - - - - Waiting for changes... - - - ); - } - return No file changes yet; + if (cloudChangesState.kind === "pr_error") { + return ( + + + Could not load file changes + + + + ); } + if (cloudChangesState.kind === "empty") { + return {cloudChangesState.message}; + } + + const rootLabel = + getRepositoryName(repo ?? task.repository) ?? "Cloud workspace"; + return ( - - - {effectiveFiles.map((file) => ( - - ))} - {isRunActive && ( + toggleRoot(taskId)} + onViewOptionsMenuOpenChange={setIsViewOptionsMenuOpen} + onSetViewMode={(mode) => setViewMode(taskId, mode)} + onToggleDirectory={toggleDirectory} + onExpandAllFolders={handleExpandAllFolders} + onCollapseAllFolders={collapseAllDirectories} + renderFileRow={(file, options) => ( + + )} + footer={ + isRunActive ? ( Agent is still running... - )} - - + ) : undefined + } + /> ); } @@ -625,54 +414,56 @@ function LocalChangesPanel({ taskId, task: _task }: ChangesPanelProps) { const repoPath = useCwd(taskId); const layout = usePanelLayoutStore((state) => state.getLayout(taskId)); const openDiffByMode = usePanelLayoutStore((state) => state.openDiffByMode); + const viewMode = useChangesPanelStore(selectChangesViewMode(taskId)); + const isRootExpanded = useChangesPanelStore( + selectIsChangesRootExpanded(taskId), + ); + const setViewMode = useChangesPanelStore((state) => state.setViewMode); + const toggleRoot = useChangesPanelStore((state) => state.toggleRoot); + const setRootExpanded = useChangesPanelStore( + (state) => state.setRootExpanded, + ); + const [isViewOptionsMenuOpen, setIsViewOptionsMenuOpen] = useState(false); const pendingPermissions = usePendingPermissionsForTask(taskId); const hasPendingPermissions = pendingPermissions.size > 0; const { changedFiles, changesLoading: isLoading } = useGitQueries(repoPath); - const getActiveIndex = useCallback((): number => { - if (!layout) return -1; - return changedFiles.findIndex((file) => - isDiffTabActiveInTree(layout.panelTree, file.path, file.status), - ); - }, [layout, changedFiles]); - - const handleKeyNavigation = useCallback( - (direction: "up" | "down") => { - if (changedFiles.length === 0) return; - - const currentIndex = getActiveIndex(); - const startIndex = - currentIndex === -1 - ? direction === "down" - ? -1 - : changedFiles.length - : currentIndex; - const newIndex = - direction === "up" - ? Math.max(0, startIndex - 1) - : Math.min(changedFiles.length - 1, startIndex + 1); - - const file = changedFiles[newIndex]; - if (file) { - openDiffByMode(taskId, file.path, file.status); - } - }, - [changedFiles, getActiveIndex, openDiffByMode, taskId], - ); + const { + treeModel, + allDirectoryPaths, + visibleTreeRowIds, + expandedPaths, + setDirectoryExpanded, + toggleDirectory, + expandAllDirectories, + collapseAllDirectories, + } = useChangesTreeViewState(taskId, changedFiles, viewMode); - useHotkeys( - "up", - () => handleKeyNavigation("up"), - { enabled: !hasPendingPermissions }, - [handleKeyNavigation, hasPendingPermissions], - ); - useHotkeys( - "down", - () => handleKeyNavigation("down"), - { enabled: !hasPendingPermissions }, - [handleKeyNavigation, hasPendingPermissions], - ); + const { + selectedEntryId, + selectedDirectoryPath, + selectedFileId, + hasKeyboardSelection, + } = useChangesKeyboardNavigation({ + taskId, + viewMode, + isRootExpanded, + isViewOptionsMenuOpen, + hasPendingPermissions, + changedFiles, + visibleTreeRowIds, + treeModel, + expandedPaths, + layout, + openDiffByMode, + setDirectoryExpanded, + }); + + const handleExpandAllFolders = useCallback(() => { + setRootExpanded(taskId, true); + expandAllDirectories(); + }, [expandAllDirectories, setRootExpanded, taskId]); const isFileActive = (file: ChangedFile): boolean => { if (!layout) return false; @@ -687,9 +478,7 @@ function LocalChangesPanel({ taskId, task: _task }: ChangesPanelProps) { return Loading changes...; } - const hasChanges = changedFiles.length > 0; - - if (!hasChanges) { + if (changedFiles.length === 0) { return ( @@ -699,30 +488,75 @@ function LocalChangesPanel({ taskId, task: _task }: ChangesPanelProps) { ); } + const rootLabel = + workspace?.worktreeName ?? + getBaseName(repoPath) ?? + getBaseName(workspace?.folderPath) ?? + "Workspace"; + return ( - - - {changedFiles.map((file) => ( - - ))} - - - - / - - - - to switch files - - - - + toggleRoot(taskId)} + onViewOptionsMenuOpenChange={setIsViewOptionsMenuOpen} + onSetViewMode={(mode) => setViewMode(taskId, mode)} + onToggleDirectory={toggleDirectory} + onExpandAllFolders={handleExpandAllFolders} + onCollapseAllFolders={collapseAllDirectories} + renderFileRow={(file, options) => ( + + )} + footer={ + isRootExpanded ? ( + + + + + / + + + {viewMode === "tree" && ( + <> + + / + + + + / + + + + )} + + to navigate + + + + ) : undefined + } + /> ); } diff --git a/apps/code/src/renderer/features/task-detail/components/ChangesRootRow.tsx b/apps/code/src/renderer/features/task-detail/components/ChangesRootRow.tsx new file mode 100644 index 000000000..76ed2dd2c --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/components/ChangesRootRow.tsx @@ -0,0 +1,123 @@ +import { Tooltip } from "@components/ui/Tooltip"; +import type { ChangesViewMode } from "@features/right-sidebar/stores/changesPanelStore"; +import { + CaretRight, + DotsThree, + FolderIcon, + FolderOpenIcon, +} from "@phosphor-icons/react"; +import { Box, DropdownMenu, Flex, IconButton, Text } from "@radix-ui/themes"; + +interface ChangesRootRowProps { + rootLabel: string; + fileCount: number; + isExpanded: boolean; + viewMode: ChangesViewMode; + isViewOptionsMenuOpen: boolean; + hasFolderNodes: boolean; + onToggleRoot: () => void; + onViewOptionsMenuOpenChange: (open: boolean) => void; + onSetViewMode: (mode: ChangesViewMode) => void; + onExpandAllFolders: () => void; + onCollapseAllFolders: () => void; +} + +export function ChangesRootRow({ + rootLabel, + fileCount, + isExpanded, + viewMode, + isViewOptionsMenuOpen, + hasFolderNodes, + onToggleRoot, + onViewOptionsMenuOpenChange, + onSetViewMode, + onExpandAllFolders, + onCollapseAllFolders, +}: ChangesRootRowProps) { + return ( + + + + + + + {isExpanded ? ( + + ) : ( + + )} + + {rootLabel} + + + {fileCount} file{fileCount === 1 ? "" : "s"} + + + + + + + + + + + + { + event.preventDefault(); + }} + > + + onSetViewMode(viewMode === "list" ? "tree" : "list") + } + > + + {viewMode === "list" ? "View as tree" : "View as list"} + + + {viewMode === "tree" && ( + <> + + + Expand all folders + + + Collapse all folders + + + )} + + + + ); +} diff --git a/apps/code/src/renderer/features/task-detail/components/changesFileUtils.ts b/apps/code/src/renderer/features/task-detail/components/changesFileUtils.ts new file mode 100644 index 000000000..0f133c2d3 --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/components/changesFileUtils.ts @@ -0,0 +1,59 @@ +import type { ChangedFile, GitFileStatus } from "@shared/types"; + +export function getStatusIndicator(status: GitFileStatus): { + label: string; + fullLabel: string; + color: "green" | "orange" | "red" | "blue" | "gray"; +} { + switch (status) { + case "added": + case "untracked": + return { label: "A", fullLabel: "Added", color: "green" }; + case "deleted": + return { label: "D", fullLabel: "Deleted", color: "red" }; + case "modified": + return { label: "M", fullLabel: "Modified", color: "orange" }; + case "renamed": + return { label: "R", fullLabel: "Renamed", color: "blue" }; + default: + return { label: "?", fullLabel: "Unknown", color: "gray" }; + } +} + +export function getDiscardInfo( + file: ChangedFile, + fileName: string, +): { message: string; action: string } { + switch (file.status) { + case "modified": + return { + message: `Are you sure you want to discard changes in '${fileName}'?`, + action: "Discard File", + }; + case "deleted": + return { + message: `Are you sure you want to restore '${fileName}'?`, + action: "Restore File", + }; + case "added": + return { + message: `Are you sure you want to remove '${fileName}'?`, + action: "Remove File", + }; + case "untracked": + return { + message: `Are you sure you want to delete '${fileName}'?`, + action: "Delete File", + }; + case "renamed": + return { + message: `Are you sure you want to undo the rename of '${fileName}'?`, + action: "Undo Rename File", + }; + default: + return { + message: `Are you sure you want to discard changes in '${fileName}'?`, + action: "Discard File", + }; + } +} diff --git a/apps/code/src/renderer/features/task-detail/components/changesRowStyles.ts b/apps/code/src/renderer/features/task-detail/components/changesRowStyles.ts new file mode 100644 index 000000000..4d2b143bf --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/components/changesRowStyles.ts @@ -0,0 +1,14 @@ +import type { CSSProperties } from "react"; + +export const ROOT_CHILD_PADDING = 20; +const TREE_INDENT = 12; + +export function getTreePadding(depth: number): number { + return ROOT_CHILD_PADDING + depth * TREE_INDENT; +} + +export function getRowPaddingStyle(paddingLeft: number): CSSProperties { + return { + "--changes-row-padding": `${paddingLeft}px`, + } as CSSProperties; +} diff --git a/apps/code/src/renderer/features/task-detail/hooks/useChangesKeyboardNavigation.ts b/apps/code/src/renderer/features/task-detail/hooks/useChangesKeyboardNavigation.ts new file mode 100644 index 000000000..5e35cfff3 --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/hooks/useChangesKeyboardNavigation.ts @@ -0,0 +1,416 @@ +import type { TaskLayout } from "@features/panels/store/panelLayoutStore"; +import { isDiffTabActiveInTree } from "@features/panels/store/panelStoreHelpers"; +import type { ChangesViewMode } from "@features/right-sidebar/stores/changesPanelStore"; +import { + type ChangesTreeModel, + getChangedFileId, + getNodeById, + getParentDirectoryId, +} from "@features/task-detail/utils/changesTreeModel"; +import type { ChangedFile } from "@shared/types"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; + +const KEY_REPEAT_PREVIEW_DEBOUNCE_MS = 100; + +type ChangesNavigableEntry = + | { type: "directory"; id: string; path: string } + | { type: "file"; id: string; file: ChangedFile }; + +type ChangesFileNavigableEntry = Extract< + ChangesNavigableEntry, + { type: "file" } +>; + +interface UseChangesKeyboardNavigationParams { + taskId: string; + viewMode: ChangesViewMode; + isRootExpanded: boolean; + isViewOptionsMenuOpen: boolean; + hasPendingPermissions: boolean; + changedFiles: ChangedFile[]; + visibleTreeRowIds: string[]; + treeModel: ChangesTreeModel; + expandedPaths: Set; + layout: TaskLayout | null; + openDiffByMode: ( + taskId: string, + filePath: string, + status?: string, + asPreview?: boolean, + ) => void; + setDirectoryExpanded: ( + directoryPath: string, + directoryId: string, + expanded: boolean, + ) => void; +} + +interface UseChangesKeyboardNavigationResult { + selectedEntryId: string | null; + selectedDirectoryPath: string | null; + selectedFileId: string | null; + hasKeyboardSelection: boolean; +} + +export function useChangesKeyboardNavigation({ + taskId, + viewMode, + isRootExpanded, + isViewOptionsMenuOpen, + hasPendingPermissions, + changedFiles, + visibleTreeRowIds, + treeModel, + expandedPaths, + layout, + openDiffByMode, + setDirectoryExpanded, +}: UseChangesKeyboardNavigationParams): UseChangesKeyboardNavigationResult { + const keyboardNavigableEntries = useMemo(() => { + if (!isRootExpanded) { + return []; + } + + if (viewMode === "list") { + return changedFiles.map((file) => ({ + type: "file", + id: getChangedFileId(file), + file, + })); + } + + const entries: ChangesNavigableEntry[] = []; + for (const rowId of visibleTreeRowIds) { + const node = getNodeById(treeModel, rowId); + if (!node) continue; + + if (node.kind === "directory") { + entries.push({ type: "directory", id: node.id, path: node.path }); + } else { + entries.push({ type: "file", id: node.id, file: node.file }); + } + } + + return entries; + }, [changedFiles, isRootExpanded, treeModel, viewMode, visibleTreeRowIds]); + + const entryIndexById = useMemo(() => { + const entries = new Map(); + for (let i = 0; i < keyboardNavigableEntries.length; i += 1) { + entries.set(keyboardNavigableEntries[i].id, i); + } + return entries; + }, [keyboardNavigableEntries]); + + const [keyboardSelectedEntryId, setKeyboardSelectedEntryId] = useState< + string | null + >(null); + const selectedEntryIdRef = useRef(null); + const previewTimeoutRef = useRef | null>(null); + const layoutRef = useRef(layout); + + useEffect(() => { + selectedEntryIdRef.current = keyboardSelectedEntryId; + }, [keyboardSelectedEntryId]); + + useEffect(() => { + layoutRef.current = layout; + }, [layout]); + + const clearPendingPreview = useCallback(() => { + if (!previewTimeoutRef.current) { + return; + } + + clearTimeout(previewTimeoutRef.current); + previewTimeoutRef.current = null; + }, []); + + useEffect(() => { + return () => { + clearPendingPreview(); + }; + }, [clearPendingPreview]); + + const isFileDiffActive = useCallback((file: ChangedFile): boolean => { + const currentLayout = layoutRef.current; + if (!currentLayout) return false; + return isDiffTabActiveInTree( + currentLayout.panelTree, + file.path, + file.status, + ); + }, []); + + const previewFileEntry = useCallback( + (entry: ChangesFileNavigableEntry, isRepeat: boolean) => { + if (!isRepeat) { + clearPendingPreview(); + if (!isFileDiffActive(entry.file)) { + openDiffByMode(taskId, entry.file.path, entry.file.status); + } + return; + } + + clearPendingPreview(); + + const targetEntryId = entry.id; + const targetFile = entry.file; + previewTimeoutRef.current = setTimeout(() => { + previewTimeoutRef.current = null; + + if (selectedEntryIdRef.current !== targetEntryId) { + return; + } + + if (!isFileDiffActive(targetFile)) { + openDiffByMode(taskId, targetFile.path, targetFile.status); + } + }, KEY_REPEAT_PREVIEW_DEBOUNCE_MS); + }, + [clearPendingPreview, isFileDiffActive, openDiffByMode, taskId], + ); + + const getActiveEntryId = useCallback((): string | null => { + const currentLayout = layoutRef.current; + if (!currentLayout) { + return null; + } + + const activeEntry = keyboardNavigableEntries.find( + (entry) => + entry.type === "file" && + isDiffTabActiveInTree( + currentLayout.panelTree, + entry.file.path, + entry.file.status, + ), + ); + + return activeEntry?.id ?? null; + }, [keyboardNavigableEntries]); + + useEffect(() => { + if (keyboardNavigableEntries.length === 0) { + setKeyboardSelectedEntryId(null); + selectedEntryIdRef.current = null; + clearPendingPreview(); + return; + } + + setKeyboardSelectedEntryId((currentId) => { + if (currentId && entryIndexById.has(currentId)) { + return currentId; + } + + return getActiveEntryId(); + }); + }, [ + clearPendingPreview, + entryIndexById, + getActiveEntryId, + keyboardNavigableEntries.length, + ]); + + const handleKeyNavigation = useCallback( + (direction: "up" | "down", event?: KeyboardEvent) => { + if (keyboardNavigableEntries.length === 0) return; + + const activeEntryId = getActiveEntryId(); + const selectedEntryId = + keyboardSelectedEntryId && entryIndexById.has(keyboardSelectedEntryId) + ? keyboardSelectedEntryId + : activeEntryId; + + const startIndex = selectedEntryId + ? (entryIndexById.get(selectedEntryId) ?? 0) + : direction === "down" + ? -1 + : keyboardNavigableEntries.length; + + const newIndex = + direction === "up" + ? Math.max(0, startIndex - 1) + : Math.min(keyboardNavigableEntries.length - 1, startIndex + 1); + + const entry = keyboardNavigableEntries[newIndex]; + if (!entry) { + return; + } + + if (selectedEntryId === entry.id) { + if (entry.type === "file" && !isFileDiffActive(entry.file)) { + previewFileEntry(entry, Boolean(event?.repeat)); + } + return; + } + + setKeyboardSelectedEntryId(entry.id); + selectedEntryIdRef.current = entry.id; + + if (entry.type === "file") { + previewFileEntry(entry, Boolean(event?.repeat)); + } else { + clearPendingPreview(); + } + }, + [ + clearPendingPreview, + entryIndexById, + getActiveEntryId, + isFileDiffActive, + keyboardNavigableEntries, + keyboardSelectedEntryId, + previewFileEntry, + ], + ); + + const handleHorizontalNavigation = useCallback( + (direction: "left" | "right") => { + if (viewMode !== "tree") return; + if (keyboardNavigableEntries.length === 0) return; + + clearPendingPreview(); + + const selectedEntryId = + keyboardSelectedEntryId && entryIndexById.has(keyboardSelectedEntryId) + ? keyboardSelectedEntryId + : getActiveEntryId(); + if (!selectedEntryId) { + return; + } + + const selectedIndex = entryIndexById.get(selectedEntryId); + if (selectedIndex === undefined) { + return; + } + + const entry = keyboardNavigableEntries[selectedIndex]; + if (!entry) { + return; + } + + if (entry.type === "file") { + if (direction !== "left") { + return; + } + + const parentDirectoryId = getParentDirectoryId(treeModel, entry.id); + if (parentDirectoryId) { + setKeyboardSelectedEntryId(parentDirectoryId); + } + return; + } + + const directoryNode = getNodeById(treeModel, entry.id); + if (!directoryNode || directoryNode.kind !== "directory") { + return; + } + + setKeyboardSelectedEntryId(directoryNode.id); + const isExpanded = expandedPaths.has(directoryNode.path); + + if (direction === "right") { + if (!isExpanded) { + setDirectoryExpanded(directoryNode.path, directoryNode.id, true); + return; + } + + const firstChildId = directoryNode.childIds[0]; + if (firstChildId) { + setKeyboardSelectedEntryId(firstChildId); + } + return; + } + + if (isExpanded) { + setDirectoryExpanded(directoryNode.path, directoryNode.id, false); + return; + } + + const parentDirectoryId = getParentDirectoryId( + treeModel, + directoryNode.id, + ); + if (parentDirectoryId) { + setKeyboardSelectedEntryId(parentDirectoryId); + } + }, + [ + clearPendingPreview, + entryIndexById, + expandedPaths, + getActiveEntryId, + keyboardNavigableEntries, + keyboardSelectedEntryId, + setDirectoryExpanded, + treeModel, + viewMode, + ], + ); + + const keyboardNavigationEnabled = + !hasPendingPermissions && + keyboardNavigableEntries.length > 0 && + !isViewOptionsMenuOpen; + + useEffect(() => { + if (!keyboardNavigationEnabled) { + clearPendingPreview(); + } + }, [clearPendingPreview, keyboardNavigationEnabled]); + + useHotkeys( + "up", + (event) => { + event.preventDefault(); + handleKeyNavigation("up", event); + }, + { enabled: keyboardNavigationEnabled }, + [handleKeyNavigation, keyboardNavigationEnabled], + ); + useHotkeys( + "down", + (event) => { + event.preventDefault(); + handleKeyNavigation("down", event); + }, + { enabled: keyboardNavigationEnabled }, + [handleKeyNavigation, keyboardNavigationEnabled], + ); + useHotkeys( + "left", + (event) => { + event.preventDefault(); + handleHorizontalNavigation("left"); + }, + { enabled: keyboardNavigationEnabled && viewMode === "tree" }, + [handleHorizontalNavigation, keyboardNavigationEnabled, viewMode], + ); + useHotkeys( + "right", + (event) => { + event.preventDefault(); + handleHorizontalNavigation("right"); + }, + { enabled: keyboardNavigationEnabled && viewMode === "tree" }, + [handleHorizontalNavigation, keyboardNavigationEnabled, viewMode], + ); + + const selectedEntryIndex = keyboardSelectedEntryId + ? entryIndexById.get(keyboardSelectedEntryId) + : undefined; + const selectedEntry = + selectedEntryIndex !== undefined + ? keyboardNavigableEntries[selectedEntryIndex] + : null; + + return { + selectedEntryId: selectedEntry?.id ?? null, + selectedDirectoryPath: + selectedEntry?.type === "directory" ? selectedEntry.path : null, + selectedFileId: selectedEntry?.type === "file" ? selectedEntry.id : null, + hasKeyboardSelection: selectedEntry !== null, + }; +} diff --git a/apps/code/src/renderer/features/task-detail/utils/changesTreeModel.test.ts b/apps/code/src/renderer/features/task-detail/utils/changesTreeModel.test.ts new file mode 100644 index 000000000..d9abcc0fd --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/utils/changesTreeModel.test.ts @@ -0,0 +1,125 @@ +import type { ChangedFile } from "@shared/types"; +import { describe, expect, it } from "vitest"; +import { + buildChangesTreeModel, + buildVisibleTreeRowIds, + collapseDirectoryInVisibleRows, + collectInitialExpandedPaths, + expandDirectoryInVisibleRows, + getParentDirectoryId, + normalizePathForTree, +} from "./changesTreeModel"; + +function file(path: string): ChangedFile { + return { + path, + status: "modified", + }; +} + +describe("changesTreeModel", () => { + it("builds tree and visible rows from expanded paths", () => { + const model = buildChangesTreeModel([ + file("src/z.ts"), + file("src/a.ts"), + file("src/nested/b.ts"), + { path: "README.md", status: "added" }, + ]); + + const collapsedRows = buildVisibleTreeRowIds(model, new Set()); + expect(collapsedRows).toEqual(["d:src", "f:README.md:added:"]); + + const expandedRows = buildVisibleTreeRowIds( + model, + new Set(["src", "src/nested"]), + ); + expect(expandedRows).toEqual([ + "d:src", + "d:src/nested", + "f:src/nested/b.ts:modified:", + "f:src/a.ts:modified:", + "f:src/z.ts:modified:", + "f:README.md:added:", + ]); + }); + + it("localized expand and collapse match full recompute", () => { + const model = buildChangesTreeModel([ + file("src/a.ts"), + file("src/nested/b.ts"), + file("src/nested/c.ts"), + ]); + + const initialRows = buildVisibleTreeRowIds(model, new Set()); + expect(initialRows).toEqual(["d:src"]); + + const expandedSrcPaths = new Set(["src"]); + const rowsAfterSrcExpand = expandDirectoryInVisibleRows( + initialRows, + model, + "d:src", + expandedSrcPaths, + ); + expect(rowsAfterSrcExpand).toEqual( + buildVisibleTreeRowIds(model, expandedSrcPaths), + ); + + const expandedNestedPaths = new Set(["src", "src/nested"]); + const rowsAfterNestedExpand = expandDirectoryInVisibleRows( + rowsAfterSrcExpand, + model, + "d:src/nested", + expandedNestedPaths, + ); + expect(rowsAfterNestedExpand).toEqual( + buildVisibleTreeRowIds(model, expandedNestedPaths), + ); + + const rowsAfterCollapse = collapseDirectoryInVisibleRows( + rowsAfterNestedExpand, + model, + "d:src", + ); + expect(rowsAfterCollapse).toEqual(buildVisibleTreeRowIds(model, new Set())); + }); + + it("collects initial expanded paths for directory-only roots", () => { + const model = buildChangesTreeModel([ + file("src/components/Button.tsx"), + file("src/components/Icon.tsx"), + file("docs/readme.md"), + ]); + + const initialPaths = collectInitialExpandedPaths(model); + expect(new Set(initialPaths)).toEqual( + new Set(["src", "src/components", "docs"]), + ); + }); + + it("does not auto-expand when root has direct files", () => { + const model = buildChangesTreeModel([file("README.md"), file("src/a.ts")]); + expect(collectInitialExpandedPaths(model)).toEqual([]); + }); + + it("normalizes tree paths", () => { + expect(normalizePathForTree("\\src\\components\\Button.tsx")).toBe( + "src/components/Button.tsx", + ); + expect(normalizePathForTree("//src///components///")).toBe( + "src/components", + ); + }); + + it("returns parent directory id for file and nested directory", () => { + const model = buildChangesTreeModel([ + file("src/components/Button.tsx"), + file("src/components/Icon.tsx"), + ]); + + expect( + getParentDirectoryId(model, "f:src/components/Button.tsx:modified:"), + ).toBe("d:src/components"); + expect(getParentDirectoryId(model, "d:src/components")).toBe("d:src"); + expect(getParentDirectoryId(model, "d:src")).toBeNull(); + }); +}); diff --git a/apps/code/src/renderer/features/task-detail/utils/changesTreeModel.ts b/apps/code/src/renderer/features/task-detail/utils/changesTreeModel.ts new file mode 100644 index 000000000..006f3ef2a --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/utils/changesTreeModel.ts @@ -0,0 +1,424 @@ +import type { ChangedFile } from "@shared/types"; + +interface BaseNode { + id: string; + name: string; + path: string; + depth: number; + parentId: string | null; +} + +export interface ChangesDirectoryNode extends BaseNode { + kind: "directory"; + childIds: string[]; +} + +export interface ChangesFileNode extends BaseNode { + kind: "file"; + file: ChangedFile; +} + +export type ChangesTreeNode = ChangesDirectoryNode | ChangesFileNode; + +interface ChangesTreeDirectoryDraft { + name: string; + path: string; + directories: Map; + files: ChangedFile[]; +} + +export interface ChangesTreeModel { + rootChildrenIds: string[]; + nodesById: Map; + directoryPaths: string[]; +} + +function compareByName(a: string, b: string): number { + return a.localeCompare(b, undefined, { sensitivity: "base" }); +} + +function toPathSegments(path: string): string[] { + const normalized = normalizePathForTree(path); + if (!normalized) return [path]; + return normalized.split("/").filter((segment) => segment.length > 0); +} + +function ensureUniqueId( + id: string, + nodesById: Map, +): string { + if (!nodesById.has(id)) { + return id; + } + + let suffix = 1; + while (nodesById.has(`${id}#${suffix}`)) { + suffix += 1; + } + return `${id}#${suffix}`; +} + +function createDirectoryId(path: string): string { + return `d:${path}`; +} + +export function getChangedFileId(file: ChangedFile): string { + return `f:${file.path}:${file.status}:${file.originalPath ?? ""}`; +} + +export function normalizePathForTree(path: string): string { + return path + .replaceAll("\\", "/") + .replace(/^\/+/, "") + .replace(/\/{2,}/g, "/") + .replace(/\/+$/, ""); +} + +export function buildChangesTreeModel(files: ChangedFile[]): ChangesTreeModel { + const root: ChangesTreeDirectoryDraft = { + name: "", + path: "", + directories: new Map(), + files: [], + }; + + for (const file of files) { + const segments = toPathSegments(file.path); + const directorySegments = segments.slice(0, -1); + + let current = root; + let currentPath = ""; + + for (const segment of directorySegments) { + currentPath = currentPath ? `${currentPath}/${segment}` : segment; + const existing = current.directories.get(segment); + if (existing) { + current = existing; + continue; + } + + const nextDirectory: ChangesTreeDirectoryDraft = { + name: segment, + path: currentPath, + directories: new Map(), + files: [], + }; + current.directories.set(segment, nextDirectory); + current = nextDirectory; + } + + current.files.push(file); + } + + const nodesById = new Map(); + const directoryPaths: string[] = []; + + const materializeDirectory = ( + directory: ChangesTreeDirectoryDraft, + parentId: string | null, + depth: number, + ): string => { + const baseDirectoryId = createDirectoryId(directory.path); + const directoryId = ensureUniqueId(baseDirectoryId, nodesById); + + const directoryChildren = [...directory.directories.values()].sort((a, b) => + compareByName(a.name, b.name), + ); + const fileChildren = [...directory.files].sort((a, b) => + compareByName(a.path, b.path), + ); + + const childIds: string[] = []; + + for (const childDirectory of directoryChildren) { + childIds.push( + materializeDirectory(childDirectory, directoryId, depth + 1), + ); + } + + for (const file of fileChildren) { + const baseFileId = getChangedFileId(file); + const fileId = ensureUniqueId(baseFileId, nodesById); + const fileName = file.path.split("/").pop() || file.path; + + nodesById.set(fileId, { + kind: "file", + id: fileId, + name: fileName, + path: file.path, + depth: depth + 1, + parentId: directoryId, + file, + }); + childIds.push(fileId); + } + + nodesById.set(directoryId, { + kind: "directory", + id: directoryId, + name: directory.name, + path: directory.path, + depth, + parentId, + childIds, + }); + directoryPaths.push(directory.path); + + return directoryId; + }; + + const rootChildrenIds: string[] = []; + const rootDirectories = [...root.directories.values()].sort((a, b) => + compareByName(a.name, b.name), + ); + const rootFiles = [...root.files].sort((a, b) => + compareByName(a.path, b.path), + ); + + for (const rootDirectory of rootDirectories) { + rootChildrenIds.push(materializeDirectory(rootDirectory, null, 0)); + } + + for (const file of rootFiles) { + const baseFileId = getChangedFileId(file); + const fileId = ensureUniqueId(baseFileId, nodesById); + const fileName = file.path.split("/").pop() || file.path; + + nodesById.set(fileId, { + kind: "file", + id: fileId, + name: fileName, + path: file.path, + depth: 0, + parentId: null, + file, + }); + rootChildrenIds.push(fileId); + } + + return { + rootChildrenIds, + nodesById, + directoryPaths, + }; +} + +export function getNodeById( + model: ChangesTreeModel, + nodeId: string, +): ChangesTreeNode | undefined { + return model.nodesById.get(nodeId); +} + +function collectVisibleDescendantRowIds( + model: ChangesTreeModel, + directoryId: string, + expandedPaths: ReadonlySet, +): string[] { + const rootNode = model.nodesById.get(directoryId); + if (!rootNode || rootNode.kind !== "directory") { + return []; + } + + const rowIds: string[] = []; + const stack = [...rootNode.childIds].reverse(); + + while (stack.length > 0) { + const nodeId = stack.pop(); + if (!nodeId) continue; + + rowIds.push(nodeId); + + const node = model.nodesById.get(nodeId); + if (!node || node.kind !== "directory") continue; + if (!expandedPaths.has(node.path)) continue; + + for (let i = node.childIds.length - 1; i >= 0; i -= 1) { + stack.push(node.childIds[i]); + } + } + + return rowIds; +} + +export function buildVisibleTreeRowIds( + model: ChangesTreeModel, + expandedPaths: ReadonlySet, +): string[] { + const rowIds: string[] = []; + const stack = [...model.rootChildrenIds].reverse(); + + while (stack.length > 0) { + const nodeId = stack.pop(); + if (!nodeId) continue; + + rowIds.push(nodeId); + + const node = model.nodesById.get(nodeId); + if (!node || node.kind !== "directory") continue; + if (!expandedPaths.has(node.path)) continue; + + for (let i = node.childIds.length - 1; i >= 0; i -= 1) { + stack.push(node.childIds[i]); + } + } + + return rowIds; +} + +function isDescendantNode( + model: ChangesTreeModel, + nodeId: string, + possibleAncestorId: string, +): boolean { + let current = model.nodesById.get(nodeId); + while (current?.parentId) { + if (current.parentId === possibleAncestorId) { + return true; + } + current = model.nodesById.get(current.parentId); + } + return false; +} + +export function expandDirectoryInVisibleRows( + currentVisibleRows: readonly string[], + model: ChangesTreeModel, + directoryId: string, + expandedPaths: ReadonlySet, +): string[] { + const directory = model.nodesById.get(directoryId); + if (!directory || directory.kind !== "directory") { + return [...currentVisibleRows]; + } + + if (directory.childIds.length === 0) { + return [...currentVisibleRows]; + } + + const index = currentVisibleRows.indexOf(directoryId); + if (index === -1) { + return buildVisibleTreeRowIds(model, expandedPaths); + } + + const firstNext = currentVisibleRows[index + 1]; + if (firstNext && isDescendantNode(model, firstNext, directoryId)) { + return [...currentVisibleRows]; + } + + const descendants = collectVisibleDescendantRowIds( + model, + directoryId, + expandedPaths, + ); + if (descendants.length === 0) { + return [...currentVisibleRows]; + } + + return [ + ...currentVisibleRows.slice(0, index + 1), + ...descendants, + ...currentVisibleRows.slice(index + 1), + ]; +} + +export function collapseDirectoryInVisibleRows( + currentVisibleRows: readonly string[], + model: ChangesTreeModel, + directoryId: string, +): string[] { + const directory = model.nodesById.get(directoryId); + if (!directory || directory.kind !== "directory") { + return [...currentVisibleRows]; + } + + const index = currentVisibleRows.indexOf(directoryId); + if (index === -1) { + return [...currentVisibleRows]; + } + + let removeUntil = index + 1; + while (removeUntil < currentVisibleRows.length) { + const nextId = currentVisibleRows[removeUntil]; + if (!isDescendantNode(model, nextId, directoryId)) { + break; + } + removeUntil += 1; + } + + if (removeUntil === index + 1) { + return [...currentVisibleRows]; + } + + return [ + ...currentVisibleRows.slice(0, index + 1), + ...currentVisibleRows.slice(removeUntil), + ]; +} + +export function collectDirectoryPaths(model: ChangesTreeModel): string[] { + return model.directoryPaths; +} + +export function collectInitialExpandedPaths(model: ChangesTreeModel): string[] { + const hasRootFiles = model.rootChildrenIds.some((nodeId) => { + const node = model.nodesById.get(nodeId); + return node?.kind === "file"; + }); + + if (hasRootFiles) { + return []; + } + + const expandedPaths = new Set(); + + const expandBranchUntilFile = (directoryId: string): void => { + const directory = model.nodesById.get(directoryId); + if (!directory || directory.kind !== "directory") { + return; + } + + expandedPaths.add(directory.path); + + const hasFileChild = directory.childIds.some((childId) => { + const child = model.nodesById.get(childId); + return child?.kind === "file"; + }); + if (hasFileChild) { + return; + } + + for (const childId of directory.childIds) { + const child = model.nodesById.get(childId); + if (child?.kind === "directory") { + expandBranchUntilFile(child.id); + } + } + }; + + for (const nodeId of model.rootChildrenIds) { + const node = model.nodesById.get(nodeId); + if (node?.kind === "directory") { + expandBranchUntilFile(node.id); + } + } + + return [...expandedPaths]; +} + +export function getParentDirectoryId( + model: ChangesTreeModel, + nodeId: string, +): string | null { + const node = model.nodesById.get(nodeId); + if (!node?.parentId) { + return null; + } + + const parent = model.nodesById.get(node.parentId); + if (!parent || parent.kind !== "directory") { + return null; + } + + return parent.id; +} diff --git a/apps/code/src/renderer/features/task-detail/utils/getCloudChangesState.ts b/apps/code/src/renderer/features/task-detail/utils/getCloudChangesState.ts new file mode 100644 index 000000000..b11351672 --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/utils/getCloudChangesState.ts @@ -0,0 +1,58 @@ +import type { ChangedFile } from "@shared/types"; + +export type CloudChangesState = + | { kind: "ready" } + | { kind: "loading" } + | { kind: "waiting"; detail: string } + | { kind: "empty"; message: string } + | { kind: "pr_error"; prUrl: string }; + +interface GetCloudChangesStateInput { + prUrl: string | null; + effectiveBranch: string | null; + isRunActive: boolean; + effectiveFiles: ChangedFile[]; + isLoading: boolean; + hasError: boolean; +} + +export function getCloudChangesState({ + prUrl, + effectiveBranch, + isRunActive, + effectiveFiles, + isLoading, + hasError, +}: GetCloudChangesStateInput): CloudChangesState { + if (!prUrl && !effectiveBranch && effectiveFiles.length === 0) { + return isRunActive + ? { + kind: "waiting", + detail: "Changes will appear once the agent starts writing code", + } + : { kind: "empty", message: "No file changes yet" }; + } + + if (isLoading && effectiveFiles.length === 0) { + return { kind: "loading" }; + } + + if (effectiveFiles.length === 0) { + if (hasError && prUrl) { + return { kind: "pr_error", prUrl }; + } + + if (prUrl) { + return { kind: "empty", message: "No file changes in pull request" }; + } + + return isRunActive + ? { + kind: "waiting", + detail: "Changes will appear as the agent modifies files", + } + : { kind: "empty", message: "No file changes yet" }; + } + + return { kind: "ready" }; +}