diff --git a/package-lock.json b/package-lock.json index 51fdb73c4..9fa13cec5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "codex-monitor", "version": "0.7.1", "dependencies": { + "@pierre/diffs": "^1.0.6", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-opener": "^2", @@ -998,6 +999,25 @@ "node": ">= 8" } }, + "node_modules/@pierre/diffs": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.0.6.tgz", + "integrity": "sha512-VoFvtGCSPi+wAj4Ap+0j80/KPnQbYZsvztjIP4nHsjCgcVUBF7+X1nHFqjl/xbTqSc0okZG1tHYb49CUON9ZXQ==", + "license": "apache-2.0", + "dependencies": { + "@shikijs/core": "^3.0.0", + "@shikijs/engine-javascript": "^3.0.0", + "@shikijs/transformers": "^3.0.0", + "diff": "8.0.2", + "hast-util-to-html": "9.0.5", + "lru_map": "0.4.1", + "shiki": "^3.0.0" + }, + "peerDependencies": { + "react": "^18.3.1 || ^19.0.0", + "react-dom": "^18.3.1 || ^19.0.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1355,6 +1375,83 @@ "win32" ] }, + "node_modules/@shikijs/core": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.21.0.tgz", + "integrity": "sha512-AXSQu/2n1UIQekY8euBJlvFYZIw0PHY63jUzGbrOma4wPxzznJXTXkri+QcHeBNaFxiiOljKxxJkVSoB3PjbyA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.21.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.21.0.tgz", + "integrity": "sha512-ATwv86xlbmfD9n9gKRiwuPpWgPENAWCLwYCGz9ugTJlsO2kOzhOkvoyV/UD+tJ0uT7YRyD530x6ugNSffmvIiQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.21.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.21.0.tgz", + "integrity": "sha512-OYknTCct6qiwpQDqDdf3iedRdzj6hFlOPv5hMvI+hkWfCKs5mlJ4TXziBG9nyabLwGulrUjHiCq3xCspSzErYQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.21.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.21.0.tgz", + "integrity": "sha512-g6mn5m+Y6GBJ4wxmBYqalK9Sp0CFkUqfNzUy2pJglUginz6ZpWbaWjDB4fbQ/8SHzFjYbtU6Ddlp1pc+PPNDVA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.21.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.21.0.tgz", + "integrity": "sha512-BAE4cr9EDiZyYzwIHEk7JTBJ9CzlPuM4PchfcA5ao1dWXb25nv6hYsoDiBq2aZK9E3dlt3WB78uI96UESD+8Mw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.21.0" + } + }, + "node_modules/@shikijs/transformers": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-3.21.0.tgz", + "integrity": "sha512-CZwvCWWIiRRiFk9/JKzdEooakAP8mQDtBOQ1TKiCaS2E1bYtyBCOkUzS8akO34/7ufICQ29oeSfkb3tT5KtrhA==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.21.0", + "@shikijs/types": "3.21.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.21.0.tgz", + "integrity": "sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, "node_modules/@tauri-apps/api": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz", @@ -2684,6 +2781,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/diff": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", + "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -3787,6 +3893,29 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -3837,6 +3966,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4557,6 +4696,12 @@ "loose-envify": "cli.js" } }, + "node_modules/lru_map": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/lru_map/-/lru_map-0.4.1.tgz", + "integrity": "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -5639,6 +5784,23 @@ "wrappy": "1" } }, + "node_modules/oniguruma-parser": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz", + "integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6011,6 +6173,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -6366,6 +6552,22 @@ "node": ">=8" } }, + "node_modules/shiki": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.21.0.tgz", + "integrity": "sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.21.0", + "@shikijs/engine-javascript": "3.21.0", + "@shikijs/engine-oniguruma": "3.21.0", + "@shikijs/langs": "3.21.0", + "@shikijs/themes": "3.21.0", + "@shikijs/types": "3.21.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", diff --git a/package.json b/package.json index 553d2e9d7..f0d78bd7e 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "tauri:build": "npm run doctor:strict && tauri build" }, "dependencies": { + "@pierre/diffs": "^1.0.6", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-opener": "^2", @@ -36,9 +37,9 @@ "@types/prismjs": "^1.26.5", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", - "@vitejs/plugin-react": "^4.6.0", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", + "@vitejs/plugin-react": "^4.6.0", "eslint": "^8.57.0", "eslint-plugin-react": "^7.36.1", "eslint-plugin-react-hooks": "^4.6.2", diff --git a/src/App.tsx b/src/App.tsx index be1a1ae59..75580e199 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -72,7 +72,7 @@ import { useCopyThread } from "./features/threads/hooks/useCopyThread"; import { usePanelVisibility } from "./features/layout/hooks/usePanelVisibility"; import { useTerminalController } from "./features/terminal/hooks/useTerminalController"; import { playNotificationSound } from "./utils/notificationSounds"; -import type { AccessMode, DiffLineReference, QueuedMessage, WorkspaceInfo } from "./types"; +import type { AccessMode, QueuedMessage, WorkspaceInfo } from "./types"; function useWindowLabel() { const [label, setLabel] = useState("main"); @@ -627,43 +627,6 @@ function MainApp() { } } - function handleActiveDiffPath(path: string) { - if (path !== selectedDiffPath) { - setSelectedDiffPath(path); - } - } - - function handleDiffLineReference(reference: DiffLineReference) { - const startLine = reference.newLine ?? reference.oldLine; - const endLine = - reference.endNewLine ?? reference.endOldLine ?? startLine ?? null; - const lineRange = - startLine && endLine && endLine !== startLine - ? `${startLine}-${endLine}` - : startLine - ? `${startLine}` - : null; - const lineLabel = lineRange - ? `${reference.path}:${lineRange}` - : reference.path; - const changeLabel = - reference.type === "add" - ? "added" - : reference.type === "del" - ? "removed" - : reference.type === "mixed" - ? "mixed" - : "context"; - const snippet = reference.lines.join("\n").trimEnd(); - const snippetBlock = snippet ? `\n\`\`\`\n${snippet}\n\`\`\`` : ""; - const label = reference.lines.length > 1 ? "Line range" : "Line reference"; - const text = `${label} (${changeLabel}): ${lineLabel}${snippetBlock}`; - setComposerInsert({ - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - text, - createdAt: Date.now() - }); - } const handleOpenSettings = useCallback( (section?: SettingsSection) => { @@ -741,13 +704,14 @@ function MainApp() { terminalOpen, onDebug: addDebugEntry, }); + const isDefaultScale = Math.abs(uiScale - 1) < 0.001; const appClassName = `app ${isCompact ? "layout-compact" : "layout-desktop"}${ isPhone ? " layout-phone" : "" }${isTablet ? " layout-tablet" : ""}${ reduceTransparency ? " reduced-transparency" : "" }${!isCompact && sidebarCollapsed ? " sidebar-collapsed" : ""}${ !isCompact && rightPanelCollapsed ? " right-panel-collapsed" : "" - }`; + }${isDefaultScale ? " ui-scale-default" : ""}`; const { sidebarNode, messagesNode, @@ -909,8 +873,6 @@ function MainApp() { gitDiffs, gitDiffLoading: isDiffLoading, gitDiffError: diffError, - onDiffLineReference: handleDiffLineReference, - onDiffActivePathChange: handleActiveDiffPath, onSend: handleSend, onQueue: queueMessage, onStop: interruptTurn, diff --git a/src/features/git/components/GitDiffPanel.tsx b/src/features/git/components/GitDiffPanel.tsx index d4b18a39b..5cbb61636 100644 --- a/src/features/git/components/GitDiffPanel.tsx +++ b/src/features/git/components/GitDiffPanel.tsx @@ -29,7 +29,6 @@ type GitDiffPanelProps = { issuesLoading?: boolean; issuesError?: string | null; gitRemoteUrl?: string | null; - selectedPath?: string | null; onSelectFile?: (path: string) => void; files: { path: string; @@ -106,7 +105,6 @@ export function GitDiffPanel({ logLoading = false, logTotal = 0, gitRemoteUrl = null, - selectedPath, onSelectFile, files, logEntries, @@ -271,13 +269,12 @@ export function GitDiffPanel({ {files.map((file) => { const { name, dir } = splitPath(file.path); const { base, extension } = splitNameAndExtension(name); - const isSelected = file.path === selectedPath; const statusSymbol = getStatusSymbol(file.status); const statusClass = getStatusClass(file.status); return (
onSelectFile?.(file.path)} diff --git a/src/features/git/components/GitDiffViewer.tsx b/src/features/git/components/GitDiffViewer.tsx index 3f4291f4f..8440ea13c 100644 --- a/src/features/git/components/GitDiffViewer.tsx +++ b/src/features/git/components/GitDiffViewer.tsx @@ -1,9 +1,11 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { DiffBlock } from "./DiffBlock"; -import { parseDiff } from "../../../utils/diff"; -import { languageFromPath } from "../../../utils/syntax"; -import type { DiffLineReference } from "../../../types"; -import type { ParsedDiffLine } from "../../../utils/diff"; +import { memo, useEffect, useMemo, useRef } from "react"; +import { FileDiff, WorkerPoolContextProvider } from "@pierre/diffs/react"; +import type { + FileDiffMetadata, + Hunk, +} from "@pierre/diffs"; +import { parsePatchFiles } from "@pierre/diffs"; +import { workerFactory } from "../../../utils/diffsWorker"; type GitDiffViewerItem = { path: string; @@ -16,271 +18,207 @@ type GitDiffViewerProps = { selectedPath: string | null; isLoading: boolean; error: string | null; - onLineReference?: (reference: DiffLineReference) => void; onActivePathChange?: (path: string) => void; }; -type SelectedRange = { - path: string; - start: number; - end: number; - anchor: number; +type LineMaps = { + oldLines: Map; + newLines: Map; }; -type SelectableDiffLine = ParsedDiffLine & { - type: "add" | "del" | "context"; +type ParsedDiffEntry = GitDiffViewerItem & { + fileDiff: FileDiffMetadata | null; + lineMaps: LineMaps | null; }; -function isSelectableLine(line: ParsedDiffLine): line is SelectableDiffLine { - return line.type === "add" || line.type === "del" || line.type === "context"; +const DIFF_SCROLL_CSS = ` +[data-column-number], +[data-buffer], +[data-separator-wrapper], +[data-annotation-content] { + position: static !important; +} + +[data-buffer] { + background-image: none !important; +} +`; + +function normalizePatchName(name: string) { + if (!name) { + return name; + } + return name.replace(/^(?:a|b)\//, ""); } +function buildLineMaps(hunks: Hunk[]): LineMaps { + const oldLines = new Map(); + const newLines = new Map(); + for (const hunk of hunks) { + let oldLine = hunk.deletionStart; + let newLine = hunk.additionStart; + for (const content of hunk.hunkContent) { + if (content.type === "context") { + for (const line of content.lines) { + oldLines.set(oldLine, line); + newLines.set(newLine, line); + oldLine += 1; + newLine += 1; + } + } else { + for (const line of content.deletions) { + oldLines.set(oldLine, line); + oldLine += 1; + } + for (const line of content.additions) { + newLines.set(newLine, line); + newLine += 1; + } + } + } + } + return { oldLines, newLines }; +} + +type DiffCardProps = { + entry: ParsedDiffEntry; + isSelected: boolean; +}; + +const DiffCard = memo(function DiffCard({ + entry, + isSelected, +}: DiffCardProps) { + const diffOptions = useMemo( + () => ({ + diffStyle: "split" as const, + hunkSeparators: "line-info" as const, + overflow: "scroll" as const, + unsafeCSS: DIFF_SCROLL_CSS, + disableFileHeader: true, + }), + [], + ); + + return ( +
+
+ {entry.status} + {entry.path} +
+ {entry.diff.trim().length > 0 && entry.fileDiff ? ( +
+ +
+ ) : ( +
Diff unavailable.
+ )} +
+ ); +}); + export function GitDiffViewer({ diffs, selectedPath, isLoading, error, - onLineReference, - onActivePathChange, }: GitDiffViewerProps) { const containerRef = useRef(null); - const itemRefs = useRef(new Map()); - const lastScrolledPath = useRef(null); - const lastActivePath = useRef(null); - const skipAutoScroll = useRef(null); - const scrollFrame = useRef(null); - const scrollLock = useRef<{ path: string; expiresAt: number } | null>(null); - const [selectedRange, setSelectedRange] = useState(null); - - useEffect(() => { - lastActivePath.current = selectedPath; - }, [selectedPath]); + const lastScrolledPathRef = useRef(null); + const poolOptions = useMemo(() => ({ workerFactory }), []); + const highlighterOptions = useMemo( + () => ({ theme: { dark: "pierre-dark", light: "pierre-light" } }), + [], + ); + const parsedDiffs = useMemo( + () => + diffs.map((entry) => { + const patch = parsePatchFiles(entry.diff); + const fileDiff = patch[0]?.files[0]; + if (!fileDiff) { + return { ...entry, fileDiff: null, lineMaps: null }; + } + const normalizedName = normalizePatchName(fileDiff.name || entry.path); + const normalizedPrevName = fileDiff.prevName + ? normalizePatchName(fileDiff.prevName) + : undefined; + const normalized: FileDiffMetadata = { + ...fileDiff, + name: normalizedName, + prevName: normalizedPrevName, + }; + return { + ...entry, + fileDiff: normalized, + lineMaps: buildLineMaps(normalized.hunks), + }; + }), + [diffs], + ); useEffect(() => { if (!selectedPath) { return; } - if (skipAutoScroll.current) { - const skipPath = skipAutoScroll.current; - skipAutoScroll.current = null; - if (skipPath === selectedPath) { - return; - } - } - if (lastScrolledPath.current === selectedPath) { + if (lastScrolledPathRef.current === selectedPath) { return; } - const target = itemRefs.current.get(selectedPath); - if (target) { - target.scrollIntoView({ behavior: "smooth", block: "start" }); - lastScrolledPath.current = selectedPath; - scrollLock.current = { - path: selectedPath, - expiresAt: performance.now() + 900, - }; - } - }, [selectedPath, diffs.length]); - - const updateActivePath = useCallback(() => { const container = containerRef.current; - if (!container || !onActivePathChange) { + if (!container) { return; } - const containerRect = container.getBoundingClientRect(); - const anchorTop = containerRect.top + 12; - if (scrollLock.current) { - const now = performance.now(); - const lock = scrollLock.current; - if (now >= lock.expiresAt) { - scrollLock.current = null; - } else { - const lockedNode = itemRefs.current.get(lock.path); - if (!lockedNode) { - scrollLock.current = null; - } else { - const rect = lockedNode.getBoundingClientRect(); - const reachedTarget = - rect.top <= anchorTop + 2 && rect.bottom >= containerRect.top; - if (reachedTarget) { - scrollLock.current = null; - } - } + let target: HTMLElement | null = null; + const items = container.querySelectorAll("[data-diff-path]"); + for (const item of items) { + if (item.dataset.diffPath === selectedPath) { + target = item; + break; } } - if (scrollLock.current) { + if (!target) { return; } - let above: { path: string; top: number } | null = null; - let below: { path: string; top: number } | null = null; - - for (const [path, node] of itemRefs.current.entries()) { - const rect = node.getBoundingClientRect(); - const isVisible = rect.bottom > containerRect.top && rect.top < containerRect.bottom; - if (!isVisible) { - continue; - } - if (rect.top <= anchorTop) { - if (!above || rect.top > above.top) { - above = { path, top: rect.top }; - } - } else if (!below || rect.top < below.top) { - below = { path, top: rect.top }; - } - } - - const nextPath = above?.path ?? below?.path ?? null; - if (!nextPath || nextPath === lastActivePath.current) { - return; - } - lastActivePath.current = nextPath; - if (nextPath !== selectedPath) { - skipAutoScroll.current = nextPath; - onActivePathChange(nextPath); - } - }, [onActivePathChange, selectedPath]); - - useEffect(() => { - const container = containerRef.current; - if (!container || !onActivePathChange) { - return; - } - const handleScroll = () => { - if (scrollFrame.current !== null) { - return; - } - scrollFrame.current = window.requestAnimationFrame(() => { - scrollFrame.current = null; - updateActivePath(); - }); - }; - - handleScroll(); - container.addEventListener("scroll", handleScroll, { passive: true }); - window.addEventListener("resize", handleScroll); - return () => { - container.removeEventListener("scroll", handleScroll); - window.removeEventListener("resize", handleScroll); - if (scrollFrame.current !== null) { - window.cancelAnimationFrame(scrollFrame.current); - scrollFrame.current = null; - } - }; - }, [diffs.length, onActivePathChange, updateActivePath]); - - useEffect(() => { - if (!selectedRange) { - return; - } - const stillExists = diffs.some((entry) => entry.path === selectedRange.path); - if (!stillExists) { - setSelectedRange(null); - } - }, [diffs, selectedRange]); - - const handleLineSelect = ( - entry: GitDiffViewerItem, - parsedLines: ParsedDiffLine[], - line: ParsedDiffLine, - index: number, - isRangeSelect: boolean, - ) => { - if (!isSelectableLine(line)) { - return; - } - const hasAnchor = selectedRange?.path === entry.path; - const anchor = isRangeSelect && hasAnchor ? selectedRange.anchor : index; - const start = isRangeSelect ? Math.min(anchor, index) : index; - const end = isRangeSelect ? Math.max(anchor, index) : index; - setSelectedRange({ path: entry.path, start, end, anchor }); - - const selectedLines = parsedLines - .slice(start, end + 1) - .filter(isSelectableLine); - if (selectedLines.length === 0) { - return; + const containerRect = container.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const isVisible = + targetRect.top >= containerRect.top && + targetRect.bottom <= containerRect.bottom; + if (!isVisible) { + target.scrollIntoView({ block: "start" }); } - - const typeSet = new Set(selectedLines.map((item) => item.type)); - const selectionType = typeSet.size === 1 ? selectedLines[0].type : "mixed"; - const firstOldLine = selectedLines.find((item) => item.oldLine !== null)?.oldLine ?? null; - const firstNewLine = selectedLines.find((item) => item.newLine !== null)?.newLine ?? null; - const lastOldLine = - [...selectedLines].reverse().find((item) => item.oldLine !== null)?.oldLine ?? - null; - const lastNewLine = - [...selectedLines].reverse().find((item) => item.newLine !== null)?.newLine ?? - null; - - onLineReference?.({ - path: entry.path, - type: selectionType, - oldLine: firstOldLine, - newLine: firstNewLine, - endOldLine: lastOldLine, - endNewLine: lastNewLine, - lines: selectedLines.map((item) => item.text), - }); - }; + lastScrolledPathRef.current = selectedPath; + }, [selectedPath, parsedDiffs]); return ( -
- {error &&
{error}
} - {!error && isLoading && diffs.length > 0 && ( -
Refreshing diff...
- )} - {!error && !isLoading && !diffs.length && ( -
No changes detected.
- )} - {!error && - diffs.map((entry) => { - const isSelected = entry.path === selectedPath; - const hasDiff = entry.diff.trim().length > 0; - const language = languageFromPath(entry.path); - const parsedLines = parseDiff(entry.diff); - const selectedRangeForEntry = - selectedRange?.path === entry.path - ? { start: selectedRange.start, end: selectedRange.end } - : null; - return ( -
+
+ {error &&
{error}
} + {!error && isLoading && diffs.length > 0 && ( +
Refreshing diff...
+ )} + {!error && !isLoading && !diffs.length && ( +
No changes detected.
+ )} + {!error && parsedDiffs.length > 0 && + parsedDiffs.map((entry) => ( + { - if (node) { - itemRefs.current.set(entry.path, node); - } else { - itemRefs.current.delete(entry.path); - } - }} - className={`diff-viewer-item ${isSelected ? "active" : ""}`} - > -
- {entry.status} - {entry.path} -
- {hasDiff ? ( -
- - handleLineSelect( - entry, - parsedLines, - line, - index, - "shiftKey" in event && event.shiftKey, - ) - } - selectedRange={selectedRangeForEntry} - /> -
- ) : ( -
Diff unavailable.
- )} -
- ); - })} -
+ entry={entry} + isSelected={entry.path === selectedPath} + /> + ))} +
+ ); } diff --git a/src/features/layout/hooks/useLayoutNodes.tsx b/src/features/layout/hooks/useLayoutNodes.tsx index 18760f09e..f44d4afc3 100644 --- a/src/features/layout/hooks/useLayoutNodes.tsx +++ b/src/features/layout/hooks/useLayoutNodes.tsx @@ -23,7 +23,6 @@ import type { ConversationItem, CustomPromptOption, DebugEntry, - DiffLineReference, DictationSessionState, DictationTranscript, GitFileStatus, @@ -152,8 +151,7 @@ type LayoutNodesOptions = { gitDiffs: GitDiffViewerItem[]; gitDiffLoading: boolean; gitDiffError: string | null; - onDiffLineReference: (reference: DiffLineReference) => void; - onDiffActivePathChange: (path: string) => void; + onDiffActivePathChange?: (path: string) => void; onSend: (text: string, images: string[]) => void | Promise; onQueue: (text: string, images: string[]) => void | Promise; onStop: () => void; @@ -429,7 +427,6 @@ export function useLayoutNodes(options: LayoutNodesOptions): LayoutNodesResult { logError={options.gitLogError} logLoading={options.gitLogLoading} files={options.gitStatus.files} - selectedPath={options.selectedDiffPath} onSelectFile={options.onSelectDiff} logEntries={options.gitLogEntries} logTotal={options.gitLogTotal} @@ -453,7 +450,6 @@ export function useLayoutNodes(options: LayoutNodesOptions): LayoutNodesResult { selectedPath={options.selectedDiffPath} isLoading={options.gitDiffLoading} error={options.gitDiffError} - onLineReference={options.onDiffLineReference} onActivePathChange={options.onDiffActivePathChange} /> ); diff --git a/src/styles/base.css b/src/styles/base.css index 1658344f4..13f12b09a 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -214,6 +214,10 @@ body { transition: grid-template-columns 220ms ease; } +.app.ui-scale-default { + transform: none; +} + .drag-strip { position: absolute; top: 0; diff --git a/src/styles/diff-viewer.css b/src/styles/diff-viewer.css index 7aece2371..7f1671a54 100644 --- a/src/styles/diff-viewer.css +++ b/src/styles/diff-viewer.css @@ -7,6 +7,13 @@ flex: 1; min-height: 0; min-width: 0; + background: var(--surface-messages); +} + + +.main .diff-viewer { + margin-top: calc(-1 * var(--main-topbar-height)); + padding-top: calc(12px + var(--main-topbar-height)); } .diff-viewer-item { @@ -56,7 +63,21 @@ color: var(--text-quiet); font-family: "SFMono-Regular", "Menlo", "Monaco", monospace; max-width: 100%; - overflow-x: auto; + min-width: 0; + width: 100%; + overflow: hidden; + --diffs-font-family: "SFMono-Regular", "Menlo", "Monaco", monospace; + --diffs-font-size: 11px; + --diffs-line-height: 1.4; + --diffs-tab-size: 2; +} + + +.diff-viewer-output diffs-container { + display: block; + max-width: 100%; + min-width: 0; + width: 100%; } .diff-line { diff --git a/src/styles/main.css b/src/styles/main.css index 6679a463a..446704a0c 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -233,6 +233,7 @@ backdrop-filter: none; } + .main-topbar-left { display: flex; align-items: center; diff --git a/src/types.ts b/src/types.ts index 51807c7da..642c86b8c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -105,16 +105,6 @@ export type GitFileDiff = { diff: string; }; -export type DiffLineReference = { - path: string; - type: "add" | "del" | "context" | "mixed"; - oldLine: number | null; - newLine: number | null; - endOldLine: number | null; - endNewLine: number | null; - lines: string[]; -}; - export type GitLogEntry = { sha: string; summary: string; diff --git a/src/utils/diffsWorker.ts b/src/utils/diffsWorker.ts new file mode 100644 index 000000000..fe03489bc --- /dev/null +++ b/src/utils/diffsWorker.ts @@ -0,0 +1,5 @@ +import WorkerUrl from "@pierre/diffs/worker/worker.js?worker&url"; + +export function workerFactory(): Worker { + return new Worker(WorkerUrl, { type: "module" }); +} diff --git a/vite.config.ts b/vite.config.ts index 6bded1f6a..c62a7d169 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,6 +7,9 @@ const host = process.env.TAURI_DEV_HOST; // https://vite.dev/config/ export default defineConfig(async () => ({ plugins: [react()], + worker: { + format: "es", + }, // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` //