Skip to content

Commit ccf0e92

Browse files
authored
Open chat file links in viewer or editor (#48)
- Route markdown file links through shared file-open logic - Add code viewer path resolution with tests - Show a toast when opening a file fails
1 parent d03694c commit ccf0e92

3 files changed

Lines changed: 134 additions & 4 deletions

File tree

apps/web/src/components/ChatMarkdown.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@ import React, {
1616
import type { Components } from "react-markdown";
1717
import ReactMarkdown from "react-markdown";
1818
import remarkGfm from "remark-gfm";
19-
import { openInPreferredEditor } from "../editorPreferences";
19+
import { useAppSettings } from "../appSettings";
20+
import { useCodeViewerStore } from "../codeViewerStore";
21+
import { openFileReference } from "../fileOpen";
2022
import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
2123
import { fnv1a32 } from "../lib/diffRendering";
2224
import { LRUCache } from "../lib/lruCache";
2325
import { useTheme } from "../hooks/useTheme";
2426
import { resolveMarkdownFileLinkTarget } from "../markdown-links";
2527
import { readNativeApi } from "../nativeApi";
28+
import { toastManager } from "./ui/toast";
2629

2730
class CodeHighlightErrorBoundary extends React.Component<
2831
{ fallback: ReactNode; children: ReactNode },
@@ -236,8 +239,11 @@ function SuspenseShikiCodeBlock({
236239
}
237240

238241
function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
242+
const { settings } = useAppSettings();
243+
const openFileInViewer = useCodeViewerStore((state) => state.openFile);
239244
const { resolvedTheme } = useTheme();
240245
const diffThemeName = resolveDiffThemeName(resolvedTheme);
246+
const openLinksExternally = settings.openLinksExternally;
241247
const markdownComponents = useMemo<Components>(
242248
() => ({
243249
a({ node: _node, href, ...props }) {
@@ -255,9 +261,21 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
255261
event.stopPropagation();
256262
const api = readNativeApi();
257263
if (api) {
258-
void openInPreferredEditor(api, targetPath);
264+
void openFileReference({
265+
api,
266+
cwd,
267+
targetPath,
268+
preferExternal: openLinksExternally,
269+
openInViewer: openFileInViewer,
270+
}).catch((error) => {
271+
toastManager.add({
272+
type: "error",
273+
title: "Unable to open file",
274+
description: error instanceof Error ? error.message : "An error occurred.",
275+
});
276+
});
259277
} else {
260-
console.warn("Native API not found. Unable to open file in editor.");
278+
console.warn("Native API not found. Unable to open file.");
261279
}
262280
}}
263281
/>
@@ -285,7 +303,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
285303
);
286304
},
287305
}),
288-
[cwd, diffThemeName, isStreaming],
306+
[cwd, diffThemeName, isStreaming, openFileInViewer, openLinksExternally],
289307
);
290308

291309
return (

apps/web/src/fileOpen.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveCodeViewerRelativePath, splitFileTargetPosition } from "./fileOpen";
3+
4+
describe("splitFileTargetPosition", () => {
5+
it("extracts line and column suffixes", () => {
6+
expect(splitFileTargetPosition("/Users/julius/project/src/main.ts:42:7")).toEqual({
7+
path: "/Users/julius/project/src/main.ts",
8+
line: 42,
9+
column: 7,
10+
});
11+
});
12+
13+
it("leaves plain paths unchanged", () => {
14+
expect(splitFileTargetPosition("/Users/julius/project/README.md")).toEqual({
15+
path: "/Users/julius/project/README.md",
16+
line: null,
17+
column: null,
18+
});
19+
});
20+
});
21+
22+
describe("resolveCodeViewerRelativePath", () => {
23+
it("maps an absolute target under cwd into a relative code viewer path", () => {
24+
expect(
25+
resolveCodeViewerRelativePath(
26+
"/Users/julius/project/src/components/ChatMarkdown.tsx:42",
27+
"/Users/julius/project",
28+
),
29+
).toBe("src/components/ChatMarkdown.tsx");
30+
});
31+
32+
it("returns null for targets outside cwd", () => {
33+
expect(
34+
resolveCodeViewerRelativePath("/Users/julius/other/file.ts:1", "/Users/julius/project"),
35+
).toBeNull();
36+
});
37+
});

apps/web/src/fileOpen.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { type NativeApi } from "@okcode/contracts";
2+
import { openInPreferredEditor } from "./editorPreferences";
3+
4+
const POSITION_SUFFIX_PATTERN = /:(\d+)(?::(\d+))?$/;
5+
6+
export interface FileTargetPosition {
7+
path: string;
8+
line: number | null;
9+
column: number | null;
10+
}
11+
12+
export function splitFileTargetPosition(targetPath: string): FileTargetPosition {
13+
const match = targetPath.match(POSITION_SUFFIX_PATTERN);
14+
if (!match?.[1]) {
15+
return {
16+
path: targetPath,
17+
line: null,
18+
column: null,
19+
};
20+
}
21+
22+
return {
23+
path: targetPath.slice(0, -match[0].length),
24+
line: Number(match[1]),
25+
column: match[2] ? Number(match[2]) : null,
26+
};
27+
}
28+
29+
function normalizePathForComparison(pathValue: string): string {
30+
return pathValue.replaceAll("\\", "/").replace(/\/+$/, "");
31+
}
32+
33+
export function resolveCodeViewerRelativePath(
34+
targetPath: string,
35+
cwd: string | undefined,
36+
): string | null {
37+
if (!cwd) return null;
38+
39+
const { path } = splitFileTargetPosition(targetPath);
40+
const normalizedPath = normalizePathForComparison(path);
41+
const normalizedCwd = normalizePathForComparison(cwd);
42+
43+
if (normalizedPath === normalizedCwd) {
44+
return null;
45+
}
46+
47+
const prefix = `${normalizedCwd}/`;
48+
if (!normalizedPath.startsWith(prefix)) {
49+
return null;
50+
}
51+
52+
return normalizedPath.slice(prefix.length);
53+
}
54+
55+
export async function openFileReference(props: {
56+
api: NativeApi;
57+
cwd: string | undefined;
58+
targetPath: string;
59+
preferExternal: boolean;
60+
openInViewer: (cwd: string, relativePath: string) => void;
61+
}): Promise<void> {
62+
const { api, cwd, openInViewer, preferExternal, targetPath } = props;
63+
64+
if (preferExternal) {
65+
await openInPreferredEditor(api, targetPath);
66+
return;
67+
}
68+
69+
const relativePath = resolveCodeViewerRelativePath(targetPath, cwd);
70+
if (!cwd || !relativePath) {
71+
throw new Error("Unable to open this file inside OK Code.");
72+
}
73+
74+
openInViewer(cwd, relativePath);
75+
}

0 commit comments

Comments
 (0)