Skip to content
Open
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
4 changes: 4 additions & 0 deletions apps/vscode/shared/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,10 @@ function validateParams(method: RpcMethod, params: unknown): boolean {
|| (Number.isInteger(params["maxCount"]) && (params["maxCount"] as number) >= 0))
&& isOptionalType(params["includeVideo"], "boolean");
case Methods.OpenFile:
return hasString(params, "filePath")
&& isPlainObject(params)
&& (params["line"] === undefined
|| (Number.isSafeInteger(params["line"]) && (params["line"] as number) >= 1));
case Methods.OpenFileDiff:
case Methods.CheckFileExists:
case Methods.GetImageDataUri:
Expand Down
26 changes: 23 additions & 3 deletions apps/vscode/src/handlers/file.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as vscode from "vscode";
import { Events, Methods } from "../../shared/bridge";
import type { FileChange, ProjectFile } from "../../shared/types";
import type { BaselineSession } from "../managers/baseline.manager";
import { relativeFsPath } from "../utils/fs-path";
import {
isWorkspacePathContained,
resolveWorkspacePath,
Expand All @@ -17,6 +18,7 @@ interface GetProjectFilesParams {
}
interface PickMediaParams { maxCount?: number; includeVideo?: boolean }
interface FilePathParams { filePath: string }
interface OpenFileParams { filePath: string; line?: number }
interface OptionalFilePathParams { filePath?: string }
interface PathsParams { paths: string[] }
interface CheckFilesExistParams { paths: string[] }
Expand Down Expand Up @@ -68,13 +70,31 @@ const pickMedia: Handler<PickMediaParams, string[]> = async (params) => {
return results;
};

const openFile: Handler<FilePathParams, { ok: boolean }> = async ({ filePath }, ctx) => {
const resolved = await resolveExistingWorkspaceFile(ctx.requireWorkDirUri(), filePath);
const openFile: Handler<OpenFileParams, { ok: boolean }> = async ({ filePath, line }, ctx) => {
const workDirUri = ctx.requireWorkDirUri();
// Assistant output also links files by absolute path; relativize when the
// target lies inside the working directory so those links open, while
// out-of-workspace paths keep failing the containment check below.
const target = isAbsolutePathInput(filePath)
? relativeFsPath(workDirUri.fsPath, filePath)
: filePath;
if (target === undefined) return { ok: false };
const resolved = await resolveExistingWorkspaceFile(workDirUri, target);
if (resolved === undefined) return { ok: false };
await vscode.commands.executeCommand("vscode.open", resolved.uri);
await vscode.commands.executeCommand(
"vscode.open",
resolved.uri,
line === undefined ? undefined : { selection: new vscode.Range(line - 1, 0, line - 1, 0) },
);
return { ok: true };
};

function isAbsolutePathInput(input: string): boolean {
// Drive-relative inputs (`C:foo`) are not absolute: they fall through to
// resolveWorkspacePath, which rejects them the same way it always has.
return path.posix.isAbsolute(input.replaceAll("\\", "/")) || path.win32.isAbsolute(input);
}

const openFileDiff: Handler<FilePathParams, { ok: boolean }> = async ({ filePath }, ctx) => {
const sessionId = ctx.getSessionId();
const resolved = await resolveExistingWorkspaceFile(ctx.requireWorkDirUri(), filePath);
Expand Down
119 changes: 119 additions & 0 deletions apps/vscode/test/file-link-parsing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* Scenario: markdown link hrefs in assistant output → local file-link targets.
* Responsibilities: external links stay anchors, local paths (plain, file://,
* vscode://file/, with optional :line suffix) resolve to an openFile target,
* and the url transform preserves the schemes the sanitizer would drop.
* Wiring: pure helpers, no VS Code host involved.
* Run: pnpm --filter kimi-code exec vitest run --config vitest.config.ts test/file-link-parsing.test.ts
*/
import { describe, expect, it } from "vitest";
import { fileAwareUrlTransform, parseFileLink } from "@/lib/link-utils";

describe("parseFileLink", () => {
it("parses an absolute path with a line suffix", () => {
expect(parseFileLink("/abs/path/report.md:33")).toEqual({ path: "/abs/path/report.md", line: 33 });
});

it("parses an absolute path without a line suffix", () => {
expect(parseFileLink("/abs/path/report.md")).toEqual({ path: "/abs/path/report.md" });
});

it("parses a relative path with a line:column suffix", () => {
expect(parseFileLink("src/app.ts:12:5")).toEqual({ path: "src/app.ts", line: 12 });
});

it("parses a root-level relative path with a line suffix", () => {
expect(parseFileLink("README.md:5")).toEqual({ path: "README.md", line: 5 });
expect(parseFileLink("app.ts:12")).toEqual({ path: "app.ts", line: 12 });
});

it("keeps a :0 suffix as part of the path instead of a line reference", () => {
expect(parseFileLink("app.ts:0")).toEqual({ path: "app.ts:0" });
});

it("keeps an unsafely large line suffix as part of the path", () => {
expect(parseFileLink("app.ts:99999999999999999999")).toEqual({ path: "app.ts:99999999999999999999" });
});

it("does not treat other vscode authorities as file links", () => {
expect(parseFileLink("vscode://fileevil/path.ts")).toBeNull();
expect(parseFileLink("vscode://file")).toBeNull();
});

it("parses a file URI with a line suffix", () => {
expect(parseFileLink("file:///abs/path/report.md:33")).toEqual({ path: "/abs/path/report.md", line: 33 });
});

it("parses a Windows drive file URI with percent-encoding", () => {
expect(parseFileLink("file:///C:/proj/file%20name.ts")).toEqual({ path: "C:/proj/file name.ts" });
});

it("parses a vscode file URI", () => {
expect(parseFileLink("vscode://file/abs/path/report.md:33")).toEqual({ path: "/abs/path/report.md", line: 33 });
});

it("parses a Windows drive vscode file URI without a bogus leading slash", () => {
expect(parseFileLink("vscode://file/C:/proj/file.ts:7")).toEqual({ path: "C:/proj/file.ts", line: 7 });
});

it("parses an authority-bearing file URI as a UNC path", () => {
expect(parseFileLink("file://server/share/project/file.ts")).toEqual({ path: "//server/share/project/file.ts" });
});

it("treats a localhost file URI authority as none", () => {
expect(parseFileLink("file://localhost/abs/report.md")).toEqual({ path: "/abs/report.md" });
});

it("splits fragments off the path and reads GitHub-style line fragments", () => {
expect(parseFileLink("README.md#usage")).toEqual({ path: "README.md" });
expect(parseFileLink("src/app.ts#L20")).toEqual({ path: "src/app.ts", line: 20 });
expect(parseFileLink("app.ts:12#L99")).toEqual({ path: "app.ts", line: 12 });
expect(parseFileLink("file:///abs/report.md#L2")).toEqual({ path: "/abs/report.md", line: 2 });
});

it("parses a native Windows path with a line suffix", () => {
expect(parseFileLink("C:\\proj\\file.ts:7")).toEqual({ path: "C:\\proj\\file.ts", line: 7 });
});

it("returns null for external and non-file links", () => {
expect(parseFileLink("https://example.com/report.md")).toBeNull();
expect(parseFileLink("http://example.com")).toBeNull();
expect(parseFileLink("mailto:someone@example.com")).toBeNull();
expect(parseFileLink("javascript:alert(1)")).toBeNull();
expect(parseFileLink("data:text/plain;base64,eA==")).toBeNull();
expect(parseFileLink("vscode://settings/editor")).toBeNull();
expect(parseFileLink("//example.com/report.md")).toBeNull();
expect(parseFileLink("#section")).toBeNull();
expect(parseFileLink("")).toBeNull();
expect(parseFileLink(undefined)).toBeNull();
});
});

describe("fileAwareUrlTransform", () => {
it("preserves file and vscode-file URLs the default sanitizer would drop", () => {
expect(fileAwareUrlTransform("file:///abs/report.md:33")).toBe("file:///abs/report.md:33");
expect(fileAwareUrlTransform("vscode://file/abs/report.md")).toBe("vscode://file/abs/report.md");
});

it("keeps http(s) links and plain paths intact", () => {
expect(fileAwareUrlTransform("https://example.com/x")).toBe("https://example.com/x");
expect(fileAwareUrlTransform("src/app.ts:12")).toBe("src/app.ts:12");
expect(fileAwareUrlTransform("/abs/path.md:3")).toBe("/abs/path.md:3");
expect(fileAwareUrlTransform("C:\\proj\\file.ts:7")).toBe("C:\\proj\\file.ts:7");
});

it("preserves root-level relative paths with a line suffix the sanitizer reads as a protocol", () => {
expect(fileAwareUrlTransform("README.md:5")).toBe("README.md:5");
expect(fileAwareUrlTransform("app.ts:12")).toBe("app.ts:12");
});

it("keeps the default sanitizer for non-href attributes such as image src", () => {
expect(fileAwareUrlTransform("file:///abs/img.png", "src")).toBe("");
expect(fileAwareUrlTransform("media/pic.png", "src")).toBe("media/pic.png");
expect(fileAwareUrlTransform("https://example.com/pic.png", "src")).toBe("https://example.com/pic.png");
});

it("still drops javascript URLs", () => {
expect(fileAwareUrlTransform("javascript:alert(1)")).toBe("");
});
});
67 changes: 67 additions & 0 deletions apps/vscode/test/workspace-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ const vscodeHost = vi.hoisted(() => {
}
}

class Range {
constructor(
readonly startLine: number,
readonly startCharacter: number,
readonly endLine: number,
readonly endCharacter: number,
) {}
}

class RelativePattern {
readonly baseUri: Uri;
readonly base: string;
Expand All @@ -89,6 +98,7 @@ const vscodeHost = vi.hoisted(() => {

return {
Uri,
Range,
RelativePattern,
readDirectory,
stat,
Expand All @@ -102,6 +112,7 @@ const vscodeHost = vi.hoisted(() => {

vi.mock("vscode", () => ({
Uri: vscodeHost.Uri,
Range: vscodeHost.Range,
RelativePattern: vscodeHost.RelativePattern,
FileType: { Unknown: 0, File: 1, Directory: 2, SymbolicLink: 64 },
QuickPickItemKind: { Separator: -1 },
Expand Down Expand Up @@ -280,6 +291,62 @@ describe("Webview workspace paths (selected-directory containment)", () => {
expect(vscodeHost.executeCommand).not.toHaveBeenCalled();
});

it("opens an absolute path inside the selected working directory", async () => {
const workDir = join(root, "project");
await mkdir(workDir);
const inside = join(workDir, "notes.md");
await writeFile(inside, "notes");
const ctx = createContext(vscodeHost.Uri.file(workDir));

const result = await fileHandlers[Methods.OpenFile]!({ filePath: inside }, ctx);

expect(result).toEqual({ ok: true });
const [command, uri] = vscodeHost.executeCommand.mock.calls[0]!;
expect(command).toBe("vscode.open");
expect(areSameFsPath((uri as vscode.Uri).fsPath, inside)).toBe(true);
});

it("refuses a drive-relative path", async () => {
const workDir = join(root, "project");
await mkdir(workDir);
await writeFile(join(workDir, "secret.txt"), "secret");
const ctx = createContext(vscodeHost.Uri.file(workDir));

const result = await fileHandlers[Methods.OpenFile]!({ filePath: "C:secret.txt" }, ctx);

expect(result).toEqual({ ok: false });
expect(vscodeHost.executeCommand).not.toHaveBeenCalled();
});

it("refuses an absolute path outside the selected working directory", async () => {
const workDir = join(root, "project");
await mkdir(workDir);
const outside = join(root, "outside.md");
await writeFile(outside, "secret");
const ctx = createContext(vscodeHost.Uri.file(workDir));

const result = await fileHandlers[Methods.OpenFile]!({ filePath: outside }, ctx);

expect(result).toEqual({ ok: false });
expect(vscodeHost.executeCommand).not.toHaveBeenCalled();
});

it("opens a file at the requested line", async () => {
const workDir = join(root, "project");
await mkdir(join(workDir, "sub"), { recursive: true });
await writeFile(join(workDir, "sub", "file.md"), "content");
const ctx = createContext(vscodeHost.Uri.file(workDir));

const result = await fileHandlers[Methods.OpenFile]!({ filePath: "sub/file.md", line: 33 }, ctx);

expect(result).toEqual({ ok: true });
const call = vscodeHost.executeCommand.mock.calls[0]!;
expect(call[0]).toBe("vscode.open");
expect(call[2]).toMatchObject({
selection: { startLine: 32, startCharacter: 0, endLine: 32, endCharacter: 0 },
});
});

it("omits an outside symlink when an SDK Write event requests baseline capture", async () => {
const workDir = join(root, "project");
const outsideRoot = await mkdtemp(join(tmpdir(), "kimi-vscode-baseline-outside-"));
Expand Down
2 changes: 1 addition & 1 deletion apps/vscode/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@
}
},
"include": ["src/**/*", "shared/**/*", "test/**/*"],
"exclude": ["dist", "node_modules", "webview-ui", "test/settings-store.test.ts"]
"exclude": ["dist", "node_modules", "webview-ui", "test/settings-store.test.ts", "test/file-link-parsing.test.ts"]
}
27 changes: 18 additions & 9 deletions apps/vscode/webview-ui/src/components/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useRequest } from "ahooks";
import { IconVideo } from "@tabler/icons-react";
import type { Components } from "react-markdown";
import { parseSegments, parseColorSegments, extractPaths, checkFilesExist, hasColors, isLocalPath } from "@/lib/text-enrichment";
import { parseFileLink, fileAwareUrlTransform } from "@/lib/link-utils";
import { CopyButton } from "@/components/CopyButton";
import { MediaPreviewModal, StreamImagePreview, ImageLoadFail } from "@/components/MediaPreviewModal";
import { getMediaTypeFromSrc } from "@/lib/media-utils";
Expand Down Expand Up @@ -37,13 +38,13 @@ function ColorSwatch({ color }: { color: string }) {
return <span className="inline-block size-2.75 rounded-sm align-middle mr-0.5 mb-0.5" style={{ backgroundColor: color }} />;
}

export function FileLink({ path, display }: { path: string; display: string }) {
export function FileLink({ path, line, display }: { path: string; line?: number; display: React.ReactNode }) {
const onClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
void bridge.openFile(path);
void bridge.openFile(path, line);
},
[path],
[path, line],
);
return (
<button type="button" className="hover:text-zinc-900 dark:hover:text-white hover:underline cursor-pointer break-all text-left" onClick={onClick}>
Expand Down Expand Up @@ -221,11 +222,19 @@ export const Markdown = memo(function Markdown({ content, className, enableEnric
h3: ({ children }) => <h3 className="text-sm font-semibold mt-2 mb-1">{children}</h3>,
ul: ({ children }) => <ul className="list-disc list-outside pl-5 mb-2 space-y-1">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-outside pl-5 mb-2 space-y-1">{children}</ol>,
a: ({ href, children }) => (
<a href={href} className="text-blue-600 dark:text-blue-400 underline hover:no-underline" target="_blank" rel="noopener noreferrer">
{children}
</a>
),
a: ({ href, children }) => {
// The webview only follows external anchors; local-file hrefs must be
// routed through the extension host to open in the editor (#2194).
const fileLink = parseFileLink(href);
if (fileLink !== null) {
return <FileLink path={fileLink.path} line={fileLink.line} display={children} />;
}
return (
<a href={href} className="text-blue-600 dark:text-blue-400 underline hover:no-underline" target="_blank" rel="noopener noreferrer">
{children}
</a>
);
},
blockquote: ({ children }) => <blockquote className="border-l-2 border-primary/50 pl-3 my-2 text-muted-foreground italic">{children}</blockquote>,
table: ({ children }) => (
<div className="overflow-x-auto my-2">
Expand Down Expand Up @@ -267,7 +276,7 @@ export const Markdown = memo(function Markdown({ content, className, enableEnric

return (
<div className={className}>
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]} components={components}>
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]} components={components} urlTransform={fileAwareUrlTransform}>
{content}
</ReactMarkdown>
<MediaPreviewModal src={previewSrc} onClose={() => setPreviewSrc(null)} />
Expand Down
Loading