Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@ import React, {
import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { openInPreferredEditor } from "../editorPreferences";
import { useAppSettings } from "../appSettings";
import { useCodeViewerStore } from "../codeViewerStore";
import { openFileReference } from "../fileOpen";
import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
import { fnv1a32 } from "../lib/diffRendering";
import { LRUCache } from "../lib/lruCache";
import { useTheme } from "../hooks/useTheme";
import { resolveMarkdownFileLinkTarget } from "../markdown-links";
import { readNativeApi } from "../nativeApi";
import { toastManager } from "./ui/toast";

class CodeHighlightErrorBoundary extends React.Component<
{ fallback: ReactNode; children: ReactNode },
Expand Down Expand Up @@ -236,8 +239,11 @@ function SuspenseShikiCodeBlock({
}

function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
const { settings } = useAppSettings();
const openFileInViewer = useCodeViewerStore((state) => state.openFile);
const { resolvedTheme } = useTheme();
const diffThemeName = resolveDiffThemeName(resolvedTheme);
const openLinksExternally = settings.openLinksExternally;
const markdownComponents = useMemo<Components>(
() => ({
a({ node: _node, href, ...props }) {
Expand All @@ -255,9 +261,21 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
event.stopPropagation();
const api = readNativeApi();
if (api) {
void openInPreferredEditor(api, targetPath);
void openFileReference({
api,
cwd,
targetPath,
preferExternal: openLinksExternally,
openInViewer: openFileInViewer,
}).catch((error) => {
toastManager.add({
type: "error",
title: "Unable to open file",
description: error instanceof Error ? error.message : "An error occurred.",
});
});
} else {
console.warn("Native API not found. Unable to open file in editor.");
console.warn("Native API not found. Unable to open file.");
}
}}
/>
Expand Down Expand Up @@ -285,7 +303,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
);
},
}),
[cwd, diffThemeName, isStreaming],
[cwd, diffThemeName, isStreaming, openFileInViewer, openLinksExternally],
);

return (
Expand Down
37 changes: 37 additions & 0 deletions apps/web/src/fileOpen.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { resolveCodeViewerRelativePath, splitFileTargetPosition } from "./fileOpen";

describe("splitFileTargetPosition", () => {
it("extracts line and column suffixes", () => {
expect(splitFileTargetPosition("/Users/julius/project/src/main.ts:42:7")).toEqual({
path: "/Users/julius/project/src/main.ts",
line: 42,
column: 7,
});
});

it("leaves plain paths unchanged", () => {
expect(splitFileTargetPosition("/Users/julius/project/README.md")).toEqual({
path: "/Users/julius/project/README.md",
line: null,
column: null,
});
});
});

describe("resolveCodeViewerRelativePath", () => {
it("maps an absolute target under cwd into a relative code viewer path", () => {
expect(
resolveCodeViewerRelativePath(
"/Users/julius/project/src/components/ChatMarkdown.tsx:42",
"/Users/julius/project",
),
).toBe("src/components/ChatMarkdown.tsx");
});

it("returns null for targets outside cwd", () => {
expect(
resolveCodeViewerRelativePath("/Users/julius/other/file.ts:1", "/Users/julius/project"),
).toBeNull();
});
});
75 changes: 75 additions & 0 deletions apps/web/src/fileOpen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { type NativeApi } from "@okcode/contracts";
import { openInPreferredEditor } from "./editorPreferences";

const POSITION_SUFFIX_PATTERN = /:(\d+)(?::(\d+))?$/;

export interface FileTargetPosition {
path: string;
line: number | null;
column: number | null;
}

export function splitFileTargetPosition(targetPath: string): FileTargetPosition {
const match = targetPath.match(POSITION_SUFFIX_PATTERN);
if (!match?.[1]) {
return {
path: targetPath,
line: null,
column: null,
};
}

return {
path: targetPath.slice(0, -match[0].length),
line: Number(match[1]),
column: match[2] ? Number(match[2]) : null,
};
}

function normalizePathForComparison(pathValue: string): string {
return pathValue.replaceAll("\\", "/").replace(/\/+$/, "");
}

export function resolveCodeViewerRelativePath(
targetPath: string,
cwd: string | undefined,
): string | null {
if (!cwd) return null;

const { path } = splitFileTargetPosition(targetPath);
const normalizedPath = normalizePathForComparison(path);
const normalizedCwd = normalizePathForComparison(cwd);

if (normalizedPath === normalizedCwd) {
return null;
}

const prefix = `${normalizedCwd}/`;
if (!normalizedPath.startsWith(prefix)) {
return null;
}

return normalizedPath.slice(prefix.length);
}

export async function openFileReference(props: {
api: NativeApi;
cwd: string | undefined;
targetPath: string;
preferExternal: boolean;
openInViewer: (cwd: string, relativePath: string) => void;
}): Promise<void> {
const { api, cwd, openInViewer, preferExternal, targetPath } = props;

if (preferExternal) {
await openInPreferredEditor(api, targetPath);
return;
}

const relativePath = resolveCodeViewerRelativePath(targetPath, cwd);
if (!cwd || !relativePath) {
throw new Error("Unable to open this file inside OK Code.");
}

openInViewer(cwd, relativePath);
}
Loading