refactor(file-explorer): add deep linking, path navigation, diff preview, and Codex session persistence
This commit is contained in:
@@ -125,10 +125,18 @@ export const UserMessage = memo(function UserMessage({
|
||||
);
|
||||
});
|
||||
|
||||
export interface InlinePathTarget {
|
||||
raw: string;
|
||||
path: string;
|
||||
lineStart?: number;
|
||||
lineEnd?: number;
|
||||
}
|
||||
|
||||
interface AssistantMessageProps {
|
||||
message: string;
|
||||
timestamp: number;
|
||||
isStreaming?: boolean;
|
||||
onInlinePathPress?: (target: InlinePathTarget) => void;
|
||||
}
|
||||
|
||||
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
@@ -211,6 +219,19 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontStyle: "italic" as const,
|
||||
},
|
||||
pathChip: {
|
||||
backgroundColor: theme.colors.secondary,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 2,
|
||||
marginRight: theme.spacing[1],
|
||||
marginVertical: 2,
|
||||
},
|
||||
pathChipText: {
|
||||
color: theme.colors.secondaryForeground,
|
||||
fontFamily: "monospace",
|
||||
fontSize: 13,
|
||||
},
|
||||
}));
|
||||
|
||||
const markdownStyles = {
|
||||
@@ -229,12 +250,129 @@ const markdownStyles = {
|
||||
blockquote_text: assistantMessageStylesheet.markdownBlockquoteText,
|
||||
};
|
||||
|
||||
function isLikelyPathToken(value: string): boolean {
|
||||
if (!value || value.length > 300) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/\s/.test(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasSlash = value.includes("/") || value.includes("\\");
|
||||
const hasExtension = /\.[a-zA-Z0-9]{1,8}$/.test(value);
|
||||
|
||||
if (!hasSlash && !hasExtension) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const looksLikeDir = value.endsWith("/") || value.startsWith("./") || value.startsWith("../");
|
||||
|
||||
return hasExtension || looksLikeDir || value.includes("/");
|
||||
}
|
||||
|
||||
function normalizeInlinePathValue(value: string): string | null {
|
||||
const trimmed = value.trim().replace(/^['"`]/, "").replace(/['"`]$/, "");
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function parseInlinePathToken(
|
||||
value: string,
|
||||
lastPathRef: React.MutableRefObject<string | null>
|
||||
): InlinePathTarget | null {
|
||||
const rawValue = value ?? "";
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rangeOnlyMatch = trimmed.match(/^:([0-9]+)(?:-([0-9]+))?$/);
|
||||
if (rangeOnlyMatch) {
|
||||
const basePath = lastPathRef.current;
|
||||
if (!basePath) {
|
||||
return null;
|
||||
}
|
||||
const lineStart = parseInt(rangeOnlyMatch[1], 10);
|
||||
const lineEnd = rangeOnlyMatch[2] ? parseInt(rangeOnlyMatch[2], 10) : undefined;
|
||||
return {
|
||||
raw: rawValue,
|
||||
path: basePath,
|
||||
lineStart,
|
||||
lineEnd,
|
||||
};
|
||||
}
|
||||
|
||||
const pathMatch = trimmed.match(/^(.*?)(?::([0-9]+)(?:-([0-9]+))?)?$/);
|
||||
if (!pathMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const basePath = pathMatch[1]?.trim();
|
||||
if (!basePath || !isLikelyPathToken(basePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedPath = normalizeInlinePathValue(basePath);
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
lastPathRef.current = normalizedPath;
|
||||
|
||||
const lineStart = pathMatch[2] ? parseInt(pathMatch[2], 10) : undefined;
|
||||
const lineEnd = pathMatch[3] ? parseInt(pathMatch[3], 10) : undefined;
|
||||
|
||||
return {
|
||||
raw: rawValue,
|
||||
path: normalizedPath,
|
||||
lineStart,
|
||||
lineEnd,
|
||||
};
|
||||
}
|
||||
|
||||
export const AssistantMessage = memo(function AssistantMessage({
|
||||
message,
|
||||
timestamp,
|
||||
isStreaming = false,
|
||||
onInlinePathPress,
|
||||
}: AssistantMessageProps) {
|
||||
const fadeAnim = useRef(new Animated.Value(0.3)).current;
|
||||
const lastPathRef = useRef<string | null>(null);
|
||||
|
||||
const markdownRules = useMemo(() => {
|
||||
if (!onInlinePathPress) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
code_inline: (node: any) => {
|
||||
const content = node.content ?? "";
|
||||
const parsed = parseInlinePathToken(content, lastPathRef);
|
||||
|
||||
if (!parsed) {
|
||||
return (
|
||||
<Text key={node.key} style={assistantMessageStylesheet.markdownCodeInline}>
|
||||
{content}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
key={node.key}
|
||||
onPress={() => parsed && onInlinePathPress?.(parsed)}
|
||||
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
|
||||
>
|
||||
{content}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [onInlinePathPress]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
@@ -260,7 +398,9 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
|
||||
return (
|
||||
<View style={assistantMessageStylesheet.container}>
|
||||
<Markdown style={markdownStyles}>{message}</Markdown>
|
||||
<Markdown style={markdownStyles} rules={markdownRules}>
|
||||
{message}
|
||||
</Markdown>
|
||||
{isStreaming && (
|
||||
<Animated.View
|
||||
style={[
|
||||
|
||||
Reference in New Issue
Block a user