Integrate pierre's diffs into commit view

This commit is contained in:
Rūdolfs Ošiņš
2026-07-20 17:40:25 +02:00
parent 6c2365d4c9
commit e2da5055ba
17 changed files with 1977 additions and 111 deletions
+312
View File
@@ -11,6 +11,8 @@
"license": "GPL-3.0-only",
"dependencies": {
"@floating-ui/dom": "^1.7.6",
"@pierre/diffs": "1.3.0-beta.9",
"@pierre/trees": "1.0.0-beta.5",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.1",
@@ -464,6 +466,79 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@pierre/diffs": {
"version": "1.3.0-beta.9",
"resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.3.0-beta.9.tgz",
"integrity": "sha512-XoSLsyHA9UjNb/5UGQoKN1Bqma+ecHHaaM2T2edYdeJ67iS27qivWNoKq+uWjKhRzZEB4JQQRzlXxKy79UvY9A==",
"license": "apache-2.0",
"dependencies": {
"@pierre/theme": "1.1.0",
"@pierre/theming": "0.0.2",
"@shikijs/transformers": "^3.0.0 || ^4.0.0",
"diff": "9.0.0",
"hast-util-to-html": "9.0.5",
"lru_map": "0.4.1",
"shiki": "^3.0.0 || ^4.0.0"
},
"peerDependencies": {
"react": "^18.3.1 || ^19.0.0",
"react-dom": "^18.3.1 || ^19.0.0"
}
},
"node_modules/@pierre/theme": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@pierre/theme/-/theme-1.1.0.tgz",
"integrity": "sha512-GC2OWTAfTIIWWYhPCygwG8t2EtePQkRfON4MI2rwIkJylmiyqIttJID2dCL8sUD8cNdEvYkEyfEHHKMeCiDLoQ==",
"license": "MIT",
"engines": {
"vscode": "^1.0.0"
}
},
"node_modules/@pierre/theming": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@pierre/theming/-/theming-0.0.2.tgz",
"integrity": "sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw==",
"license": "apache-2.0",
"peerDependencies": {
"@pierre/theme": "^1.1.0",
"@shikijs/themes": "^3.0.0 || ^4.0.0",
"react": "^18.3.1 || ^19.0.0",
"react-dom": "^18.3.1 || ^19.0.0",
"shiki": "^3.0.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"@pierre/theme": {
"optional": true
},
"@shikijs/themes": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"shiki": {
"optional": true
}
}
},
"node_modules/@pierre/trees": {
"version": "1.0.0-beta.5",
"resolved": "https://registry.npmjs.org/@pierre/trees/-/trees-1.0.0-beta.5.tgz",
"integrity": "sha512-IzxkB9qv6GLbeEXObhlAD205LfYHiLeRwJdnaIdX0f5keTZF4X9EfiuEQ3QiyxOxouVVmUX3rX7m6a8zNMo/wA==",
"license": "apache-2.0",
"dependencies": {
"@pierre/theming": "0.0.2",
"preact": "11.0.0-beta.0",
"preact-render-to-string": "6.6.5"
},
"peerDependencies": {
"react": "^18.3.1 || ^19.0.0",
"react-dom": "^18.3.1 || ^19.0.0"
}
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
@@ -769,6 +844,119 @@
"dev": true,
"license": "MIT"
},
"node_modules/@shikijs/core": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.3.0.tgz",
"integrity": "sha512-EooU3i9F6IAE8kEu+AnGf9DFZWkQBZ+hJn3tLVbsH+61mtQiva5biai66fAA6nvFPXkLgvrh7BrR7YcJU83xQQ==",
"license": "MIT",
"dependencies": {
"@shikijs/primitive": "4.3.0",
"@shikijs/types": "4.3.0",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4",
"hast-util-to-html": "^9.0.5"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/engine-javascript": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.3.0.tgz",
"integrity": "sha512-hTv/KiFf2tpiqlACPiztGGurEARWIutB8YUhcrA1pUC7VzzwKO+g5crUocrLztrZ5ro5Z4hbXg7bYclETn3gSQ==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.3.0",
"@shikijs/vscode-textmate": "^10.0.2",
"oniguruma-to-es": "^4.3.6"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/engine-oniguruma": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.3.0.tgz",
"integrity": "sha512-1vMdN3gHfnKfLYwecUI2ITJI4RhHt96xEaJumVn7Heb0IlJ8WQMIH0Voak+2j22BpSNKdnOfB/pCTPnPm2gq7A==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.3.0",
"@shikijs/vscode-textmate": "^10.0.2"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/langs": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.3.0.tgz",
"integrity": "sha512-rnlqFbBRSys9bT4gl/5rw9RnS0W/I84ZldXPkO7cvlEMoV85TyF/aU01N7/NbSR776RNLjrJKjfFUXJR6wN1Cg==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.3.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/primitive": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.3.0.tgz",
"integrity": "sha512-CPkz64PTa5diRW1ggzMZH9VM/du4RNChYgVtgqrFcgruvIybmCvySv8GkiHSczUHXYuuR8TdKEwFx+UnZMpgdg==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.3.0",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/themes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.3.0.tgz",
"integrity": "sha512-Avgt05YiT+Y3prjIc9lmQxhJzHBcCfR6cjiFW4OyaMBbt2A6trX5rfjUzx+Vj/mE9qpArYjatnqo9XPjQNW/AQ==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.3.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/transformers": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-4.3.0.tgz",
"integrity": "sha512-5/elhJEcUrxdyQ95SSx0HzrnbzVPuipk6TYiXZL67tHhw8J+4N5shzmrTYyCaudiSnpuuwPdpaVtciwRFwSA7A==",
"license": "MIT",
"dependencies": {
"@shikijs/core": "4.3.0",
"@shikijs/types": "4.3.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/types": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.3.0.tgz",
"integrity": "sha512-oc8b9U2SYvofKZk8e/737nIX0qwf6eV2vHFATeObAu7r+mUVpLs8Re0BmVkIjAWAYgkmG/CzLNo7rzuBzRu/wQ==",
"license": "MIT",
"dependencies": {
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4"
},
"engines": {
"node": ">=20"
}
},
"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/@sindresorhus/merge-streams": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
@@ -2092,6 +2280,15 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/diff": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz",
"integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dompurify": {
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
@@ -3565,6 +3762,12 @@
"dev": true,
"license": "MIT"
},
"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": "11.3.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
@@ -3907,6 +4110,23 @@
],
"license": "MIT"
},
"node_modules/oniguruma-parser": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz",
"integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==",
"license": "MIT"
},
"node_modules/oniguruma-to-es": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz",
"integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==",
"license": "MIT",
"dependencies": {
"oniguruma-parser": "^0.12.2",
"regex": "^6.1.0",
"regex-recursion": "^6.0.2"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -4202,6 +4422,25 @@
"node": ">=4"
}
},
"node_modules/preact": {
"version": "11.0.0-beta.0",
"resolved": "https://registry.npmjs.org/preact/-/preact-11.0.0-beta.0.tgz",
"integrity": "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/preact-render-to-string": {
"version": "6.6.5",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.6.5.tgz",
"integrity": "sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA==",
"license": "MIT",
"peerDependencies": {
"preact": ">=10 || >= 11.0.0-0"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -4285,6 +4524,29 @@
"node": ">=6"
}
},
"node_modules/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.7"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -4299,6 +4561,30 @@
"url": "https://paulmillr.com/funding/"
}
},
"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/rolldown": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
@@ -4356,6 +4642,13 @@
"node": ">=6"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT",
"peer": true
},
"node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
@@ -4391,6 +4684,25 @@
"node": ">=8"
}
},
"node_modules/shiki": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/shiki/-/shiki-4.3.0.tgz",
"integrity": "sha512-NKKjWzR6LIGL3sXBrWDw9sDS9cxx42/DkysaNqJEeOWE8Kix5gpak0bc00OfDVEO4oyXSyz8+aRaqKoBD1yo7A==",
"license": "MIT",
"dependencies": {
"@shikijs/core": "4.3.0",
"@shikijs/engine-javascript": "4.3.0",
"@shikijs/engine-oniguruma": "4.3.0",
"@shikijs/langs": "4.3.0",
"@shikijs/themes": "4.3.0",
"@shikijs/types": "4.3.0",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4"
},
"engines": {
"node": ">=20"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+2
View File
@@ -27,6 +27,8 @@
"license": "GPL-3.0-only",
"dependencies": {
"@floating-ui/dom": "^1.7.6",
"@pierre/diffs": "1.3.0-beta.9",
"@pierre/trees": "1.0.0-beta.5",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.1",
+4 -1
View File
@@ -75,12 +75,14 @@
}
[data-codefont="system"] {
--font-family-code: monospace;
--txt-code-small: 400 0.75rem/1rem monospace;
--txt-code-regular: 400 0.875rem/1.25rem monospace;
--txt-code-semibold: 600 0.875rem/1.25rem monospace;
}
[data-codefont="jetbrains"] {
--font-family-code: "JetBrains Mono";
--txt-code-small: 400 0.75rem/1rem "JetBrains Mono";
--txt-code-regular: 400 0.875rem/1.25rem "JetBrains Mono";
--txt-code-semibold: 600 0.875rem/1.25rem "JetBrains Mono";
@@ -90,7 +92,8 @@ html {
-ms-text-size-adjust: 100%;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: 100%;
font-family: Booton, sans-serif;
--font-family-ui: Booton, sans-serif;
font-family: var(--font-family-ui);
/* The root element font size has to be set in px,
* otherwise Safari breaks. */
font-size: var(--font-size);
+15 -30
View File
@@ -3,15 +3,15 @@
import { saveToDisk, writeToClipboard } from "@app/lib/invoke";
import Button from "@app/components/Button.svelte";
import DropdownList from "@app/components/DropdownList.svelte";
import DropdownListItem from "@app/components/DropdownListItem.svelte";
import Icon from "@app/components/Icon.svelte";
import Popover, { closeFocused } from "@app/components/Popover.svelte";
interface Props {
// Fetched lazily so diff text isn't generated until an action runs.
// A thunk so the diff text isn't generated until an action runs.
text: () => Promise<string>;
// Default filename offered when saving to disk.
fileName: string;
title?: string;
}
@@ -49,42 +49,27 @@
}
</script>
<style>
.trigger {
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--color-text-secondary);
display: flex;
align-items: center;
}
.trigger:hover,
.trigger.active {
color: var(--color-text-primary);
}
.menu {
padding: 0.25rem;
background-color: var(--color-surface-canvas);
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-sm);
}
</style>
<Popover placement="bottom-end" bind:expanded>
{#snippet toggle(onclick)}
<button
class="trigger"
class:active={expanded}
<Button
variant="naked"
active={expanded}
{title}
{onclick}
aria-label={title}>
styleHeight="1.75rem"
styleWidth="1.75rem"
stylePadding="0"
styleJustifyContent="center">
<Icon name="ellipsis-vertical" />
</button>
</Button>
{/snippet}
{#snippet popover()}
<div class="menu">
<div
style:border="1px solid var(--color-border-subtle)"
style:border-radius="var(--border-radius-md)"
style:background-color="var(--color-surface-canvas)"
style:padding="0.25rem">
<DropdownList items={actions}>
{#snippet item(action)}
<DropdownListItem
+149
View File
@@ -0,0 +1,149 @@
<script lang="ts">
import type { DiffFileHeaderState } from "./diffFileHeaderState.svelte";
import Button from "@app/components/Button.svelte";
import DiffActions from "@app/components/DiffActions.svelte";
import Icon from "@app/components/Icon.svelte";
import Path from "@app/components/Path.svelte";
interface Props {
state: DiffFileHeaderState;
}
const { state }: Props = $props();
const name = $derived(state.fileDiff?.name ?? "");
const prevName = $derived(state.fileDiff?.prevName);
const isRename = $derived(
state.status === "moved" || state.status === "copied",
);
const stats = $derived.by(() => {
let additions = 0;
let deletions = 0;
for (const hunk of state.fileDiff?.hunks ?? []) {
additions += hunk.additionLines;
deletions += hunk.deletionLines;
}
return { additions, deletions };
});
const fileName = $derived(`${name.split("/").pop() || "file"}.diff`);
const text = $derived(state.text);
const statusLabel = $derived(
state.status === "added"
? "Added"
: state.status === "deleted"
? "Deleted"
: state.status === "moved"
? "Moved"
: state.status === "copied"
? "Copied"
: undefined,
);
</script>
<style>
.header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
min-width: 0;
min-height: 2.5rem;
padding: 0 0.5rem;
font: var(--txt-body-m-regular);
}
/* Binary/empty marker matches the collapse Button's footprint so filenames
stay column-aligned across all files. */
.marker {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.75rem;
height: 1.75rem;
line-height: 0;
color: var(--color-text-secondary);
}
.marker :global(svg) {
display: block;
}
.arrow {
color: var(--color-text-secondary);
}
.status {
padding: 0.125rem 0.5rem;
border-radius: var(--border-radius-sm);
white-space: nowrap;
flex-shrink: 0;
}
.status.added {
color: var(--color-feedback-success-text);
background-color: var(--color-feedback-success-bg);
}
.status.deleted {
color: var(--color-feedback-error-text);
background-color: var(--color-feedback-error-bg);
}
.status.moved,
.status.copied {
color: var(--color-text-secondary);
background: var(--color-surface-subtle);
}
.stats {
margin-left: auto;
font: var(--txt-code-regular);
white-space: nowrap;
flex-shrink: 0;
}
.add {
color: var(--color-feedback-success-text);
}
.del {
color: var(--color-feedback-error-text);
}
</style>
<div class="header">
{#if state.note === "binary"}
<span class="marker" title="Binary file"><Icon name="binary" /></span>
{:else if state.note === "empty"}
<span class="marker" title="Empty file"><Icon name="none" /></span>
{:else}
<Button
variant="naked"
styleHeight="1.75rem"
styleWidth="1.75rem"
stylePadding="0"
styleJustifyContent="center"
title={state.collapsed ? "Expand file" : "Collapse file"}
onclick={() => state.onToggleCollapse()}>
<Icon name={state.collapsed ? "chevron-right" : "chevron-down"} />
</Button>
{/if}
{#if isRename && prevName}
<Path fullPath={prevName} />
<span class="arrow">→</span>
<Path fullPath={name} />
{:else}
<Path fullPath={name} />
{/if}
{#if statusLabel}
<span class="status {state.status}">{statusLabel}</span>
{/if}
<span class="stats">
{#if !state.note && stats.additions + stats.deletions > 0}
<span class="add">+{stats.additions}</span>
<span class="del">-{stats.deletions}</span>
{/if}
</span>
{#if text !== undefined}
<DiffActions {text} {fileName} title="File diff actions" />
{/if}
</div>
+154
View File
@@ -0,0 +1,154 @@
<script lang="ts" module>
import type { ComponentProps } from "svelte";
import type { DiffOptions } from "@app/lib/diffOptions.svelte";
import Icon from "@app/components/Icon.svelte";
type IndicatorOption = {
value: DiffOptions["indicators"];
icon: ComponentProps<typeof Icon>["name"];
title: string;
};
const indicatorOptions: IndicatorOption[] = [
{ value: "classic", icon: "diff-classic", title: "Classic (+/−)" },
{ value: "bars", icon: "diff-bars", title: "Bars" },
{ value: "none", icon: "eye-slash", title: "None" },
];
type WordDiffOption = {
value: DiffOptions["lineDiffType"];
label: string;
title: string;
};
const wordDiffOptions: WordDiffOption[] = [
{
value: "word-alt",
label: "Word+",
title: "Highlight entire words with enhanced algorithm",
},
{
value: "word",
label: "Word",
title: "Highlight changed words within lines",
},
{ value: "char", label: "Char", title: "Highlight character changes" },
{ value: "none", label: "None", title: "Show line-level changes only" },
];
</script>
<script lang="ts">
import { diffOptions } from "@app/lib/diffOptions.svelte";
import Button from "@app/components/Button.svelte";
import Popover from "@app/components/Popover.svelte";
let expanded = $state(false);
</script>
<style>
.panel {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 20rem;
padding: 0.75rem 1rem;
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-md);
background-color: var(--color-surface-canvas);
font: var(--txt-body-m-regular);
color: var(--color-text-primary);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.switch {
display: flex;
align-items: center;
}
</style>
{#snippet boolSwitch(checked: boolean, onchange: (value: boolean) => void)}
<div class="switch">
<Button
variant="ghost"
flatRight
active={checked}
onclick={() => onchange(true)}>
On
</Button>
<Button
variant="ghost"
flatLeft
active={!checked}
onclick={() => onchange(false)}>
Off
</Button>
</div>
{/snippet}
<Popover placement="bottom-end" bind:expanded>
{#snippet toggle(onclick)}
<Button
variant="naked"
active={expanded}
{onclick}
title="Diff settings"
styleHeight="1.75rem"
styleWidth="1.75rem"
stylePadding="0"
styleJustifyContent="center">
<Icon name="settings" />
</Button>
{/snippet}
{#snippet popover()}
<div class="panel">
<div class="row">
Word wrap
{@render boolSwitch(
diffOptions.wordWrap,
value => (diffOptions.wordWrap = value),
)}
</div>
<div class="row">
Indicator style
<div class="switch">
{#each indicatorOptions as option, index}
<Button
variant="ghost"
flatLeft={index > 0}
flatRight={index < indicatorOptions.length - 1}
active={diffOptions.indicators === option.value}
title={option.title}
onclick={() => (diffOptions.indicators = option.value)}
styleJustifyContent="center">
<Icon name={option.icon} />
</Button>
{/each}
</div>
</div>
<div class="row">
Word diff
<div class="switch">
{#each wordDiffOptions as option, index}
<Button
variant="ghost"
flatLeft={index > 0}
flatRight={index < wordDiffOptions.length - 1}
active={diffOptions.lineDiffType === option.value}
title={option.title}
onclick={() => (diffOptions.lineDiffType = option.value)}>
{option.label}
</Button>
{/each}
</div>
</div>
</div>
{/snippet}
</Popover>
+20
View File
@@ -41,6 +41,10 @@
| "dashboard"
| "device"
| "diff"
| "diff-bars"
| "diff-classic"
| "diff-split"
| "diff-unified"
| "disconnect"
| "document"
| "download"
@@ -339,6 +343,22 @@
{:else if name === "diff"}
<path
d="M13.5 1.5V14.5H2.5V1.5H13.5ZM3.5 13.5H12.5V2.5H3.5V13.5ZM10 10.5V11.5H6V10.5H10ZM8.5 4V5.5H10V6.5H8.5V8H7.5V6.5H6V5.5H7.5V4H8.5Z" />
{:else if name === "diff-bars"}
<path d="M5 8.5V7.5L15 7.5V8.5L5 8.5Z" />
<path d="M5 11.5V10.5H13V11.5H5Z" />
<path d="M3 2.5L3 1.5L7 1.5L7 2.5L3 2.5Z" />
<path d="M1.5 7.5H3.5L3.5 11.5H1.5L1.5 7.5Z" />
<path d="M4 5.5V4.5L12 4.5V5.5L4 5.5Z" />
<path d="M7 13.5V14.5H3V13.5H7Z" />
{:else if name === "diff-classic"}
<path d="M8.5 5.5H12V6.5H8.5V10H7.5V6.5H4V5.5H7.5V2H8.5V5.5Z" />
<path d="M12 12.5V13.5H4V12.5H12Z" />
{:else if name === "diff-split"}
<path d="M1.5 2.5H14.5V13.5H1.5V2.5ZM2.5 3.5V12.5H13.5V3.5H2.5Z" />
<path d="M7.5 2.5H8.5V13.5H7.5V2.5Z" />
{:else if name === "diff-unified"}
<path d="M1.5 2.5H14.5V13.5H1.5V2.5ZM2.5 3.5V12.5H13.5V3.5H2.5Z" />
<path d="M1.5 7.5H14.5V8.5H1.5V7.5Z" />
{:else if name === "disconnect"}
<path
d="M2.5 2.5L9.5 2.5L9.5 5.5L8.5 5.5L8.5 3.5L3.5 3.5L3.5 12.5L8.5 12.5L8.5 10.5L9.5 10.5L9.5 13.5L2.5 13.5L2.5 2.5Z" />
+639
View File
@@ -0,0 +1,639 @@
<script lang="ts" module>
import type { WorkerPoolManager } from "@pierre/diffs/worker";
import { getOrCreateWorkerPoolSingleton } from "@pierre/diffs/worker";
// Vite bundles Pierre's highlighting worker (and its Shiki/WASM deps) into a
// self-contained, same-origin worker module.
import PierreDiffWorker from "@pierre/diffs/worker/worker.js?worker";
const themes = { dark: "github-dark", light: "github-light" } as const;
// Shiki tokenization is the expensive part of rendering a large diff. Run it
// in a shared worker pool so it stays off the main thread; if the worker
// cannot be created (e.g. blocked by CSP), fall back to main-thread
// highlighting rather than failing to render.
let workerPoolResolved = false;
let workerPool: WorkerPoolManager | undefined;
function getWorkerPool(): WorkerPoolManager | undefined {
if (workerPoolResolved) {
return workerPool;
}
workerPoolResolved = true;
try {
workerPool = getOrCreateWorkerPoolSingleton({
poolOptions: {
workerFactory: () => new PierreDiffWorker(),
poolSize: 2,
},
highlighterOptions: { theme: themes },
});
} catch (error) {
console.error(
"PierreDiff: worker pool unavailable; highlighting on the main thread",
error,
);
workerPool = undefined;
}
return workerPool;
}
</script>
<script lang="ts">
import type {
CodeViewItem,
CodeViewSlotSnapshot,
FileDiffLoadedFiles,
FileDiffMetadata,
} from "@pierre/diffs";
import type { Snippet } from "svelte";
import { CodeView, CUSTOM_HEADER_SLOT_ID } from "@pierre/diffs";
import { mount, unmount, untrack } from "svelte";
import { fontSettings } from "@app/lib/appearance.svelte";
import { parsePatch } from "@app/lib/pierreParse";
import DiffFileHeader from "@app/components/DiffFileHeader.svelte";
import { DiffFileHeaderState } from "@app/components/diffFileHeaderState.svelte";
import { theme } from "@app/components/ThemeSwitch.svelte";
interface Props {
// Unified `git diff` patch text; Pierre renders the diff from this.
patch: string;
diffStyle?: "unified" | "split";
disableBackground?: boolean;
disableLineNumbers?: boolean;
wordWrap?: boolean;
diffIndicators?: "classic" | "bars" | "none";
lineDiffType?: "word-alt" | "word" | "char" | "none";
// Content shown above the first file. Handed to CodeView via its
// `renderCodeViewHeader` API, which renders it non-virtualized at the top of
// the scroll content, so it scrolls out of view leaving only the sticky file
// headers pinned. CodeView measures its height itself (ResizeObserver).
header?: Snippet;
// Fetch the full old/new contents of a file (by its new-side path) on
// demand. When provided, it is adapted into Pierre's `loadDiffFiles`
// loader: the native hunk-expand markers become live, and clicking one
// makes Pierre hydrate that file's full content lazily (one click, no
// pre-loading).
loadFullFile?: (
path: string,
) => Promise<{ oldContents: string; newContents: string }>;
// Files with no renderable text diff, keyed by new-side path. Pierre has no
// binary/empty concept, so these get a header note and no expand caret.
fileNotes?: ReadonlyMap<string, "binary" | "empty">;
// Per-file change status, keyed by new-side path — renders as a plain text
// label after the filename (nothing for a plain modification).
fileStatuses?: ReadonlyMap<
string,
"added" | "deleted" | "modified" | "moved" | "copied"
>;
// Lazily fetch a single file's raw diff text (by new-side path) for the
// header's copy-to-clipboard / save-to-disk actions.
fileDiffText?: (path: string) => Promise<string>;
// Unique id for this diff (e.g. the commit or revision id). Used as the
// Pierre `cacheKey` prefix so the shared worker-pool highlight cache does
// not collide across diffs that share file paths (see `parsePatch`).
cacheKeyPrefix?: string;
}
const {
patch,
diffStyle = "unified",
disableBackground = false,
disableLineNumbers = false,
wordWrap = false,
diffIndicators = "bars",
lineDiffType = "word-alt",
header = undefined,
loadFullFile = undefined,
fileNotes = undefined,
fileStatuses = undefined,
fileDiffText = undefined,
cacheKeyPrefix = undefined,
}: Props = $props();
// Pierre's first-party lazy content loader. It calls this the first time a
// partial (patch-parsed) file is expanded, hydrates the file's full content
// in place (flips `isPartial`, reuses the highlight cache), and drives its own
// expand markers. Stable reference so option updates never re-trigger loader
// wiring. Pierre only invokes it for changed/renamed files (added/deleted
// already carry a side).
const loadDiffFiles = $derived(
loadFullFile
? async (fileDiff: FileDiffMetadata): Promise<FileDiffLoadedFiles> => {
const { oldContents, newContents } = await loadFullFile(
fileDiff.name,
);
return {
oldFile: {
name: fileDiff.prevName ?? fileDiff.name,
contents: oldContents,
},
newFile: { name: fileDiff.name, contents: newContents },
};
}
: undefined,
);
// Pierre only emits unprefixed `user-select: none` on its gutters, which
// WebKit ignores — so with the subtree opted into selection, gutters would
// become selectable there. Re-assert it with the `-webkit-` prefix inside the
// shadow root (injected into the highest `@layer unsafe`, so it wins).
const gutterUnsafeCSS = `
[data-column-number],
[data-content-buffer],
[data-gutter-buffer],
[data-separator-wrapper],
[data-separator-content] {
-webkit-user-select: none;
user-select: none;
}
`;
// Per-file "card" treatment. Each file is its own `<diffs-container>` custom
// element (own shadow root), so `:host` — reachable from the per-file
// `unsafeCSS` — is the whole-file box (header + diff). Horizontal spacing via
// margin; vertical spacing between files stays on `layout.gap` (vertical
// margins fight the virtualizer's height math). `background: transparent`
// gives the "no background" look — unchanged lines then show the app canvas,
// and only the border + change tints define the card.
//
// The border is a `box-shadow`, NOT a real border: the virtualizer computes
// each file's height from metrics (for `overflow: scroll` it never measures
// the container), so a real border's 2px would drift the layout model from
// the DOM — corrupting scroll-to targets and the sticky math for short
// collapsed files. A box-shadow adds zero layout height. It must be an
// *outset* ring: the diff surface now equals the view background, so the
// opaque header/line backgrounds would paint over an inset shadow. (The
// header/body divider is an inset shadow on `DiffFileHeader`'s own row, so it
// adds no height and keeps the header matching the metric.)
//
// Corners are rounded per-element, not with `overflow: hidden` on `:host`
// (that would make the header stick within the file box instead of the scroll
// viewport). The header's opaque sticky background would otherwise square off
// over the rounded box-shadow, so we round its top corners; the body wrapper
// (a sibling of the header, not the sticky element) gets rounded + clipped
// bottom corners — safe because the horizontal scroll lives on the inner
// `[data-code]`. A header-only card (collapsed, binary, or empty — no rendered
// body) rounds all its header corners, driven by the `data-app-no-body`
// attribute we set on the host.
//
// Context separators ("N unmodified lines" / "More context" bars) are also
// restyled here: recessed one surface level below the diff, 4px corners, no
// hover underline, and Pierre's expand glyph swapped for the app's caret (an
// app-file chevron painted via a CSS mask, like the tree's folder glyphs).
const caretMask =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='black' d='M4 5.29297L8 9.29297L12 5.29297L12.707 6L8 10.707L3.29297 6L4 5.29297Z'/%3E%3C/svg%3E\")";
const cardUnsafeCSS = `
:host {
margin: 0 0.5rem;
border-radius: var(--border-radius-md);
box-shadow: 0 0 0 1px var(--color-border-subtle);
background: transparent;
/* Force the diff surface to the app's view background. The Shiki theme
sets \`--diffs-light-bg\`/\`--diffs-dark-bg\` on \`:host\` directly, so
overriding those by inheritance fails; overriding the resolved
\`--diffs-bg\` from this higher \`@layer\` wins. The header, lines and
gutter read it directly, and the separator/context/change tints are
\`color-mix\`es off it — so everything sits on the view background with
only the border, syntax and add/del/mod tints standing out, while the
separators keep their subtle Pierre tint (now mixed off our base). */
--diffs-bg: var(--color-surface-canvas);
/* Lift the context separators one surface level above the diff. */
--diffs-bg-separator-override: var(--color-surface-subtle);
}
[data-diffs-header] {
border-top-left-radius: var(--border-radius-md);
border-top-right-radius: var(--border-radius-md);
}
:host([data-app-no-body]) [data-diffs-header] {
border-radius: var(--border-radius-md);
}
[data-diffs-header] ~ [data-diff],
[data-diffs-header] ~ [data-file] {
border-bottom-left-radius: var(--border-radius-md);
border-bottom-right-radius: var(--border-radius-md);
overflow: hidden;
}
/* Context separators: 4px corners, no hover underline. */
[data-separator-content],
[data-separator="line-info"] [data-separator-wrapper] {
border-radius: var(--border-radius-md);
}
[data-separator-content]:hover {
text-decoration: none;
}
/* Swap Pierre's expand glyph for the app caret (down; flipped when the
separator expands upward). */
[data-expand-button] [data-icon] {
display: none;
}
[data-expand-button]::before {
content: "";
width: 1rem;
height: 1rem;
background-color: currentColor;
-webkit-mask: center / contain no-repeat ${caretMask};
mask: center / contain no-repeat ${caretMask};
}
[data-expand-button][data-expand-up]::before {
transform: rotate(180deg);
}
`;
// Kept out of `$state` proxying (it is an external stateful instance), but
// still reactive on reassignment so the option effects re-run once it exists.
let container = $state<HTMLElement>();
let headerEl = $state<HTMLElement>();
// Handed to CodeView's `renderCodeViewHeader`: it mounts this element at the
// top of the scroll content and tracks its height itself. Stable reference so
// option updates do not re-trigger header reconciliation.
const renderHeader = (): HTMLElement | undefined => headerEl;
// `DiffFileHeader`'s row height (`2.5rem`), fed to Pierre as the exact
// `diffHeaderHeight` metric. It must be constant across all files — Pierre
// estimates each file's position from this single metric (not the measured
// header), so a varying header height drifts scroll-to-file. Expanded files
// get a 1px divider above the body but no extra height (inset shadow). The
// app's root font size is user-configurable (`--font-size`, 14–24px) and one
// rem equals `fontSettings.size` px, so scale with it rather than hardcoding
// pixels.
function fileHeaderHeight(): number {
return fontSettings.size * 2.5;
}
// Diff line height, rounded to a whole pixel. `1.25rem` is fractional at some
// font sizes (e.g. 17.5px at a 14px root) and WebKit snaps each rendered row
// to a whole pixel, so a fractional metric drifts scroll-to-file down a long
// diff. Feed this same integer to both the CSS line height and Pierre's
// `itemMetrics.lineHeight` so the rendered rows and the virtualization model
// agree exactly.
const lineHeightPx = $derived(Math.round(fontSettings.size * 1.25));
let view = $state.raw<CodeView | undefined>(undefined);
// The parsed files, kept so `scrollToFile` can map a path to its diff item.
let parsedFiles = $state.raw<FileDiffMetadata[]>([]);
// Scroll the diff to a file by its path (item id is its index in the patch).
export function scrollToFile(path: string): void {
const index = parsedFiles.findIndex(file => file.name === path);
if (index < 0) {
return;
}
view?.scrollTo({ type: "item", id: String(index), align: "start" });
}
// Collapse or expand every file at once (drives the topbar toggle). Renders
// coalesce, so one pass over all items is fine.
export function setAllCollapsed(collapsed: boolean): void {
const instance = view;
if (!instance) {
return;
}
for (let index = 0; index < parsedFiles.length; index++) {
const item = instance.getItem(String(index));
if (item) {
instance.updateItem({
...item,
collapsed,
version: (item.version ?? 0) + 1,
});
}
}
}
// `renderCustomHeader` makes Pierre render only our custom-header slot (it
// drops its own filename/status-icon/stats), so there is no built-in chrome
// to override with unsafeCSS. A stable no-op marker enables the slot; we fill
// it ourselves (see `syncHeaderSlots`). Pierre still supplies the sticky
// header container and the body's context-expand markers.
const renderCustomHeader = (): undefined => undefined;
// One `DiffFileHeader` Svelte component per rendered file, keyed by CodeView
// item id (not by host element). Pierre pools its `<diffs-container>` host
// elements, and in container-managed mode its `cleanElement` deliberately
// leaves our slotted light-DOM child attached on release — so a host only
// re-enters the reuse pool once *we* detach our wrapper (its `isElementClean`
// gate is `childNodes.length === 0`). We therefore reconcile against every
// snapshot: mount for newly-rendered ids, and unmount + detach the wrapper for
// ids that left, which frees the vacated host for reuse and prevents leaking
// components across scroll. This mirrors Pierre's own React wrapper, which
// portals slot content per item id and unmounts it when the id drops out.
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- imperative id→component lookup, never rendered reactively
const mountedHeaders = new Map<
string,
{
state: DiffFileHeaderState;
instance: ReturnType<typeof mount>;
// The slot wrapper we appended to the host; detached on removal so the
// host becomes child-free and poolable again.
target: HTMLElement;
// The host this wrapper currently lives in, to detect a recycle.
host: HTMLElement;
}
>();
function unmountHeaders(): void {
for (const { instance, target } of mountedHeaders.values()) {
void unmount(instance);
target.remove();
}
mountedHeaders.clear();
}
// The virtualized CodeView is container-managed, so it emits a snapshot of
// rendered items through a slot coordinator (`{ items, header, footer }`). For
// each rendered file we ensure a `DiffFileHeader` is mounted into its
// custom-header slot and push the current file data into its reactive state;
// afterwards we tear down headers whose file is no longer rendered.
function syncHeaderSlots(
snapshot: CodeViewSlotSnapshot<undefined> | undefined,
): void {
const items = snapshot?.items;
if (!items) {
return;
}
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local set, never reactive
const present = new Set<string>();
for (const rendered of items) {
if (rendered.type !== "diff") {
continue;
}
present.add(rendered.id);
const host = rendered.element;
let entry = mountedHeaders.get(rendered.id);
if (!entry) {
const target = document.createElement("div");
target.slot = CUSTOM_HEADER_SLOT_ID;
target.dataset.pierreHeader = "";
// Transparent wrapper: the component's own `.header` participates in the
// slot layout directly, without an extra box.
target.style.display = "contents";
host.appendChild(target);
const state = new DiffFileHeaderState();
const instance = mount(DiffFileHeader, { target, props: { state } });
entry = { state, instance, target, host };
mountedHeaders.set(rendered.id, entry);
} else if (entry.host !== host) {
// Defensive: if Pierre ever moves an id's content onto a different
// pooled host without dropping it from the snapshot, follow it (as the
// React wrapper's portal does when its container prop changes).
host.appendChild(entry.target);
entry.host = host;
}
const { item } = rendered;
const fileDiff = item.fileDiff;
entry.state.fileDiff = fileDiff;
entry.state.status = fileStatuses?.get(fileDiff.name);
// Binary comes from the backend (Pierre can't tell binary from empty —
// both have no hunks). Any other zero-hunk file (empty/mode-only/pure
// rename adds like `.gitkeep`) is treated as empty regardless of how the
// backend labelled its diff.
const note =
fileNotes?.get(fileDiff.name) ??
(fileDiff.hunks.length === 0 ? "empty" : undefined);
entry.state.note = note;
entry.state.collapsed = item.collapsed === true;
// Flag header-only cards (collapsed, binary, or empty — anything with no
// rendered body) so `cardUnsafeCSS` rounds all four corners of the header
// instead of only the top two.
host.toggleAttribute(
"data-app-no-body",
item.collapsed === true || note !== undefined,
);
entry.state.text = fileDiffText
? () => fileDiffText(fileDiff.name)
: undefined;
entry.state.onToggleCollapse = () => {
// Bump `version`: CodeView ignores an updateItem whose version is
// unchanged (see syncItemRecord).
view?.updateItem({
...item,
collapsed: !(item.collapsed === true),
version: (item.version ?? 0) + 1,
});
};
}
// Reconcile removals: unmount headers whose file left the snapshot and
// detach their slot wrapper so the vacated host is child-free — only then
// does Pierre's pool promote it for reuse (see the map comment above).
for (const [id, entry] of mountedHeaders) {
if (!present.has(id)) {
void unmount(entry.instance);
entry.target.remove();
mountedHeaders.delete(id);
}
}
}
function options(
themeType: "dark" | "light",
style: "unified" | "split",
headerHeight: number,
) {
return {
theme: themes,
themeType,
diffStyle: style,
disableBackground,
disableLineNumbers,
overflow: (wordWrap ? "wrap" : "scroll") as "wrap" | "scroll",
diffIndicators,
lineDiffType,
stickyHeaders: true,
unsafeCSS: gutterUnsafeCSS + cardUnsafeCSS,
layout: { paddingTop: 0, paddingBottom: 8, gap: 8 },
// Own the whole file header: Pierre renders only our custom-header slot
// (mounted per file in `syncHeaderSlots`), so no built-in icon/stat chrome
// to fight with CSS.
renderCustomHeader,
// Lazy full-file hydration for context expansion. Pierre shows live
// expand markers on the partial diff and calls this only when the user
// expands — one click, nothing loaded up front.
...(loadDiffFiles ? { loadDiffFiles } : {}),
// Commit metadata as a non-virtualized header at the top of the scroll
// content. It scrolls away with the diff and CodeView measures its height
// itself, so no `paddingTop` reservation is needed.
...(header ? { renderCodeViewHeader: renderHeader } : {}),
// Pierre estimates each file's position from these two metrics, so they
// must equal the real rendered heights or scroll-to-file drifts.
// `diffHeaderHeight` is passed statically (not measured) so it can't lag
// behind a font-size change. `lineHeight` must match `--diffs-line-height`
// (`lineHeightPx`); Pierre's default is a fixed 20px, which only matches at
// the 16px root font.
itemMetrics: {
diffHeaderHeight: headerHeight,
lineHeight: lineHeightPx,
},
};
}
function buildItems(files: FileDiffMetadata[]): CodeViewItem[] {
return files.map((fileDiff, index) => ({
id: String(index),
type: "diff",
fileDiff,
}));
}
// Structural rebuild: only when the patch or layout style changes.
$effect(() => {
const el = container;
if (!el) {
return;
}
const p = patch;
// `diffStyle` is applied via `setOptions` below, so reading it untracked
// keeps a split/unified toggle from re-parsing and rebuilding the diff.
const instance = new CodeView(
untrack(() => options($theme, diffStyle, fileHeaderHeight())),
// Off-thread highlighting; falls back to the main thread if unavailable.
getWorkerPool(),
// The container element is owned by this component.
true,
);
instance.setup(el);
// Header content in the virtualized path is delivered via a slot
// coordinator, not the render* options.
instance.setSlotCoordinator({
hasHeaderRenderers: true,
hasAnnotationRenderer: false,
hasGutterRenderer: false,
onSnapshotChange: syncHeaderSlots,
});
// Mount empty first so the header paints immediately, then fill in the
// files once the patch has been parsed off the main thread.
instance.render(true);
view = instance;
let cancelled = false;
// `cacheKeyPrefix` changes in lockstep with `patch` (both derive from the
// commit), so read it untracked — the `patch` dependency already rebuilds.
parsePatch(
p,
untrack(() => cacheKeyPrefix),
)
.then(files => {
if (cancelled) {
return;
}
parsedFiles = files;
instance.setItems(buildItems(files));
instance.render(true);
})
.catch((error: unknown) => {
if (!cancelled) {
console.error("PierreDiff: failed to parse patch", error);
}
});
return () => {
cancelled = true;
view = undefined;
unmountHeaders();
instance.cleanUp();
};
});
// Theme toggle, layout style, font-size (header-height), and diff-preference
// changes: update options in place instead of rebuilding (and re-parsing) the
// diff.
$effect(() => {
const themeType = $theme;
const style = diffStyle;
// Read reactively (outside `untrack`) so a font-size change re-runs this
// effect and pushes the new header height to Pierre.
const headerHeight = fileHeaderHeight();
// Touch the preference props so a change re-runs this effect; `options()`
// reads their current values.
void disableBackground;
void disableLineNumbers;
void wordWrap;
void diffIndicators;
void lineDiffType;
const instance = view;
if (!instance) {
return;
}
untrack(() => {
instance.setOptions(options(themeType, style, headerHeight));
instance.render(true);
});
});
// `lineDiffType` (inline word/char highlighting) is computed in the highlight
// worker, and the shared pool caches results keyed by a render-options version
// that only `setRenderOptions` bumps — `CodeView.setOptions` never reaches the
// worker, so changing it there has no effect (a stale cache hit is served).
// Push it to the pool instead (which also clears the highlight cache) and
// re-render to recompute decorations. The main-thread fallback path (no pool)
// reads `lineDiffType` from the CodeView options set above.
$effect(() => {
const ln = lineDiffType;
const instance = view;
const pool = getWorkerPool();
if (!instance || !pool) {
return;
}
void pool.setRenderOptions({ lineDiffType: ln }).then(() => {
// Guard against a teardown between the async resolve and here.
if (view === instance) {
instance.render(true);
}
});
});
</script>
<style>
.pierre-diff {
flex: 1;
min-height: 0;
min-width: 0;
/* Feed the app's configured code font and sizing into Pierre (its `:host`
reads these custom properties, which inherit across the shadow boundary).
Matches `--txt-code-regular`. The line height is set on the element itself
(`--diffs-line-height`, below) as a whole-pixel value that mirrors
`itemMetrics.lineHeight`, so the rendered rows and Pierre's model agree. */
--diffs-font-family: var(--font-family-code);
--diffs-font-size: 0.875rem;
/* Chrome colours from our design tokens. Syntax token colours still come
from the Shiki theme (github light/dark); these only retint the diff
surfaces, line numbers, and add/delete/modify accents so the frame
matches the app without changing the highlighting look and feel. */
/* Text colour from our tokens. The base *surface* cannot be set here: the
Shiki theme (github-dark/light) writes `--diffs-light-bg`/`--diffs-dark-bg`
directly onto the shadow `:host`, which beats any value inherited from this
light-DOM ancestor. So `--diffs-bg` is forced to the app's view background
in `cardUnsafeCSS` (a higher `@layer`) instead — every neutral surface and
the change-tint mixes derive from it. */
--diffs-light: var(--color-text-primary);
--diffs-dark: var(--color-text-primary);
/* Line numbers. */
--diffs-fg-number-override: var(--color-text-tertiary);
/* Add / delete / modify accents feed the line-tint mixes. */
--diffs-addition-color-override: var(--color-feedback-success-text);
--diffs-deletion-color-override: var(--color-feedback-error-text);
--diffs-modified-color-override: var(--color-text-brand);
/* CodeView attaches its scroll listener to this element but does not set
`overflow` itself, so it must be the scroll viewport. */
overflow-y: auto;
/* The app disables selection globally on `html` and opts in per element.
`user-select` inherits across the shadow boundary, so opt the whole
Pierre subtree back in; its own CSS keeps gutters/line numbers
unselectable. */
-webkit-user-select: text;
user-select: text;
}
</style>
<div
bind:this={container}
class="pierre-diff"
style:--diffs-line-height="{lineHeightPx}px">
{#if header}
<div bind:this={headerEl} class="pierre-diff-header">
{@render header()}
</div>
{/if}
</div>
+177
View File
@@ -0,0 +1,177 @@
<script lang="ts" module>
import type { GitStatusEntry } from "@pierre/trees";
import { FileTree } from "@pierre/trees";
// App file glyph (path lifted from Icon.svelte, viewBox 0 0 16 16) exposed as
// a sprite so the tree uses our file icon instead of Pierre's. Folders are
// handled separately via CSS masks (see `unsafeCSS` below), because Pierre's
// folder icon slot holds an expand chevron whose open/closed state is only
// reachable through the `aria-expanded` attribute in CSS.
const iconSpriteSheet = `
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" style="display: none">
<symbol id="app-tree-file" viewBox="0 0 16 16">
<path fill="currentColor" d="M13.5 14.5L2.5 14.5L2.5 1.5L9.20703 1.5L13.5 5.79297L13.5 14.5ZM8.5 6.5L8.5 2.5L3.5 2.5L3.5 13.5L12.5 13.5L12.5 6.5L8.5 6.5ZM9.5 3.20703L9.5 5.5L11.793 5.5L9.5 3.20703Z" />
</symbol>
</svg>
`;
// Folder glyphs as CSS mask data URIs (open + closed), swapped by
// `aria-expanded` on the row. `fill` is irrelevant — masks use alpha only;
// the visible color comes from `background-color`.
const folderClosedMask =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='black' d='M7.20703 2.5L9.20703 4.5H14.5V14.5H1.5V2.5H7.20703ZM2.5 13.5H13.5V5.5H2.5V13.5ZM2.5 4.5H7.79297L6.79297 3.5H2.5V4.5Z'/%3E%3C/svg%3E\")";
const folderOpenMask =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='black' d='M7.20703 2.5L9.20703 4.5H14.5V6.5H15.6631L13.377 14.5H1.5V2.5H7.20703ZM2.70898 13.5H12.623L14.3369 7.5H4.85254L2.70898 13.5ZM2.5 11.1133L4.0293 6.83203L4.14746 6.5H13.5V5.5H8.79297L6.79297 3.5H2.5V11.1133Z'/%3E%3C/svg%3E\")";
</script>
<script lang="ts">
import { theme } from "@app/components/ThemeSwitch.svelte";
interface Props {
paths: string[];
gitStatus?: GitStatusEntry[];
// Fires only for leaf files; folder selections are ignored.
onSelect: (path: string) => void;
}
const { paths, gitStatus = undefined, onSelect }: Props = $props();
let container = $state<HTMLElement>();
$effect(() => {
const el = container;
if (!el) {
return;
}
const p = paths;
const status = gitStatus;
// The tree renders in a shadow root and picks light/dark via CSS
// `light-dark()`, which follows `color-scheme`. Its base `:host` hard-sets
// `light dark` (so it would follow the OS), so force the app's theme via
// `unsafeCSS` (injected into the winning `@layer unsafe`). Reading `$theme`
// here also rebuilds the tree when the theme changes.
const unsafeCSS = `
:host { color-scheme: ${$theme}; }
/* Force monochrome file icons: override Pierre's per-file-type colored
rules (which win on specificity in the base layer) from the highest
@layer unsafe. */
[data-item-section="icon"] [data-icon-token] {
color: var(--trees-fg-muted);
}
/* Folders: Pierre puts an expand chevron in the icon slot. Hide it and
paint a folder glyph in its place via a mask, so the icon keeps its
natural position and indent. Open/closed follows the row's
\`aria-expanded\` state. */
[data-item-type="folder"] > [data-item-section="icon"] > [data-icon-name="file-tree-icon-chevron"] {
display: none;
}
[data-item-type="folder"] > [data-item-section="icon"]::after {
content: "";
width: var(--trees-icon-width);
height: var(--trees-icon-width);
background-color: var(--trees-fg-muted);
-webkit-mask: center / contain no-repeat ${folderClosedMask};
mask: center / contain no-repeat ${folderClosedMask};
}
[aria-expanded="true"][data-item-type="folder"] > [data-item-section="icon"]::after {
-webkit-mask-image: ${folderOpenMask};
mask-image: ${folderOpenMask};
}
/* The directory rollup dot is nudged by \`translateY(.65ex - 50%)\`, an
x-height offset tuned for system-ui. Our Booton override has a
different x-height, so it lands too high; the row already flex-centers,
so drop the nudge and let centering handle it. */
[data-item-section="git"] [data-icon-name="file-tree-icon-dot"] {
transform: none;
}
/* Flattened folder segments (e.g. ".github / workflows") underline on
hover by default; the app doesn't underline these, so drop it. */
[data-item-flattened-subitem]:hover {
text-decoration: none;
}
`;
// Only the leaf files are selectable targets; folder selections are ignored.
const files = new Set(p);
const tree = new FileTree({
paths: p,
gitStatus: status,
initialExpansion: "open",
flattenEmptyDirectories: true,
unsafeCSS,
// `set: "none"` drops Pierre's per-file-type glyphs; files use the app's
// file glyph via the custom sprite. Folders are handled by the CSS mask
// in `unsafeCSS` above (the folder icon slot is really an expand chevron).
icons: {
set: "none",
spriteSheet: iconSpriteSheet,
remap: {
"file-tree-icon-file": "app-tree-file",
},
},
onSelectionChange: selected => {
const path = selected.at(-1);
if (path !== undefined && files.has(path)) {
onSelect(path);
}
},
});
tree.render({ containerWrapper: el });
return () => tree.cleanUp();
});
</script>
<style>
.pierre-tree {
height: 100%;
min-height: 0;
overflow: hidden;
/* Own the inline gap on the host (not a scroll container) so both sides
are an even 0.5rem. Pierre's own inline padding is zeroed below because
its `scrollbar-gutter: stable` eats the right padding, leaving the two
sides uneven. */
box-sizing: border-box;
padding: 0 0.5rem;
/* Match the tree to the app's design tokens. These `--trees-*-override`
hooks are the top of Pierre's fallback chain (override → theme → default)
and inherit across the shadow boundary. */
/* Surfaces & text */
--trees-bg-override: var(--color-surface-canvas);
--trees-fg-override: var(--color-text-primary);
--trees-fg-muted-override: var(--color-text-secondary);
--trees-bg-muted-override: var(--color-surface-subtle);
--trees-selected-bg-override: var(--color-surface-mid);
--trees-selected-fg-override: var(--color-text-primary);
--trees-border-color-override: var(--color-border-subtle);
--trees-indent-guide-bg-override: var(--color-border-subtle);
/* Accent uses our brand (not Pierre's default blue) for any non-border
accents (e.g. search matches). */
--trees-accent-override: var(--color-border-brand);
/* But no blue border around the active/selected row — the app indicates
selection with a background, not a focus/selection ring. */
--trees-selected-focused-border-color-override: transparent;
--trees-focus-ring-color-override: transparent;
/* Search input */
--trees-input-bg-override: var(--color-surface-base);
--trees-search-bg-override: var(--color-surface-base);
--trees-search-fg-override: var(--color-text-primary);
/* Git status indicators */
--trees-git-added-color-override: var(--color-feedback-success-text);
--trees-git-deleted-color-override: var(--color-feedback-error-text);
--trees-git-modified-color-override: var(--color-text-brand);
--trees-git-renamed-color-override: var(--color-feedback-warning-text);
--trees-git-untracked-color-override: var(--color-feedback-success-text);
--trees-git-ignored-color-override: var(--color-text-tertiary);
/* Typography & shape */
--trees-font-family-override: var(--font-family-ui);
--trees-font-size-override: 0.875rem;
--trees-border-radius-override: var(--border-radius-sm);
/* Inline gap is handled by the host's padding (above); zero Pierre's own so
the two aren't added together and the right side isn't eaten by its
scrollbar gutter. */
--trees-padding-inline-override: 0;
}
</style>
<div bind:this={container} class="pierre-tree"></div>
+1
View File
@@ -94,6 +94,7 @@
}
.popover {
box-shadow: var(--elevation-low);
border-radius: var(--border-radius-md);
position: fixed;
top: 0;
left: 0;
@@ -0,0 +1,21 @@
import type { FileDiffMetadata } from "@pierre/diffs";
export type FileStatus = "added" | "deleted" | "modified" | "moved" | "copied";
export type FileNote = "binary" | "empty";
// Reactive state for a single `DiffFileHeader`. One instance is created per
// pooled CodeView item element and mutated in place as the element is recycled
// for different files while scrolling (see `PierreDiff.svelte`), so the header
// updates without remounting.
export class DiffFileHeaderState {
fileDiff = $state.raw<FileDiffMetadata | undefined>(undefined);
status = $state<FileStatus | undefined>(undefined);
note = $state<FileNote | undefined>(undefined);
collapsed = $state(false);
// `undefined` hides the actions menu.
text = $state<(() => Promise<string>) | undefined>(undefined);
// Not `$state`: read only at click time. Set per file in `syncHeaderSlots`.
onToggleCollapse: () => void = () => {
// Replaced per file.
};
}
+50
View File
@@ -0,0 +1,50 @@
import { z } from "zod";
// User-configurable diff/view preferences, persisted to localStorage and shared
// across all commit views so they survive navigation between commits.
export const diffOptionsSchema = z.object({
showTree: z.boolean(),
diffStyle: z.enum(["unified", "split"]),
wordWrap: z.boolean(),
indicators: z.enum(["classic", "bars", "none"]),
lineDiffType: z.enum(["word-alt", "word", "char", "none"]),
});
export type DiffOptions = z.infer<typeof diffOptionsSchema>;
const KEY = "diffOptions";
const defaults: DiffOptions = {
showTree: true,
diffStyle: "unified",
wordWrap: false,
indicators: "bars",
lineDiffType: "word-alt",
};
function load(): DiffOptions {
const raw = globalThis.localStorage?.getItem(KEY);
if (!raw) {
return { ...defaults };
}
try {
// Merge over defaults so a blob written by an older schema still loads.
const parsed = diffOptionsSchema.partial().safeParse(JSON.parse(raw));
if (parsed.success) {
return { ...defaults, ...parsed.data };
}
} catch {
// Ignore malformed storage.
}
return { ...defaults };
}
export const diffOptions = $state<DiffOptions>(load());
// Persist on any change. A detached root effect keeps this alive for the whole
// session (the store is a module singleton, so it is never torn down).
$effect.root(() => {
$effect(() => {
globalThis.localStorage?.setItem(KEY, JSON.stringify(diffOptions));
});
});
+10
View File
@@ -178,6 +178,16 @@ export async function getDiffText(
});
}
// Commits and patch revisions are immutable, so the same (base, head, unified,
// path) always yields the same patch text — safe to cache. Capped since a
// commit's full patch can be sizeable.
export const cachedGetDiffText = cached(
getDiffText,
(...[rid, base, head, unified, path]) =>
`get_diff_text:${rid}:${base}:${head}:${unified}:${path}`,
{ max: 100 },
);
async function getCommitDiff(
rid: string,
sha: string,
+103
View File
@@ -0,0 +1,103 @@
import type { FileDiffMetadata } from "@pierre/diffs";
import { processPatch } from "@pierre/diffs";
import type { ParseRequest, ParseResponse } from "@app/lib/pierreParse.worker";
import PierreParseWorker from "@app/lib/pierreParse.worker?worker";
// Parse a unified patch into per-file (partial) diffs off the main thread so
// large diffs do not stutter. Falls back to synchronous main-thread parsing if
// the worker cannot be created.
let parseWorker: Worker | undefined | null;
let parseSeq = 0;
const pending = new Map<
number,
{
resolve: (response: ParseResponse) => void;
reject: (error: unknown) => void;
}
>();
function getParseWorker(): Worker | undefined {
if (parseWorker !== undefined) {
return parseWorker ?? undefined;
}
try {
const worker = new PierreParseWorker();
worker.onmessage = (event: MessageEvent<ParseResponse>) => {
const data = event.data;
const entry = pending.get(data.id);
if (!entry) {
return;
}
pending.delete(data.id);
if ("error" in data) {
entry.reject(new Error(data.error));
} else {
entry.resolve(data);
}
};
// If the worker dies, in-flight requests would otherwise hang forever.
// Reject them and drop the worker (`null` = permanent fallback) so this and
// future parses run on the main thread instead.
worker.onerror = event => {
const error = new Error(event.message || "parse worker crashed");
for (const entry of pending.values()) {
entry.reject(error);
}
pending.clear();
parseWorker = null;
};
parseWorker = worker;
} catch (error) {
console.error(
"PierreDiff: parse worker unavailable; parsing on the main thread",
error,
);
parseWorker = null;
}
return parseWorker ?? undefined;
}
function send(
worker: Worker,
patch: string,
cacheKeyPrefix: string | undefined,
): Promise<ParseResponse> {
const id = ++parseSeq;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
worker.postMessage({ id, patch, cacheKeyPrefix } satisfies ParseRequest);
});
}
// Parse a unified patch into per-file (partial) diffs.
//
// `cacheKeyPrefix` must be unique per diff (e.g. the commit or revision id): it
// becomes each file's `cacheKey` (`<prefix>-<index>`), which the worker pool
// uses to cache highlight results. Without it Pierre keys the cache by file
// path alone, so the same path across two commits collides and the shared pool
// serves a stale, differently-sized highlight — the walk then overruns it and
// the file renders blank.
export async function parsePatch(
patch: string,
cacheKeyPrefix?: string,
): Promise<FileDiffMetadata[]> {
const worker = getParseWorker();
if (!worker) {
return processPatch(patch, cacheKeyPrefix).files;
}
try {
const response = await send(worker, patch, cacheKeyPrefix);
return "files" in response ? response.files : [];
} catch (error) {
// Worker crash or a parse error inside it — fall back to the main thread so
// the diff still renders (a genuinely unparseable patch throws again here).
console.error(
"PierreDiff: parse worker failed; parsing on the main thread",
error,
);
return processPatch(patch, cacheKeyPrefix).files;
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { FileDiffMetadata } from "@pierre/diffs";
import { processPatch } from "@pierre/diffs";
// Parsing a unified patch into per-file `FileDiffMetadata` (partial — only the
// patch's own context) is CPU-heavy and touches no DOM, so it runs here off the
// main thread. Results are plain data and post back via structured clone.
// (Full-file hydration for context expansion is handled by Pierre itself via
// the `loadDiffFiles` option, so it does not go through this worker.)
export type ParseRequest = {
id: number;
patch: string;
cacheKeyPrefix?: string;
};
export type ParseResponse =
| { id: number; files: FileDiffMetadata[] }
| { id: number; error: string };
// `self` is typed against the DOM lib here, so narrow to the worker surface we
// actually use to avoid the `Window.postMessage` signature.
const ctx = self as unknown as {
onmessage: ((event: MessageEvent<ParseRequest>) => void) | null;
postMessage: (message: ParseResponse) => void;
};
ctx.onmessage = (event: MessageEvent<ParseRequest>) => {
const request = event.data;
try {
ctx.postMessage({
id: request.id,
files: processPatch(request.patch, request.cacheKeyPrefix).files,
});
} catch (error) {
ctx.postMessage({ id: request.id, error: String(error) });
}
};
+266 -76
View File
@@ -1,9 +1,17 @@
<script lang="ts">
import type {
FileNote,
FileStatus,
} from "@app/components/diffFileHeaderState.svelte";
import type { Diff } from "@bindings/diff/Diff";
import type { FileDiff } from "@bindings/diff/FileDiff";
import type { Commit } from "@bindings/repo/Commit";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import type { Blob } from "@bindings/source/Blob";
import type { GitStatusEntry } from "@pierre/trees";
import { getDiffText } from "@app/lib/invoke";
import { diffOptions } from "@app/lib/diffOptions.svelte";
import { getDiffText, invoke } from "@app/lib/invoke";
import * as router from "@app/lib/router";
import {
absoluteTimestamp,
@@ -14,14 +22,16 @@
pluralize,
} from "@app/lib/utils";
import Changeset from "@app/components/Changeset.svelte";
import Button from "@app/components/Button.svelte";
import DiffActions from "@app/components/DiffActions.svelte";
import DiffOptionsButton from "@app/components/DiffOptionsButton.svelte";
import DiffStatBadge from "@app/components/DiffStatBadge.svelte";
import ExternalLink from "@app/components/ExternalLink.svelte";
import Icon from "@app/components/Icon.svelte";
import Id from "@app/components/Id.svelte";
import JobCob from "@app/components/JobCob.svelte";
import ScrollArea from "@app/components/ScrollArea.svelte";
import PierreDiff from "@app/components/PierreDiff.svelte";
import PierreTree from "@app/components/PierreTree.svelte";
import Topbar from "@app/components/Topbar.svelte";
import Layout from "./Layout.svelte";
@@ -30,9 +40,111 @@
repo: RepoInfo;
commit: Commit;
diff: Diff;
// Unified patch text feeding the rendered diff (Pierre renders from this,
// not from the structured `diff`).
patch: string;
}
const { repo, commit, diff }: Props = $props();
const { repo, commit, diff, patch }: Props = $props();
let diffView = $state<{
scrollToFile: (path: string) => void;
setAllCollapsed: (collapsed: boolean) => void;
}>();
let allCollapsed = $state(false);
// The new-side path Pierre keys each file by — renames and copies use the new
// path, everything else its only path.
function newSidePath(file: FileDiff): string {
return file.status === "moved" || file.status === "copied"
? file.newPath
: file.path;
}
const changedFiles = $derived(
diff.files.map((file): GitStatusEntry => {
const path = newSidePath(file);
switch (file.status) {
case "added":
return { path, status: "added" };
case "deleted":
return { path, status: "deleted" };
case "modified":
return { path, status: "modified" };
case "moved":
return { path, status: "renamed" };
case "copied":
return { path, status: "added" };
}
}),
);
const treePaths = $derived(changedFiles.map(file => file.path));
// `notes` marks files with no renderable text diff: Pierre has no binary
// concept and would render them as an empty body with a dead expand caret, so
// the header shows a note and drops the caret instead.
const fileMeta = $derived.by(() => {
/* eslint-disable svelte/prefer-svelte-reactivity -- rebuilt fresh each derivation, never mutated in place */
const notes = new Map<string, FileNote>();
const statuses = new Map<string, FileStatus>();
/* eslint-enable svelte/prefer-svelte-reactivity */
for (const file of diff.files) {
const path = newSidePath(file);
statuses.set(path, file.status);
if (file.diff.type === "binary") {
notes.set(path, "binary");
} else if (file.diff.type === "empty") {
notes.set(path, "empty");
}
}
return { notes, statuses };
});
const fileNotes = $derived(fileMeta.notes);
const fileStatuses = $derived(fileMeta.statuses);
// Fetch a file's full old/new contents on demand (for diff expansion). The
// old side comes from the commit's first parent; added/deleted files only
// have one side.
async function fetchBlob(path: string, sha: string): Promise<string> {
const blob = await invoke<Blob>("repo_blob", { rid: repo.rid, path, sha });
return blob.binary ? "" : blob.content;
}
async function loadFullFile(
path: string,
): Promise<{ oldContents: string; newContents: string }> {
const parent = commit.parents[0];
const file = diff.files.find(entry => newSidePath(entry) === path);
let oldContents = "";
let newContents = "";
if (!file) {
return { oldContents, newContents };
}
switch (file.status) {
case "added":
newContents = await fetchBlob(file.path, commit.id);
break;
case "deleted":
if (parent) {
oldContents = await fetchBlob(file.path, parent);
}
break;
case "modified":
newContents = await fetchBlob(file.path, commit.id);
if (parent) {
oldContents = await fetchBlob(file.path, parent);
}
break;
case "moved":
case "copied":
newContents = await fetchBlob(file.newPath, commit.id);
if (parent) {
oldContents = await fetchBlob(file.oldPath, parent);
}
break;
}
return { oldContents, newContents };
}
</script>
<style>
@@ -42,11 +154,28 @@
height: 100%;
min-height: 0;
}
.body {
display: flex;
flex: 1;
min-height: 0;
}
.tree-col {
width: 16.5rem;
flex: none;
min-height: 0;
border-right: 1px solid var(--color-border-subtle);
padding-top: 0.5rem;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 0.375rem;
}
.topbar-right {
margin-left: auto;
display: flex;
align-items: center;
}
.breadcrumb-link {
cursor: pointer;
background: none;
@@ -84,6 +213,9 @@
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
/* Reserve the JobCob widget's height (2rem) so the row doesn't shift when
the job status loads in asynchronously. */
min-height: 2rem;
color: var(--color-text-secondary);
font: var(--txt-body-m-regular);
}
@@ -147,9 +279,87 @@
}
</style>
{#snippet commitMeta()}
<section class="meta">
<div class="meta-header">
<div class="meta-title">
<div class="summary txt-selectable">
{#if !commit.summary}
<span class="txt-missing">No commit message</span>
{:else}
{commit.summary}
{/if}
</div>
<div class="summary-meta">
<span class="summary-author">
<img
class="summary-avatar"
alt=""
src={gravatarURL(commit.author.email)} />
<span class="txt-selectable">{commit.author.name}</span>
</span>
committed
<Id id={commit.id} clipboard={commit.id} />
<span
class="summary-timestamp"
title={absoluteTimestamp(commit.committer.time * 1000)}>
{formatTimestamp(commit.committer.time * 1000)}
</span>
<JobCob rid={repo.rid} commit={commit.id} />
</div>
<div class="summary-parents">
<span class="summary-parents-label">
{commit.parents.length === 1 ? "parent" : "parents"}
</span>
{#if commit.parents.length === 0}
<span>Initial commit</span>
{:else}
{#each commit.parents as parent}
<button
class="parent-link txt-id"
onclick={() => {
void router.push({
resource: "repo.commit",
rid: repo.rid,
commit: parent,
});
}}>
{formatOid(parent)}
</button>
{/each}
{/if}
</div>
<pre class="summary-message txt-selectable">{commit.message
.replace(commit.summary, "")
.trim()}</pre>
</div>
<div class="chips">
<div class="files-chip">
{diff.stats.filesChanged}
{pluralize("file", diff.stats.filesChanged)} changed
</div>
<DiffStatBadge stats={diff.stats} />
<DiffActions
text={() => Promise.resolve(patch)}
fileName={`${formatOid(commit.id)}.diff`}
title="Diff actions" />
</div>
</div>
</section>
{/snippet}
<Layout selfScroll>
<div class="page">
<Topbar>
<Button
variant="naked"
onclick={() => (diffOptions.showTree = !diffOptions.showTree)}
title="Toggle file tree">
<Icon
name={diffOptions.showTree
? "sidebar-left-filled"
: "sidebar-left"} />
</Button>
<div class="breadcrumb">
<button
class="breadcrumb-link"
@@ -166,78 +376,58 @@
href={explorerUrl(`${repo.rid}/commits/${commit.id}`)}
title="Open in app.radicle.xyz" />
</div>
<div class="topbar-right">
<Button
variant="naked"
onclick={() => {
allCollapsed = !allCollapsed;
diffView?.setAllCollapsed(allCollapsed);
}}
title={allCollapsed ? "Expand all files" : "Collapse all files"}>
<Icon name={allCollapsed ? "expand-vertical" : "collapse-vertical"} />
</Button>
<Button
variant="naked"
onclick={() =>
(diffOptions.diffStyle =
diffOptions.diffStyle === "unified" ? "split" : "unified")}
title={diffOptions.diffStyle === "unified"
? "Switch to split view"
: "Switch to unified view"}>
<Icon
name={diffOptions.diffStyle === "unified"
? "diff-unified"
: "diff-split"} />
</Button>
<DiffOptionsButton />
</div>
</Topbar>
<ScrollArea style="height: 100%; min-width: 0;">
<Changeset {diff} rid={repo.rid} head={commit.id}>
{#snippet header()}
<section class="meta">
<div class="meta-header">
<div class="meta-title">
<div class="summary txt-selectable">
{#if !commit.summary}
<span class="txt-missing">No commit message</span>
{:else}
{commit.summary}
{/if}
</div>
<div class="summary-meta">
<span class="summary-author">
<img
class="summary-avatar"
alt=""
src={gravatarURL(commit.author.email)} />
<span class="txt-selectable">{commit.author.name}</span>
</span>
committed
<Id id={commit.id} clipboard={commit.id} />
<span
class="summary-timestamp"
title={absoluteTimestamp(commit.committer.time * 1000)}>
{formatTimestamp(commit.committer.time * 1000)}
</span>
<JobCob rid={repo.rid} commit={commit.id} />
</div>
<div class="summary-parents">
<span class="summary-parents-label">
{commit.parents.length === 1 ? "parent" : "parents"}
</span>
{#if commit.parents.length === 0}
<span>Initial commit</span>
{:else}
{#each commit.parents as parent}
<button
class="parent-link txt-id"
onclick={() => {
void router.push({
resource: "repo.commit",
rid: repo.rid,
commit: parent,
});
}}>
{formatOid(parent)}
</button>
{/each}
{/if}
</div>
<pre class="summary-message txt-selectable">{commit.message
.replace(commit.summary, "")
.trim()}</pre>
</div>
<div class="chips">
<div class="files-chip">
{diff.stats.filesChanged}
{pluralize("file", diff.stats.filesChanged)} changed
</div>
<DiffStatBadge stats={diff.stats} />
<DiffActions
text={() => getDiffText(repo.rid, undefined, commit.id, 3)}
fileName={`${formatOid(commit.id)}.diff`}
title="Diff actions" />
</div>
</div>
</section>
{/snippet}
</Changeset>
</ScrollArea>
<div class="body">
{#if diffOptions.showTree}
<div class="tree-col">
<PierreTree
paths={treePaths}
gitStatus={changedFiles}
onSelect={path => diffView?.scrollToFile(path)} />
</div>
{/if}
<!-- The metadata is passed as the header snippet so Pierre renders it
inside its scroll container: it scrolls away, leaving only the
sticky file headers pinned to the top. -->
<PierreDiff
bind:this={diffView}
{patch}
diffStyle={diffOptions.diffStyle}
wordWrap={diffOptions.wordWrap}
diffIndicators={diffOptions.indicators}
lineDiffType={diffOptions.lineDiffType}
{loadFullFile}
{fileNotes}
{fileStatuses}
fileDiffText={path =>
getDiffText(repo.rid, undefined, commit.id, 3, path)}
cacheKeyPrefix={commit.id}
header={commitMeta} />
</div>
</div>
</Layout>
+16 -4
View File
@@ -16,7 +16,11 @@ import type { Tree } from "@bindings/source/Tree";
import type { DraftReview } from "@app/lib/draftReviewStorage";
import { draftReviewStorage } from "@app/lib/draftReviewStorage";
import { cachedGetCommitDiff, invoke } from "@app/lib/invoke";
import {
cachedGetCommitDiff,
cachedGetDiffText,
invoke,
} from "@app/lib/invoke";
import type { SidebarData } from "@app/lib/router/definitions";
import { loadSidebarData } from "@app/lib/router/definitions";
import { didFromPublicKey, unreachable } from "@app/lib/utils";
@@ -88,6 +92,7 @@ export interface LoadedRepoCommitRoute {
repo: RepoInfo;
commit: Commit;
diff: Diff;
patch: string;
sidebarData: SidebarData;
};
}
@@ -345,7 +350,7 @@ export async function loadRepoCommits(
export async function loadRepoCommit(
route: RepoCommitRoute,
): Promise<LoadedRepoCommitRoute> {
const [sidebarData, repo, commit, diff] = await Promise.all([
const [sidebarData, repo, commit, diff, patch] = await Promise.all([
loadSidebarData(),
invoke<RepoInfo>("repo_by_id", {
rid: route.rid,
@@ -354,12 +359,19 @@ export async function loadRepoCommit(
rid: route.rid,
sha: route.commit,
}),
cachedGetCommitDiff(route.rid, route.commit, 3, true),
// @pierre/diffs highlights client-side from the patch text, so we skip the
// server-side tree-sitter pass (highlight=false). The structured diff still
// drives the file tree, per-file binary/empty notes and status labels, the
// stats badge, and the expansion blob lookup.
cachedGetCommitDiff(route.rid, route.commit, 3, false),
// Patch text that feeds the rendered diff; fetched in parallel so it isn't a
// serial round-trip, and cached since a commit's patch is immutable.
cachedGetDiffText(route.rid, undefined, route.commit, 3),
]);
return {
resource: "repo.commit",
params: { sidebarData, repo, commit, diff },
params: { sidebarData, repo, commit, diff, patch },
};
}