mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 07:33:18 +00:00
* Add appearance settings for theme, fonts, and syntax highlighting New Appearance section in Settings to configure the app theme, UI and mono font family + size, and a syntax-highlighting theme chosen independently of light/dark. Font family fields default to empty and show the active system stack as the placeholder; a live split-diff preview reflects the choices as they change. The theme picker moves here from General. Preferences are client-only (AsyncStorage) and applied at runtime by patching every registered Unistyles theme via UnistylesRuntime.updateTheme, so existing StyleSheet consumers repaint with no per-component preference reads and no useUnistyles. No daemon/protocol change. * Expand syntax themes and move the preview under the chooser Replace the small fixed syntax-theme set with eight popular themes — GitHub, Catppuccin, Dracula, Tokyo Night, One, Nord, Gruvbox, Solarized — built from a compact per-theme role palette. The app theme and syntax theme stay separate; the only coupling is the light/dark axis: a theme with both variants uses its light palette on a light app and its dark palette on a dark app, while the code frame (gutter, line numbers, background) keeps following the app theme. Default is now GitHub (GitHub keeps its existing hand-tuned maps for an exact match). Move the live preview directly under the syntax-theme chooser, drop the "preview.ts" filename header, and use a clearer code snippet for it. * Update settings e2e for the moved theme picker and Appearance section The theme picker moved from General to the new Appearance section, so the General-content assertion no longer finds "Theme" — point it at "Default send" (which stays in General) and add Appearance to the e2e section map plus an expectAppearanceContent check, and exercise the Appearance section in the sidebar-navigation test. * Render the appearance preview as a unified diff Switch the live preview from side-by-side columns to a single unified diff: unchanged context lines plus "-"/"+" rows for the change, matching how diffs read elsewhere in the app. Same snippet, mono font, syntax colors, and live code-font draft behavior. * Fix code font size change shrinking all UI text applyAppearance scaled the live (already-patched) theme ramp instead of the authored FONT_SIZE ramp, so it was not idempotent: every appearance change re-scaled the current sizes, compounding the whole-UI font scale. With any non-default UI size, changing the code size — or any setting — shrank (or grew) all app text, and it persisted into the theme. Build the ramp from the canonical FONT_SIZE ramp each apply (code size still set absolutely), so applies are idempotent and a code-size change never touches the UI ramp. * Apply the interface font across the app on web react-native-web stamps a default font onto every text element, so the interface font only reached the few components that set a font explicitly. Inject one rule that points all text at a CSS variable (updated live as the setting changes), with high specificity (1,2,0) so it deterministically beats RN-web's base font and Unistyles' generated classes — no stylesheet-order fragility. Code, diff, and monospace surfaces carry a `data-pmono` marker (and their subtrees are excluded), so they keep their code font. Web-only: React Native has no global font cascade, so native still applies the UI font only where components opt in. Inline `code` within chat prose is a known minor gap (its render paths aren't tagged yet). * Keep preview code in the code font * Apply code font settings to terminals * Fix settings host picker import after rebase * Format terminal webview bundle
280 lines
7.3 KiB
TypeScript
280 lines
7.3 KiB
TypeScript
import React from "react";
|
|
import { View, Text, ScrollView as RNScrollView } from "react-native";
|
|
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
|
import { StyleSheet } from "react-native-unistyles";
|
|
import type { DiffLine } from "@/utils/tool-call-parsers";
|
|
import { diffLinePrefix } from "@/utils/diff-highlight";
|
|
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
|
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
|
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
|
import { getCodeInsets } from "./code-insets";
|
|
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
|
import { isWeb } from "@/constants/platform";
|
|
|
|
const ScrollView = isWeb ? RNScrollView : GHScrollView;
|
|
|
|
interface DiffViewerProps {
|
|
diffLines: DiffLine[];
|
|
maxHeight?: number;
|
|
emptyLabel?: string;
|
|
fillAvailableHeight?: boolean;
|
|
}
|
|
|
|
function DiffLineRow({ line }: { line: DiffLine }) {
|
|
const lineContainerStyle = React.useMemo(
|
|
() => [
|
|
styles.line,
|
|
line.type === "header" && styles.headerLine,
|
|
line.type === "add" && styles.addLine,
|
|
line.type === "remove" && styles.removeLine,
|
|
line.type === "context" && styles.contextLine,
|
|
],
|
|
[line.type],
|
|
);
|
|
const plainLineTextStyle = React.useMemo(
|
|
() => [
|
|
styles.lineText,
|
|
line.type === "header" && styles.headerText,
|
|
line.type === "add" && styles.addText,
|
|
line.type === "remove" && styles.removeText,
|
|
line.type === "context" && styles.contextText,
|
|
],
|
|
[line.type],
|
|
);
|
|
|
|
const prefixStyle = React.useMemo(
|
|
() => [
|
|
line.type === "add" && styles.addText,
|
|
line.type === "remove" && styles.removeText,
|
|
line.type === "context" && styles.contextText,
|
|
],
|
|
[line.type],
|
|
);
|
|
|
|
if (line.tokens) {
|
|
return (
|
|
<View style={lineContainerStyle}>
|
|
<Text style={styles.lineText}>
|
|
<Text style={prefixStyle}>{diffLinePrefix(line)}</Text>
|
|
<DiffTokens tokens={line.tokens} />
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={lineContainerStyle}>
|
|
{line.segments ? (
|
|
<Text style={styles.lineText}>
|
|
<Text style={line.type === "add" ? styles.addText : styles.removeText}>
|
|
{line.content[0]}
|
|
</Text>
|
|
{line.segments.map((segment) => (
|
|
<DiffSegment
|
|
key={`${segment.changed ? "c" : "u"}:${segment.text}`}
|
|
segment={segment}
|
|
lineType={line.type}
|
|
/>
|
|
))}
|
|
</Text>
|
|
) : (
|
|
<Text style={plainLineTextStyle}>{line.content}</Text>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function DiffTokens({ tokens }: { tokens: NonNullable<DiffLine["tokens"]> }) {
|
|
const keyed = React.useMemo(
|
|
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
|
|
[tokens],
|
|
);
|
|
return (
|
|
<>
|
|
{keyed.map(({ key, token }) => (
|
|
<Text key={key} style={token.style ? syntaxTokenStyleFor(token.style) : undefined}>
|
|
{token.text}
|
|
</Text>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function DiffSegment({
|
|
segment,
|
|
lineType,
|
|
}: {
|
|
segment: NonNullable<DiffLine["segments"]>[number];
|
|
lineType: DiffLine["type"];
|
|
}) {
|
|
const segmentStyle = React.useMemo(
|
|
() => [
|
|
lineType === "add" ? styles.addText : styles.removeText,
|
|
segment.changed && (lineType === "add" ? styles.addHighlight : styles.removeHighlight),
|
|
],
|
|
[lineType, segment.changed],
|
|
);
|
|
return <Text style={segmentStyle}>{segment.text}</Text>;
|
|
}
|
|
|
|
export function DiffViewer({
|
|
diffLines,
|
|
maxHeight,
|
|
emptyLabel = "No changes to display",
|
|
fillAvailableHeight = false,
|
|
}: DiffViewerProps) {
|
|
const [scrollViewWidth, setScrollViewWidth] = React.useState(0);
|
|
const webScrollbarStyle = useWebScrollbarStyle();
|
|
const handleInnerLayout = React.useCallback(
|
|
(e: { nativeEvent: { layout: { width: number } } }) =>
|
|
setScrollViewWidth(e.nativeEvent.layout.width),
|
|
[],
|
|
);
|
|
|
|
const outerScrollStyle = React.useMemo(
|
|
() => [
|
|
styles.verticalScroll,
|
|
maxHeight !== undefined && inlineUnistylesStyle({ maxHeight }),
|
|
fillAvailableHeight && styles.fillHeight,
|
|
webScrollbarStyle,
|
|
],
|
|
[maxHeight, fillAvailableHeight, webScrollbarStyle],
|
|
);
|
|
const linesContainerStyle = React.useMemo(
|
|
() => [
|
|
styles.linesContainer,
|
|
scrollViewWidth > 0 && inlineUnistylesStyle({ minWidth: scrollViewWidth }),
|
|
],
|
|
[scrollViewWidth],
|
|
);
|
|
const keyedDiffLines = React.useMemo(
|
|
() => diffLines.map((line, index) => ({ key: `${index}-${line.type}-${line.content}`, line })),
|
|
[diffLines],
|
|
);
|
|
const webVerticalContentStyle = React.useMemo(
|
|
() => [styles.verticalContent, fillAvailableHeight && styles.fillHeight],
|
|
[fillAvailableHeight],
|
|
);
|
|
|
|
if (!diffLines.length) {
|
|
return (
|
|
<View style={styles.emptyState}>
|
|
<Text style={styles.emptyText}>{emptyLabel}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const lines = (
|
|
<View style={linesContainerStyle} dataSet={CODE_SURFACE_DATASET}>
|
|
{keyedDiffLines.map(({ key, line }) => (
|
|
<DiffLineRow key={key} line={line} />
|
|
))}
|
|
</View>
|
|
);
|
|
|
|
const horizontalScroll = (
|
|
<ScrollView
|
|
horizontal
|
|
nestedScrollEnabled
|
|
showsHorizontalScrollIndicator
|
|
style={webScrollbarStyle}
|
|
contentContainerStyle={styles.horizontalContent}
|
|
onLayout={handleInnerLayout}
|
|
>
|
|
{lines}
|
|
</ScrollView>
|
|
);
|
|
|
|
const content = (
|
|
<ScrollView
|
|
style={outerScrollStyle}
|
|
contentContainerStyle={webVerticalContentStyle}
|
|
nestedScrollEnabled
|
|
showsVerticalScrollIndicator
|
|
>
|
|
{horizontalScroll}
|
|
</ScrollView>
|
|
);
|
|
|
|
return content;
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => {
|
|
const insets = getCodeInsets(theme);
|
|
|
|
return {
|
|
verticalScroll: {},
|
|
fillHeight: {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
},
|
|
verticalContent: {
|
|
flexGrow: 1,
|
|
paddingBottom: insets.extraBottom,
|
|
},
|
|
horizontalContent: {
|
|
flexDirection: "column" as const,
|
|
paddingRight: insets.extraRight,
|
|
},
|
|
linesContainer: {
|
|
alignSelf: "flex-start",
|
|
padding: insets.padding,
|
|
},
|
|
line: {
|
|
minWidth: "100%",
|
|
paddingHorizontal: 0,
|
|
paddingVertical: theme.spacing[1],
|
|
},
|
|
lineText: {
|
|
fontFamily: theme.fontFamily.mono,
|
|
fontSize: theme.fontSize.code,
|
|
color: theme.colors.foreground,
|
|
...(isWeb
|
|
? {
|
|
whiteSpace: "pre",
|
|
overflowWrap: "normal",
|
|
}
|
|
: null),
|
|
},
|
|
headerLine: {
|
|
backgroundColor: theme.colors.surface1,
|
|
},
|
|
headerText: {
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
addLine: {
|
|
backgroundColor: "rgba(46, 160, 67, 0.15)",
|
|
},
|
|
addText: {
|
|
color: theme.colors.foreground,
|
|
},
|
|
removeLine: {
|
|
backgroundColor: "rgba(248, 81, 73, 0.1)",
|
|
},
|
|
removeText: {
|
|
color: theme.colors.foreground,
|
|
},
|
|
addHighlight: {
|
|
backgroundColor: "rgba(46, 160, 67, 0.4)",
|
|
},
|
|
removeHighlight: {
|
|
backgroundColor: "rgba(248, 81, 73, 0.35)",
|
|
},
|
|
contextLine: {
|
|
backgroundColor: theme.colors.surface1,
|
|
},
|
|
contextText: {
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
emptyState: {
|
|
padding: theme.spacing[4],
|
|
alignItems: "center" as const,
|
|
justifyContent: "center" as const,
|
|
},
|
|
emptyText: {
|
|
fontSize: theme.fontSize.sm,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
};
|
|
});
|