From 57dedf1419489912df05b08ec5348d5d204500dc Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Mon, 17 Aug 2026 15:57:49 -0700 Subject: [PATCH 01/13] feat(web): customize diff colors and markers --- .../src/components/chat/MessagesTimeline.tsx | 4 +- .../diffs/StyledDiffCodeView.test.tsx | 54 ++++- .../components/diffs/StyledDiffCodeView.tsx | 67 ++++++- .../components/settings/SettingsPanels.tsx | 184 +++++++++++++++++- .../settings/settingsSearch.test.ts | 8 + .../src/components/settings/settingsSearch.ts | 10 + apps/web/src/lib/diffRendering.ts | 30 +-- docs/README.md | 1 + docs/user/appearance.md | 15 ++ packages/contracts/src/settings.test.ts | 26 +++ packages/contracts/src/settings.ts | 16 ++ 11 files changed, 392 insertions(+), 23 deletions(-) create mode 100644 docs/user/appearance.md diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c90aa771f8d1..c4eacb471aed 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -30,7 +30,6 @@ import { type ReactNode, } from "react"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; -import { FileDiff } from "@pierre/diffs/react"; import { deriveTimelineEntries, workEntryIndicatesToolFailure, @@ -45,6 +44,7 @@ import { resolveFileDiffPath, } from "../../lib/diffRendering"; import ChatMarkdown from "../ChatMarkdown"; +import { StyledFileDiff } from "../diffs/StyledDiffCodeView"; import { BotIcon, CheckIcon, @@ -1869,7 +1869,7 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte )} {renderablePatch?.kind === "files" && renderablePatch.files.map((fileDiff) => ( - ({ codeViewClassName: null as string | null, codeViewOptions: null as Record | null, + fileDiffClassName: null as string | null, + fileDiffOptions: null as Record | null, })); vi.mock("@pierre/diffs/react", () => ({ @@ -12,14 +14,25 @@ vi.mock("@pierre/diffs/react", () => ({ testState.codeViewOptions = props.options; return null; }, + FileDiff: (props: { className: string; options: Record }) => { + testState.fileDiffClassName = props.className; + testState.fileDiffOptions = props.options; + return null; + }, })); -import { StyledDiffCodeView } from "./StyledDiffCodeView"; +import { + getDiffColorSchemeClassName, + StyledDiffCodeView, + StyledFileDiff, +} from "./StyledDiffCodeView"; describe("StyledDiffCodeView", () => { beforeEach(() => { testState.codeViewClassName = null; testState.codeViewOptions = null; + testState.fileDiffClassName = null; + testState.fileDiffOptions = null; }); it("always pairs the shared diff styling with its virtualized geometry", () => { @@ -36,12 +49,15 @@ describe("StyledDiffCodeView", () => { ); expect(testState.codeViewClassName).toBe( - "diff-render-surface [--code-background:var(--background)] outline-none min-h-0", + "diff-render-surface [--code-background:var(--background)] outline-none " + + "[--t3-diff-addition-color:var(--success)] " + + "[--t3-diff-deletion-color:var(--destructive)] min-h-0", ); expect(testState.codeViewOptions).toMatchObject({ theme: "pierre-dark", stickyHeaders: true, loadDiffFiles, + diffIndicators: "bars", itemMetrics: { diffHeaderHeight: 32, hunkSeparatorHeight: 24, @@ -57,4 +73,38 @@ describe("StyledDiffCodeView", () => { expect.stringContaining(")[data-expand-index]\n [data-unmodified-lines]"), ); }); + + it("maps the alternate palette to blue additions and orange deletions", () => { + expect(getDiffColorSchemeClassName("orange-blue")).toBe( + "[--t3-diff-addition-color:var(--info)] [--t3-diff-deletion-color:var(--warning)]", + ); + }); + + it("applies the same appearance defaults to compact file diffs", () => { + renderToStaticMarkup( + , + ); + + expect(testState.fileDiffClassName).toContain("[--t3-diff-addition-color:var(--success)]"); + expect(testState.fileDiffOptions).toMatchObject({ + diffStyle: "unified", + theme: "pierre-dark", + diffIndicators: "bars", + }); + expect(testState.fileDiffOptions?.unsafeCSS).toEqual( + expect.stringContaining("--diffs-addition-color-override"), + ); + }); }); diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.tsx index 14939de09820..42d330f9eccf 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.tsx @@ -4,11 +4,15 @@ import { type CodeViewHandle, type CodeViewProps, type ControlledCodeViewProps, + FileDiff, + type FileDiffProps, type UncontrolledCodeViewProps, } from "@pierre/diffs/react"; /* oxlint-enable eslint/no-restricted-imports */ import type { Ref } from "react"; +import type { DiffColorScheme } from "@t3tools/contracts/settings"; +import { useClientSettings } from "~/hooks/useSettings"; import { DIFF_SURFACE_THEME_UNSAFE_CSS } from "~/lib/diffRendering"; const DIFF_VIEW_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS} @@ -258,9 +262,29 @@ const DIFF_VIEW_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS} } `; +const DIFF_COLOR_SCHEME_CLASSES: Record = { + "red-green": + "[--t3-diff-addition-color:var(--success)] [--t3-diff-deletion-color:var(--destructive)]", + "orange-blue": "[--t3-diff-addition-color:var(--info)] [--t3-diff-deletion-color:var(--warning)]", +}; + +export function getDiffColorSchemeClassName(scheme: DiffColorScheme): string { + return DIFF_COLOR_SCHEME_CLASSES[scheme]; +} + +function getDiffSurfaceClassName(colorScheme: DiffColorScheme, className?: string): string { + return [ + "diff-render-surface [--code-background:var(--background)] outline-none", + getDiffColorSchemeClassName(colorScheme), + className, + ] + .filter(Boolean) + .join(" "); +} + export type StyledDiffCodeViewOptions = Omit< NonNullable["options"]>, - "unsafeCSS" | "itemMetrics" | "layout" + "unsafeCSS" | "itemMetrics" | "layout" | "diffIndicators" >; type StyledDiffCodeViewProps = ( @@ -284,19 +308,19 @@ export function StyledDiffCodeView({ unsafeCSSExtra, ...props }: StyledDiffCodeViewProps) { + const diffColorScheme = useClientSettings((settings) => settings.diffColorScheme); + const diffIndicatorStyle = useClientSettings((settings) => settings.diffIndicatorStyle); + return ( {...props} {...(viewerRef ? { ref: viewerRef } : {})} // The custom element itself is focusable for keyboard scrolling. Its native outline sits // outside the panel clipping boundary; actual controls inside retain their own indicators. - className={ - className - ? `diff-render-surface [--code-background:var(--background)] outline-none ${className}` - : "diff-render-surface [--code-background:var(--background)] outline-none" - } + className={getDiffSurfaceClassName(diffColorScheme, className)} options={{ ...options, + diffIndicators: diffIndicatorStyle, unsafeCSS: unsafeCSSExtra ? `${DIFF_VIEW_UNSAFE_CSS}\n${unsafeCSSExtra}` : DIFF_VIEW_UNSAFE_CSS, @@ -320,3 +344,34 @@ export function StyledDiffCodeView({ /> ); } + +export type StyledFileDiffOptions = Omit< + NonNullable["options"]>, + "unsafeCSS" | "diffIndicators" +>; + +type StyledFileDiffProps = Omit, "options"> & { + readonly options?: StyledFileDiffOptions; +}; + +/** The non-virtualized counterpart used for compact inline review-comment diffs. */ +export function StyledFileDiff({ + options, + className, + ...props +}: StyledFileDiffProps) { + const diffColorScheme = useClientSettings((settings) => settings.diffColorScheme); + const diffIndicatorStyle = useClientSettings((settings) => settings.diffIndicatorStyle); + + return ( + + {...props} + className={getDiffSurfaceClassName(diffColorScheme, className)} + options={{ + ...options, + diffIndicators: diffIndicatorStyle, + unsafeCSS: DIFF_SURFACE_THEME_UNSAFE_CSS, + }} + /> + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e3f00c1e0e23..8ff0ebfd954a 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -19,6 +19,8 @@ import { import { DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE, DEFAULT_UNIFIED_SETTINGS, + type DiffColorScheme, + type DiffIndicatorStyle, type EnvironmentIdentificationMode, MAX_CODE_FONT_SIZE, MAX_GLASS_OPACITY, @@ -75,7 +77,7 @@ import { sortProviderInstanceEntries, } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; -import { isMacPlatform } from "../../lib/utils"; +import { cn, isMacPlatform } from "../../lib/utils"; import { primaryServerObservabilityAtom, primaryServerProvidersAtom } from "../../state/server"; import { useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; @@ -498,6 +500,12 @@ export function useSettingsRestore(onRestored?: () => void) { : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), ...getChangedTypographySettingLabels(settings), + ...(settings.diffColorScheme !== DEFAULT_UNIFIED_SETTINGS.diffColorScheme + ? ["Diff colors"] + : []), + ...(settings.diffIndicatorStyle !== DEFAULT_UNIFIED_SETTINGS.diffIndicatorStyle + ? ["Diff markers"] + : []), ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), @@ -549,6 +557,8 @@ export function useSettingsRestore(onRestored?: () => void) { settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, + settings.diffColorScheme, + settings.diffIndicatorStyle, settings.diffIgnoreWhitespace, settings.environmentIdentificationMode, settings.fontFamilyCode, @@ -639,6 +649,8 @@ export function useSettingsRestore(onRestored?: () => void) { updateSettings({ timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, + diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme, + diffIndicatorStyle: DEFAULT_UNIFIED_SETTINGS.diffIndicatorStyle, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, @@ -961,6 +973,110 @@ function BackgroundActivityAdvancedDialog({ ); } +function DiffPreviewLine({ + color, + label, + marker, + showBar, +}: { + readonly color: string; + readonly label: string; + readonly marker?: "+" | "-"; + readonly showBar?: boolean; +}) { + return ( +