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`
//