diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c7fc71..27f91fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,9 +13,6 @@ importers: bits-ui: specifier: ^2.20.2 version: 2.20.2(@internationalized/date@3.12.1)(@sveltejs/kit@3.1.0(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.72.0))(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)))(svelte@5.57.2(@typescript-eslint/types@8.72.0))(typescript@6.0.3)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)))(svelte@5.57.2(@typescript-eslint/types@8.72.0)) - chroma-js: - specifier: ^3.2.0 - version: 3.2.0 diff: specifier: ^9.0.0 version: 9.0.0 @@ -65,9 +62,6 @@ importers: '@tailwindcss/vite': specifier: ^4.3.3 version: 4.3.3(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)) - '@types/chroma-js': - specifier: ^3.1.3 - version: 3.1.3 '@types/luxon': specifier: ^3.7.6 version: 3.7.6 @@ -981,9 +975,6 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/chroma-js@3.1.3': - resolution: {integrity: sha512-r4kI3HTbLdayBkY1f2Xv7/1TJaHTS//8f6Etv036NQMIj2jVPngUV11NczKiLFKyy01o6hGYVDypP7tJVoFPew==} - '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -1159,9 +1150,6 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - chroma-js@3.2.0: - resolution: {integrity: sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw==} - chrome-types@0.1.459: resolution: {integrity: sha512-ev1r7o/gAQ4wU97Uz9yWAhAeXQrti1BbuPsMcleabjpgCigG9mF1rnDW7uVypDy2SyOIfqHZHp+Us4AZolDe6g==} @@ -2919,8 +2907,6 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - '@types/chroma-js@3.1.3': {} - '@types/deep-eql@4.0.2': {} '@types/esrecurse@4.3.1': {} @@ -3115,8 +3101,6 @@ snapshots: dependencies: readdirp: 4.1.2 - chroma-js@3.2.0: {} - chrome-types@0.1.459: {} clsx@2.1.1: {} diff --git a/web/package.json b/web/package.json index e5a3d66..9c176db 100644 --- a/web/package.json +++ b/web/package.json @@ -29,7 +29,6 @@ "@sveltejs/kit": "^3.1.0", "@sveltejs/vite-plugin-svelte": "^7.3.1", "@tailwindcss/vite": "^4.3.3", - "@types/chroma-js": "^3.1.3", "@types/luxon": "^3.7.6", "@types/node": "^26.6.5", "@types/wicg-file-system-access": "^2023.10.7", @@ -52,7 +51,6 @@ }, "dependencies": { "bits-ui": "^2.20.2", - "chroma-js": "^3.2.0", "diff": "^9.0.0", "luxon": "^3.7.2", "paneforge": "^1.0.2", diff --git a/web/src/lib/components/diff/TextDiff.svelte b/web/src/lib/components/diff/TextDiff.svelte index deecaa7..3898ec8 100644 --- a/web/src/lib/components/diff/TextDiff.svelte +++ b/web/src/lib/components/diff/TextDiff.svelte @@ -196,7 +196,7 @@ {@const lineType = patchLineTypeProps[line.type]} {@const lineTypeSelectable = line.type !== PatchLineType.HEADER && line.type !== PatchLineType.SPACER}
.diff-content { + /* From the syntax highlighting theme when available (see getBaseColors) */ --editor-fg: var(--editor-fg-themed, var(--color-em-high)); - /* TODO: better fallback, remove dark mode override */ - --select-bg: var(--select-bg-themed, var(--color-blue-300)); --editor-bg: var(--editor-bg-themed, var(--color-neutral)); + --select-bg: var(--select-bg-themed, color-mix(in oklab, var(--color-blue-500) 35%, transparent)); - --inserted-text-bg: var(--inserted-text-bg-themed, initial); - --removed-text-bg: var(--removed-text-bg-themed, initial); - --inserted-line-bg: var(--inserted-line-bg-themed, initial); - --removed-line-bg: var(--removed-line-bg-themed, initial); - --inner-inserted-line-bg: var(--inner-inserted-line-bg-themed, initial); - --inner-removed-line-bg: var(--inner-removed-line-bg-themed, initial); - --inner-inserted-line-fg: var(--inner-inserted-line-fg-themed, initial); - --inner-removed-line-fg: var(--inner-removed-line-fg-themed, initial); + /* Our own diff colors; themes define theirs too inconsistently to rely on. Tints are translucent so they sit on + any theme background. */ + --added: light-dark(var(--color-green-600), var(--color-green-500)); + --deleted: light-dark(var(--color-red-600), var(--color-red-500)); - --color-editor-bg-600: oklch(0.6 var(--editor-background-c) var(--editor-background-h)); - --color-editor-fg-200: oklch( - calc(max(0.9, var(--editor-foreground-l))) var(--editor-foreground-c) var(--editor-foreground-h) + /* Changed lines get a light tint of the accent and a bar at the left edge of the row, and changed words within + them a stronger tint on top */ + --inserted-line-bg: light-dark( + color-mix(in oklab, var(--added) 8%, transparent), + color-mix(in oklab, var(--added) 10%, transparent) ); - --hunk-header-bg: var(--hunk-header-bg-themed, var(--color-editor-bg-600, var(--color-gray-200))); + --removed-line-bg: light-dark( + color-mix(in oklab, var(--deleted) 8%, transparent), + color-mix(in oklab, var(--deleted) 10%, transparent) + ); + --inserted-text-bg: light-dark( + color-mix(in oklab, var(--added) 18%, transparent), + color-mix(in oklab, var(--added) 22%, transparent) + ); + --removed-text-bg: light-dark( + color-mix(in oklab, var(--deleted) 18%, transparent), + color-mix(in oklab, var(--deleted) 22%, transparent) + ); + + /* Text of changed lines within a diffed patch file; mixed towards the theme foreground to stay readable */ + --inner-inserted-fg: color-mix(in oklab, var(--added) 65%, var(--editor-fg)); + --inner-removed-fg: color-mix(in oklab, var(--deleted) 65%, var(--editor-fg)); + + --hunk-header-bg: color-mix(in oklab, var(--editor-fg) 8%, var(--editor-bg)); --hunk-header-fg: var(--hunk-header-fg-themed, var(--editor-fg)); display: grid; @@ -309,6 +324,9 @@ vertical-align: top; text-wrap: nowrap; } + .old-line-number { + box-shadow: inset 3px 0 0 0 var(--line-bar, transparent); + } .prefix { position: relative; diff --git a/web/src/lib/components/diff/text-diff.svelte.ts b/web/src/lib/components/diff/text-diff.svelte.ts index c085798..14b3954 100644 --- a/web/src/lib/components/diff/text-diff.svelte.ts +++ b/web/src/lib/components/diff/text-diff.svelte.ts @@ -13,8 +13,6 @@ import { } from "shiki"; import { guessLanguageFromExtension, type MutableValue } from "#lib/util.ts"; import type { IRawThemeSetting } from "shiki/textmate"; -import chroma from "chroma-js"; -import { getEffectiveGlobalTheme } from "#lib/theme.svelte.ts"; import { onDestroy } from "svelte"; import { DEFAULT_THEME_LIGHT } from "#lib/global-options.svelte.ts"; import type { ReadableBoxedValues, WritableBoxedValues } from "svelte-toolbelt"; @@ -157,12 +155,12 @@ export const patchLineTypeProps: Record = { }, [PatchLineType.ADD]: { classes: "bg-[var(--inserted-line-bg)]", - lineNoClasses: "bg-[var(--inserted-line-bg)]", + lineNoClasses: "bg-[var(--inserted-line-bg)] [--line-bar:var(--added)]", prefix: "+", }, [PatchLineType.REMOVE]: { classes: "bg-[var(--removed-line-bg)]", - lineNoClasses: "bg-[var(--removed-line-bg)]", + lineNoClasses: "bg-[var(--removed-line-bg)] [--line-bar:var(--deleted)]", prefix: "-", }, [PatchLineType.CONTEXT]: { @@ -183,18 +181,10 @@ export interface InnerPatchLineTypeProps { export const innerPatchLineTypeProps: Record = { [InnerPatchLineType.ADD]: { - // Make sure tailwind emits these props - // "bg-green-100 bg-green-300 bg-green-400 bg-green-800" - style: ` - --fg-override: var(--inner-inserted-line-fg); - background-color: var(--inner-inserted-line-bg);`, + style: "--inner-fg: var(--inner-inserted-fg);", }, [InnerPatchLineType.REMOVE]: { - // Make sure tailwind emits these props - // "bg-red-100 bg-red-300 bg-red-400 bg-red-800" - style: ` - --fg-override: var(--inner-removed-line-fg); - background-color: var(--inner-removed-line-bg);`, + style: "--inner-fg: var(--inner-removed-fg);", }, [InnerPatchLineType.NONE]: { style: "", @@ -354,23 +344,16 @@ class LineProcessor { const tokensResult = await this.codeToTokensResult(text, state); if (tokensResult) { return tokensResult.tokens[0].map((token) => { - return { text: token.content, style: this.getSegmentStyle(state, token.color) }; + return { text: token.content, style: this.getSegmentStyle(token.color) }; }); } else { - return [{ text, style: this.getSegmentStyle(state) }]; + return [{ text, style: this.getSegmentStyle() }]; } } - private getSegmentStyle(state: LineProcessorState, color?: string | undefined): string { + private getSegmentStyle(color?: string | undefined): string { const segmentFg = color ? `--segment-fg: ${color};` : ""; - switch (state) { - case LineProcessorState.ADD: - return `color: var(--fg-override, var(--inserted-line-fg-themed, var(--segment-fg, var(--editor-fg)))); ${segmentFg}`; - case LineProcessorState.REMOVE: - return `color: var(--fg-override, var(--removed-line-fg-themed, var(--segment-fg, var(--editor-fg)))); ${segmentFg}`; - case LineProcessorState.CONTEXT: - return `color: var(--fg-override, var(--segment-fg, var(--editor-fg)));${segmentFg};`; - } + return `color: var(--inner-fg, var(--segment-fg, var(--editor-fg)));${segmentFg}`; } private async codeToTokensResult(text: string, state: LineProcessorState): Promise { @@ -491,13 +474,7 @@ class LineProcessor { for (const change of diffResult) { const text = change.value.join(""); if (change.added) { - const segments = this.makeSegments( - addShikiResult, - addPos, - text, - `bg-[var(--inserted-text-bg)]`, - LineProcessorState.ADD, - ); + const segments = this.makeSegments(addShikiResult, addPos, text, `bg-[var(--inserted-text-bg)]`); segments[0].classes = segments[0].classes + " rounded-l-sm"; segments[segments.length - 1].classes = segments[segments.length - 1].classes + " rounded-r-sm"; @@ -505,13 +482,7 @@ class LineProcessor { addLine.push(...segments); addPos += text.length; } else if (change.removed) { - const segments = this.makeSegments( - removeShikiResult, - removePos, - text, - `bg-[var(--removed-text-bg)]`, - LineProcessorState.REMOVE, - ); + const segments = this.makeSegments(removeShikiResult, removePos, text, `bg-[var(--removed-text-bg)]`); segments[0].classes = segments[0].classes + " rounded-l-sm"; segments[segments.length - 1].classes = segments[segments.length - 1].classes + " rounded-r-sm"; @@ -519,10 +490,10 @@ class LineProcessor { removeLine.push(...segments); removePos += text.length; } else { - addLine.push(...this.makeSegments(addShikiResult, addPos, text, "", LineProcessorState.ADD)); + addLine.push(...this.makeSegments(addShikiResult, addPos, text, "")); addPos += text.length; - removeLine.push(...this.makeSegments(removeShikiResult, removePos, text, "", LineProcessorState.REMOVE)); + removeLine.push(...this.makeSegments(removeShikiResult, removePos, text, "")); removePos += text.length; } } @@ -563,13 +534,12 @@ class LineProcessor { startPosition: number, text: string, baseClasses: string, - lineState: LineProcessorState, ): LineSegment[] { if (shikiResult) { - return this.makeSegmentsShiki(shikiResult, startPosition, text, baseClasses, lineState); + return this.makeSegmentsShiki(shikiResult, startPosition, text, baseClasses); } - return [{ text, classes: baseClasses, style: this.getSegmentStyle(lineState) }]; + return [{ text, classes: baseClasses, style: this.getSegmentStyle() }]; } // Use the Shiki color data to split the text into colored segments @@ -578,7 +548,6 @@ class LineProcessor { startPosition: number, text: string, baseClasses: string, - lineState: LineProcessorState, ): LineSegment[] { const segments: LineSegment[] = []; let remainingText = text; @@ -610,7 +579,7 @@ class LineProcessor { segments.push({ text: overlapText, classes: baseClasses, - style: this.getSegmentStyle(lineState, token.color), + style: this.getSegmentStyle(token.color), }); remainingText = trailingText; @@ -841,202 +810,36 @@ function hasScope(token: IRawThemeSetting, scope: string) { return token.scope && (token.scope === scope || token.scope.includes(scope)); } -type ThemeColorQuery = { - // color name in theme.colors - color?: string; - // scope of token to use background color from - bgTokenScope?: string; - // scope of token to use foreground color from - fgTokenScope?: string; - // optional modifier for the color value - modifier?: (value: string | undefined) => string | undefined; -}; - -function extractColor(theme: ThemeRegistration, opts: ThemeColorQuery): string | undefined { - const colors = theme.colors || {}; - const tokenColors = theme.tokenColors || []; - const modifier = opts.modifier || ((v) => v); - const value = opts.color ? colors[opts.color] : null; - if (value) { - return modifier(value); - } - if (opts.bgTokenScope) { - const token = tokenColors.find((t) => hasScope(t, opts.bgTokenScope!) && t.settings.background); - if (token) { - return modifier(token.settings.background); - } - } - if (opts.fgTokenScope) { - const token = tokenColors.find((t) => hasScope(t, opts.fgTokenScope!) && t.settings.foreground); - if (token) { - return modifier(token.settings.foreground); - } - } - return undefined; -} - -function makeLCHVars(prefix: string, color: string | undefined, into: Map) { - if (!color) { - return; - } - const oklch = chroma(color).oklch(); - into.set(`${prefix}-l`, `${oklch[0]}`); - into.set(`${prefix}-c`, `${oklch[1]}`); - if (isNaN(oklch[2])) { - into.set(`${prefix}-h`, "0"); - } else { - into.set(`${prefix}-h`, `${oklch[2]}`); - } -} - -function moreChroma(color: string | undefined, value: number = 1) { - if (!color) return undefined; - return chroma(color).saturate(value).css("oklch"); -} - -function darken(color: string | undefined, value: number = 1) { - if (!color) return undefined; - return chroma(color).darken(value).css("oklch"); -} - -function makeTransparent(hex: string | undefined) { - if (!hex) return undefined; - const rgb = chroma(hex).rgb(); - return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.5)`; +function scopeForeground(theme: ThemeRegistration, scope: string): string | undefined { + return theme.tokenColors?.find((t) => hasScope(t, scope) && t.settings.foreground)?.settings.foreground; } +// Picks the few base colors we take from the theme. Diff colors are our own rather than the theme's, as themes define +// them too inconsistently; everything else is derived in TextDiff.svelte, which also provides fallbacks. export async function getBaseColors(themeKey: BundledTheme | undefined, syntaxHighlighting: boolean): Promise { - const theme = await getTheme(themeKey); - if (!syntaxHighlighting || !theme) { - let styles = ""; - if (getEffectiveGlobalTheme() === "dark") { - // Make sure tailwind emits these props - // "text-green-600 text-red-600 text-green-700 text-red-700 text-green-800 text-red-800 text-blue-800" - styles += ` - --hunk-header-bg-themed: var(--color-gray-800); - --select-bg-themed: var(--color-blue-800); - - --inserted-text-bg-themed: var(--color-green-700); - --removed-text-bg-themed: var(--color-red-700); - --inserted-line-bg-themed: var(--color-green-800); - --removed-line-bg-themed: var(--color-red-800); - --inner-inserted-line-bg-themed: var(--color-green-600); - --inner-removed-line-bg-themed: var(--color-red-600); - --inner-inserted-line-fg-themed: var(--color-green-300); - --inner-removed-line-fg-themed: var(--color-red-300); - `; - } else { - styles += ` - --inserted-text-bg-themed: var(--color-green-400); - --removed-text-bg-themed: var(--color-red-400); - --inserted-line-bg-themed: var(--color-green-100); - --removed-line-bg-themed: var(--color-red-100); - --inner-inserted-line-bg-themed: var(--color-green-300); - --inner-removed-line-bg-themed: var(--color-red-300); - --inner-inserted-line-fg-themed: var(--color-green-800); - --inner-removed-line-fg-themed: var(--color-red-800); - `; - } - return styles; - } - const tokenColors = theme.default.tokenColors || []; - - const style: Map = new Map(); - - // Find the foreground/default text color for the theme - const foundFg = extractColor(theme.default, { color: "editor.foreground" }); - if (foundFg) { - style.set("--editor-fg-themed", foundFg); - makeLCHVars("--editor-foreground", foundFg, style); - } else { - let globalScope = tokenColors.find((t) => t.scope === undefined); - if (!globalScope) { - // Tokenize something to force Shiki to run it's fix for 'broken' themes - await codeToTokens("hi", { theme: theme.default, lang: "text" }); - globalScope = tokenColors.find((t) => t.scope === undefined); - } - const globalFg = globalScope?.settings.foreground; - if (globalFg) { - style.set("--editor-fg-themed", globalFg); - makeLCHVars("--editor-foreground", globalFg, style); - } else { - console.error("No foreground color found in theme"); - } - } - - // These colors are mostly universal - style.set("--editor-bg-themed", extractColor(theme.default, { color: "editor.background" })); - makeLCHVars("--editor-background", style.get("--editor-bg-themed"), style); - style.set("--select-bg-themed", extractColor(theme.default, { color: "editor.selectionBackground" })); - - // Don't use these - just add chroma to the inner diff highlight below for consistency - // These are also applied to the line by VSCode...? - // style.set("--inserted-text-bg-themed", extractColor(theme.default, { color: "diffEditor.insertedTextBackground" })); - // style.set("--removed-text-bg-themed", extractColor(theme.default, { color: "diffEditor.removedTextBackground" })); - - // 1) Try diffEditor.insertedLineBackground for inserted line highlight color - // 2) Try editorGutter.addedBackground for inserted line highlight color - // 3) Try markup.inserted scope bg for inserted line highlight color - // 4) Try markup.inserted scope fg for inserted line text color - let insertLineBg = extractColor(theme.default, { color: "diffEditor.insertedLineBackground" }); - if (!insertLineBg) { - insertLineBg = extractColor(theme.default, { color: "editorGutter.addedBackground", modifier: makeTransparent }); - } - if (!insertLineBg) { - insertLineBg = extractColor(theme.default, { bgTokenScope: "markup.inserted", modifier: makeTransparent }); - } - if (insertLineBg) { - style.set("--inserted-line-bg-themed", insertLineBg); - style.set("--inner-inserted-line-bg-themed", moreChroma(insertLineBg, 0.5)); - style.set("--inserted-text-bg-themed", darken(moreChroma(insertLineBg, 1.25), 0.25)); - - // Only use the fg color if we have a bg color -- otherwise it will conflict with the top level diff add/remove lines - // Increase chroma to match our adjustments to bg color above - style.set( - "--inner-inserted-line-fg-themed", - moreChroma(extractColor(theme.default, { fgTokenScope: "markup.inserted" })), - ); - } else { - style.set("--inserted-line-fg-themed", extractColor(theme.default, { fgTokenScope: "markup.inserted" })); + const theme = syntaxHighlighting ? (await getTheme(themeKey))?.default : undefined; + if (!theme) { + return ""; } - // 1) Try diffEditor.removedLineBackground for removed line highlight color - // 2) Try editorGutter.deletedBackground for removed line highlight color - // 3) Try markup.deleted scope bg for removed line highlight color - // 4) Try markup.deleted scope fg for removed line text color - let removeLineBg = extractColor(theme.default, { color: "diffEditor.removedLineBackground" }); - if (!removeLineBg) { - removeLineBg = extractColor(theme.default, { color: "editorGutter.deletedBackground", modifier: makeTransparent }); - } - if (!removeLineBg) { - removeLineBg = extractColor(theme.default, { bgTokenScope: "markup.deleted", modifier: makeTransparent }); + let editorFg = theme.colors?.["editor.foreground"]; + if (!editorFg) { + // Tokenize something to force Shiki to run its fix for 'broken' themes, which adds a global scope + await codeToTokens("hi", { theme, lang: "text" }); + editorFg = theme.tokenColors?.find((t) => t.scope === undefined)?.settings.foreground; } - if (removeLineBg) { - style.set("--removed-line-bg-themed", removeLineBg); - style.set("--inner-removed-line-bg-themed", moreChroma(removeLineBg, 0.5)); - style.set("--removed-text-bg-themed", darken(moreChroma(removeLineBg, 1.25), 0.25)); - - // Only use the fg color if we have a bg color -- otherwise it will conflict with the top level diff add/remove lines - // Increase chroma to match our adjustments to bg color above - style.set( - "--inner-removed-line-fg-themed", - moreChroma(extractColor(theme.default, { fgTokenScope: "markup.deleted" })), - ); - } else { - style.set("--removed-line-fg-themed", extractColor(theme.default, { fgTokenScope: "markup.deleted" })); - } - - // One or both of these is often missing, see TextDiff.svelte