mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-15 11:20:36 +08:00
Integrate pierre's diffs into commit view
This commit is contained in:
Generated
+312
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
@@ -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,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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user