diff --git a/apps/array/src/main/preload.ts b/apps/array/src/main/preload.ts index 7e9ca34f..a766ca78 100644 --- a/apps/array/src/main/preload.ts +++ b/apps/array/src/main/preload.ts @@ -257,6 +257,12 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.invoke("save-question-answers", repoPath, taskId, answers), readRepoFile: (repoPath: string, filePath: string): Promise => ipcRenderer.invoke("read-repo-file", repoPath, filePath), + writeRepoFile: ( + repoPath: string, + filePath: string, + content: string, + ): Promise => + ipcRenderer.invoke("write-repo-file", repoPath, filePath, content), getChangedFilesHead: ( repoPath: string, ): Promise> => diff --git a/apps/array/src/main/services/fs.ts b/apps/array/src/main/services/fs.ts index f9611152..48b416c2 100644 --- a/apps/array/src/main/services/fs.ts +++ b/apps/array/src/main/services/fs.ts @@ -434,4 +434,31 @@ export function registerFsIpc(): void { } }, ); + + ipcMain.handle( + "write-repo-file", + async ( + _event: IpcMainInvokeEvent, + repoPath: string, + filePath: string, + content: string, + ): Promise => { + try { + const fullPath = path.join(repoPath, filePath); + const resolvedPath = path.resolve(fullPath); + const resolvedRepo = path.resolve(repoPath); + if (!resolvedPath.startsWith(resolvedRepo)) { + throw new Error("Access denied: path outside repository"); + } + + await fsPromises.writeFile(fullPath, content, "utf-8"); + log.debug(`Wrote file ${filePath} to ${repoPath}`); + + repoFileCache.delete(repoPath); + } catch (error) { + log.error(`Failed to write file ${filePath} to ${repoPath}:`, error); + throw error; + } + }, + ); } diff --git a/apps/array/src/renderer/features/code-editor/components/CodeMirrorDiffEditor.tsx b/apps/array/src/renderer/features/code-editor/components/CodeMirrorDiffEditor.tsx index 858918d3..c0e8cce7 100644 --- a/apps/array/src/renderer/features/code-editor/components/CodeMirrorDiffEditor.tsx +++ b/apps/array/src/renderer/features/code-editor/components/CodeMirrorDiffEditor.tsx @@ -9,12 +9,14 @@ interface CodeMirrorDiffEditorProps { originalContent: string; modifiedContent: string; filePath?: string; + onContentChange?: (content: string) => void; } export function CodeMirrorDiffEditor({ originalContent, modifiedContent, filePath, + onContentChange, }: CodeMirrorDiffEditorProps) { const [viewMode, setViewMode] = useState("split"); const extensions = useEditorExtensions(filePath, true); @@ -25,8 +27,16 @@ export function CodeMirrorDiffEditor({ extensions, mode: viewMode, filePath, + onContentChange, }), - [originalContent, modifiedContent, extensions, viewMode, filePath], + [ + originalContent, + modifiedContent, + extensions, + viewMode, + filePath, + onContentChange, + ], ); const containerRef = useCodeMirror(options); diff --git a/apps/array/src/renderer/features/code-editor/components/DiffEditorPanel.tsx b/apps/array/src/renderer/features/code-editor/components/DiffEditorPanel.tsx index 8cbce24a..41398d03 100644 --- a/apps/array/src/renderer/features/code-editor/components/DiffEditorPanel.tsx +++ b/apps/array/src/renderer/features/code-editor/components/DiffEditorPanel.tsx @@ -5,7 +5,8 @@ import { getRelativePath } from "@features/code-editor/utils/pathUtils"; import { useTaskData } from "@features/task-detail/hooks/useTaskData"; import { Box } from "@radix-ui/themes"; import type { Task } from "@shared/types"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; import { selectWorktreePath, useWorkspaceStore, @@ -26,6 +27,7 @@ export function DiffEditorPanel({ const worktreePath = useWorkspaceStore(selectWorktreePath(taskId)); const repoPath = worktreePath ?? taskData.repoPath; const filePath = getRelativePath(absolutePath, repoPath); + const queryClient = useQueryClient(); const { data: changedFiles = [] } = useQuery({ queryKey: ["changed-files-head", repoPath], @@ -56,6 +58,24 @@ export function DiffEditorPanel({ staleTime: Infinity, }); + const handleContentChange = useCallback( + async (newContent: string) => { + if (!repoPath) return; + + try { + await window.electronAPI.writeRepoFile(repoPath, filePath, newContent); + + queryClient.invalidateQueries({ + queryKey: ["repo-file", repoPath, filePath], + }); + queryClient.invalidateQueries({ + queryKey: ["changed-files-head", repoPath], + }); + } catch (_error) {} + }, + [repoPath, filePath, queryClient], + ); + if (!repoPath) { return No repository path available; } @@ -76,6 +96,7 @@ export function DiffEditorPanel({ originalContent={originalContent ?? ""} modifiedContent={modifiedContent ?? ""} filePath={absolutePath} + onContentChange={handleContentChange} /> ) : ( 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 = "Reject"; + button.name = "reject"; + button.style.background = "var(--red-9)"; + button.style.color = "white"; + button.style.border = "none"; + button.style.padding = "2px 6px"; + button.style.borderRadius = "3px"; + button.style.cursor = "pointer"; + button.style.fontSize = "11px"; + + button.onmousedown = (e) => { + action(e); + onReject?.(); + }; + + return button; + }; +}; + +const getBaseDiffConfig = ( + onReject?: () => void, +): Partial[0]> => ({ + collapseUnchanged: { margin: 3, minSize: 4 }, + highlightChanges: false, + gutter: true, + mergeControls: createMergeControls(onReject), +}); + export function useCodeMirror(options: SingleDocOptions | DiffOptions) { const containerRef = useRef(null); const instanceRef = useRef(null); @@ -40,21 +81,63 @@ export function useCodeMirror(options: SingleDocOptions | DiffOptions) { parent: containerRef.current, }); } else if (options.mode === "split") { + const diffConfig = getBaseDiffConfig( + 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); + } + }) + : []; + instanceRef.current = new MergeView({ a: { doc: options.original, extensions: options.extensions }, - b: { doc: options.modified, extensions: options.extensions }, + b: { + doc: options.modified, + extensions: [ + ...options.extensions, + ...(Array.isArray(updateListener) + ? updateListener + : [updateListener]), + ], + }, + ...diffConfig, parent: containerRef.current, + revertControls: "a-to-b", }); } else { + const diffConfig = getBaseDiffConfig( + 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, - highlightChanges: true, - gutter: true, - mergeControls: false, + ...diffConfig, }), ], parent: containerRef.current, @@ -95,3 +178,5 @@ export function useCodeMirror(options: SingleDocOptions | DiffOptions) { return containerRef; } + +export { acceptChunk, rejectChunk }; diff --git a/apps/array/src/renderer/features/code-editor/hooks/useEditorExtensions.ts b/apps/array/src/renderer/features/code-editor/hooks/useEditorExtensions.ts index 14f227d6..877bdd60 100644 --- a/apps/array/src/renderer/features/code-editor/hooks/useEditorExtensions.ts +++ b/apps/array/src/renderer/features/code-editor/hooks/useEditorExtensions.ts @@ -6,7 +6,7 @@ import { } from "@codemirror/view"; import { useThemeStore } from "@stores/themeStore"; import { useMemo } from "react"; -import { oneDark, oneLight } from "../theme/editorTheme"; +import { mergeViewTheme, oneDark, oneLight } from "../theme/editorTheme"; import { getLanguageExtension } from "../utils/languages"; export function useEditorExtensions(filePath?: string, readOnly = false) { @@ -20,6 +20,7 @@ export function useEditorExtensions(filePath?: string, readOnly = false) { lineNumbers(), highlightActiveLineGutter(), theme, + mergeViewTheme, EditorView.editable.of(!readOnly), ...(readOnly ? [EditorState.readOnly.of(true)] : []), ...(languageExtension ? [languageExtension] : []), diff --git a/apps/array/src/renderer/features/code-editor/theme/editorTheme.ts b/apps/array/src/renderer/features/code-editor/theme/editorTheme.ts index c34dc7ad..b16d5a92 100644 --- a/apps/array/src/renderer/features/code-editor/theme/editorTheme.ts +++ b/apps/array/src/renderer/features/code-editor/theme/editorTheme.ts @@ -197,3 +197,116 @@ export const oneLight: Extension = [ createEditorTheme(light, false), syntaxHighlighting(createHighlightStyle(light)), ]; + +export const mergeViewTheme = EditorView.baseTheme({ + ".cm-mergeView": { + overflowY: "auto", + }, + ".cm-mergeViewEditors": { + display: "flex", + alignItems: "stretch", + }, + ".cm-mergeViewEditor": { + flexGrow: "1", + flexBasis: "0", + overflow: "hidden", + }, + ".cm-merge-revert": { + width: "1.6em", + flexGrow: "0", + flexShrink: "0", + position: "relative", + }, + ".cm-merge-revert button": { + position: "absolute", + display: "block", + width: "100%", + boxSizing: "border-box", + textAlign: "center", + background: "none", + border: "none", + font: "inherit", + cursor: "pointer", + }, + ".cm-mergeView & .cm-scroller, .cm-mergeView &": { + height: "auto !important", + overflowY: "visible !important", + }, + "&.cm-merge-a .cm-changedLine, .cm-deletedChunk": { + backgroundColor: "rgba(160, 128, 100, .08)", + }, + "&.cm-merge-b .cm-changedLine, .cm-inlineChangedLine": { + backgroundColor: "rgba(100, 160, 128, .08)", + }, + "&light.cm-merge-a .cm-changedText, &light .cm-deletedChunk .cm-deletedText": + { + background: + "linear-gradient(#ee443366, #ee443366) bottom/100% 2px no-repeat", + }, + "&dark.cm-merge-a .cm-changedText, &dark .cm-deletedChunk .cm-deletedText": { + background: + "linear-gradient(#ffaa9966, #ffaa9966) bottom/100% 2px no-repeat", + }, + "&light.cm-merge-b .cm-changedText": { + background: + "linear-gradient(#22bb22aa, #22bb22aa) bottom/100% 2px no-repeat", + }, + "&dark.cm-merge-b .cm-changedText": { + background: + "linear-gradient(#88ff88aa, #88ff88aa) bottom/100% 2px no-repeat", + }, + "&.cm-merge-b .cm-deletedText": { + background: "#ff000033", + }, + ".cm-insertedLine, .cm-deletedLine, .cm-deletedLine del": { + textDecoration: "none", + }, + ".cm-deletedChunk": { + paddingLeft: "6px", + "& .cm-chunkButtons": { + position: "absolute", + insetInlineEnd: "5px", + }, + "& button": { + border: "none", + cursor: "pointer", + color: "white", + margin: "0 2px", + borderRadius: "3px", + "&[name=accept]": { background: "#2a2" }, + "&[name=reject]": { background: "#d43" }, + }, + }, + ".cm-collapsedLines": { + padding: "5px 5px 5px 10px", + cursor: "pointer", + "&:before": { + content: '"⦚"', + marginInlineEnd: "7px", + }, + "&:after": { + content: '"⦚"', + marginInlineStart: "7px", + }, + }, + "&light .cm-collapsedLines": { + color: "#444", + background: + "linear-gradient(to bottom, transparent 0, #f3f3f3 30%, #f3f3f3 70%, transparent 100%)", + }, + "&dark .cm-collapsedLines": { + color: "#ddd", + background: + "linear-gradient(to bottom, transparent 0, #222 30%, #222 70%, transparent 100%)", + }, + ".cm-changeGutter": { width: "3px", paddingLeft: "1px" }, + "&light.cm-merge-a .cm-changedLineGutter, &light .cm-deletedLineGutter": { + background: "#e43", + }, + "&dark.cm-merge-a .cm-changedLineGutter, &dark .cm-deletedLineGutter": { + background: "#fa9", + }, + "&light.cm-merge-b .cm-changedLineGutter": { background: "#2b2" }, + "&dark.cm-merge-b .cm-changedLineGutter": { background: "#8f8" }, + ".cm-inlineChangedLineGutter": { background: "#75d" }, +}); diff --git a/apps/array/src/renderer/features/editor/components/MarkdownRenderer.tsx b/apps/array/src/renderer/features/editor/components/MarkdownRenderer.tsx index 525d4c0f..7d1d9272 100644 --- a/apps/array/src/renderer/features/editor/components/MarkdownRenderer.tsx +++ b/apps/array/src/renderer/features/editor/components/MarkdownRenderer.tsx @@ -34,22 +34,22 @@ const fontStyle = { const components: Components = { h1: ({ children }) => ( - + {children} ), h2: ({ children }) => ( - + {children} ), h3: ({ children }) => ( - + {children} ), h4: ({ children }) => ( - + {children} ), diff --git a/apps/array/src/renderer/features/sessions/components/VirtualizedList.tsx b/apps/array/src/renderer/features/sessions/components/VirtualizedList.tsx index 30ca7ac8..7bccd9e8 100644 --- a/apps/array/src/renderer/features/sessions/components/VirtualizedList.tsx +++ b/apps/array/src/renderer/features/sessions/components/VirtualizedList.tsx @@ -1,5 +1,11 @@ import { useVirtualizer } from "@tanstack/react-virtual"; -import { type ReactNode, useCallback, useEffect, useRef } from "react"; +import { + type ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useRef, +} from "react"; interface VirtualizedListProps { items: T[]; @@ -26,6 +32,7 @@ export function VirtualizedList({ }: VirtualizedListProps) { const scrollRef = useRef(null); const isAtBottomRef = useRef(true); + const isInitialMountRef = useRef(true); const virtualizer = useVirtualizer({ count: items.length, @@ -53,11 +60,44 @@ export function VirtualizedList({ return () => el.removeEventListener("scroll", handleScroll); }, [handleScroll]); + useLayoutEffect(() => { + const el = scrollRef.current; + if (!el || !autoScrollToBottom || items.length === 0) { + return; + } + + if (isInitialMountRef.current) { + isInitialMountRef.current = false; + el.scrollTop = el.scrollHeight; + return; + } + }, [autoScrollToBottom, items.length]); + useEffect(() => { - if (autoScrollToBottom && isAtBottomRef.current && items.length > 0) { - virtualizer.scrollToIndex(items.length - 1, { align: "end" }); + const el = scrollRef.current; + if ( + !el || + !autoScrollToBottom || + items.length === 0 || + isInitialMountRef.current + ) { + return; + } + + if (!isAtBottomRef.current) { + return; } - }, [autoScrollToBottom, items.length, virtualizer]); + + const scrollToBottom = () => { + const el = scrollRef.current; + if (el) { + el.scrollTop = el.scrollHeight; + } + }; + requestAnimationFrame(() => { + requestAnimationFrame(scrollToBottom); + }); + }, [autoScrollToBottom, items]); if (items.length === 0) { return null; diff --git a/apps/array/src/renderer/types/electron.d.ts b/apps/array/src/renderer/types/electron.d.ts index f7aa9a65..cab9ff03 100644 --- a/apps/array/src/renderer/types/electron.d.ts +++ b/apps/array/src/renderer/types/electron.d.ts @@ -179,6 +179,11 @@ declare global { repoPath: string, filePath: string, ) => Promise; + writeRepoFile: ( + repoPath: string, + filePath: string, + content: string, + ) => Promise; getChangedFilesHead: (repoPath: string) => Promise; getFileAtHead: ( repoPath: string,