Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const clientSettings: ClientSettings = {
confirmThreadDelete: false,
dismissedProviderUpdateNotificationKeys: [],
diffIgnoreWhitespace: true,
diffTheme: "pierre-dark",
environmentIdentificationMode: "artwork",
favorites: [],
glassOpacity: 80,
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@ import { ScrollArea } from "./ui/scroll-area";
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
import { stackedThreadToast, toastManager } from "./ui/toast";
import { useOpenInPreferredEditor } from "../editorPreferences";
import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
import { DIFF_HIGHLIGHTER_THEME_NAMES, type DiffThemeName } from "../lib/diffRendering";
import { fnv1a32 } from "../lib/diffRendering";
import { useDiffThemeName } from "../hooks/useDiffThemeName";
import { LRUCache } from "../lib/lruCache";
import { useTheme } from "../hooks/useTheme";
import { getClientSettings } from "../hooks/useSettings";
Expand Down Expand Up @@ -338,7 +339,7 @@ function getHighlighterPromise(language: string): Promise<DiffsHighlighter> {
if (cached) return cached;

const promise = getSharedHighlighter({
themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")],
themes: [...DIFF_HIGHLIGHTER_THEME_NAMES],
langs: [language as SupportedLanguages],
preferredHighlighter: "shiki-js",
}).catch((err) => {
Expand Down Expand Up @@ -1317,7 +1318,7 @@ function ChatMarkdown({
environmentId,
serverConfig?.availableEditors ?? [],
);
const diffThemeName = resolveDiffThemeName(resolvedTheme);
const diffThemeName = useDiffThemeName();
const markdownFileLinkMetaByHref = useMemo(() => {
const metaByHref = new Map<
string,
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ import {
getDiffCollapseIconClassName,
getDiffLineStat,
getRenderablePatch,
resolveDiffThemeName,
resolveFileDiffPath,
} from "../lib/diffRendering";
import { useDiffThemeName } from "../hooks/useDiffThemeName";
import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse";
import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
import { useProject, useThread } from "../state/entities";
Expand Down Expand Up @@ -197,6 +197,7 @@ export default function DiffPanel({
initialGitScope: initialGitScopeProp,
}: DiffPanelProps) {
const { resolvedTheme } = useTheme();
const diffThemeName = useDiffThemeName();
const settings = useClientSettings();
const [initialGitScope] = useState(initialGitScopeProp);
const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
Expand Down Expand Up @@ -970,7 +971,7 @@ export default function DiffPanel({
diffStyle: diffRenderMode === "split" ? "split" : "unified",
lineDiffType: "none",
overflow: wordWrap ? "wrap" : "scroll",
theme: resolveDiffThemeName(resolvedTheme),
theme: diffThemeName,
themeType: resolvedTheme as DiffThemeType,
unsafeCSS: DIFF_PANEL_UNSAFE_CSS,
stickyHeaders: true,
Expand Down
9 changes: 4 additions & 5 deletions apps/web/src/components/DiffWorkerPoolProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react";
import DiffsWorker from "@pierre/diffs/worker/worker.js?worker";
import * as Schema from "effect/Schema";
import { useEffect, useMemo, type ReactNode } from "react";
import { useTheme } from "../hooks/useTheme";
import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
import { useDiffThemeName } from "../hooks/useDiffThemeName";
import { type DiffThemeName } from "../lib/diffRendering";

export class DiffWorkerError extends Schema.TaggedErrorClass<DiffWorkerError>()("DiffWorkerError", {
operation: Schema.Literals(["create-worker", "get-render-options", "set-render-options"]),
themeName: Schema.Literals(["pierre-light", "pierre-dark"]),
themeName: Schema.String,
cause: Schema.Defect(),
}) {
override get message(): string {
Expand Down Expand Up @@ -46,8 +46,7 @@ function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) {
}

export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) {
const { resolvedTheme } = useTheme();
const diffThemeName = resolveDiffThemeName(resolvedTheme);
const diffThemeName = useDiffThemeName();
const workerPoolSize = useMemo(() => {
const cores =
typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4);
Expand Down
10 changes: 4 additions & 6 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,8 @@ import {
workLogEntryIsToolLike,
} from "../../session-logic";
import { type TurnDiffSummary } from "../../types";
import {
getRenderablePatch,
resolveDiffThemeName,
resolveFileDiffPath,
} from "../../lib/diffRendering";
import { getRenderablePatch, resolveFileDiffPath } from "../../lib/diffRendering";
import { useDiffThemeName } from "../../hooks/useDiffThemeName";
import ChatMarkdown from "../ChatMarkdown";
import {
BotIcon,
Expand Down Expand Up @@ -1647,6 +1644,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {

function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentContext }) {
const ctx = use(TimelineRowCtx);
const diffThemeName = useDiffThemeName();
const fenceLanguage = comment.fenceLanguage ?? "diff";
const renderablePatch = getRenderablePatch(
buildReviewCommentRenderablePatch(comment),
Expand Down Expand Up @@ -1685,7 +1683,7 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte
options={{
collapsed: false,
diffStyle: "unified",
theme: resolveDiffThemeName(ctx.resolvedTheme),
theme: diffThemeName,
}}
/>
))}
Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { OpenInPicker } from "~/components/chat/OpenInPicker";
import { useClientSettings } from "~/hooks/useSettings";
import { useTheme } from "~/hooks/useTheme";
import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage";
import { resolveDiffThemeName } from "~/lib/diffRendering";
import { useDiffThemeName } from "~/hooks/useDiffThemeName";
import { cn } from "~/lib/utils";
import { isPreviewSupportedInRuntime } from "~/previewStateStore";
import { resolvePathLinkTarget } from "~/terminal-links";
Expand Down Expand Up @@ -341,6 +341,7 @@ function EditableFileSurface({
onPostRender,
onPendingChange,
}: EditableFileSurfaceProps) {
const diffThemeName = useDiffThemeName();
const addReviewComment = useComposerDraftStore((store) => store.addReviewComment);
const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment);
const [lineAnnotations, setLineAnnotations] = useState<FileCommentLineAnnotation[]>([]);
Expand Down Expand Up @@ -559,7 +560,7 @@ function EditableFileSurface({
onLineSelectionChange: setSelectedRange,
onLineSelectionEnd: handleLineSelectionEnd,
overflow: wordWrap ? "wrap" : "scroll",
theme: resolveDiffThemeName(resolvedTheme),
theme: diffThemeName,
themeType: resolvedTheme,
unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS,
onPostRender: handlePostRender,
Expand Down Expand Up @@ -660,6 +661,7 @@ export default function FilePreviewPanel({
onPendingChange,
}: FilePreviewPanelProps) {
const { resolvedTheme } = useTheme();
const diffThemeName = useDiffThemeName();
const wordWrap = useClientSettings((settings) => settings.wordWrap);
const primaryEnvironmentId = usePrimaryEnvironmentId();
const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId);
Expand Down Expand Up @@ -902,7 +904,7 @@ export default function FilePreviewPanel({
options={{
disableFileHeader: true,
overflow: wordWrap ? "wrap" : "scroll",
theme: resolveDiffThemeName(resolvedTheme),
theme: diffThemeName,
themeType: resolvedTheme,
unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS,
onPostRender: onFilePostRender,
Expand Down
43 changes: 43 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import {
DEFAULT_DIFF_THEME,
DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE,
DEFAULT_UNIFIED_SETTINGS,
type EnvironmentIdentificationMode,
Expand Down Expand Up @@ -63,6 +64,7 @@ import {
} from "../SidebarStageBackdrop";
import { isElectron } from "../../env";
import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing";
import { DIFF_THEME_OPTIONS } from "../../lib/diffRendering";
import { useTheme } from "../../hooks/useTheme";
import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
import { useThreadActions } from "../../hooks/useThreadActions";
Expand Down Expand Up @@ -588,6 +590,7 @@ export function useSettingsRestore(onRestored?: () => void) {
...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
? ["Diff whitespace changes"]
: []),
...(settings.diffTheme !== DEFAULT_UNIFIED_SETTINGS.diffTheme ? ["Diff theme"] : []),
...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar
? ["Auto-open task panel"]
: []),
Expand Down Expand Up @@ -627,6 +630,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.defaultThreadEnvMode,
settings.newWorktreesStartFromOrigin,
settings.diffIgnoreWhitespace,
settings.diffTheme,
settings.environmentIdentificationMode,
settings.glassOpacity,
settings.enableAssistantStreaming,
Expand Down Expand Up @@ -654,6 +658,7 @@ export function useSettingsRestore(onRestored?: () => void) {
timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
diffTheme: DEFAULT_UNIFIED_SETTINGS.diffTheme,
environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,
glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity,
sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
Expand Down Expand Up @@ -997,6 +1002,44 @@ export function AppearanceSettingsPanel() {
}
/>

<SettingsRow
title="Diff theme"
description="Syntax colors for code and diffs. Applies in dark mode; light mode uses the default."
resetAction={
settings.diffTheme !== DEFAULT_DIFF_THEME ? (
<SettingResetButton
label="diff theme"
onClick={() => updateSettings({ diffTheme: DEFAULT_DIFF_THEME })}
/>
) : null
}
control={
<Select
value={settings.diffTheme}
onValueChange={(value) => {
const match = DIFF_THEME_OPTIONS.find((option) => option.value === value);
if (match) {
updateSettings({ diffTheme: match.value });
}
}}
>
<SelectTrigger className="w-full sm:w-52" aria-label="Diff theme">
<SelectValue>
{DIFF_THEME_OPTIONS.find((option) => option.value === settings.diffTheme)
?.label ?? "Pierre Dark"}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
{DIFF_THEME_OPTIONS.map((option) => (
<SelectItem hideIndicator key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectPopup>
</Select>
}
/>

<SettingsRow
title="Glass opacity"
description="Control how transparent glass surfaces are. Higher values make menus, dialogs, and the composer more solid."
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/hooks/useDiffThemeName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
import { usePrimarySettings } from "./useSettings";
import { useTheme } from "./useTheme";

/**
* Resolves the diff syntax theme from the current light/dark mode and the
* user's selected diff theme. Re-renders when either the app theme or the
* `diffTheme` setting changes.
*/
export function useDiffThemeName(): DiffThemeName {
const { resolvedTheme } = useTheme();
const diffTheme = usePrimarySettings((settings) => settings.diffTheme);
return resolveDiffThemeName(resolvedTheme, diffTheme);
}
52 changes: 45 additions & 7 deletions apps/web/src/lib/diffRendering.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,53 @@
import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
import type { FileDiffMetadata } from "@pierre/diffs/types";
import { DEFAULT_DIFF_THEME, type DiffTheme } from "@t3tools/contracts/settings";

export const DIFF_THEME_NAMES = {
light: "pierre-light",
dark: "pierre-dark",
} as const;
/**
* Light mode always uses Pierre's light theme; the user-selectable diff theme
* (`DiffTheme`) applies in dark mode.
*/
export const DIFF_LIGHT_THEME_NAME = "pierre-light";

export type DiffThemeName = (typeof DIFF_THEME_NAMES)[keyof typeof DIFF_THEME_NAMES];
export type DiffThemeName = DiffTheme | typeof DIFF_LIGHT_THEME_NAME;

export function resolveDiffThemeName(theme: "light" | "dark"): DiffThemeName {
return theme === "dark" ? DIFF_THEME_NAMES.dark : DIFF_THEME_NAMES.light;
export interface DiffThemeOption {
readonly value: DiffTheme;
readonly label: string;
}

/** Curated dark diff themes shown in Settings → Appearance. */
export const DIFF_THEME_OPTIONS: readonly DiffThemeOption[] = [
{ value: "pierre-dark", label: "Pierre Dark" },
{ value: "tokyo-night", label: "Tokyo Night" },
{ value: "one-dark-pro", label: "One Dark Pro" },
{ value: "ayu-dark", label: "Ayu Dark" },
{ value: "dracula", label: "Dracula" },
{ value: "catppuccin-mocha", label: "Catppuccin Mocha" },
{ value: "github-dark-default", label: "GitHub Dark" },
{ value: "github-dark-dimmed", label: "GitHub Dark Dimmed" },
{ value: "material-theme-palenight", label: "Material Palenight" },
{ value: "night-owl", label: "Night Owl" },
{ value: "nord", label: "Nord" },
{ value: "monokai", label: "Monokai" },
{ value: "poimandres", label: "Poimandres" },
{ value: "vesper", label: "Vesper" },
{ value: "synthwave-84", label: "SynthWave '84" },
];

/**
* Every theme name the highlighter may need registered up front, so switching
* the setting at runtime never references a theme that was never attached.
*/
export const DIFF_HIGHLIGHTER_THEME_NAMES: readonly DiffThemeName[] = [
...DIFF_THEME_OPTIONS.map((option) => option.value),
DIFF_LIGHT_THEME_NAME,
];

export function resolveDiffThemeName(
resolvedTheme: "light" | "dark",
darkTheme: DiffTheme = DEFAULT_DIFF_THEME,
): DiffThemeName {
return resolvedTheme === "dark" ? darkTheme : DIFF_LIGHT_THEME_NAME;
}

const FNV_OFFSET_BASIS_32 = 0x811c9dc5;
Expand Down
25 changes: 25 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,29 @@ export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill",
export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type;
export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork";

// Syntax highlighting theme for code diffs. These names map to themes bundled
// by `@pierre/diffs` (Pierre's own themes plus the Shiki theme collection).
// The selected theme applies in dark mode; light mode always uses pierre-light.
export const DiffTheme = Schema.Literals([
"pierre-dark",
"tokyo-night",
"one-dark-pro",
"ayu-dark",
"dracula",
"catppuccin-mocha",
"github-dark-default",
"github-dark-dimmed",
"material-theme-palenight",
"night-owl",
"nord",
"monokai",
"poimandres",
"vesper",
"synthwave-84",
]);
export type DiffTheme = typeof DiffTheme.Type;
export const DEFAULT_DIFF_THEME: DiffTheme = "pierre-dark";

export const ClientSettingsSchema = Schema.Struct({
autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
Expand All @@ -70,6 +93,7 @@ export const ClientSettingsSchema = Schema.Struct({
Schema.withDecodingDefault(Effect.succeed([])),
),
diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
diffTheme: DiffTheme.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_DIFF_THEME))),
environmentIdentificationMode: EnvironmentIdentificationMode.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE)),
),
Expand Down Expand Up @@ -678,6 +702,7 @@ export const ClientSettingsPatch = Schema.Struct({
confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),
diffTheme: Schema.optionalKey(DiffTheme),
environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode),
glassOpacity: Schema.optionalKey(GlassOpacity),
favorites: Schema.optionalKey(
Expand Down