Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -4,6 +4,8 @@ import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
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";
|
||||
@@ -40,6 +42,26 @@ function DiffLineRow({ line }: { line: DiffLine }) {
|
||||
[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 ? (
|
||||
@@ -62,6 +84,22 @@ function DiffLineRow({ line }: { line: DiffLine }) {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -4,10 +4,11 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { Check, Copy } from "lucide-react-native";
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import type { HighlightToken } from "@getpaseo/highlight";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { highlightToKeyedLines, type KeyedLine } from "@/utils/highlight-cache";
|
||||
|
||||
interface HighlightedCodeBlockProps {
|
||||
code: string;
|
||||
@@ -44,34 +45,6 @@ function stripTerminalFenceNewline(code: string): string {
|
||||
return code.endsWith("\n") ? code.slice(0, -1) : code;
|
||||
}
|
||||
|
||||
// Cross-instance cache for tokenized code blocks. Tokenization is
|
||||
// theme-independent (colors are applied at render time), so the key is just
|
||||
// (language, code). Bounded by entry count — 200 is generous for a chat
|
||||
// transcript, code blocks rarely repeat beyond a handful of distinct shapes.
|
||||
class LRUCache<K, V> {
|
||||
private readonly map = new Map<K, V>();
|
||||
constructor(private readonly max: number) {}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
const value = this.map.get(key);
|
||||
if (value === undefined) return undefined;
|
||||
this.map.delete(key);
|
||||
this.map.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
set(key: K, value: V): void {
|
||||
if (this.map.has(key)) this.map.delete(key);
|
||||
else if (this.map.size >= this.max) {
|
||||
const oldest = this.map.keys().next().value;
|
||||
if (oldest !== undefined) this.map.delete(oldest);
|
||||
}
|
||||
this.map.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const tokenizationCache = new LRUCache<string, KeyedLine[]>(200);
|
||||
|
||||
export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
|
||||
code,
|
||||
language,
|
||||
@@ -87,22 +60,10 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
|
||||
);
|
||||
const renderedCode = useMemo(() => stripTerminalFenceNewline(code), [code]);
|
||||
|
||||
const keyedLines = useMemo<KeyedLine[] | null>(() => {
|
||||
const ext = fenceLanguageToExtension(language);
|
||||
if (!ext) return null;
|
||||
const cacheKey = `${ext}:${renderedCode}`;
|
||||
const cached = tokenizationCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
let tokenizedLines: HighlightToken[][];
|
||||
try {
|
||||
tokenizedLines = highlightCode(renderedCode, `x.${ext}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const result = tokenizedLines.map(toKeyedLine);
|
||||
tokenizationCache.set(cacheKey, result);
|
||||
return result;
|
||||
}, [renderedCode, language]);
|
||||
const keyedLines = useMemo<KeyedLine[] | null>(
|
||||
() => highlightToKeyedLines(renderedCode, fenceLanguageToExtension(language)),
|
||||
[renderedCode, language],
|
||||
);
|
||||
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
@@ -127,26 +88,6 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
|
||||
);
|
||||
});
|
||||
|
||||
interface KeyedToken {
|
||||
key: string;
|
||||
token: HighlightToken;
|
||||
}
|
||||
|
||||
interface KeyedLine {
|
||||
key: string;
|
||||
tokens: KeyedToken[];
|
||||
}
|
||||
|
||||
function toKeyedLine(tokens: HighlightToken[], lineIndex: number): KeyedLine {
|
||||
return {
|
||||
key: `line-${lineIndex}`,
|
||||
tokens: tokens.map((token, tokenIndex) => ({
|
||||
key: `${lineIndex}-${tokenIndex}`,
|
||||
token,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function renderCodeSegments(keyedLines: KeyedLine[]): React.ReactNode[] {
|
||||
const segments: React.ReactNode[] = [];
|
||||
for (let lineIndex = 0; lineIndex < keyedLines.length; lineIndex += 1) {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import type { KeyedLine, KeyedToken } from "@/utils/highlight-cache";
|
||||
|
||||
// Line height kept in sync with the plain `scrollText` style in
|
||||
// tool-call-details so a highlighted block lines up with an unhighlighted
|
||||
// fallback and blank lines keep their height.
|
||||
const CODE_LINE_HEIGHT = 18;
|
||||
const ZERO_WIDTH = "";
|
||||
|
||||
interface HighlightedLinesProps {
|
||||
lines: KeyedLine[];
|
||||
// 1-based line number of the first line; when set, a line-number gutter is
|
||||
// rendered (used by Read, which carries a server-normalized offset).
|
||||
startLine?: number;
|
||||
}
|
||||
|
||||
function ContentLine({ line }: { line: KeyedLine }) {
|
||||
return (
|
||||
<Text selectable style={styles.lineText}>
|
||||
{line.tokens.length === 0
|
||||
? ZERO_WIDTH
|
||||
: line.tokens.map(({ key, token }: KeyedToken) => (
|
||||
<Text key={key} style={token.style ? syntaxTokenStyleFor(token.style) : undefined}>
|
||||
{token.text}
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const GutteredLine = React.memo(function GutteredLine({
|
||||
line,
|
||||
lineNumber,
|
||||
digits,
|
||||
}: {
|
||||
line: KeyedLine;
|
||||
lineNumber: number;
|
||||
digits: number;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.gutterText}>{String(lineNumber).padStart(digits)} </Text>
|
||||
<ContentLine line={line} />
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
// Renders pre-tokenized lines (from the shared highlight cache), optionally with
|
||||
// a line-number gutter. Callers decide whether to highlight at all, so the
|
||||
// expensive size-cap / unsupported-language fallback stays a single plain Text.
|
||||
export function HighlightedLines({ lines, startLine }: HighlightedLinesProps) {
|
||||
if (startLine === undefined) {
|
||||
return (
|
||||
<View>
|
||||
{lines.map((line) => (
|
||||
<ContentLine key={line.key} line={line} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const lastLineNumber = startLine + lines.length - 1;
|
||||
const digits = Math.max(2, String(lastLineNumber).length);
|
||||
return (
|
||||
<View>
|
||||
{lines.map((line, index) => (
|
||||
<GutteredLine key={line.key} line={line} lineNumber={startLine + index} digits={digits} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
minHeight: CODE_LINE_HEIGHT,
|
||||
},
|
||||
gutterText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: CODE_LINE_HEIGHT,
|
||||
color: theme.colors.foregroundMuted,
|
||||
opacity: 0.6,
|
||||
userSelect: "none",
|
||||
flexShrink: 0,
|
||||
},
|
||||
lineText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: CODE_LINE_HEIGHT,
|
||||
...(isWeb
|
||||
? {
|
||||
whiteSpace: "pre",
|
||||
overflowWrap: "normal",
|
||||
}
|
||||
: null),
|
||||
},
|
||||
}));
|
||||
@@ -11,9 +11,12 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
|
||||
import { highlightDiffLines } from "@/utils/diff-highlight";
|
||||
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { extensionFromPath, highlightToKeyedLines } from "@/utils/highlight-cache";
|
||||
import { HighlightedLines } from "./highlighted-content";
|
||||
import { DiffViewer } from "./diff-viewer";
|
||||
import { getCodeInsets } from "./code-insets";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -143,10 +146,10 @@ function useDetailStyles(
|
||||
function useDiffLines(detail: ToolCallDetail | undefined): DiffLine[] | undefined {
|
||||
return useMemo(() => {
|
||||
if (!detail || detail.type !== "edit") return undefined;
|
||||
if (detail.unifiedDiff) {
|
||||
return parseUnifiedDiff(detail.unifiedDiff);
|
||||
}
|
||||
return buildLineDiff(detail.oldString ?? "", detail.newString ?? "");
|
||||
const diffLines = detail.unifiedDiff
|
||||
? parseUnifiedDiff(detail.unifiedDiff)
|
||||
: buildLineDiff(detail.oldString ?? "", detail.newString ?? "");
|
||||
return highlightDiffLines(diffLines, detail.filePath);
|
||||
}, [detail]);
|
||||
}
|
||||
|
||||
@@ -431,9 +434,22 @@ interface ScrollableContentProps {
|
||||
content: string;
|
||||
ds: DetailStyles;
|
||||
wrapInSectionFill?: boolean;
|
||||
// Drives syntax highlighting (extension only) and, with startLine, a gutter.
|
||||
filePath?: string | null;
|
||||
startLine?: number;
|
||||
}
|
||||
|
||||
function ScrollableTextSection({ content, ds, wrapInSectionFill = true }: ScrollableContentProps) {
|
||||
function ScrollableTextSection({
|
||||
content,
|
||||
ds,
|
||||
wrapInSectionFill = true,
|
||||
filePath,
|
||||
startLine,
|
||||
}: ScrollableContentProps) {
|
||||
const keyedLines = useMemo(
|
||||
() => (filePath ? highlightToKeyedLines(content, extensionFromPath(filePath)) : null),
|
||||
[content, filePath],
|
||||
);
|
||||
const body = (
|
||||
<ScrollView
|
||||
style={ds.scrollAreaFillStyle}
|
||||
@@ -447,9 +463,13 @@ function ScrollableTextSection({ content, ds, wrapInSectionFill = true }: Scroll
|
||||
showsHorizontalScrollIndicator={true}
|
||||
style={ds.webScrollbarStyle}
|
||||
>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{content}
|
||||
</Text>
|
||||
{keyedLines ? (
|
||||
<HighlightedLines lines={keyedLines} startLine={startLine} />
|
||||
) : (
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{content}
|
||||
</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
);
|
||||
@@ -669,14 +689,27 @@ function buildDetailSections(
|
||||
return [
|
||||
<View key="write" style={ds.sectionFillStyle}>
|
||||
{detail.content ? (
|
||||
<ScrollableTextSection content={detail.content} ds={ds} wrapInSectionFill={false} />
|
||||
<ScrollableTextSection
|
||||
content={detail.content}
|
||||
ds={ds}
|
||||
wrapInSectionFill={false}
|
||||
filePath={detail.filePath}
|
||||
/>
|
||||
) : null}
|
||||
</View>,
|
||||
];
|
||||
}
|
||||
if (detail.type === "read") {
|
||||
if (!detail.content) return [];
|
||||
return [<ScrollableTextSection key="read" content={detail.content} ds={ds} />];
|
||||
return [
|
||||
<ScrollableTextSection
|
||||
key="read"
|
||||
content={detail.content}
|
||||
ds={ds}
|
||||
filePath={detail.filePath}
|
||||
startLine={detail.offset ?? 1}
|
||||
/>,
|
||||
];
|
||||
}
|
||||
if (detail.type === "search") {
|
||||
return buildSearchSections(detail, ds);
|
||||
|
||||
@@ -170,6 +170,46 @@ describe("checkout-git-actions-store", () => {
|
||||
).toBe("idle");
|
||||
});
|
||||
|
||||
it("refreshes git and GitHub state and reports success", async () => {
|
||||
const client = {
|
||||
checkoutRefresh: vi.fn(async () => ({ success: true, error: null })),
|
||||
};
|
||||
useSessionStore.setState((state) => ({
|
||||
...state,
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[serverId]: { client } as unknown as (typeof state.sessions)[string],
|
||||
},
|
||||
}));
|
||||
|
||||
await useCheckoutGitActionsStore.getState().refresh({ serverId, cwd });
|
||||
|
||||
expect(client.checkoutRefresh).toHaveBeenCalledWith(cwd);
|
||||
expect(
|
||||
useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "refresh" }),
|
||||
).toBe("success");
|
||||
});
|
||||
|
||||
it("surfaces a refresh error and returns to idle", async () => {
|
||||
const client = {
|
||||
checkoutRefresh: vi.fn(async () => ({ error: { message: "not a git repository" } })),
|
||||
};
|
||||
useSessionStore.setState((state) => ({
|
||||
...state,
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[serverId]: { client } as unknown as (typeof state.sessions)[string],
|
||||
},
|
||||
}));
|
||||
|
||||
await expect(useCheckoutGitActionsStore.getState().refresh({ serverId, cwd })).rejects.toThrow(
|
||||
"not a git repository",
|
||||
);
|
||||
expect(
|
||||
useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "refresh" }),
|
||||
).toBe("idle");
|
||||
});
|
||||
|
||||
it("enables PR auto-merge when the daemon advertises auto-merge actions", async () => {
|
||||
const client = {
|
||||
checkoutGithubSetAutoMerge: vi.fn(async () => ({
|
||||
|
||||
@@ -28,6 +28,7 @@ export type CheckoutGitAsyncActionId =
|
||||
| "pull"
|
||||
| "push"
|
||||
| "pull-and-push"
|
||||
| "refresh"
|
||||
| "create-pr"
|
||||
| "merge-pr-squash"
|
||||
| "merge-pr-merge"
|
||||
@@ -236,6 +237,7 @@ interface CheckoutGitActionsStoreState {
|
||||
pull: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||
push: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||
pullAndPush: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||
refresh: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||
createPr: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||
mergePr: (params: {
|
||||
serverId: string;
|
||||
@@ -360,6 +362,21 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
||||
});
|
||||
},
|
||||
|
||||
refresh: async ({ serverId, cwd }) => {
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
actionId: "refresh",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const payload = await client.checkoutRefresh(cwd);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
pullAndPush: async ({ serverId, cwd }) => {
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
type ViewStyle,
|
||||
type TextStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
AlignJustify,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
ListChevronsUpDown,
|
||||
Pilcrow,
|
||||
RefreshCcw,
|
||||
RotateCw,
|
||||
Upload,
|
||||
WrapText,
|
||||
} from "lucide-react-native";
|
||||
@@ -78,6 +80,10 @@ import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { GitActionsSplitButton } from "@/git/actions-split-button";
|
||||
import { useGitActions } from "@/git/use-actions";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||
@@ -1222,6 +1228,44 @@ function DiffFilesToolbar({
|
||||
);
|
||||
}
|
||||
|
||||
interface DiffRefreshButtonProps {
|
||||
isRefreshing: boolean;
|
||||
toggleStyle: PressableStyleFn;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const refreshIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function DiffRefreshButton({ isRefreshing, toggleStyle, onPress }: DiffRefreshButtonProps) {
|
||||
return (
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isRefreshing ? "Refreshing" : "Refresh git and GitHub state"}
|
||||
testID="changes-refresh"
|
||||
style={toggleStyle}
|
||||
onPress={onPress}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<View style={styles.refreshIcon}>
|
||||
{isRefreshing ? (
|
||||
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={refreshIconColorMapping} />
|
||||
) : (
|
||||
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={refreshIconColorMapping} />
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<Text style={styles.tooltipText}>Refresh</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
type DiffFlatItem =
|
||||
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
|
||||
| { type: "body"; file: ParsedDiffFile; fileIndex: number };
|
||||
@@ -1559,6 +1603,29 @@ export function GitDiffPane({
|
||||
[controlSurfaceColor],
|
||||
);
|
||||
|
||||
const refreshToggleStyle = useMemo(
|
||||
() => buildExpandAllButtonStyle(controlSurfaceColor),
|
||||
[controlSurfaceColor],
|
||||
);
|
||||
|
||||
const toast = useToast();
|
||||
const refreshSupported = useSessionStore(
|
||||
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
|
||||
);
|
||||
const runRefresh = useCheckoutGitActionsStore((s) => s.refresh);
|
||||
const isRefreshing =
|
||||
useCheckoutGitActionsStore((s) => s.getStatus({ serverId, cwd, actionId: "refresh" })) ===
|
||||
"pending";
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (isRefreshing) {
|
||||
return;
|
||||
}
|
||||
void runRefresh({ serverId, cwd }).catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to refresh git state.");
|
||||
});
|
||||
}, [cwd, isRefreshing, runRefresh, serverId, toast]);
|
||||
|
||||
const {
|
||||
status,
|
||||
isLoading: isStatusLoading,
|
||||
@@ -2088,6 +2155,13 @@ export function GitDiffPane({
|
||||
onToggleExpandAll={handleToggleExpandAll}
|
||||
/>
|
||||
) : null}
|
||||
{refreshSupported ? (
|
||||
<DiffRefreshButton
|
||||
isRefreshing={isRefreshing}
|
||||
toggleStyle={refreshToggleStyle}
|
||||
onPress={handleRefresh}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -2216,6 +2290,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
toggleButtonSelected: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
refreshIcon: {
|
||||
width: ICON_SIZE.md,
|
||||
height: ICON_SIZE.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
expandAllButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -45,7 +45,14 @@ import {
|
||||
type Settings as EffectiveSettings,
|
||||
} from "@/hooks/use-settings";
|
||||
import { THEME_SWATCHES } from "@/styles/theme";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
isHostRuntimeConnected,
|
||||
useHostRuntimeIsConnected,
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
@@ -442,29 +449,114 @@ function DiagnosticsSection({
|
||||
}
|
||||
|
||||
interface AboutSectionProps {
|
||||
appVersion: string | null;
|
||||
appVersionText: string;
|
||||
isDesktopApp: boolean;
|
||||
}
|
||||
|
||||
function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
|
||||
function AboutSection({ appVersion, appVersionText, isDesktopApp }: AboutSectionProps) {
|
||||
return (
|
||||
<SettingsSection title="About">
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Version</Text>
|
||||
<>
|
||||
<SettingsSection title="About">
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>App version</Text>
|
||||
<Text style={settingsStyles.rowHint}>This device</Text>
|
||||
</View>
|
||||
<Text style={styles.aboutValue}>{appVersionText}</Text>
|
||||
</View>
|
||||
<Text style={styles.aboutValue}>{appVersionText}</Text>
|
||||
{isDesktopApp ? <DesktopAppUpdateRow /> : null}
|
||||
</View>
|
||||
{isDesktopApp ? <DesktopAppUpdateRow /> : null}
|
||||
</View>
|
||||
</SettingsSection>
|
||||
<ConnectedHostsSection clientVersion={appVersion} />
|
||||
<View style={styles.aboutCommunity}>
|
||||
<CommunityLinks />
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeVersion(version: string | null | undefined): string | null {
|
||||
const trimmed = version?.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.replace(/^v/i, "");
|
||||
}
|
||||
|
||||
function ConnectedHostsSection({ clientVersion }: { clientVersion: string | null }) {
|
||||
const hosts = useHosts();
|
||||
if (hosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<SettingsSection title="Connected hosts">
|
||||
<View style={settingsStyles.card}>
|
||||
{hosts.map((host, index) => (
|
||||
<HostVersionRow
|
||||
key={host.serverId}
|
||||
host={host}
|
||||
showBorder={index > 0}
|
||||
clientVersion={clientVersion}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function HostVersionRow({
|
||||
host,
|
||||
showBorder,
|
||||
clientVersion,
|
||||
}: {
|
||||
host: HostProfile;
|
||||
showBorder: boolean;
|
||||
clientVersion: string | null;
|
||||
}) {
|
||||
const isConnected = useHostRuntimeIsConnected(host.serverId);
|
||||
const daemonVersion = useSessionStore(
|
||||
(state) => state.sessions[host.serverId]?.serverInfo?.version ?? null,
|
||||
);
|
||||
|
||||
const rowStyle = useMemo(
|
||||
() => [settingsStyles.row, showBorder && settingsStyles.rowBorder],
|
||||
[showBorder],
|
||||
);
|
||||
|
||||
const normalizedHost = normalizeVersion(daemonVersion);
|
||||
const normalizedClient = normalizeVersion(clientVersion);
|
||||
const isMismatch =
|
||||
normalizedHost !== null && normalizedClient !== null && normalizedHost !== normalizedClient;
|
||||
|
||||
let valueText: string;
|
||||
if (!isConnected) {
|
||||
valueText = "Offline";
|
||||
} else if (normalizedHost) {
|
||||
valueText = formatVersionWithPrefix(normalizedHost);
|
||||
} else {
|
||||
valueText = "—";
|
||||
}
|
||||
|
||||
const valueStyle = useMemo(
|
||||
() => [styles.aboutValue, isMismatch && styles.aboutVersionMismatch],
|
||||
[isMismatch],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={rowStyle}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
||||
{host.label}
|
||||
</Text>
|
||||
{isMismatch ? (
|
||||
<Text style={settingsStyles.rowHint}>Version differs from this device</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={valueStyle}>{valueText}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function getUpdateButtonLabel(
|
||||
isInstalling: boolean,
|
||||
latestVersion: string | null | undefined,
|
||||
@@ -1106,7 +1198,13 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
/>
|
||||
);
|
||||
case "about":
|
||||
return <AboutSection appVersionText={appVersionText} isDesktopApp={isDesktopApp} />;
|
||||
return (
|
||||
<AboutSection
|
||||
appVersion={appVersion}
|
||||
appVersionText={appVersionText}
|
||||
isDesktopApp={isDesktopApp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -1266,6 +1364,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
aboutVersionMismatch: {
|
||||
color: theme.colors.palette.amber[500],
|
||||
},
|
||||
aboutErrorText: {
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.xs,
|
||||
|
||||
@@ -203,6 +203,7 @@ function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDa
|
||||
return {
|
||||
mcp: { injectIntoAgents: false },
|
||||
providers,
|
||||
metadataGeneration: { providers: [] },
|
||||
autoArchiveAfterMerge: false,
|
||||
appendSystemPrompt: "",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { highlightDiffLines } from "./diff-highlight";
|
||||
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "./tool-call-parsers";
|
||||
|
||||
function joinTokens(line: DiffLine): string {
|
||||
return (line.tokens ?? []).map((token) => token.text).join("");
|
||||
}
|
||||
|
||||
describe("highlightDiffLines", () => {
|
||||
it("attaches tokens to add/remove/context lines for a supported language", () => {
|
||||
const diff = buildLineDiff("const a = 1;\nconst b = 2;", "const a = 1;\nconst b = 3;");
|
||||
const result = highlightDiffLines(diff, "/repo/src/index.ts");
|
||||
|
||||
const context = result.find((line) => line.type === "context");
|
||||
const remove = result.find((line) => line.type === "remove");
|
||||
const add = result.find((line) => line.type === "add");
|
||||
|
||||
expect(context?.tokens).toBeDefined();
|
||||
expect(remove?.tokens).toBeDefined();
|
||||
expect(add?.tokens).toBeDefined();
|
||||
// Tokens reconstruct the code with the diff marker excluded.
|
||||
expect(joinTokens(context as DiffLine)).toBe("const a = 1;");
|
||||
expect(joinTokens(remove as DiffLine)).toBe("const b = 2;");
|
||||
expect(joinTokens(add as DiffLine)).toBe("const b = 3;");
|
||||
// A keyword token is recognised (proves real highlighting, not pass-through).
|
||||
expect(add?.tokens?.some((token) => token.style === "keyword")).toBe(true);
|
||||
});
|
||||
|
||||
it("highlights a Codex-style unified diff with bare @@ headers (no line ranges)", () => {
|
||||
const unified = ["@@", "-const x = 1;", "+const x = 2;", " const y = 3;"].join("\n");
|
||||
const result = highlightDiffLines(parseUnifiedDiff(unified), "/repo/a.ts");
|
||||
|
||||
const remove = result.find((line) => line.type === "remove");
|
||||
const add = result.find((line) => line.type === "add");
|
||||
const context = result.find((line) => line.type === "context");
|
||||
expect(joinTokens(remove as DiffLine)).toBe("const x = 1;");
|
||||
expect(joinTokens(add as DiffLine)).toBe("const x = 2;");
|
||||
expect(joinTokens(context as DiffLine)).toBe("const y = 3;");
|
||||
expect(result.find((line) => line.type === "header")?.tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns lines unchanged for an unsupported language", () => {
|
||||
const diff = buildLineDiff("a", "b");
|
||||
const result = highlightDiffLines(diff, "/repo/notes.unknownext");
|
||||
expect(result).toBe(diff);
|
||||
expect(result.every((line) => line.tokens === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns lines unchanged when there is no file path", () => {
|
||||
const diff = buildLineDiff("a", "b");
|
||||
expect(highlightDiffLines(diff, undefined)).toBe(diff);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { isLanguageSupported, type HighlightToken } from "@getpaseo/highlight";
|
||||
import { extensionFromPath, tokenizeToLines } from "@/utils/highlight-cache";
|
||||
import type { DiffLine } from "@/utils/tool-call-parsers";
|
||||
|
||||
// The leading diff marker glyph for a line. The code on the line is the content
|
||||
// with this prefix removed; we render the glyph separately so token colors
|
||||
// apply only to the code.
|
||||
export function diffLinePrefix(line: DiffLine): string {
|
||||
switch (line.type) {
|
||||
case "add":
|
||||
return "+";
|
||||
case "remove":
|
||||
return "-";
|
||||
case "context":
|
||||
return " ";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function diffLineCode(line: DiffLine): string {
|
||||
const { content, type } = line;
|
||||
if (type === "add" || type === "remove") {
|
||||
return content.startsWith(type === "add" ? "+" : "-") ? content.slice(1) : content;
|
||||
}
|
||||
if (type === "context") {
|
||||
return content.startsWith(" ") ? content.slice(1) : content;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
// Attach syntax-highlight tokens to each diff line. Language comes from the file
|
||||
// path (extension only). We reconstruct the old and new document text from the
|
||||
// diff lines by position — counting context/remove into "old" and context/add
|
||||
// into "new" — so this works regardless of whether the source diff carried real
|
||||
// `@@ -n,m +n,m @@` line ranges (Codex emits bare `@@`). Each document is
|
||||
// highlighted as a whole so the parser has cross-line context (multi-line
|
||||
// strings, template literals, comments). Returns the input unchanged when the
|
||||
// language is unsupported or the content exceeds the highlighter size cap.
|
||||
export function highlightDiffLines(
|
||||
diffLines: DiffLine[],
|
||||
filePath: string | null | undefined,
|
||||
): DiffLine[] {
|
||||
const ext = extensionFromPath(filePath);
|
||||
// Gate on real grammar support: an unsupported language would tokenize to a
|
||||
// single style-less token per line, which would shadow the word-level change
|
||||
// segments the diff already computes. Better to keep those.
|
||||
if (!ext || diffLines.length === 0 || !isLanguageSupported(`x.${ext}`)) {
|
||||
return diffLines;
|
||||
}
|
||||
|
||||
const oldCode: string[] = [];
|
||||
const newCode: string[] = [];
|
||||
const positions = diffLines.map((line) => {
|
||||
const code = diffLineCode(line);
|
||||
if (line.type === "context") {
|
||||
const position = { oldIndex: oldCode.length, newIndex: newCode.length };
|
||||
oldCode.push(code);
|
||||
newCode.push(code);
|
||||
return position;
|
||||
}
|
||||
if (line.type === "remove") {
|
||||
const position = { oldIndex: oldCode.length, newIndex: -1 };
|
||||
oldCode.push(code);
|
||||
return position;
|
||||
}
|
||||
if (line.type === "add") {
|
||||
const position = { oldIndex: -1, newIndex: newCode.length };
|
||||
newCode.push(code);
|
||||
return position;
|
||||
}
|
||||
return { oldIndex: -1, newIndex: -1 };
|
||||
});
|
||||
|
||||
const oldTokens = oldCode.length > 0 ? tokenizeToLines(oldCode.join("\n"), ext) : null;
|
||||
const newTokens = newCode.length > 0 ? tokenizeToLines(newCode.join("\n"), ext) : null;
|
||||
if (!oldTokens && !newTokens) {
|
||||
return diffLines;
|
||||
}
|
||||
|
||||
return diffLines.map((line, index) => {
|
||||
const { oldIndex, newIndex } = positions[index];
|
||||
let tokens: HighlightToken[] | undefined;
|
||||
if ((line.type === "add" || line.type === "context") && newTokens) {
|
||||
tokens = newTokens[newIndex];
|
||||
} else if (line.type === "remove" && oldTokens) {
|
||||
tokens = oldTokens[oldIndex];
|
||||
}
|
||||
return tokens ? { ...line, tokens } : line;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extensionFromPath,
|
||||
highlightToKeyedLines,
|
||||
MAX_HIGHLIGHT_CHARS,
|
||||
tokenizeToLines,
|
||||
} from "./highlight-cache";
|
||||
|
||||
describe("extensionFromPath", () => {
|
||||
it("extracts a lowercased extension regardless of absolute/relative path", () => {
|
||||
expect(extensionFromPath("/repo/src/Index.TS")).toBe("ts");
|
||||
expect(extensionFromPath("src/index.ts")).toBe("ts");
|
||||
expect(extensionFromPath("a.b/c.tsx")).toBe("tsx");
|
||||
});
|
||||
|
||||
it("returns null for paths without a usable extension", () => {
|
||||
expect(extensionFromPath(null)).toBeNull();
|
||||
expect(extensionFromPath(undefined)).toBeNull();
|
||||
expect(extensionFromPath("Makefile")).toBeNull();
|
||||
expect(extensionFromPath(".gitignore")).toBeNull();
|
||||
expect(extensionFromPath("trailingdot.")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tokenizeToLines", () => {
|
||||
it("returns one token array per line for a supported language", () => {
|
||||
const lines = tokenizeToLines("const a = 1;\nconst b = 2;", "ts");
|
||||
expect(lines).not.toBeNull();
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines?.[0].some((token) => token.style === "keyword")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns null when there is no extension", () => {
|
||||
expect(tokenizeToLines("whatever", null)).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to style-less per-line tokens for an unknown extension", () => {
|
||||
const lines = tokenizeToLines("line one\nline two", "unknownext");
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines?.[0]).toEqual([{ text: "line one", style: null }]);
|
||||
});
|
||||
|
||||
it("returns null above the size cap so callers fall back to plain text", () => {
|
||||
const huge = "x".repeat(MAX_HIGHLIGHT_CHARS + 1);
|
||||
expect(tokenizeToLines(huge, "ts")).toBeNull();
|
||||
});
|
||||
|
||||
it("serves a cached result on repeat calls (identity-stable)", () => {
|
||||
const first = tokenizeToLines("const cached = true;", "ts");
|
||||
const second = tokenizeToLines("const cached = true;", "ts");
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
describe("highlightToKeyedLines", () => {
|
||||
it("produces stable keys for lines and tokens", () => {
|
||||
const keyed = highlightToKeyedLines("const a = 1;", "ts");
|
||||
expect(keyed?.[0].key).toBe("line-0");
|
||||
expect(keyed?.[0].tokens[0].key).toBe("0-0");
|
||||
});
|
||||
|
||||
it("returns null when highlighting is unavailable", () => {
|
||||
expect(highlightToKeyedLines("text", null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
|
||||
// Shared, theme-independent tokenization + cache for syntax highlighting.
|
||||
// Used by markdown code blocks, file preview, and tool-call detail blocks
|
||||
// (Edit diff / Write / Read). Colors are applied at render time, so the cache
|
||||
// key is just (extension, code) and one entry serves both light and dark.
|
||||
|
||||
export interface KeyedToken {
|
||||
key: string;
|
||||
token: HighlightToken;
|
||||
}
|
||||
|
||||
export interface KeyedLine {
|
||||
key: string;
|
||||
tokens: KeyedToken[];
|
||||
}
|
||||
|
||||
// Above this, highlighting a whole document on the main thread risks a visible
|
||||
// stall when a large Read/Write block is expanded. Callers fall back to plain
|
||||
// monospace text. Generous enough to cover the vast majority of real blocks.
|
||||
export const MAX_HIGHLIGHT_CHARS = 100_000;
|
||||
|
||||
class LRUCache<K, V> {
|
||||
private readonly map = new Map<K, V>();
|
||||
constructor(private readonly max: number) {}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
const value = this.map.get(key);
|
||||
if (value === undefined) return undefined;
|
||||
this.map.delete(key);
|
||||
this.map.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
set(key: K, value: V): void {
|
||||
if (this.map.has(key)) this.map.delete(key);
|
||||
else if (this.map.size >= this.max) {
|
||||
const oldest = this.map.keys().next().value;
|
||||
if (oldest !== undefined) this.map.delete(oldest);
|
||||
}
|
||||
this.map.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const tokenizationCache = new LRUCache<string, HighlightToken[][]>(200);
|
||||
|
||||
// Tokenize `code` to per-line tokens, cached. Returns null when the language is
|
||||
// unsupported, the input is over the size cap, or parsing throws — callers then
|
||||
// render plain text.
|
||||
export function tokenizeToLines(code: string, ext: string | null): HighlightToken[][] | null {
|
||||
if (!ext) return null;
|
||||
if (code.length > MAX_HIGHLIGHT_CHARS) return null;
|
||||
const cacheKey = `${ext}:${code}`;
|
||||
const cached = tokenizationCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
let lines: HighlightToken[][];
|
||||
try {
|
||||
lines = highlightCode(code, `x.${ext}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
tokenizationCache.set(cacheKey, lines);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function toKeyedLine(tokens: HighlightToken[], lineIndex: number): KeyedLine {
|
||||
return {
|
||||
key: `line-${lineIndex}`,
|
||||
tokens: tokens.map((token, tokenIndex) => ({
|
||||
key: `${lineIndex}-${tokenIndex}`,
|
||||
token,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function highlightToKeyedLines(code: string, ext: string | null): KeyedLine[] | null {
|
||||
const lines = tokenizeToLines(code, ext);
|
||||
return lines ? lines.map(toKeyedLine) : null;
|
||||
}
|
||||
|
||||
// Extension for grammar selection from a file path. We only need the suffix —
|
||||
// absolute vs relative paths are equivalent here.
|
||||
export function extensionFromPath(filePath: string | null | undefined): string | null {
|
||||
if (!filePath) return null;
|
||||
const name = filePath.split(/[\\/]/).pop() ?? filePath;
|
||||
const dot = name.lastIndexOf(".");
|
||||
if (dot <= 0 || dot === name.length - 1) return null;
|
||||
return name.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import type { HighlightToken } from "@getpaseo/highlight";
|
||||
|
||||
export interface DiffSegment {
|
||||
text: string;
|
||||
@@ -9,6 +10,9 @@ export interface DiffLine {
|
||||
type: "add" | "remove" | "context" | "header";
|
||||
content: string;
|
||||
segments?: DiffSegment[];
|
||||
// Syntax-highlight tokens for the code on this line (prefix char excluded),
|
||||
// attached by highlightDiffLines when the file's language is supported.
|
||||
tokens?: HighlightToken[];
|
||||
}
|
||||
|
||||
function splitIntoLines(text: string): string[] {
|
||||
|
||||
Reference in New Issue
Block a user