refactor(file-explorer): add deep linking, path navigation, diff preview, and Codex session persistence

This commit is contained in:
Mohamed Boudra
2025-11-14 19:02:54 +01:00
parent f766a18d7b
commit b2c89df7c7
18 changed files with 3393 additions and 285 deletions
+141 -1
View File
@@ -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={[