diff --git a/apps/code/package.json b/apps/code/package.json index b700dd947..36d34a521 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -125,6 +125,7 @@ "@opentelemetry/semantic-conventions": "^1.39.0", "@parcel/watcher": "^2.5.6", "@phosphor-icons/react": "^2.1.10", + "@pierre/diffs": "^1.1.7", "@posthog/agent": "workspace:*", "@posthog/electron-trpc": "workspace:*", "@posthog/git": "workspace:*", diff --git a/apps/code/src/renderer/features/code-editor/components/CloudDiffEditorPanel.tsx b/apps/code/src/renderer/features/code-editor/components/CloudDiffEditorPanel.tsx deleted file mode 100644 index fb5a92998..000000000 --- a/apps/code/src/renderer/features/code-editor/components/CloudDiffEditorPanel.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { PanelMessage } from "@components/ui/PanelMessage"; -import { CodeMirrorDiffEditor } from "@features/code-editor/components/CodeMirrorDiffEditor"; -import { useSessionForTask } from "@features/sessions/hooks/useSession"; -import { - buildCloudEventSummary, - extractCloudFileDiff, -} from "@features/task-detail/utils/cloudToolChanges"; -import { Box } from "@radix-ui/themes"; -import type { AcpMessage } from "@shared/types/session-events"; -import { useMemo } from "react"; - -const EMPTY_EVENTS: AcpMessage[] = []; - -interface CloudDiffEditorPanelProps { - taskId: string; - relativePath: string; -} - -export function CloudDiffEditorPanel({ - taskId, - relativePath, -}: CloudDiffEditorPanelProps) { - const session = useSessionForTask(taskId); - const events = session?.events ?? EMPTY_EVENTS; - - const { toolCalls } = useMemo(() => buildCloudEventSummary(events), [events]); - const diff = useMemo( - () => extractCloudFileDiff(toolCalls, relativePath), - [toolCalls, relativePath], - ); - - if (!diff) { - return ( - - File was modified outside of tracked tool calls - - ); - } - - const { oldText, newText } = diff; - - // File was created then deleted — nothing to show - if (oldText === null && newText === null) { - return No diff data available for this file; - } - - // Always show the diff editor — treat null as empty string so additions - // appear green and deletions red (Write tools don't capture oldText, so - // null oldText just means "show everything as added"). - return ( - - - - ); -} diff --git a/apps/code/src/renderer/features/code-editor/components/CodeMirrorDiffEditor.stories.tsx b/apps/code/src/renderer/features/code-editor/components/CodeMirrorDiffEditor.stories.tsx deleted file mode 100644 index 715b9f6bc..000000000 --- a/apps/code/src/renderer/features/code-editor/components/CodeMirrorDiffEditor.stories.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { useDiffViewerStore } from "@features/code-editor/stores/diffViewerStore"; -import { useThemeStore } from "@stores/themeStore"; -import type { Decorator, Meta, StoryObj } from "@storybook/react-vite"; -import { CodeMirrorDiffEditor } from "./CodeMirrorDiffEditor"; - -const originalCode = `import { formatDate, parseJSON } from "./utils"; -import { Logger } from "./logger"; - -interface ProcessOptions { - timeout: number; - retries: number; -} - -const DEFAULT_TIMEOUT = 5000; - -export function processItems(items: string[], options: ProcessOptions) { - const log = new Logger("processor"); - log.info("Starting processing", { count: items.length }); - - const results: string[] = []; - for (const item of items) { - if (item.startsWith("#")) { - continue; - } - const parsed = parseJSON(item); - results.push(formatDate(parsed.timestamp)); - } - - log.info("Processing complete", { resultCount: results.length }); - return results; -} - -export function validateInput(input: unknown): input is string { - return typeof input === "string" && input.length > 0; -} - -export function deprecatedHelper(data: string): string { - return data.trim().toLowerCase(); -} - -export function getVersion(): string { - return "1.0.0"; -} -`; - -const modifiedCode = `import { formatDate, parseJSON, batchProcess } from "./utils"; -import { Logger } from "./logger"; - -interface ProcessOptions { - timeout: number; - retries: number; - batchSize?: number; -} - -const DEFAULT_TIMEOUT = 10000; - -export function processItems(entries: string[], options: ProcessOptions) { - const log = new Logger("processor"); - log.info("Starting processing", { count: entries.length }); - - const results: string[] = []; - for (const entry of entries) { - if (entry.startsWith("#") || entry.startsWith("//")) { - continue; - } - const parsed = parseJSON(entry); - const formatted = formatDate(parsed.timestamp); - if (formatted) { - results.push(formatted); - } - } - - log.info("Processing complete", { resultCount: results.length }); - return results; -} - -export function validateInput(input: unknown): input is string { - return typeof input === "string" && input.length > 0; -} - -export function processBatch( - entries: string[], - options: ProcessOptions, -): string[] { - const batchSize = options.batchSize ?? 100; - const batches: string[][] = []; - - for (let i = 0; i < entries.length; i += batchSize) { - batches.push(entries.slice(i, i + batchSize)); - } - - return batches.flatMap((batch) => batchProcess(batch)); -} - -export function getVersion(): string { - return "2.0.0"; -} -`; - -function withDiffViewerState( - state: Partial<{ - viewMode: "split" | "unified"; - wordWrap: boolean; - loadFullFiles: boolean; - wordDiffs: boolean; - }>, -): Decorator { - return (Story) => { - useDiffViewerStore.setState(state); - return ; - }; -} - -const meta: Meta = { - title: "Features/CodeEditor/CodeMirrorDiffEditor", - component: CodeMirrorDiffEditor, - parameters: { - layout: "fullscreen", - }, - decorators: [ - (Story, context) => { - const isDark = context.globals.theme !== "light"; - useThemeStore.setState({ - theme: isDark ? "dark" : "light", - isDarkMode: isDark, - }); - return ( -
- -
- ); - }, - ], - beforeEach: () => { - useDiffViewerStore.setState({ - viewMode: "unified", - wordWrap: true, - loadFullFiles: false, - wordDiffs: true, - }); - }, - argTypes: { - onContentChange: { action: "content-changed" }, - onRefresh: { action: "refresh" }, - }, - args: { - originalContent: originalCode, - modifiedContent: modifiedCode, - }, -}; - -export default meta; -type Story = StoryObj; - -export const UnifiedView: Story = {}; - -export const SplitView: Story = { - decorators: [withDiffViewerState({ viewMode: "split" })], -}; - -export const UnifiedFullFile: Story = { - decorators: [withDiffViewerState({ loadFullFiles: true })], -}; - -export const SplitFullFile: Story = { - decorators: [withDiffViewerState({ viewMode: "split", loadFullFiles: true })], -}; - -export const WithoutWordDiffs: Story = { - decorators: [withDiffViewerState({ wordDiffs: false })], -}; - -export const Editable: Story = {}; - -export const WithRelativePath: Story = { - args: { - relativePath: "src/services/dataProcessor.ts", - }, -}; diff --git a/apps/code/src/renderer/features/code-editor/components/CodeMirrorDiffEditor.tsx b/apps/code/src/renderer/features/code-editor/components/CodeMirrorDiffEditor.tsx deleted file mode 100644 index fa76880f6..000000000 --- a/apps/code/src/renderer/features/code-editor/components/CodeMirrorDiffEditor.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { openSearchPanel } from "@codemirror/search"; -import { EditorView } from "@codemirror/view"; -import { DotsThree } from "@phosphor-icons/react"; -import { Box, DropdownMenu, Flex, IconButton, Text } from "@radix-ui/themes"; -import { useEffect, useMemo } from "react"; -import { useCodeMirror } from "../hooks/useCodeMirror"; -import { useEditorExtensions } from "../hooks/useEditorExtensions"; -import { useDiffViewerStore } from "../stores/diffViewerStore"; - -interface CodeMirrorDiffEditorProps { - originalContent: string; - modifiedContent: string; - filePath?: string; - relativePath?: string; - onContentChange?: (content: string) => void; - onRefresh?: () => void; -} - -export function CodeMirrorDiffEditor({ - originalContent, - modifiedContent, - filePath, - relativePath, - onContentChange, - onRefresh, -}: CodeMirrorDiffEditorProps) { - const viewMode = useDiffViewerStore((s) => s.viewMode); - const toggleViewMode = useDiffViewerStore((s) => s.toggleViewMode); - const wordWrap = useDiffViewerStore((s) => s.wordWrap); - const toggleWordWrap = useDiffViewerStore((s) => s.toggleWordWrap); - const loadFullFiles = useDiffViewerStore((s) => s.loadFullFiles); - const toggleLoadFullFiles = useDiffViewerStore((s) => s.toggleLoadFullFiles); - const wordDiffs = useDiffViewerStore((s) => s.wordDiffs); - const toggleWordDiffs = useDiffViewerStore((s) => s.toggleWordDiffs); - const hideWhitespaceChanges = useDiffViewerStore( - (s) => s.hideWhitespaceChanges, - ); - const toggleHideWhitespaceChanges = useDiffViewerStore( - (s) => s.toggleHideWhitespaceChanges, - ); - const extensions = useEditorExtensions(filePath, true, true); - const options = useMemo( - () => ({ - original: originalContent, - modified: modifiedContent, - extensions, - mode: viewMode, - loadFullFiles, - wordDiffs, - hideWhitespaceChanges, - filePath, - onContentChange, - }), - [ - originalContent, - modifiedContent, - extensions, - viewMode, - loadFullFiles, - wordDiffs, - hideWhitespaceChanges, - filePath, - onContentChange, - ], - ); - const { containerRef, instanceRef } = useCodeMirror(options); - - // Capture Cmd+F / Ctrl+F globally and open CodeMirror search when diff is mounted - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (!(e.metaKey || e.ctrlKey) || e.key !== "f") return; - - const instance = instanceRef.current; - if (!instance) return; - - e.preventDefault(); - e.stopPropagation(); - const editorView = instance instanceof EditorView ? instance : instance.b; - openSearchPanel(editorView); - }; - - document.addEventListener("keydown", handleKeyDown, { capture: true }); - return () => - document.removeEventListener("keydown", handleKeyDown, { capture: true }); - }, [instanceRef]); - - return ( - - - {relativePath ? ( - - {relativePath} - - ) : ( - - )} - - - - - - - - - - {viewMode === "split" ? "Unified view" : "Split view"} - - - - - {wordWrap ? "Disable word wrap" : "Enable word wrap"} - - - - - {loadFullFiles ? "Collapse unchanged" : "Load full files"} - - - - - {wordDiffs ? "Disable word diffs" : "Enable word diffs"} - - - - - {hideWhitespaceChanges ? "Show whitespace" : "Hide whitespace"} - - - - {onRefresh && ( - <> - - - Refresh - - - )} - - - - -
- - - ); -} diff --git a/apps/code/src/renderer/features/code-editor/components/DiffEditorPanel.tsx b/apps/code/src/renderer/features/code-editor/components/DiffEditorPanel.tsx deleted file mode 100644 index 7061378ec..000000000 --- a/apps/code/src/renderer/features/code-editor/components/DiffEditorPanel.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { PanelMessage } from "@components/ui/PanelMessage"; -import { CodeMirrorDiffEditor } from "@features/code-editor/components/CodeMirrorDiffEditor"; -import { CodeMirrorEditor } from "@features/code-editor/components/CodeMirrorEditor"; -import { getImageMimeType } from "@features/code-editor/utils/imageUtils"; -import { getRelativePath } from "@features/code-editor/utils/pathUtils"; -import { isImageFile } from "@features/message-editor/utils/imageUtils"; -import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; -import { useCwd } from "@features/sidebar/hooks/useCwd"; -import { Box, Flex } from "@radix-ui/themes"; -import { trpcClient, useTRPC } from "@renderer/trpc/client"; -import type { Task } from "@shared/types"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect } from "react"; - -interface DiffEditorPanelProps { - taskId: string; - task: Task; - absolutePath: string; -} - -export function DiffEditorPanel({ - taskId, - task: _task, - absolutePath, -}: DiffEditorPanelProps) { - const trpc = useTRPC(); - const repoPath = useCwd(taskId); - const filePath = getRelativePath(absolutePath, repoPath); - const isImage = isImageFile(absolutePath); - const queryClient = useQueryClient(); - const closeDiffTabsForFile = usePanelLayoutStore( - (s) => s.closeDiffTabsForFile, - ); - - const { data: changedFiles = [], isLoading: loadingChangelist } = useQuery( - trpc.git.getChangedFilesHead.queryOptions( - { directoryPath: repoPath as string }, - { enabled: !!repoPath, staleTime: 30_000 }, - ), - ); - - const fileInfo = changedFiles.find((f) => f.path === filePath); - const isFileStillChanged = !!fileInfo; - const status = fileInfo?.status ?? "modified"; - const originalPath = fileInfo?.originalPath ?? filePath; - const isDeleted = status === "deleted"; - const isNew = status === "untracked" || status === "added"; - - const { data: modifiedContent, isLoading: loadingModified } = useQuery( - trpc.fs.readRepoFile.queryOptions( - { repoPath: repoPath as string, filePath }, - { enabled: !!repoPath && !isDeleted, staleTime: 30_000 }, - ), - ); - - const { data: originalContent, isLoading: loadingOriginal } = useQuery( - trpc.git.getFileAtHead.queryOptions( - { directoryPath: repoPath as string, filePath: originalPath }, - { enabled: !!repoPath && !isNew, staleTime: 30_000 }, - ), - ); - - const imageQuery = useQuery( - trpc.fs.readFileAsBase64.queryOptions( - { filePath: absolutePath }, - { enabled: isImage, staleTime: Infinity }, - ), - ); - - const handleRefresh = useCallback(() => { - if (!repoPath) return; - queryClient.invalidateQueries( - trpc.fs.readRepoFile.queryFilter({ repoPath, filePath }), - ); - queryClient.invalidateQueries(trpc.git.getFileAtHead.pathFilter()); - queryClient.invalidateQueries( - trpc.git.getChangedFilesHead.queryFilter({ directoryPath: repoPath }), - ); - }, [repoPath, filePath, queryClient, trpc]); - - const handleContentChange = useCallback( - async (newContent: string) => { - if (!repoPath) return; - - try { - await trpcClient.fs.writeRepoFile.mutate({ - repoPath, - filePath, - content: newContent, - }); - - queryClient.invalidateQueries( - trpc.fs.readRepoFile.queryFilter({ repoPath, filePath }), - ); - queryClient.invalidateQueries( - trpc.git.getChangedFilesHead.queryFilter({ directoryPath: repoPath }), - ); - } catch (_error) {} - }, - [repoPath, filePath, queryClient, trpc], - ); - - const isLoading = - loadingChangelist || - (!isDeleted && loadingModified) || - (!isNew && loadingOriginal); - - const hasNoChanges = - !!repoPath && - !isLoading && - (!isFileStillChanged || - (!isDeleted && !isNew && originalContent === modifiedContent)); - - useEffect(() => { - if (hasNoChanges) { - closeDiffTabsForFile(taskId, filePath); - } - }, [hasNoChanges, closeDiffTabsForFile, taskId, filePath]); - - if (isImage) { - if (imageQuery.isLoading) { - return Loading image...; - } - if (imageQuery.error || !imageQuery.data) { - return ( - Failed to load image - ); - } - const mimeType = getImageMimeType(absolutePath); - return ( - - {filePath} - - ); - } - - if (!repoPath) { - return No repository path available; - } - - if (isLoading) { - return Loading diff...; - } - - if (hasNoChanges) { - return null; - } - - const showDiff = !isDeleted && !isNew; - const content = isDeleted ? originalContent : modifiedContent; - - return ( - - {showDiff ? ( - - ) : ( - - )} - - ); -} diff --git a/apps/code/src/renderer/features/code-editor/hooks/collapseUnchangedExtension.test.ts b/apps/code/src/renderer/features/code-editor/hooks/collapseUnchangedExtension.test.ts deleted file mode 100644 index 26c58fcd8..000000000 --- a/apps/code/src/renderer/features/code-editor/hooks/collapseUnchangedExtension.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { EditorState } from "@codemirror/state"; -import { describe, expect, it } from "vitest"; -import { - applyExpandEffect, - buildDecorations, - type CollapsedRange, - expandAll, - expandDown, - expandUp, - mapPosBetweenSides, -} from "./collapseUnchangedExtension"; - -function makeState(lineCount: number): EditorState { - const lines = Array.from({ length: lineCount }, (_, i) => `line ${i + 1}`); - return EditorState.create({ doc: lines.join("\n") }); -} - -function range( - from: number, - to: number, - limitFrom?: number, - limitTo?: number, -): CollapsedRange { - return { - fromLine: from, - toLine: to, - limitFromLine: limitFrom ?? from, - limitToLine: limitTo ?? to, - }; -} - -describe("mapPosBetweenSides", () => { - const chunks = [ - { fromA: 10, toA: 20, fromB: 10, toB: 25 }, - { fromA: 50, toA: 60, fromB: 55, toB: 70 }, - ]; - - it("maps position before first chunk", () => { - expect(mapPosBetweenSides(5, chunks, true)).toBe(5); - expect(mapPosBetweenSides(5, chunks, false)).toBe(5); - }); - - it("maps position between chunks from side A", () => { - expect(mapPosBetweenSides(30, chunks, true)).toBe(35); - }); - - it("maps position between chunks from side B", () => { - expect(mapPosBetweenSides(35, chunks, false)).toBe(30); - }); - - it("maps position after last chunk from side A", () => { - expect(mapPosBetweenSides(80, chunks, true)).toBe(90); - }); - - it("handles empty chunks array", () => { - expect(mapPosBetweenSides(42, [], true)).toBe(42); - expect(mapPosBetweenSides(42, [], false)).toBe(42); - }); - - it("maps position at exact chunk boundary", () => { - expect(mapPosBetweenSides(10, chunks, true)).toBe(10); - }); -}); - -describe("applyExpandEffect", () => { - const state = makeState(20); - - const ranges: CollapsedRange[] = [range(1, 5), range(12, 18)]; - - it("expandAll removes the targeted range", () => { - const pos = state.doc.line(3).from; - const effect = expandAll.of(pos); - const result = applyExpandEffect(ranges, state, effect); - - expect(result).toEqual([range(12, 18)]); - }); - - it("expandAll leaves non-targeted ranges intact", () => { - const pos = state.doc.line(8).from; - const effect = expandAll.of(pos); - const result = applyExpandEffect(ranges, state, effect); - - expect(result).toEqual(ranges); - }); - - it("expandUp reveals lines above the collapsed range", () => { - const pos = state.doc.line(14).from; - const effect = expandUp.of({ pos, lines: 3 }); - const result = applyExpandEffect(ranges, state, effect); - - expect(result).toEqual([range(1, 5), range(15, 18, 12, 18)]); - }); - - it("expandDown reveals lines below the collapsed range", () => { - const pos = state.doc.line(14).from; - const effect = expandDown.of({ pos, lines: 3 }); - const result = applyExpandEffect(ranges, state, effect); - - expect(result).toEqual([range(1, 5), range(12, 15, 12, 18)]); - }); - - it("expandUp removes range when lines exceed range size", () => { - const pos = state.doc.line(3).from; - const effect = expandUp.of({ pos, lines: 100 }); - const result = applyExpandEffect(ranges, state, effect); - - expect(result).toEqual([range(12, 18)]); - }); - - it("expandDown removes range when lines exceed range size", () => { - const pos = state.doc.line(3).from; - const effect = expandDown.of({ pos, lines: 100 }); - const result = applyExpandEffect(ranges, state, effect); - - expect(result).toEqual([range(12, 18)]); - }); - - it("preserves original boundaries through multiple expansions", () => { - const pos = state.doc.line(14).from; - const first = applyExpandEffect( - ranges, - state, - expandUp.of({ pos, lines: 2 }), - ); - const second = applyExpandEffect( - first, - state, - expandDown.of({ pos: state.doc.line(16).from, lines: 2 }), - ); - - expect(second).toEqual([range(1, 5), range(14, 16, 12, 18)]); - }); -}); - -describe("buildDecorations", () => { - it("skips ranges where fromLine > toLine", () => { - const state = makeState(10); - const ranges: CollapsedRange[] = [range(5, 3)]; - const deco = buildDecorations(state, ranges); - - expect(deco.size).toBe(0); - }); - - it("creates decorations for valid ranges", () => { - const state = makeState(20); - const ranges: CollapsedRange[] = [range(3, 7), range(15, 18)]; - const deco = buildDecorations(state, ranges); - - expect(deco.size).toBe(2); - }); - - it("handles empty ranges array", () => { - const state = makeState(10); - const deco = buildDecorations(state, []); - - expect(deco.size).toBe(0); - }); - - it("creates single-line range decoration", () => { - const state = makeState(10); - const ranges: CollapsedRange[] = [range(5, 5)]; - const deco = buildDecorations(state, ranges); - - expect(deco.size).toBe(1); - }); -}); diff --git a/apps/code/src/renderer/features/code-editor/hooks/collapseUnchangedExtension.ts b/apps/code/src/renderer/features/code-editor/hooks/collapseUnchangedExtension.ts deleted file mode 100644 index 4c59ee745..000000000 --- a/apps/code/src/renderer/features/code-editor/hooks/collapseUnchangedExtension.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { getChunks, mergeViewSiblings } from "@codemirror/merge"; -import { - type EditorState, - type Extension, - RangeSetBuilder, - StateEffect, - StateField, -} from "@codemirror/state"; -import { - Decoration, - type DecorationSet, - EditorView, - GutterMarker, - gutterWidgetClass, - WidgetType, -} from "@codemirror/view"; - -const EXPAND_LINES = 20; - -export interface CollapsedRange { - fromLine: number; - toLine: number; - limitFromLine: number; - limitToLine: number; -} - -export const expandUp = StateEffect.define<{ pos: number; lines: number }>(); -export const expandDown = StateEffect.define<{ pos: number; lines: number }>(); -export const expandAll = StateEffect.define(); - -const SVG_ARROW_LINE_DOWN = ``; -const SVG_ARROW_LINE_UP = ``; -const SVG_ARROWS_OUT_LINE_VERTICAL = ``; - -class CollapsedGutterMarker extends GutterMarker { - elementClass = "cm-collapsed-gutter-el"; -} - -const collapsedGutterMarker = new CollapsedGutterMarker(); - -class ExpandWidget extends WidgetType { - constructor( - readonly collapsedLines: number, - readonly showUp: boolean, - readonly showDown: boolean, - readonly expandableUp: number, - readonly expandableDown: number, - ) { - super(); - } - - eq(other: ExpandWidget) { - return ( - this.collapsedLines === other.collapsedLines && - this.showUp === other.showUp && - this.showDown === other.showDown && - this.expandableUp === other.expandableUp && - this.expandableDown === other.expandableDown - ); - } - - toDOM(view: EditorView) { - const outer = document.createElement("div"); - outer.className = "cm-collapsed-context"; - - if (this.showUp) { - const upBtn = document.createElement("button"); - upBtn.className = "cm-collapsed-expand-btn"; - const upLines = Math.min(EXPAND_LINES, this.collapsedLines); - upBtn.title = `Expand ${upLines} lines`; - upBtn.innerHTML = `${SVG_ARROW_LINE_DOWN}${upLines} lines`; - upBtn.addEventListener("mousedown", (e) => { - e.preventDefault(); - const pos = view.posAtDOM(outer); - view.dispatch({ effects: expandUp.of({ pos, lines: EXPAND_LINES }) }); - syncSibling(view, expandUp, pos, EXPAND_LINES); - }); - outer.appendChild(upBtn); - } - - const label = document.createElement("button"); - label.className = "cm-collapsed-expand-btn"; - label.title = `Expand all ${this.collapsedLines} lines`; - label.innerHTML = `${SVG_ARROWS_OUT_LINE_VERTICAL}All ${this.collapsedLines} lines`; - label.addEventListener("mousedown", (e) => { - e.preventDefault(); - const pos = view.posAtDOM(outer); - view.dispatch({ effects: expandAll.of(pos) }); - syncSibling(view, expandAll, pos); - }); - outer.appendChild(label); - - if (this.showDown) { - const downBtn = document.createElement("button"); - downBtn.className = "cm-collapsed-expand-btn"; - const downLines = Math.min(EXPAND_LINES, this.collapsedLines); - downBtn.title = `Expand ${downLines} lines`; - downBtn.innerHTML = `${SVG_ARROW_LINE_UP}${downLines} lines`; - downBtn.addEventListener("mousedown", (e) => { - e.preventDefault(); - const pos = view.posAtDOM(outer); - view.dispatch({ effects: expandDown.of({ pos, lines: EXPAND_LINES }) }); - syncSibling(view, expandDown, pos, EXPAND_LINES); - }); - outer.appendChild(downBtn); - } - - return outer; - } - - ignoreEvent(e: Event) { - return e instanceof MouseEvent; - } - - get estimatedHeight() { - return 33; - } -} - -function syncSibling( - view: EditorView, - effect: typeof expandUp | typeof expandDown, - pos: number, - lines?: number, -): void; -function syncSibling( - view: EditorView, - effect: typeof expandAll, - pos: number, -): void; -function syncSibling( - view: EditorView, - effect: typeof expandUp | typeof expandDown | typeof expandAll, - pos: number, - lines?: number, -): void { - const siblings = mergeViewSiblings(view); - if (!siblings) return; - - const info = getChunks(view.state); - if (!info) return; - - const otherView = siblings.a === view ? siblings.b : siblings.a; - const mappedPos = mapPosBetweenSides(pos, info.chunks, info.side === "a"); - - if (effect === expandAll) { - otherView.dispatch({ effects: expandAll.of(mappedPos) }); - } else if (lines !== undefined) { - otherView.dispatch({ - effects: (effect as typeof expandUp | typeof expandDown).of({ - pos: mappedPos, - lines, - }), - }); - } -} - -export function mapPosBetweenSides( - pos: number, - chunks: readonly { fromA: number; toA: number; fromB: number; toB: number }[], - isA: boolean, -): number { - let startOur = 0; - let startOther = 0; - for (let i = 0; ; i++) { - const next = i < chunks.length ? chunks[i] : null; - if (!next || (isA ? next.fromA : next.fromB) >= pos) { - return startOther + (pos - startOur); - } - [startOur, startOther] = isA ? [next.toA, next.toB] : [next.toB, next.toA]; - } -} - -export function buildDecorations( - state: EditorState, - ranges: CollapsedRange[], -): DecorationSet { - const builder = new RangeSetBuilder(); - for (const range of ranges) { - if (range.fromLine > range.toLine) continue; - const lines = range.toLine - range.fromLine + 1; - const from = state.doc.line(range.fromLine).from; - const to = state.doc.line(range.toLine).to; - const expandableUp = range.fromLine - range.limitFromLine; - const expandableDown = range.limitToLine - range.toLine; - const canExpandUp = expandableUp > 0 && lines >= EXPAND_LINES; - const canExpandDown = expandableDown > 0 && lines >= EXPAND_LINES; - builder.add( - from, - to, - Decoration.replace({ - widget: new ExpandWidget( - lines, - canExpandUp, - canExpandDown, - expandableUp, - expandableDown, - ), - block: true, - }), - ); - } - return builder.finish(); -} - -export function computeInitialRanges( - state: EditorState, - margin: number, - minSize: number, -): CollapsedRange[] { - const info = getChunks(state); - if (!info) return []; - - const { chunks, side } = info; - const isA = side === "a"; - const ranges: CollapsedRange[] = []; - let prevLine = 1; - - for (let i = 0; ; i++) { - const chunk = i < chunks.length ? chunks[i] : null; - const limitFrom = i ? prevLine : 1; - const limitTo = chunk - ? state.doc.lineAt(isA ? chunk.fromA : chunk.fromB).number - 1 - : state.doc.lines; - const collapseFrom = i ? prevLine + margin : 1; - const collapseTo = chunk ? limitTo - margin : state.doc.lines; - const lines = collapseTo - collapseFrom + 1; - - if (lines >= minSize) { - ranges.push({ - fromLine: collapseFrom, - toLine: collapseTo, - limitFromLine: limitFrom, - limitToLine: limitTo, - }); - } - - if (!chunk) break; - prevLine = state.doc.lineAt( - Math.min(state.doc.length, isA ? chunk.toA : chunk.toB), - ).number; - } - - return ranges; -} - -export function applyExpandEffect( - ranges: CollapsedRange[], - state: EditorState, - effect: StateEffect, -): CollapsedRange[] { - const isAll = effect.is(expandAll); - const isUp = effect.is(expandUp); - const isDown = effect.is(expandDown); - - const pos = isAll - ? (effect.value as number) - : (effect.value as { pos: number; lines: number }).pos; - - return ranges.flatMap((range) => { - const from = state.doc.line(range.fromLine).from; - const to = state.doc.line(range.toLine).to; - if (pos < from || pos > to) return [range]; - - if (isAll) return []; - - const { lines } = effect.value as { pos: number; lines: number }; - - if (isUp) { - const newFrom = range.fromLine + lines; - if (newFrom > range.toLine) return []; - return [{ ...range, fromLine: newFrom }]; - } - - if (isDown) { - const newTo = range.toLine - lines; - if (newTo < range.fromLine) return []; - return [{ ...range, toLine: newTo }]; - } - - return [range]; - }); -} - -export function gradualCollapseUnchanged({ - margin = 3, - minSize = 4, -}: { - margin?: number; - minSize?: number; -} = {}): Extension { - const collapsedField = StateField.define<{ - ranges: CollapsedRange[]; - deco: DecorationSet; - }>({ - create(state) { - const ranges = computeInitialRanges(state, margin, minSize); - return { ranges, deco: buildDecorations(state, ranges) }; - }, - update(prev, tr) { - let newRanges = prev.ranges; - let changed = false; - - if (tr.docChanged || (prev.ranges.length === 0 && getChunks(tr.state))) { - newRanges = computeInitialRanges(tr.state, margin, minSize); - changed = true; - } - - for (const e of tr.effects) { - if (e.is(expandUp) || e.is(expandDown) || e.is(expandAll)) { - newRanges = applyExpandEffect(newRanges, tr.state, e); - changed = true; - } - } - - if (!changed) return prev; - - return { ranges: newRanges, deco: buildDecorations(tr.state, newRanges) }; - }, - provide: (f) => EditorView.decorations.from(f, (v) => v.deco), - }); - - const collapsedGutterFill = gutterWidgetClass.of((_view, widget) => { - if (widget instanceof ExpandWidget) return collapsedGutterMarker; - return null; - }); - - return [collapsedField, collapsedGutterFill]; -} diff --git a/apps/code/src/renderer/features/code-editor/hooks/useCodeMirror.ts b/apps/code/src/renderer/features/code-editor/hooks/useCodeMirror.ts index 824c796cf..4dd162a5d 100644 --- a/apps/code/src/renderer/features/code-editor/hooks/useCodeMirror.ts +++ b/apps/code/src/renderer/features/code-editor/hooks/useCodeMirror.ts @@ -1,100 +1,19 @@ -import { - diff as defaultDiff, - MergeView, - unifiedMergeView, -} from "@codemirror/merge"; import { EditorState, type Extension } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; import { trpcClient } from "@renderer/trpc/client"; import { handleExternalAppAction } from "@utils/handleExternalAppAction"; import { useEffect, useRef } from "react"; -import { gradualCollapseUnchanged } from "./collapseUnchangedExtension"; - -type EditorInstance = EditorView | MergeView; interface UseCodeMirrorOptions { + doc: string; extensions: Extension[]; filePath?: string; } -interface SingleDocOptions extends UseCodeMirrorOptions { - doc: string; -} - -interface DiffOptions extends UseCodeMirrorOptions { - original: string; - modified: string; - mode: "split" | "unified"; - loadFullFiles?: boolean; - wordDiffs?: boolean; - hideWhitespaceChanges?: boolean; - onContentChange?: (content: string) => void; -} - -const createMergeControls = (onReject?: () => void) => { - return (type: "accept" | "reject", action: (e: MouseEvent) => void) => { - if (type === "accept") { - return document.createElement("span"); - } - - const button = document.createElement("button"); - button.textContent = "\u21a9 Revert"; - button.name = "reject"; - button.style.background = "var(--red-9)"; - button.style.color = "white"; - button.style.border = "none"; - button.style.padding = "4px 10px"; - button.style.borderRadius = "4px"; - button.style.cursor = "pointer"; - button.style.fontSize = "11px"; - button.style.fontWeight = "500"; - button.style.lineHeight = "1"; - - button.onmouseenter = () => { - button.style.background = "var(--red-10)"; - }; - button.onmouseleave = () => { - button.style.background = "var(--red-9)"; - }; - - button.onmousedown = (e) => { - action(e); - onReject?.(); - }; - - return button; - }; -}; - -const whitespaceIgnoringDiff = (a: string, b: string) => { - const changes = defaultDiff(a, b); - return changes.filter((change) => { - const textA = a.slice(change.fromA, change.toA); - const textB = b.slice(change.fromB, change.toB); - return textA.replace(/\s/g, "") !== textB.replace(/\s/g, ""); - }); -}; - -const collapseExtension = (loadFullFiles?: boolean): Extension => - loadFullFiles ? [] : gradualCollapseUnchanged({ margin: 3, minSize: 4 }); - -const getBaseDiffConfig = ( - hideWhitespaceChanges?: boolean, - onReject?: () => void, -): Partial[0]> => ({ - highlightChanges: false, - gutter: true, - mergeControls: createMergeControls(onReject), - diffConfig: { - scanLimit: 50000, - ...(hideWhitespaceChanges && { override: whitespaceIgnoringDiff }), - }, -}); - -export function useCodeMirror(options: SingleDocOptions | DiffOptions) { +export function useCodeMirror(options: UseCodeMirrorOptions) { const containerRef = useRef(null); - const instanceRef = useRef(null); + const instanceRef = useRef(null); useEffect(() => { if (!containerRef.current) return; @@ -102,91 +21,13 @@ export function useCodeMirror(options: SingleDocOptions | DiffOptions) { instanceRef.current?.destroy(); instanceRef.current = null; - if ("doc" in options) { - instanceRef.current = new EditorView({ - state: EditorState.create({ - doc: options.doc, - extensions: options.extensions, - }), - parent: containerRef.current, - }); - } else if (options.mode === "split") { - const diffConfig = getBaseDiffConfig( - options.hideWhitespaceChanges, - options.onContentChange - ? () => { - if (instanceRef.current instanceof MergeView) { - const content = instanceRef.current.b.state.doc.toString(); - options.onContentChange?.(content); - } - } - : undefined, - ); - - const updateListener = options.onContentChange - ? EditorView.updateListener.of((update) => { - if ( - update.docChanged && - update.transactions.some((tr) => tr.isUserEvent("revert")) - ) { - const content = update.state.doc.toString(); - options.onContentChange?.(content); - } - }) - : []; - - const collapse = collapseExtension(options.loadFullFiles); - - instanceRef.current = new MergeView({ - a: { - doc: options.original, - extensions: [ - ...options.extensions, - EditorView.editable.of(false), - EditorState.readOnly.of(true), - collapse, - ], - }, - b: { - doc: options.modified, - extensions: [ - ...options.extensions, - ...(Array.isArray(updateListener) - ? updateListener - : [updateListener]), - collapse, - ], - }, - ...diffConfig, - parent: containerRef.current, - revertControls: "a-to-b", - }); - } else { - const diffConfig = getBaseDiffConfig( - options.hideWhitespaceChanges, - options.onContentChange - ? () => { - if (instanceRef.current instanceof EditorView) { - const content = instanceRef.current.state.doc.toString(); - options.onContentChange?.(content); - } - } - : undefined, - ); - - instanceRef.current = new EditorView({ - doc: options.modified, - extensions: [ - ...options.extensions, - unifiedMergeView({ - original: options.original, - ...diffConfig, - }), - collapseExtension(options.loadFullFiles), - ], - parent: containerRef.current, - }); - } + instanceRef.current = new EditorView({ + state: EditorState.create({ + doc: options.doc, + extensions: options.extensions, + }), + parent: containerRef.current, + }); return () => { instanceRef.current?.destroy(); @@ -198,10 +39,7 @@ export function useCodeMirror(options: SingleDocOptions | DiffOptions) { if (!instanceRef.current || !options.filePath) return; const filePath = options.filePath; - const domElement = - instanceRef.current instanceof EditorView - ? instanceRef.current.dom - : instanceRef.current.a.dom; + const domElement = instanceRef.current.dom; const handleContextMenu = async (e: MouseEvent) => { e.preventDefault(); diff --git a/apps/code/src/renderer/features/code-editor/stores/diffViewerStore.ts b/apps/code/src/renderer/features/code-editor/stores/diffViewerStore.ts index b2f011820..bca5e0a2d 100644 --- a/apps/code/src/renderer/features/code-editor/stores/diffViewerStore.ts +++ b/apps/code/src/renderer/features/code-editor/stores/diffViewerStore.ts @@ -28,7 +28,7 @@ export const useDiffViewerStore = create()( viewMode: "unified", wordWrap: true, loadFullFiles: false, - wordDiffs: false, + wordDiffs: true, hideWhitespaceChanges: false, setViewMode: (mode) => set({ viewMode: mode }), toggleViewMode: () => diff --git a/apps/code/src/renderer/features/code-review/components/CloudReviewPage.tsx b/apps/code/src/renderer/features/code-review/components/CloudReviewPage.tsx new file mode 100644 index 000000000..9c1c88f64 --- /dev/null +++ b/apps/code/src/renderer/features/code-review/components/CloudReviewPage.tsx @@ -0,0 +1,170 @@ +import { useCloudChangedFiles } from "@features/task-detail/hooks/useCloudChangedFiles"; +import { + buildCloudEventSummary, + extractCloudFileDiff, + type ParsedToolCall, +} from "@features/task-detail/utils/cloudToolChanges"; +import type { FileDiffOptions } from "@pierre/diffs"; +import { MultiFileDiff } from "@pierre/diffs/react"; +import { Flex, Spinner, Text } from "@radix-ui/themes"; +import type { ChangedFile, Task } from "@shared/types"; +import type { AcpMessage } from "@shared/types/session-events"; +import { useMemo } from "react"; +import { + DeferredDiffPlaceholder, + DiffFileHeader, + ReviewShell, + useReviewState, +} from "./ReviewShell"; + +const EMPTY_EVENTS: AcpMessage[] = []; + +interface CloudReviewPageProps { + taskId: string; + task: Task; +} + +export function CloudReviewPage({ taskId, task }: CloudReviewPageProps) { + const { + session, + effectiveBranch, + prUrl, + isRunActive, + changedFiles, + isLoading, + } = useCloudChangedFiles(taskId, task); + const events = session?.events ?? EMPTY_EVENTS; + const summary = useMemo(() => buildCloudEventSummary(events), [events]); + + const allPaths = useMemo( + () => changedFiles.map((f) => f.path), + [changedFiles], + ); + + const { + diffOptions, + linesAdded, + linesRemoved, + collapsedFiles, + toggleFile, + expandAll, + collapseAll, + uncollapseFile, + revealFile, + getDeferredReason, + } = useReviewState(changedFiles, allPaths); + + if (!prUrl && !effectiveBranch && changedFiles.length === 0) { + if (isRunActive) { + return ( + + + + + Waiting for changes... + + + + ); + } + return ( + + + No file changes yet + + + ); + } + + return ( + + {changedFiles.map((file) => { + const isCollapsed = collapsedFiles.has(file.path); + const deferredReason = getDeferredReason(file.path); + + if (deferredReason) { + return ( +
+ toggleFile(file.path)} + onShow={() => revealFile(file.path)} + /> +
+ ); + } + + return ( +
+ toggleFile(file.path)} + /> +
+ ); + })} +
+ ); +} + +function CloudFileDiff({ + file, + toolCalls, + options, + collapsed, + onToggle, +}: { + file: ChangedFile; + toolCalls: Map; + options: FileDiffOptions; + collapsed: boolean; + onToggle: () => void; +}) { + const diff = useMemo( + () => extractCloudFileDiff(toolCalls, file.path), + [toolCalls, file.path], + ); + + const fileName = file.path.split("/").pop() || file.path; + const oldFile = useMemo( + () => ({ name: fileName, contents: diff?.oldText ?? "" }), + [fileName, diff], + ); + const newFile = useMemo( + () => ({ name: fileName, contents: diff?.newText ?? "" }), + [fileName, diff], + ); + + return ( + ( + + )} + /> + ); +} diff --git a/apps/code/src/renderer/features/code-review/components/DiffSettingsMenu.tsx b/apps/code/src/renderer/features/code-review/components/DiffSettingsMenu.tsx new file mode 100644 index 000000000..f0e62907b --- /dev/null +++ b/apps/code/src/renderer/features/code-review/components/DiffSettingsMenu.tsx @@ -0,0 +1,48 @@ +import { DotsThree } from "@phosphor-icons/react"; +import { DropdownMenu, IconButton, Text } from "@radix-ui/themes"; +import { useDiffViewerStore } from "@renderer/features/code-editor/stores/diffViewerStore"; + +export function DiffSettingsMenu() { + const wordWrap = useDiffViewerStore((s) => s.wordWrap); + const toggleWordWrap = useDiffViewerStore((s) => s.toggleWordWrap); + const wordDiffs = useDiffViewerStore((s) => s.wordDiffs); + const toggleWordDiffs = useDiffViewerStore((s) => s.toggleWordDiffs); + const hideWhitespaceChanges = useDiffViewerStore( + (s) => s.hideWhitespaceChanges, + ); + const toggleHideWhitespaceChanges = useDiffViewerStore( + (s) => s.toggleHideWhitespaceChanges, + ); + + return ( + + + + + + + + + + {wordWrap ? "Disable word wrap" : "Enable word wrap"} + + + + + {wordDiffs ? "Disable word diffs" : "Enable word diffs"} + + + + + {hideWhitespaceChanges ? "Show whitespace" : "Hide whitespace"} + + + + + ); +} diff --git a/apps/code/src/renderer/features/code-review/components/RevertableFileDiff.tsx b/apps/code/src/renderer/features/code-review/components/RevertableFileDiff.tsx new file mode 100644 index 000000000..d27aa15b4 --- /dev/null +++ b/apps/code/src/renderer/features/code-review/components/RevertableFileDiff.tsx @@ -0,0 +1,181 @@ +import { ArrowCounterClockwise } from "@phosphor-icons/react"; +import { + type DiffLineAnnotation, + diffAcceptRejectHunk, + type FileDiffMetadata, + type FileDiffOptions, + parseDiffFromFile, +} from "@pierre/diffs"; +import { FileDiff } from "@pierre/diffs/react"; +import { trpcClient, useTRPC } from "@renderer/trpc/client"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useMemo, useState } from "react"; + +interface HunkRevertMetadata { + kind: "hunk-revert"; + hunkIndex: number; +} + +export type ReviewAnnotationMetadata = HunkRevertMetadata; + +function getLastChangeLineNumber( + hunk: FileDiffMetadata["hunks"][number], +): number { + let lastChangeLine = hunk.additionStart; + let offset = 0; + for (const content of hunk.hunkContent) { + if (content.type === "change") { + lastChangeLine = hunk.additionStart + offset + content.additions - 1; + } + if (content.type === "context") offset += content.lines; + if (content.type === "change") offset += content.additions; + } + return lastChangeLine; +} + +function buildHunkAnnotations( + fileDiff: FileDiffMetadata, +): DiffLineAnnotation[] { + return fileDiff.hunks + .filter((h) => h.additionLines > 0 || h.deletionLines > 0) + .map((hunk) => { + const hunkIndex = fileDiff.hunks.indexOf(hunk); + return { + side: "additions" as const, + lineNumber: getLastChangeLineNumber(hunk), + metadata: { kind: "hunk-revert" as const, hunkIndex }, + }; + }); +} + +interface RevertableFileDiffProps { + fileDiff: FileDiffMetadata; + repoPath: string; + options: FileDiffOptions; + renderCustomHeader: (fd: FileDiffMetadata) => React.ReactNode; +} + +export function RevertableFileDiff({ + fileDiff: initialFileDiff, + repoPath, + options, + renderCustomHeader, +}: RevertableFileDiffProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [fileDiff, setFileDiff] = useState(initialFileDiff); + const [revertingHunks, setRevertingHunks] = useState>( + () => new Set(), + ); + + // Sync with parent when the diff data changes (e.g. after query refetch) + const [lastInitial, setLastInitial] = useState(initialFileDiff); + if (initialFileDiff !== lastInitial) { + setLastInitial(initialFileDiff); + setFileDiff(initialFileDiff); + setRevertingHunks(new Set()); + } + + const annotations = useMemo(() => buildHunkAnnotations(fileDiff), [fileDiff]); + + const handleRevert = useCallback( + async (hunkIndex: number) => { + const filePath = fileDiff.name ?? fileDiff.prevName ?? ""; + if (!filePath) return; + + setRevertingHunks((prev) => new Set(prev).add(hunkIndex)); + setFileDiff((prev) => diffAcceptRejectHunk(prev, hunkIndex, "reject")); + + try { + const [originalContent, modifiedContent] = await Promise.all([ + trpcClient.git.getFileAtHead.query({ + directoryPath: repoPath, + filePath, + }), + trpcClient.fs.readRepoFile.query({ repoPath, filePath }), + ]); + + const fullDiff = parseDiffFromFile( + { name: filePath, contents: originalContent ?? "" }, + { name: filePath, contents: modifiedContent ?? "" }, + ); + + const reverted = diffAcceptRejectHunk(fullDiff, hunkIndex, "reject"); + const newContent = reverted.additionLines.join(""); + + await trpcClient.fs.writeRepoFile.mutate({ + repoPath, + filePath, + content: newContent, + }); + + queryClient.invalidateQueries( + trpc.git.getDiffHead.queryFilter({ directoryPath: repoPath }), + ); + queryClient.invalidateQueries( + trpc.git.getChangedFilesHead.queryFilter({ directoryPath: repoPath }), + ); + } catch { + setFileDiff(initialFileDiff); + } finally { + setRevertingHunks((prev) => { + const next = new Set(prev); + next.delete(hunkIndex); + return next; + }); + } + }, + [fileDiff, repoPath, initialFileDiff, queryClient, trpc], + ); + + const renderAnnotation = useCallback( + (annotation: DiffLineAnnotation) => { + if (annotation.metadata.kind !== "hunk-revert") return null; + const isReverting = revertingHunks.has(annotation.metadata.hunkIndex); + + return ( +
+ +
+ ); + }, + [handleRevert, revertingHunks], + ); + + const mergedOptions = useMemo( + () => ({ + ...options, + unsafeCSS: `${(options.unsafeCSS as string) ?? ""} [data-line-annotation] { height: 0; min-height: 0; overflow: visible; }`, + }), + [options], + ); + + return ( + + ); +} diff --git a/apps/code/src/renderer/features/code-review/components/ReviewPage.tsx b/apps/code/src/renderer/features/code-review/components/ReviewPage.tsx new file mode 100644 index 000000000..877c7c801 --- /dev/null +++ b/apps/code/src/renderer/features/code-review/components/ReviewPage.tsx @@ -0,0 +1,198 @@ +import { useDiffViewerStore } from "@features/code-editor/stores/diffViewerStore"; +import { useGitQueries } from "@features/git-interaction/hooks/useGitQueries"; +import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; +import { useCwd } from "@features/sidebar/hooks/useCwd"; +import { type FileDiffOptions, parsePatchFiles } from "@pierre/diffs"; +import { MultiFileDiff } from "@pierre/diffs/react"; +import { Flex, Text } from "@radix-ui/themes"; +import { useTRPC } from "@renderer/trpc/client"; +import type { ChangedFile } from "@shared/types"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { RevertableFileDiff } from "./RevertableFileDiff"; +import { + DeferredDiffPlaceholder, + DiffFileHeader, + ReviewShell, + sumHunkStats, + useReviewState, +} from "./ReviewShell"; + +interface ReviewPageProps { + taskId: string; +} + +export function ReviewPage({ taskId }: ReviewPageProps) { + const trpc = useTRPC(); + const repoPath = useCwd(taskId); + const { changedFiles, changesLoading } = useGitQueries(repoPath); + const hideWhitespace = useDiffViewerStore((s) => s.hideWhitespaceChanges); + const openFile = usePanelLayoutStore((s) => s.openFile); + + const { data: rawDiff, isLoading: diffLoading } = useQuery( + trpc.git.getDiffHead.queryOptions( + { directoryPath: repoPath as string, ignoreWhitespace: hideWhitespace }, + { enabled: !!repoPath, staleTime: 30_000, refetchOnMount: "always" }, + ), + ); + + const parsedFiles = useMemo(() => { + if (!rawDiff) return []; + const patches = parsePatchFiles(rawDiff); + return patches.flatMap((p) => p.files); + }, [rawDiff]); + + const untrackedFiles = useMemo( + () => changedFiles.filter((f) => f.status === "untracked"), + [changedFiles], + ); + + const totalFileCount = parsedFiles.length + untrackedFiles.length; + + const allPaths = useMemo( + () => [ + ...parsedFiles.map((f) => f.name ?? f.prevName ?? ""), + ...untrackedFiles.map((f) => f.path), + ], + [parsedFiles, untrackedFiles], + ); + + const { + diffOptions, + linesAdded, + linesRemoved, + collapsedFiles, + toggleFile, + expandAll, + collapseAll, + revealFile, + getDeferredReason, + uncollapseFile, + } = useReviewState(changedFiles, allPaths); + + if (!repoPath) { + return ( + + + No repository path available + + + ); + } + + return ( + + {parsedFiles.map((fileDiff) => { + const key = fileDiff.name ?? fileDiff.prevName ?? ""; + const isCollapsed = collapsedFiles.has(key); + const deferredReason = getDeferredReason(key); + + if (deferredReason) { + const { additions, deletions } = sumHunkStats(fileDiff.hunks); + return ( +
+ toggleFile(key)} + onShow={() => revealFile(key)} + /> +
+ ); + } + + return ( +
+ ( + toggleFile(key)} + onOpenFile={() => + openFile(taskId, `${repoPath}/${key}`, false) + } + /> + )} + /> +
+ ); + })} + {untrackedFiles.map((file) => { + const isCollapsed = collapsedFiles.has(file.path); + return ( +
+ toggleFile(file.path)} + /> +
+ ); + })} +
+ ); +} + +function UntrackedFileDiff({ + file, + repoPath, + options, + collapsed, + onToggle, +}: { + file: ChangedFile; + repoPath: string; + options: FileDiffOptions; + collapsed: boolean; + onToggle: () => void; +}) { + const trpc = useTRPC(); + const { data: content } = useQuery( + trpc.fs.readRepoFile.queryOptions( + { repoPath, filePath: file.path }, + { staleTime: 30_000 }, + ), + ); + + const fileName = file.path.split("/").pop() || file.path; + const oldFile = useMemo(() => ({ name: fileName, contents: "" }), [fileName]); + const newFile = useMemo( + () => ({ name: fileName, contents: content ?? "" }), + [fileName, content], + ); + + return ( + ( + + )} + /> + ); +} diff --git a/apps/code/src/renderer/features/code-review/components/ReviewShell.tsx b/apps/code/src/renderer/features/code-review/components/ReviewShell.tsx new file mode 100644 index 000000000..1c6b02150 --- /dev/null +++ b/apps/code/src/renderer/features/code-review/components/ReviewShell.tsx @@ -0,0 +1,553 @@ +import { FileIcon } from "@components/ui/FileIcon"; +import { useDiffViewerStore } from "@features/code-editor/stores/diffViewerStore"; +import { computeDiffStats } from "@features/git-interaction/utils/diffStats"; +import { ArrowSquareOut, CaretDown } from "@phosphor-icons/react"; +import type { FileDiffMetadata } from "@pierre/diffs/react"; +import { WorkerPoolContextProvider } from "@pierre/diffs/react"; +import WorkerUrl from "@pierre/diffs/worker/worker.js?worker&url"; +import { Flex, Spinner, Text } from "@radix-ui/themes"; +import { useReviewNavigationStore } from "@renderer/features/code-review/stores/reviewNavigationStore"; +import type { ChangedFile } from "@shared/types"; +import { useThemeStore } from "@stores/themeStore"; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { ReviewToolbar } from "./ReviewToolbar"; + +function splitFilePath(fullPath: string): { + dirPath: string; + fileName: string; +} { + const lastSlash = fullPath.lastIndexOf("/"); + return { + dirPath: lastSlash >= 0 ? fullPath.slice(0, lastSlash + 1) : "", + fileName: lastSlash >= 0 ? fullPath.slice(lastSlash + 1) : fullPath, + }; +} + +export function sumHunkStats(hunks: FileDiffMetadata["hunks"]): { + additions: number; + deletions: number; +} { + let additions = 0; + let deletions = 0; + for (const hunk of hunks) { + additions += hunk.additionLines; + deletions += hunk.deletionLines; + } + return { additions, deletions }; +} + +function workerFactory(): Worker { + return new Worker(WorkerUrl, { type: "module" }); +} + +const STICKY_HEADER_CSS = `[data-diffs-header] { position: sticky; top: 0; z-index: 1; background: var(--gray-2); }`; + +const LARGE_DIFF_LINE_THRESHOLD = 500; + +const AUTO_COLLAPSE_PATTERNS = [ + /package-lock\.json$/, + /pnpm-lock\.yaml$/, + /yarn\.lock$/, + /bun\.lockb?$/, + /Cargo\.lock$/, + /poetry\.lock$/, + /Pipfile\.lock$/, + /composer\.lock$/, + /Gemfile\.lock$/, + /flake\.lock$/, + /deno\.lock$/, + /[.-]min\.(js|css)$/, + /\.map$/, + /(^|\/)dist\//, + /(^|\/)vendor\//, + /(^|\/)node_modules\//, + /(^|\/)__generated__\//, + /\.generated\./, + /\.designer\.(cs|vb)$/, + /\.snap$/, + /\.pbxproj$/, +]; + +export type DeferredReason = "deleted" | "large" | "generated"; + +export function computeAutoDeferred( + files: { + path: string; + status?: string; + linesAdded?: number; + linesRemoved?: number; + }[], +): Map { + const map = new Map(); + for (const file of files) { + if (file.status === "deleted") { + map.set(file.path, "deleted"); + continue; + } + const totalLines = (file.linesAdded ?? 0) + (file.linesRemoved ?? 0); + if (totalLines > LARGE_DIFF_LINE_THRESHOLD) { + map.set(file.path, "large"); + } else if (AUTO_COLLAPSE_PATTERNS.some((p) => p.test(file.path))) { + map.set(file.path, "generated"); + } + } + return map; +} + +function useDiffOptions() { + const viewMode = useDiffViewerStore((s) => s.viewMode); + const wordWrap = useDiffViewerStore((s) => s.wordWrap); + const loadFullFiles = useDiffViewerStore((s) => s.loadFullFiles); + const wordDiffs = useDiffViewerStore((s) => s.wordDiffs); + const isDarkMode = useThemeStore((s) => s.isDarkMode); + + return useMemo( + () => ({ + diffStyle: viewMode as "split" | "unified", + overflow: (wordWrap ? "wrap" : "scroll") as "wrap" | "scroll", + expandUnchanged: loadFullFiles, + lineDiffType: (wordDiffs ? "word-alt" : "none") as "word-alt" | "none", + themeType: (isDarkMode ? "dark" : "light") as "dark" | "light", + theme: { dark: "github-dark" as const, light: "github-light" as const }, + unsafeCSS: STICKY_HEADER_CSS, + }), + [viewMode, wordWrap, loadFullFiles, wordDiffs, isDarkMode], + ); +} + +export function useReviewState( + changedFiles: ChangedFile[], + allPaths: string[], +) { + const diffOptions = useDiffOptions(); + + const { linesAdded, linesRemoved } = useMemo( + () => computeDiffStats(changedFiles), + [changedFiles], + ); + + const autoDeferred = useMemo( + () => computeAutoDeferred(changedFiles), + [changedFiles], + ); + + const collapseState = useCollapseState(allPaths, autoDeferred); + + return { diffOptions, linesAdded, linesRemoved, ...collapseState }; +} + +function useCollapseState( + filePaths: string[], + deferredPaths: Map, +) { + const [revealedFiles, setRevealedFiles] = useState>( + () => new Set(), + ); + const [collapsedFiles, setCollapsedFiles] = useState>( + () => new Set(), + ); + + const [lastDeferred, setLastDeferred] = useState(deferredPaths); + if (deferredPaths !== lastDeferred) { + setLastDeferred(deferredPaths); + setRevealedFiles(new Set()); + } + + const revealFile = useCallback((filePath: string) => { + setRevealedFiles((prev) => new Set(prev).add(filePath)); + }, []); + + const toggleFile = useCallback((filePath: string) => { + setCollapsedFiles((prev) => { + const next = new Set(prev); + if (next.has(filePath)) next.delete(filePath); + else next.add(filePath); + return next; + }); + }, []); + + const uncollapseFile = useCallback((filePath: string) => { + setCollapsedFiles((prev) => { + if (!prev.has(filePath)) return prev; + const next = new Set(prev); + next.delete(filePath); + return next; + }); + }, []); + + const expandAll = useCallback(() => setCollapsedFiles(new Set()), []); + + const collapseAll = useCallback( + () => setCollapsedFiles(new Set(filePaths)), + [filePaths], + ); + + const getDeferredReason = useCallback( + (path: string): DeferredReason | null => { + if (revealedFiles.has(path)) return null; + return deferredPaths.get(path) ?? null; + }, + [deferredPaths, revealedFiles], + ); + + return { + collapsedFiles, + toggleFile, + uncollapseFile, + expandAll, + collapseAll, + revealFile, + getDeferredReason, + }; +} + +export interface ReviewShellProps { + taskId: string; + fileCount: number; + linesAdded: number; + linesRemoved: number; + isLoading: boolean; + isEmpty: boolean; + children: ReactNode; + onUncollapseFile?: (filePath: string) => void; + allExpanded: boolean; + onExpandAll: () => void; + onCollapseAll: () => void; +} + +export function ReviewShell({ + taskId, + fileCount, + linesAdded, + linesRemoved, + isLoading, + isEmpty, + children, + onUncollapseFile, + allExpanded, + onExpandAll, + onCollapseAll, +}: ReviewShellProps) { + const scrollContainerRef = useRef(null); + + const scrollRequest = useReviewNavigationStore( + (s) => s.scrollRequests[taskId] ?? null, + ); + const clearScrollRequest = useReviewNavigationStore( + (s) => s.clearScrollRequest, + ); + const setActiveFilePath = useReviewNavigationStore( + (s) => s.setActiveFilePath, + ); + const clearTask = useReviewNavigationStore((s) => s.clearTask); + + useEffect(() => { + return () => clearTask(taskId); + }, [taskId, clearTask]); + + useEffect(() => { + if (!scrollRequest) return; + + const container = scrollContainerRef.current; + if (!container) return; + + const target = container.querySelector( + `[data-file-path="${CSS.escape(scrollRequest)}"]`, + ); + if (!target) return; + + onUncollapseFile?.(scrollRequest); + + target.scrollIntoView({ block: "start" }); + setActiveFilePath(taskId, scrollRequest); + clearScrollRequest(taskId); + }, [ + scrollRequest, + clearScrollRequest, + setActiveFilePath, + taskId, + onUncollapseFile, + ]); + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + const fileDivs = + container.querySelectorAll("[data-file-path]"); + if (fileDivs.length === 0) return; + + const observer = new IntersectionObserver( + (entries) => { + let topEntry: IntersectionObserverEntry | null = null; + for (const entry of entries) { + if ( + entry.isIntersecting && + (!topEntry || + entry.boundingClientRect.top < topEntry.boundingClientRect.top) + ) { + topEntry = entry; + } + } + if (topEntry) { + const path = + (topEntry.target as HTMLElement).dataset.filePath ?? null; + const current = + useReviewNavigationStore.getState().activeFilePaths[taskId] ?? null; + if (path !== current) { + setActiveFilePath(taskId, path); + } + } + }, + { root: container, rootMargin: "0px 0px -80% 0px", threshold: 0 }, + ); + + for (const div of fileDivs) { + observer.observe(div); + } + + return () => observer.disconnect(); + }, [taskId, setActiveFilePath]); + + if (isLoading) { + return ( + + + + ); + } + + if (isEmpty) { + return ( + + + No file changes to review + + + ); + } + + return ( + + + +
+ {children} +
+
+
+ ); +} + +function FileHeaderRow({ + dirPath, + fileName, + additions, + deletions, + collapsed, + onToggle, + trailing, +}: { + dirPath: string; + fileName: string; + additions: number; + deletions: number; + collapsed: boolean; + onToggle: () => void; + trailing?: ReactNode; +}) { + return ( + + ); +} + +export function DiffFileHeader({ + fileDiff, + collapsed, + onToggle, + onOpenFile, +}: { + fileDiff: FileDiffMetadata; + collapsed: boolean; + onToggle: () => void; + onOpenFile?: () => void; +}) { + const fullPath = + fileDiff.prevName && fileDiff.prevName !== fileDiff.name + ? `${fileDiff.prevName} \u2192 ${fileDiff.name}` + : fileDiff.name; + const { dirPath, fileName } = splitFilePath(fullPath); + const { additions, deletions } = sumHunkStats(fileDiff.hunks); + + return ( + { + e.stopPropagation(); + onOpenFile(); + }} + className="hover:bg-gray-4" + style={{ + marginLeft: "auto", + color: "var(--gray-9)", + cursor: "pointer", + padding: "2px", + borderRadius: "3px", + display: "inline-flex", + background: "none", + border: "none", + }} + > + + + ) + } + /> + ); +} + +function getDeferredMessage( + reason: DeferredReason, + totalLines: number, +): string { + switch (reason) { + case "deleted": + return `Deleted file not rendered — ${totalLines} lines removed.`; + case "generated": + return `Generated file not rendered — ${totalLines} lines changed.`; + case "large": + return `Large diff not rendered — ${totalLines} lines changed.`; + } +} + +export function DeferredDiffPlaceholder({ + filePath, + linesAdded, + linesRemoved, + reason, + collapsed, + onToggle, + onShow, +}: { + filePath: string; + linesAdded: number; + linesRemoved: number; + reason: DeferredReason; + collapsed: boolean; + onToggle: () => void; + onShow: () => void; +}) { + const { dirPath, fileName } = splitFilePath(filePath); + + return ( +
+ + {!collapsed && ( + + )} +
+ ); +} diff --git a/apps/code/src/renderer/features/code-review/components/ReviewToolbar.tsx b/apps/code/src/renderer/features/code-review/components/ReviewToolbar.tsx new file mode 100644 index 000000000..d7a49109c --- /dev/null +++ b/apps/code/src/renderer/features/code-review/components/ReviewToolbar.tsx @@ -0,0 +1,83 @@ +import { useDiffViewerStore } from "@features/code-editor/stores/diffViewerStore"; +import { ArrowsIn, ArrowsOut, Columns, Rows } from "@phosphor-icons/react"; +import { Flex, IconButton, Text } from "@radix-ui/themes"; +import { DiffSettingsMenu } from "@renderer/features/code-review/components/DiffSettingsMenu"; +import { memo } from "react"; + +interface ReviewToolbarProps { + fileCount: number; + linesAdded: number; + linesRemoved: number; + allExpanded: boolean; + onExpandAll: () => void; + onCollapseAll: () => void; +} + +export const ReviewToolbar = memo(function ReviewToolbar({ + fileCount, + linesAdded, + linesRemoved, + allExpanded, + onExpandAll, + onCollapseAll, +}: ReviewToolbarProps) { + const viewMode = useDiffViewerStore((s) => s.viewMode); + const toggleViewMode = useDiffViewerStore((s) => s.toggleViewMode); + + return ( + + + {fileCount} file{fileCount !== 1 ? "s" : ""} changed + + + {linesAdded > 0 && ( + +{linesAdded} + )} + {linesRemoved > 0 && ( + -{linesRemoved} + )} + + + + + {viewMode === "split" ? : } + + + + {allExpanded ? : } + + + + + + ); +}); diff --git a/apps/code/src/renderer/features/code-review/stores/reviewNavigationStore.ts b/apps/code/src/renderer/features/code-review/stores/reviewNavigationStore.ts new file mode 100644 index 000000000..a08aacdcd --- /dev/null +++ b/apps/code/src/renderer/features/code-review/stores/reviewNavigationStore.ts @@ -0,0 +1,44 @@ +import { create } from "zustand"; + +interface ReviewNavigationStoreState { + activeFilePaths: Record; + scrollRequests: Record; +} + +interface ReviewNavigationStoreActions { + setActiveFilePath: (taskId: string, path: string | null) => void; + requestScrollToFile: (taskId: string, path: string) => void; + clearScrollRequest: (taskId: string) => void; + clearTask: (taskId: string) => void; +} + +type ReviewNavigationStore = ReviewNavigationStoreState & + ReviewNavigationStoreActions; + +export const useReviewNavigationStore = create()( + (set) => ({ + activeFilePaths: {}, + scrollRequests: {}, + + setActiveFilePath: (taskId, path) => + set((state) => ({ + activeFilePaths: { ...state.activeFilePaths, [taskId]: path }, + })), + + requestScrollToFile: (taskId, path) => + set((state) => ({ + scrollRequests: { ...state.scrollRequests, [taskId]: path }, + })), + + clearScrollRequest: (taskId) => + set((state) => ({ + scrollRequests: { ...state.scrollRequests, [taskId]: null }, + })), + + clearTask: (taskId) => + set((state) => ({ + activeFilePaths: { ...state.activeFilePaths, [taskId]: null }, + scrollRequests: { ...state.scrollRequests, [taskId]: null }, + })), + }), +); diff --git a/apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts b/apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts index 22afba10f..95118bdb3 100644 --- a/apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts +++ b/apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts @@ -13,7 +13,9 @@ import { createBranch, getBranchNameInputState, } from "@features/git-interaction/utils/branchCreation"; +import { sanitizeBranchName } from "@features/git-interaction/utils/branchNameValidation"; import type { DiffStats } from "@features/git-interaction/utils/diffStats"; +import { getSuggestedBranchName } from "@features/git-interaction/utils/getSuggestedBranchName"; import { invalidateGitBranchQueries } from "@features/git-interaction/utils/gitCacheKeys"; import { updateGitCacheFromSnapshot } from "@features/git-interaction/utils/updateGitCache"; import { trpc, trpcClient } from "@renderer/trpc"; @@ -22,8 +24,6 @@ import { useQueryClient } from "@tanstack/react-query"; import { track } from "@utils/analytics"; import { logger } from "@utils/logger"; import { useMemo } from "react"; -import { sanitizeBranchName } from "../utils/branchNameValidation"; -import { getSuggestedBranchName } from "../utils/getSuggestedBranchName"; const log = logger.scope("git-interaction"); diff --git a/apps/code/src/renderer/features/panels/components/DraggableTab.tsx b/apps/code/src/renderer/features/panels/components/DraggableTab.tsx index 4264f5b0d..120342b52 100644 --- a/apps/code/src/renderer/features/panels/components/DraggableTab.tsx +++ b/apps/code/src/renderer/features/panels/components/DraggableTab.tsx @@ -70,7 +70,7 @@ export const DraggableTab: React.FC = ({ e.preventDefault(); let filePath: string | undefined; - if (tabData.type === "file" || tabData.type === "diff") { + if (tabData.type === "file") { filePath = tabData.absolutePath; } @@ -94,9 +94,7 @@ export const DraggableTab: React.FC = ({ case "external-app": if (filePath) { const repoPath = - tabData.type === "file" || tabData.type === "diff" - ? tabData.repoPath - : undefined; + tabData.type === "file" ? tabData.repoPath : undefined; const workspaces = await workspaceApi.getAll(); const workspace = repoPath ? (Object.values(workspaces).find( diff --git a/apps/code/src/renderer/features/panels/constants/panelConstants.ts b/apps/code/src/renderer/features/panels/constants/panelConstants.ts index aa990772c..753911836 100644 --- a/apps/code/src/renderer/features/panels/constants/panelConstants.ts +++ b/apps/code/src/renderer/features/panels/constants/panelConstants.ts @@ -24,4 +24,5 @@ export const DEFAULT_TAB_IDS = { SHELL: "shell", FILES: "files", CHANGES: "changes", + REVIEW: "review", } as const; diff --git a/apps/code/src/renderer/features/panels/hooks/usePanelLayoutHooks.tsx b/apps/code/src/renderer/features/panels/hooks/usePanelLayoutHooks.tsx index ce8e6e12e..468bb8d4c 100644 --- a/apps/code/src/renderer/features/panels/hooks/usePanelLayoutHooks.tsx +++ b/apps/code/src/renderer/features/panels/hooks/usePanelLayoutHooks.tsx @@ -2,7 +2,7 @@ import { FileIcon } from "@components/ui/FileIcon"; import { ActionTabIcon } from "@features/actions/components/ActionTabIcon"; import { useCwd } from "@features/sidebar/hooks/useCwd"; import { TabContentRenderer } from "@features/task-detail/components/TabContentRenderer"; -import { ChatCenteredText, Terminal } from "@phosphor-icons/react"; +import { ChatCenteredText, GitDiff, Terminal } from "@phosphor-icons/react"; import type { Task } from "@shared/types"; import { isAbsolutePath } from "@utils/path"; import { useCallback, useEffect, useMemo, useRef } from "react"; @@ -85,7 +85,7 @@ export function useTabInjection( () => tabs.map((tab) => { let updatedData = tab.data; - if (tab.data.type === "file" || tab.data.type === "diff") { + if (tab.data.type === "file") { const rp = tab.data.relativePath; const absolutePath = isAbsolutePath(rp) ? rp : `${repoPath}/${rp}`; updatedData = { @@ -97,13 +97,15 @@ export function useTabInjection( let icon = tab.icon; if (!icon) { - if (tab.data.type === "file" || tab.data.type === "diff") { + if (tab.data.type === "file") { const filename = tab.data.relativePath.split("/").pop() || ""; icon = ; } else if (tab.data.type === "terminal") { icon = ; } else if (tab.data.type === "logs") { icon = ; + } else if (tab.data.type === "review") { + icon = ; } else if (tab.data.type === "action") { icon = ; } diff --git a/apps/code/src/renderer/features/panels/index.ts b/apps/code/src/renderer/features/panels/index.ts index 3c37e5870..0c09a6ec2 100644 --- a/apps/code/src/renderer/features/panels/index.ts +++ b/apps/code/src/renderer/features/panels/index.ts @@ -7,10 +7,7 @@ export { export { useDragDropHandlers } from "./hooks/useDragDropHandlers"; export { usePanelLayoutStore } from "./store/panelLayoutStore"; export { usePanelStore } from "./store/panelStore"; -export { - isDiffTabActiveInTree, - isFileTabActiveInTree, -} from "./store/panelStoreHelpers"; +export { isFileTabActiveInTree } from "./store/panelStoreHelpers"; export type { GroupId, diff --git a/apps/code/src/renderer/features/panels/store/panelLayoutStore.test.ts b/apps/code/src/renderer/features/panels/store/panelLayoutStore.test.ts index c9883102d..6f63b48f4 100644 --- a/apps/code/src/renderer/features/panels/store/panelLayoutStore.test.ts +++ b/apps/code/src/renderer/features/panels/store/panelLayoutStore.test.ts @@ -19,7 +19,6 @@ vi.mock("@utils/electronStorage", () => ({ }, })); -import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { usePanelLayoutStore } from "./panelLayoutStore"; describe("panelLayoutStore", () => { @@ -50,7 +49,7 @@ describe("panelLayoutStore", () => { assertPanelLayout(tree, [ { panelId: "main-panel", - expectedTabs: ["logs", "shell"], + expectedTabs: ["logs", "review", "shell"], activeTab: "logs", }, ]); @@ -65,11 +64,11 @@ describe("panelLayoutStore", () => { it("adds file tab to main panel by default", () => { usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx"); - assertTabCount(getPanelTree("task-1"), "main-panel", 3); + assertTabCount(getPanelTree("task-1"), "main-panel", 4); assertPanelLayout(getPanelTree("task-1"), [ { panelId: "main-panel", - expectedTabs: ["logs", "shell", "file-src/App.tsx"], + expectedTabs: ["logs", "review", "shell", "file-src/App.tsx"], }, ]); }); @@ -94,7 +93,7 @@ describe("panelLayoutStore", () => { activeTab: "file-src/App.tsx", }, ]); - assertTabCount(getPanelTree("task-1"), "main-panel", 1); + assertTabCount(getPanelTree("task-1"), "main-panel", 2); }); it("falls back to main panel if focused panel does not exist", () => { @@ -106,11 +105,11 @@ describe("panelLayoutStore", () => { usePanelLayoutStore.getState().openFile("task-1", "src/App.tsx"); // File should fall back to main-panel - assertTabCount(getPanelTree("task-1"), "main-panel", 3); + assertTabCount(getPanelTree("task-1"), "main-panel", 4); assertPanelLayout(getPanelTree("task-1"), [ { panelId: "main-panel", - expectedTabs: ["logs", "shell", "file-src/App.tsx"], + expectedTabs: ["logs", "review", "shell", "file-src/App.tsx"], }, ]); }); @@ -371,21 +370,21 @@ describe("panelLayoutStore", () => { }); it("reorders tabs within a panel", () => { - // tabs: [logs, shell, file-src/App.tsx, file-src/Other.tsx, file-src/Third.tsx] - // move index 2 to index 4 - usePanelLayoutStore.getState().reorderTabs("task-1", "main-panel", 2, 4); + // tabs: [logs, review, shell, file-src/App.tsx, file-src/Other.tsx, file-src/Third.tsx] + // move index 3 to index 5 + usePanelLayoutStore.getState().reorderTabs("task-1", "main-panel", 3, 5); const panel = findPanelById(getPanelTree("task-1"), "main-panel"); const tabIds = panel?.content.tabs.map((t: { id: string }) => t.id); - expect(tabIds?.[2]).toBe("file-src/Other.tsx"); - expect(tabIds?.[4]).toBe("file-src/App.tsx"); + expect(tabIds?.[3]).toBe("file-src/Other.tsx"); + expect(tabIds?.[5]).toBe("file-src/App.tsx"); }); it("preserves active tab after reorder", () => { usePanelLayoutStore .getState() .setActiveTab("task-1", "main-panel", "file-src/App.tsx"); - usePanelLayoutStore.getState().reorderTabs("task-1", "main-panel", 1, 3); + usePanelLayoutStore.getState().reorderTabs("task-1", "main-panel", 2, 4); assertActiveTab(getPanelTree("task-1"), "main-panel", "file-src/App.tsx"); }); @@ -645,125 +644,5 @@ describe("panelLayoutStore", () => { fileTabs?.find((t) => t.id === "file-src/Other.tsx")?.isPreview, ).toBe(true); }); - - it("openDiffByMode creates preview tab by default", () => { - usePanelLayoutStore - .getState() - .openDiffByMode("task-1", "src/App.tsx", "modified"); - - const panel = findPanelById(getPanelTree("task-1"), "main-panel"); - const diffTab = panel?.content.tabs.find((t: { id: string }) => - t.id.startsWith("diff-"), - ); - expect(diffTab?.isPreview).toBe(true); - }); - - it("openDiffByMode creates permanent tab when asPreview is false", () => { - usePanelLayoutStore - .getState() - .openDiffByMode("task-1", "src/App.tsx", "modified", false); - - const panel = findPanelById(getPanelTree("task-1"), "main-panel"); - const diffTab = panel?.content.tabs.find((t: { id: string }) => - t.id.startsWith("diff-"), - ); - expect(diffTab?.isPreview).toBe(false); - }); - }); - - describe("openDiffByMode", () => { - beforeEach(() => { - usePanelLayoutStore.getState().initializeTask("task-1"); - }); - - it("opens diff in split pane when mode is split", () => { - useSettingsStore.setState({ diffOpenMode: "split" }); - - usePanelLayoutStore - .getState() - .openDiffByMode("task-1", "src/App.tsx", "modified"); - - const tree = getPanelTree("task-1"); - expect(tree.type).toBe("group"); - }); - - it("opens diff in main panel when mode is same-pane", () => { - useSettingsStore.setState({ diffOpenMode: "same-pane" }); - - usePanelLayoutStore - .getState() - .openDiffByMode("task-1", "src/App.tsx", "modified"); - - const tree = getPanelTree("task-1"); - expect(tree.type).toBe("leaf"); - const panel = findPanelById(tree, "main-panel"); - const diffTab = panel?.content.tabs.find((t: { id: string }) => - t.id.startsWith("diff-"), - ); - expect(diffTab).toBeDefined(); - }); - - it("opens diff in focused panel when mode is last-active-pane", () => { - useSettingsStore.setState({ diffOpenMode: "last-active-pane" }); - - usePanelLayoutStore - .getState() - .splitPanel("task-1", "shell", "main-panel", "main-panel", "right"); - - const tree = getPanelTree("task-1"); - if (tree.type !== "group") throw new Error("Expected group"); - const newPanelId = tree.children[1].id; - usePanelLayoutStore.getState().setFocusedPanel("task-1", newPanelId); - - usePanelLayoutStore - .getState() - .openDiffByMode("task-1", "src/App.tsx", "modified"); - - const updatedTree = getPanelTree("task-1"); - if (updatedTree.type !== "group") throw new Error("Expected group"); - const secondPanel = findPanelById(updatedTree, newPanelId); - const diffTab = secondPanel?.content.tabs.find((t: { id: string }) => - t.id.startsWith("diff-"), - ); - expect(diffTab).toBeDefined(); - expect(secondPanel?.content.activeTabId).toBe(diffTab?.id); - }); - - it("opens diff in split on wide window when mode is auto", () => { - useSettingsStore.setState({ diffOpenMode: "auto" }); - - Object.defineProperty(window, "outerWidth", { - value: 1440, - writable: true, - }); - - usePanelLayoutStore - .getState() - .openDiffByMode("task-1", "src/App.tsx", "modified"); - - const tree = getPanelTree("task-1"); - expect(tree.type).toBe("group"); - }); - - it("opens diff in same pane on narrow window when mode is auto", () => { - useSettingsStore.setState({ diffOpenMode: "auto" }); - - Object.defineProperty(window, "outerWidth", { - value: 1200, - writable: true, - }); - - usePanelLayoutStore - .getState() - .openDiffByMode("task-1", "src/App.tsx", "modified"); - - const tree = getPanelTree("task-1"); - expect(tree.type).toBe("leaf"); - const panel = findPanelById(tree, "main-panel"); - const diffTab = panel?.content.tabs.find((t: { id: string }) => - t.id.startsWith("diff-"), - ); - expect(diffTab).toBeDefined(); - }); }); }); diff --git a/apps/code/src/renderer/features/panels/store/panelLayoutStore.ts b/apps/code/src/renderer/features/panels/store/panelLayoutStore.ts index 822601a28..d1bf0a0df 100644 --- a/apps/code/src/renderer/features/panels/store/panelLayoutStore.ts +++ b/apps/code/src/renderer/features/panels/store/panelLayoutStore.ts @@ -1,4 +1,4 @@ -import { useSettingsStore } from "@features/settings/stores/settingsStore"; +import { getFileExtension } from "@renderer/utils/path"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { track } from "@utils/analytics"; import { persist } from "zustand/middleware"; @@ -10,12 +10,8 @@ import { import { addNewTabToPanel, applyCleanupWithFallback, - createCloudDiffTabId, - createDiffTabId, createFileTabId, generatePanelId, - getCloudDiffTabIdsForFile, - getDiffTabIdsForFile, getLeafPanel, getSplitConfig, selectNextTabAfterClose, @@ -33,29 +29,6 @@ import { } from "./panelTree"; import type { PanelNode, Tab } from "./panelTypes"; -function getFileExtension(filePath: string): string { - const parts = filePath.split("."); - return parts.length > 1 ? parts[parts.length - 1] : ""; -} - -function trackDiffViewed( - taskId: string, - filePath: string, - status?: string, -): void { - const changeType = - status === "added" - ? "added" - : status === "deleted" - ? "deleted" - : "modified"; - track(ANALYTICS_EVENTS.FILE_DIFF_VIEWED, { - file_extension: getFileExtension(filePath), - change_type: changeType, - task_id: taskId, - }); -} - const MAX_RECENT_FILES = 10; export interface TaskLayout { @@ -80,24 +53,13 @@ export interface PanelLayoutStore { filePath: string, asPreview?: boolean, ) => void; - openDiffByMode: ( - taskId: string, - filePath: string, - status?: string, - asPreview?: boolean, - ) => void; - openCloudDiffByMode: ( - taskId: string, - filePath: string, - status?: string, - asPreview?: boolean, - ) => void; + openReview: (taskId: string) => void; keepTab: (taskId: string, panelId: string, tabId: string) => void; closeTab: (taskId: string, panelId: string, tabId: string) => void; closeOtherTabs: (taskId: string, panelId: string, tabId: string) => void; closeTabsToRight: (taskId: string, panelId: string, tabId: string) => void; closeTabsForFile: (taskId: string, filePath: string) => void; - closeDiffTabsForFile: (taskId: string, filePath: string) => void; + setActiveTab: (taskId: string, panelId: string, tabId: string) => void; setDraggingTab: ( taskId: string, @@ -161,6 +123,14 @@ function createDefaultPanelTree(): PanelNode { closeable: false, draggable: true, }, + { + id: DEFAULT_TAB_IDS.REVIEW, + label: "Review", + data: { type: "review" as const }, + component: null, + closeable: false, + draggable: true, + }, { id: DEFAULT_TAB_IDS.SHELL, label: "Terminal", @@ -258,39 +228,6 @@ function findNonMainLeafPanel(node: PanelNode): PanelNode | null { return null; } -function openTabByDiffMode( - state: { taskLayouts: Record }, - taskId: string, - tabId: string, - asPreview: boolean, -): { taskLayouts: Record } { - const mode = useSettingsStore.getState().diffOpenMode; - switch (mode) { - case "split": - return openTabInSplit(state, taskId, tabId, asPreview); - case "same-pane": - return openTab( - state, - taskId, - tabId, - asPreview, - DEFAULT_PANEL_IDS.MAIN_PANEL, - ); - case "last-active-pane": - return openTab(state, taskId, tabId, asPreview); - default: - return window.outerWidth >= 1440 - ? openTabInSplit(state, taskId, tabId, asPreview) - : openTab( - state, - taskId, - tabId, - asPreview, - DEFAULT_PANEL_IDS.MAIN_PANEL, - ); - } -} - function openTabInSplit( state: { taskLayouts: Record }, taskId: string, @@ -459,16 +396,11 @@ export const usePanelLayoutStore = createWithEqualityFn()( }); }, - openDiffByMode: (taskId, filePath, status, asPreview = true) => { - const tabId = createDiffTabId(filePath, status); - set((state) => openTabByDiffMode(state, taskId, tabId, asPreview)); - trackDiffViewed(taskId, filePath, status); - }, - - openCloudDiffByMode: (taskId, filePath, status, asPreview = true) => { - const tabId = createCloudDiffTabId(filePath, status); - set((state) => openTabByDiffMode(state, taskId, tabId, asPreview)); - trackDiffViewed(taskId, filePath, status); + openReview: (taskId) => { + set((state) => openTab(state, taskId, "review", false)); + track(ANALYTICS_EVENTS.REVIEW_PANEL_VIEWED, { + task_id: taskId, + }); }, keepTab: (taskId, panelId, tabId) => { @@ -610,34 +542,10 @@ export const usePanelLayoutStore = createWithEqualityFn()( const layout = get().taskLayouts[taskId]; if (!layout) return; - const tabIds = [ - createFileTabId(filePath), - ...getDiffTabIdsForFile(filePath), - ...getCloudDiffTabIdsForFile(filePath), - ]; - - for (const tabId of tabIds) { - const tabLocation = findTabInTree(layout.panelTree, tabId); - if (tabLocation) { - get().closeTab(taskId, tabLocation.panelId, tabId); - } - } - }, - - closeDiffTabsForFile: (taskId, filePath) => { - const layout = get().taskLayouts[taskId]; - if (!layout) return; - - const tabIds = [ - ...getDiffTabIdsForFile(filePath), - ...getCloudDiffTabIdsForFile(filePath), - ]; - - for (const tabId of tabIds) { - const tabLocation = findTabInTree(layout.panelTree, tabId); - if (tabLocation) { - get().closeTab(taskId, tabLocation.panelId, tabId); - } + const tabId = createFileTabId(filePath); + const tabLocation = findTabInTree(layout.panelTree, tabId); + if (tabLocation) { + get().closeTab(taskId, tabLocation.panelId, tabId); } }, diff --git a/apps/code/src/renderer/features/panels/store/panelStoreHelpers.ts b/apps/code/src/renderer/features/panels/store/panelStoreHelpers.ts index 0e8b16afb..d5887d622 100644 --- a/apps/code/src/renderer/features/panels/store/panelStoreHelpers.ts +++ b/apps/code/src/renderer/features/panels/store/panelStoreHelpers.ts @@ -1,4 +1,3 @@ -import type { GitFileStatus } from "@shared/types"; import { DEFAULT_TAB_IDS } from "../constants/panelConstants"; import type { SplitDirection, TaskLayout } from "./panelLayoutStore"; import type { GroupPanel, LeafPanel, PanelNode, Tab } from "./panelTypes"; @@ -7,15 +6,7 @@ import type { GroupPanel, LeafPanel, PanelNode, Tab } from "./panelTypes"; export const DEFAULT_FALLBACK_TAB = DEFAULT_TAB_IDS.LOGS; // Tab ID utilities -export type TabType = "file" | "diff" | "cloud-diff" | "system"; - -const DIFF_STATUSES = [ - "modified", - "deleted", - "added", - "untracked", - "renamed", -] as const; +export type TabType = "file" | "system"; export interface ParsedTabId { type: TabType; @@ -26,77 +17,19 @@ export function createFileTabId(filePath: string): string { return `file-${filePath}`; } -export function createDiffTabId(filePath: string, status?: string): string { - if (status) { - return `diff-${status}:${filePath}`; - } - return `diff-${filePath}`; -} - -export function getDiffTabIdsForFile(filePath: string): string[] { - return DIFF_STATUSES.map((s) => createDiffTabId(filePath, s)); -} - -export function getCloudDiffTabIdsForFile(filePath: string): string[] { - return DIFF_STATUSES.map((s) => createCloudDiffTabId(filePath, s)); -} - -export function createCloudDiffTabId( - filePath: string, - status?: string, -): string { - return `cloud-diff-${status ?? "modified"}:${filePath}`; -} - export function parseTabId(tabId: string): ParsedTabId & { status?: string } { if (tabId.startsWith("file-")) { return { type: "file", value: tabId.slice(5) }; } - if (tabId.startsWith("cloud-diff-")) { - const rest = tabId.slice(11); - const colonIndex = rest.indexOf(":"); - const status = colonIndex !== -1 ? rest.slice(0, colonIndex) : "modified"; - const value = colonIndex !== -1 ? rest.slice(colonIndex + 1) : rest; - return { type: "cloud-diff", value, status }; - } - if (tabId.startsWith("diff-")) { - const rest = tabId.slice(5); - // Check for status:path format - const colonIndex = rest.indexOf(":"); - if (colonIndex !== -1) { - const status = rest.slice(0, colonIndex); - const value = rest.slice(colonIndex + 1); - return { type: "diff", value, status }; - } - return { type: "diff", value: rest }; - } return { type: "system", value: tabId }; } -function getStatusLabel(status?: string): string { - switch (status) { - case "deleted": - return "Deleted"; - case "untracked": - case "added": - return "New"; - case "renamed": - return "Renamed"; - default: - return "diff"; - } -} - export function createTabLabel(tabId: string): string { + if (tabId === "review") return "Review"; const parsed = parseTabId(tabId); if (parsed.type === "file") { return parsed.value.split("/").pop() || parsed.value; } - if (parsed.type === "diff" || parsed.type === "cloud-diff") { - const fileName = parsed.value.split("/").pop() || parsed.value; - const label = getStatusLabel(parsed.status); - return `${fileName} (${label})`; - } return parsed.value; } @@ -187,24 +120,10 @@ export function createNewTab( repoPath: "", // Will be populated by tab injection }; break; - case "diff": - data = { - type: "diff", - relativePath: parsed.value, - absolutePath: "", // Will be populated by tab injection - repoPath: "", // Will be populated by tab injection - status: (parsed.status || "modified") as GitFileStatus, - }; - break; - case "cloud-diff": - data = { - type: "cloud-diff", - relativePath: parsed.value, - status: (parsed.status || "modified") as GitFileStatus, - }; - break; case "system": - if (tabId === "logs") { + if (tabId === "review") { + data = { type: "review" }; + } else if (tabId === "logs") { data = { type: "logs" }; } else if (tabId.startsWith("shell")) { data = { @@ -325,24 +244,6 @@ function isTabActiveInTree(tree: PanelNode, tabId: string): boolean { return tree.children.some((child) => isTabActiveInTree(child, tabId)); } -export function isDiffTabActiveInTree( - tree: PanelNode, - filePath: string, - status?: string, -): boolean { - const tabId = createDiffTabId(filePath, status); - return isTabActiveInTree(tree, tabId); -} - -export function isCloudDiffTabActiveInTree( - tree: PanelNode, - filePath: string, - status?: string, -): boolean { - const tabId = createCloudDiffTabId(filePath, status); - return isTabActiveInTree(tree, tabId); -} - export function isFileTabActiveInTree( tree: PanelNode, filePath: string, diff --git a/apps/code/src/renderer/features/panels/store/panelTypes.ts b/apps/code/src/renderer/features/panels/store/panelTypes.ts index 77bc2d723..6f9869634 100644 --- a/apps/code/src/renderer/features/panels/store/panelTypes.ts +++ b/apps/code/src/renderer/features/panels/store/panelTypes.ts @@ -1,5 +1,3 @@ -import type { GitFileStatus } from "@shared/types"; - export type PanelId = string; export type TabId = string; export type GroupId = string; @@ -16,22 +14,13 @@ export type TabData = repoPath: string; } | { - type: "diff"; - relativePath: string; - absolutePath: string; - repoPath: string; - status: GitFileStatus; + type: "review"; } | { type: "terminal"; terminalId: string; cwd: string; } - | { - type: "cloud-diff"; - relativePath: string; - status: GitFileStatus; - } | { type: "action"; actionId: string; 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 24a53391a..844954f3a 100644 --- a/apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx +++ b/apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx @@ -2,24 +2,13 @@ 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 { 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 { usePendingPermissionsForTask } from "@features/sessions/stores/sessionStore"; import { useCwd } from "@features/sidebar/hooks/useCwd"; -import { useCloudRunState } from "@features/task-detail/hooks/useCloudRunState"; +import { useCloudChangedFiles } from "@features/task-detail/hooks/useCloudChangedFiles"; import { ArrowCounterClockwiseIcon, - CaretDownIcon, - CaretUpIcon, CodeIcon, CopyIcon, FilePlus, @@ -34,14 +23,18 @@ import { Spinner, Text, } from "@radix-ui/themes"; +import { useReviewNavigationStore } from "@renderer/features/code-review/stores/reviewNavigationStore"; +import { getStatusIndicator } from "@renderer/features/git-interaction/utils/gitStatusUtils"; import { useWorkspace } from "@renderer/features/workspace/hooks/useWorkspace"; import { trpcClient } from "@renderer/trpc/client"; -import type { ChangedFile, GitFileStatus, Task } from "@shared/types"; +import { track } from "@renderer/utils/analytics"; +import { getFileExtension } from "@renderer/utils/path"; +import type { ChangedFile, Task } from "@shared/types"; +import { ANALYTICS_EVENTS, type FileChangeType } from "@shared/types/analytics"; 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 { useState } from "react"; interface ChangesPanelProps { taskId: string; @@ -51,31 +44,12 @@ interface ChangesPanelProps { interface ChangedFileItemProps { file: ChangedFile; taskId: string; - repoPath: string; isActive: boolean; + /** When provided, enables the hover toolbar (discard, open-with, context menu) */ + repoPath?: string; mainRepoPath?: string; } -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 getDiscardInfo( file: ChangedFile, fileName: string, @@ -117,13 +91,13 @@ function getDiscardInfo( function ChangedFileItem({ file, taskId, - repoPath, isActive, + repoPath, mainRepoPath, }: ChangedFileItemProps) { - const openDiffByMode = usePanelLayoutStore((state) => state.openDiffByMode); - const closeDiffTabsForFile = usePanelLayoutStore( - (state) => state.closeDiffTabsForFile, + const openReview = usePanelLayoutStore((state) => state.openReview); + const requestScrollToFile = useReviewNavigationStore( + (state) => state.requestScrollToFile, ); const queryClient = useQueryClient(); const { detectedApps } = useExternalApps(); @@ -132,19 +106,21 @@ function ChangedFileItem({ const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isHovered, setIsHovered] = useState(false); - // show toolbar when hovered OR when dropdown is open - const isToolbarVisible = isHovered || isDropdownOpen; + const isLocal = !!repoPath; + const isToolbarVisible = isLocal && (isHovered || isDropdownOpen); const fileName = file.path.split("/").pop() || file.path; - const fullPath = `${repoPath}/${file.path}`; + const fullPath = repoPath ? `${repoPath}/${file.path}` : file.path; const indicator = getStatusIndicator(file.status); const handleClick = () => { - openDiffByMode(taskId, file.path, file.status); - }; - - const handleDoubleClick = () => { - openDiffByMode(taskId, file.path, file.status, false); + track(ANALYTICS_EVENTS.FILE_DIFF_VIEWED, { + change_type: file.status as FileChangeType, + file_extension: getFileExtension(file.path), + task_id: taskId, + }); + requestScrollToFile(taskId, file.path); + openReview(taskId); }; const workspaceContext = { @@ -152,23 +128,25 @@ function ChangedFileItem({ 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 handleContextMenu = repoPath + ? 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, + ); + } + } + : undefined; const handleOpenWith = async (appId: string) => { await handleExternalAppAction( @@ -178,7 +156,6 @@ function ChangedFileItem({ workspaceContext, ); - // blur active element to dismiss any open tooltip if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } @@ -188,35 +165,39 @@ function ChangedFileItem({ 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 handleDiscard = repoPath + ? 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, + }); + + if (discardResult.state) { + updateGitCacheFromSnapshot( + queryClient, + repoPath, + discardResult.state, + ); + } + } + : undefined; const hasLineStats = file.linesAdded !== undefined || file.linesRemoved !== undefined; @@ -229,7 +210,7 @@ function ChangedFileItem({ align="center" gap="1" onClick={handleClick} - onDoubleClick={handleDoubleClick} + onDoubleClick={handleClick} onContextMenu={handleContextMenu} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} @@ -298,7 +279,7 @@ function ChangedFileItem({ )} - {isToolbarVisible && ( + {isToolbarVisible && handleDiscard && ( 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 ( - - - - - {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) { - const { prUrl, effectiveBranch, repo, isRunActive, fallbackFiles } = - useCloudRunState(taskId, task); - - const layout = usePanelLayoutStore((state) => state.getLayout(taskId)); - - 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, - isError: branchError, - } = useCloudBranchChangedFiles( - !prUrl ? repo : null, - !prUrl ? effectiveBranch : null, + prUrl, + effectiveBranch, + isRunActive, + changedFiles, + isLoading, + hasError, + } = useCloudChangedFiles(taskId, task); + + const activeFilePath = useReviewNavigationStore( + (s) => s.activeFilePaths[taskId] ?? null, ); - 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; + const effectiveFiles = changedFiles; // No branch/PR yet and run is active — show waiting state if (!prUrl && !effectiveBranch && effectiveFiles.length === 0) { @@ -590,11 +440,11 @@ function CloudChangesPanel({ taskId, task }: ChangesPanelProps) { {effectiveFiles.map((file) => ( - ))} {isRunActive && ( @@ -625,61 +475,10 @@ export function ChangesPanel({ taskId, task }: ChangesPanelProps) { function LocalChangesPanel({ taskId, task: _task }: ChangesPanelProps) { const workspace = useWorkspace(taskId); const repoPath = useCwd(taskId); - const layout = usePanelLayoutStore((state) => state.getLayout(taskId)); - const openDiffByMode = usePanelLayoutStore((state) => state.openDiffByMode); - 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], - ); - - useHotkeys( - "up", - () => handleKeyNavigation("up"), - { enabled: !hasPendingPermissions }, - [handleKeyNavigation, hasPendingPermissions], - ); - useHotkeys( - "down", - () => handleKeyNavigation("down"), - { enabled: !hasPendingPermissions }, - [handleKeyNavigation, hasPendingPermissions], + const activeFilePath = useReviewNavigationStore( + (s) => s.activeFilePaths[taskId] ?? null, ); - - const isFileActive = (file: ChangedFile): boolean => { - if (!layout) return false; - return isDiffTabActiveInTree(layout.panelTree, file.path, file.status); - }; + const { changedFiles, changesLoading: isLoading } = useGitQueries(repoPath); if (!repoPath) { return No repository path available; @@ -710,20 +509,10 @@ function LocalChangesPanel({ taskId, task: _task }: ChangesPanelProps) { file={file} taskId={taskId} repoPath={repoPath} - isActive={isFileActive(file)} + isActive={activeFilePath === file.path} mainRepoPath={workspace?.folderPath} /> ))} - - - - / - - - - to switch files - - ); diff --git a/apps/code/src/renderer/features/task-detail/components/TabContentRenderer.tsx b/apps/code/src/renderer/features/task-detail/components/TabContentRenderer.tsx index fa6124ef5..32ad9e0d9 100644 --- a/apps/code/src/renderer/features/task-detail/components/TabContentRenderer.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TabContentRenderer.tsx @@ -1,12 +1,13 @@ -import { CloudDiffEditorPanel } from "@features/code-editor/components/CloudDiffEditorPanel"; import { CodeEditorPanel } from "@features/code-editor/components/CodeEditorPanel"; -import { DiffEditorPanel } from "@features/code-editor/components/DiffEditorPanel"; import type { Tab } from "@features/panels/store/panelTypes"; import { ActionPanel } from "@features/task-detail/components/ActionPanel"; import { ChangesPanel } from "@features/task-detail/components/ChangesPanel"; import { FileTreePanel } from "@features/task-detail/components/FileTreePanel"; import { TaskLogsPanel } from "@features/task-detail/components/TaskLogsPanel"; import { TaskShellPanel } from "@features/task-detail/components/TaskShellPanel"; +import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; +import { CloudReviewPage } from "@renderer/features/code-review/components/CloudReviewPage"; +import { ReviewPage } from "@renderer/features/code-review/components/ReviewPage"; import type { Task } from "@shared/types"; interface TabContentRendererProps { @@ -20,6 +21,7 @@ export function TabContentRenderer({ taskId, task, }: TabContentRendererProps) { + const workspace = useWorkspace(taskId); const { data } = tab; switch (data.type) { @@ -40,14 +42,15 @@ export function TabContentRenderer({ /> ); - case "diff": - return ( - + case "review": { + const isCloud = + workspace?.mode === "cloud" || task.latest_run?.environment === "cloud"; + return isCloud ? ( + + ) : ( + ); + } case "action": return ( @@ -59,14 +62,6 @@ export function TabContentRenderer({ /> ); - case "cloud-diff": - return ( - - ); - case "other": // Handle system tabs by ID // TODO: These should all have their own type as well diff --git a/apps/code/src/renderer/features/task-detail/hooks/useCloudChangedFiles.ts b/apps/code/src/renderer/features/task-detail/hooks/useCloudChangedFiles.ts new file mode 100644 index 000000000..bf4355e6f --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/hooks/useCloudChangedFiles.ts @@ -0,0 +1,41 @@ +import { + useCloudBranchChangedFiles, + useCloudPrChangedFiles, +} from "@features/git-interaction/hooks/useGitQueries"; +import { useCloudRunState } from "@features/task-detail/hooks/useCloudRunState"; +import type { ChangedFile, Task } from "@shared/types"; + +export function useCloudChangedFiles(taskId: string, task: Task) { + const cloudRunState = useCloudRunState(taskId, task); + const { prUrl, effectiveBranch, repo, fallbackFiles } = cloudRunState; + + const { + data: prFiles, + isPending: prPending, + isError: prError, + } = useCloudPrChangedFiles(prUrl); + + const { + data: branchFiles, + isPending: branchPending, + isError: branchError, + } = useCloudBranchChangedFiles( + !prUrl ? repo : null, + !prUrl ? effectiveBranch : null, + ); + + const remoteFiles: ChangedFile[] = prUrl + ? (prFiles ?? []) + : (branchFiles ?? []); + const isLoading = prUrl ? prPending : effectiveBranch ? branchPending : false; + const hasError = prUrl ? prError : effectiveBranch ? branchError : false; + + const changedFiles = remoteFiles.length > 0 ? remoteFiles : fallbackFiles; + + return { + ...cloudRunState, + changedFiles, + isLoading, + hasError, + }; +} diff --git a/apps/code/src/renderer/features/task-detail/utils/cloudToolChanges.ts b/apps/code/src/renderer/features/task-detail/utils/cloudToolChanges.ts index 0e3b1cc07..f72fca800 100644 --- a/apps/code/src/renderer/features/task-detail/utils/cloudToolChanges.ts +++ b/apps/code/src/renderer/features/task-detail/utils/cloudToolChanges.ts @@ -8,7 +8,7 @@ import { isJsonRpcNotification, } from "@shared/types/session-events"; -interface ParsedToolCall { +export interface ParsedToolCall { toolCallId: string; kind?: string | null; title?: string; diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index 1b26ebd60..20e3e15cd 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -17,7 +17,7 @@ type GitActionType = | "branch-here"; export type FeedbackType = "good" | "bad" | "general"; type FileOpenSource = "sidebar" | "agent-suggestion" | "search" | "diff"; -type FileChangeType = "added" | "modified" | "deleted"; +export type FileChangeType = "added" | "modified" | "deleted"; type StopReason = "user_cancelled" | "completed" | "error" | "timeout"; export type CommandMenuAction = | "home" @@ -116,6 +116,10 @@ export interface FileDiffViewedProperties { task_id?: string; } +export interface ReviewPanelViewedProperties { + task_id: string; +} + // Workspace events export interface WorkspaceCreatedProperties { task_id: string; @@ -216,6 +220,7 @@ export const ANALYTICS_EVENTS = { // File interactions FILE_OPENED: "File opened", FILE_DIFF_VIEWED: "File diff viewed", + REVIEW_PANEL_VIEWED: "Review panel viewed", // Workspace events WORKSPACE_CREATED: "Workspace created", @@ -269,6 +274,7 @@ export type EventPropertyMap = { // File interactions [ANALYTICS_EVENTS.FILE_OPENED]: FileOpenedProperties; [ANALYTICS_EVENTS.FILE_DIFF_VIEWED]: FileDiffViewedProperties; + [ANALYTICS_EVENTS.REVIEW_PANEL_VIEWED]: ReviewPanelViewedProperties; // Workspace events [ANALYTICS_EVENTS.WORKSPACE_CREATED]: WorkspaceCreatedProperties; diff --git a/apps/code/vite.renderer.config.mts b/apps/code/vite.renderer.config.mts index afb4f41b2..78491741f 100644 --- a/apps/code/vite.renderer.config.mts +++ b/apps/code/vite.renderer.config.mts @@ -24,6 +24,9 @@ export default defineConfig(({ mode }) => { tsconfigPaths(), createPosthogPlugin(env, "posthog-code-renderer"), ].filter(Boolean), + worker: { + format: "es", + }, build: { sourcemap: true, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 424e704ab..24e62d15f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -151,6 +151,9 @@ importers: '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@pierre/diffs': + specifier: ^1.1.7 + version: 1.1.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@posthog/agent': specifier: workspace:* version: link:../../packages/agent @@ -3412,6 +3415,16 @@ packages: react: '>= 16.8' react-dom: '>= 16.8' + '@pierre/diffs@1.1.7': + resolution: {integrity: sha512-FWs2hHrjZPXmJl6ewnfFzOjNEM3aeSH1CB8ynZg4SOg95Wc5AxomeyJJhXf44PK9Cc+PNm1CgsJ1IvOdfgHyHA==} + peerDependencies: + react: ^18.3.1 || ^19.0.0 + react-dom: ^18.3.1 || ^19.0.0 + + '@pierre/theme@0.0.22': + resolution: {integrity: sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA==} + engines: {vscode: ^1.0.0} + '@pixi/colord@2.9.6': resolution: {integrity: sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==} @@ -4492,6 +4505,30 @@ packages: cpu: [x64] os: [win32] + '@shikijs/core@3.23.0': + resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} + + '@shikijs/engine-javascript@3.23.0': + resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==} + + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + + '@shikijs/transformers@3.23.0': + resolution: {integrity: sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ==} + + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sinclair/typebox-codegen@0.11.1': resolution: {integrity: sha512-Bckbrf1sJFTIVD88PvI0vWUfE3Sh/6pwu6Jov+6xyMrEqnabOxEFAmPSDWjB1FGPL5C1/HfdScwa1imwAtGi9w==} @@ -6107,6 +6144,10 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + diff@8.0.3: + resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + engines: {node: '>=0.3.1'} + dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} @@ -7160,6 +7201,9 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} @@ -7202,6 +7246,9 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -7943,6 +7990,9 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} + lru_map@0.4.1: + resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -8594,6 +8644,12 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + oniguruma-parser@0.12.1: + resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} + + oniguruma-to-es@4.3.5: + resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==} + open@10.2.0: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} @@ -9455,6 +9511,15 @@ packages: regenerator-runtime@0.13.11: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + regexpu-core@6.4.0: resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} engines: {node: '>=4'} @@ -9714,6 +9779,9 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} + shiki@3.23.0: + resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -14263,6 +14331,19 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + '@pierre/diffs@1.1.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@pierre/theme': 0.0.22 + '@shikijs/transformers': 3.23.0 + diff: 8.0.3 + hast-util-to-html: 9.0.5 + lru_map: 0.4.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + shiki: 3.23.0 + + '@pierre/theme@0.0.22': {} + '@pixi/colord@2.9.6': {} '@pkgjs/parseargs@0.11.0': @@ -15400,6 +15481,44 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true + '@shikijs/core@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.5 + + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/transformers@3.23.0': + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/types': 3.23.0 + + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + '@sinclair/typebox-codegen@0.11.1': dependencies: '@sinclair/typebox': 0.33.22 @@ -17212,6 +17331,8 @@ snapshots: didyoumean@1.2.2: {} + diff@8.0.3: {} + dir-compare@4.2.0: dependencies: minimatch: 3.1.2 @@ -18371,6 +18492,20 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -18425,6 +18560,8 @@ snapshots: html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} + http-cache-semantics@4.2.0: {} http-errors@2.0.1: @@ -19178,6 +19315,8 @@ snapshots: lru-cache@7.18.3: {} + lru_map@0.4.1: {} + lz-string@1.5.0: {} macos-alias@0.2.12: @@ -20203,6 +20342,14 @@ snapshots: dependencies: mimic-function: 5.0.1 + oniguruma-parser@0.12.1: {} + + oniguruma-to-es@4.3.5: + dependencies: + oniguruma-parser: 0.12.1 + regex: 6.1.0 + regex-recursion: 6.0.2 + open@10.2.0: dependencies: default-browser: 5.5.0 @@ -21217,6 +21364,16 @@ snapshots: regenerator-runtime@0.13.11: {} + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + regexpu-core@6.4.0: dependencies: regenerate: 1.4.2 @@ -21539,6 +21696,17 @@ snapshots: shell-quote@1.8.3: {} + shiki@3.23.0: + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/engine-javascript': 3.23.0 + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0