Skip to content
17 changes: 16 additions & 1 deletion apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ const KNOWN_EXTENSIONS = [
"https://marketplace.visualstudio.com/items?itemName=openai.chatgpt",
viewType: "chatgpt.sidebarView",
},
{
id: "moonshot-ai.kimi-code",
name: "Kimi Code",
publisher: "Moonshot AI",
description: "AI coding assistant by Moonshot AI",
marketplaceUrl:
"https://marketplace.visualstudio.com/items?itemName=moonshot-ai.kimi-code",
viewType: "kimi.webview",
},
] as const;

function getExtensionsDir(): string {
Expand Down Expand Up @@ -514,11 +523,17 @@ export const createVscodeExtensionsRouter = () => {
leftUri: string;
rightUri: string;
title?: string;
leftContent?: string;
}>((emit) => {
const manager = getExtensionHostManager();
const handler = (
wsId: string,
data: { leftUri: string; rightUri: string; title?: string },
data: {
leftUri: string;
rightUri: string;
title?: string;
leftContent?: string;
},
) => {
if (input?.workspaceId && wsId !== input.workspaceId) return;
emit.next(data);
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/main/extension-host-worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,15 @@ async function main() {
leftUri: data.leftUri,
rightUri: data.rightUri,
title: data.title,
leftContent: data.leftContent,
});
});

// Supported extension IDs
const SUPPORTED_EXTENSIONS = new Set(
(
process.env.EXTENSION_HOST_SUPPORTED_IDS ??
"anthropic.claude-code,openai.chatgpt"
"anthropic.claude-code,openai.chatgpt,moonshot-ai.kimi-code"
)
.split(",")
.map((s) => s.trim()),
Expand Down
79 changes: 74 additions & 5 deletions apps/desktop/src/main/lib/vscode-shim/api/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,42 @@

import { shimLog, shimWarn } from "./debug-log";
import { Disposable } from "./event-emitter";
import { fireOpenDiff } from "./window";
import { Uri } from "./uri";
import { fireOpenDiff, fireOpenFile } from "./window";
import { resolveTextDocumentContent } from "./workspace";

const UNHANDLED = Symbol("unhandled");

function toUri(
value:
| Uri
| {
scheme?: string;
authority?: string;
path?: string;
query?: string;
fragment?: string;
}
| undefined,
): Uri | undefined {
if (!value) {
return undefined;
}
if (value instanceof Uri) {
return value;
}
if (value.scheme) {
return Uri.from({
scheme: value.scheme,
authority: value.authority,
path: value.path,
query: value.query,
fragment: value.fragment,
});
}
return undefined;
}

/**
* Handle VS Code built-in commands that extensions expect to work.
* Returns UNHANDLED if the command is not a known built-in.
Expand All @@ -20,7 +52,15 @@ function handleBuiltinCommand(
// Diff view (Claude Code / Codex uses this for file diffs)
case "vscode.diff": {
const leftUri = args[0] as
| { fsPath?: string; toString?(): string }
| {
fsPath?: string;
toString?(): string;
scheme?: string;
authority?: string;
path?: string;
query?: string;
fragment?: string;
}
| undefined;
const rightUri = args[1] as
| { fsPath?: string; toString?(): string }
Expand All @@ -29,18 +69,47 @@ function handleBuiltinCommand(
const left = leftUri?.fsPath ?? leftUri?.toString?.() ?? "";
const right = rightUri?.fsPath ?? rightUri?.toString?.() ?? "";
shimLog(`[vscode-shim] vscode.diff called: ${left} → ${right}`);
if (left && right) {
if (!left || !right) {
return undefined;
}

const resolvedLeftUri = toUri(leftUri);
if (!resolvedLeftUri || resolvedLeftUri.scheme === "file") {
fireOpenDiff(left, right, title);
return undefined;
}
return undefined;

return resolveTextDocumentContent(resolvedLeftUri)
.then((leftContent) => {
fireOpenDiff(left, right, title, leftContent);
return undefined;
})
.catch((error) => {
shimWarn(
"[vscode-shim] Failed to resolve diff baseline content:",
error,
);
fireOpenDiff(left, right, title);
return undefined;
});
}

// Open file
case "vscode.open": {
shimLog(`[vscode-shim] vscode.open called with`, args[0]);
const uri = args[0] as
| { fsPath?: string; scheme?: string; toString?(): string }
| undefined;
shimLog(`[vscode-shim] vscode.open called with`, uri);
if (uri?.scheme === "file" && uri.fsPath) {
fireOpenFile(uri.fsPath);
}
return undefined;
}

case "vscode.openFolder":
case "kimi.webview.focus":
return undefined;

// Reveal file in OS file manager
case "revealFileInOS":
case "revealInExplorer": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ export interface VscodeExtensionContext {
globalState: Memento;
workspaceState: Memento;
secrets: SecretStorage;
storageUri: Uri | undefined;
globalStorageUri: Uri;
logUri: Uri;
storagePath: string | undefined;
globalStoragePath: string;
logPath: string;
Expand Down Expand Up @@ -223,6 +226,9 @@ export function createExtensionContext(
globalState: new Memento(path.join(globalStoragePath, "state.json")),
workspaceState: new Memento(path.join(storagePath, "state.json")),
secrets: new SecretStorage(path.join(globalStoragePath, "secrets.json")),
storageUri: Uri.file(storagePath),
globalStorageUri: Uri.file(globalStoragePath),
logUri: Uri.file(logPath),
storagePath,
globalStoragePath,
logPath,
Expand Down
161 changes: 161 additions & 0 deletions apps/desktop/src/main/lib/vscode-shim/api/glob-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { describe, expect, it } from "bun:test";
import {
compileGlobMatchers,
compileGlobPatterns,
directoryMayContainMatches,
expandBracePatterns,
globToRegExp,
matchesAnyGlob,
normalizeGlobPath,
} from "./glob-utils";

describe("normalizeGlobPath", () => {
it("leaves forward slashes unchanged", () => {
expect(normalizeGlobPath("src/deep/file.ts")).toBe("src/deep/file.ts");
});

it("normalizes platform separator to forward slashes", () => {
const sep = process.platform === "win32" ? "\\" : "/";
const input = `src${sep}deep${sep}file.ts`;
expect(normalizeGlobPath(input)).toBe("src/deep/file.ts");
});
});

describe("globToRegExp", () => {
it("matches literal paths", () => {
const re = globToRegExp("src/index.ts");
expect(re.test("src/index.ts")).toBe(true);
expect(re.test("src/other.ts")).toBe(false);
});

it("matches single * (non-separator)", () => {
const re = globToRegExp("*.ts");
expect(re.test("foo.ts")).toBe(true);
expect(re.test("bar.ts")).toBe(true);
expect(re.test("dir/foo.ts")).toBe(false);
});

it("matches **/*.ts recursively", () => {
const re = globToRegExp("**/*.ts");
expect(re.test("foo.ts")).toBe(true);
expect(re.test("src/foo.ts")).toBe(true);
expect(re.test("src/deep/foo.ts")).toBe(true);
expect(re.test("foo.js")).toBe(false);
});

it("matches **/ prefix", () => {
const re = globToRegExp("**/node_modules");
expect(re.test("node_modules")).toBe(true);
expect(re.test("packages/foo/node_modules")).toBe(true);
});

it("matches ? as single non-separator", () => {
const re = globToRegExp("file?.ts");
expect(re.test("file1.ts")).toBe(true);
expect(re.test("fileA.ts")).toBe(true);
expect(re.test("file.ts")).toBe(false);
expect(re.test("file12.ts")).toBe(false);
});

it("matches character class [...]", () => {
const re = globToRegExp("file[0-9].ts");
expect(re.test("file0.ts")).toBe(true);
expect(re.test("file9.ts")).toBe(true);
expect(re.test("filea.ts")).toBe(false);
});

it("escapes special regex chars", () => {
const re = globToRegExp("file.name.ts");
expect(re.test("file.name.ts")).toBe(true);
expect(re.test("fileXname.ts")).toBe(false);
});

it("handles unclosed [ as literal", () => {
const re = globToRegExp("file[.ts");
expect(re.test("file[.ts")).toBe(true);
});
});

describe("expandBracePatterns", () => {
it("expands simple braces", () => {
expect(expandBracePatterns("{a,b,c}")).toEqual(["a", "b", "c"]);
});

it("expands braces with prefix and suffix", () => {
expect(expandBracePatterns("src/*.{ts,js}")).toEqual([
"src/*.ts",
"src/*.js",
]);
});

it("handles nested braces", () => {
expect(expandBracePatterns("{a,{b,c}}")).toEqual(["a", "b", "c"]);
});

it("returns pattern unchanged if no braces", () => {
expect(expandBracePatterns("**/*.ts")).toEqual(["**/*.ts"]);
});

it("handles escaped braces", () => {
expect(expandBracePatterns("\\{a,b}")).toEqual(["\\{a,b}"]);
});
});

describe("compileGlobPatterns", () => {
it("returns empty for null/undefined/empty", () => {
expect(compileGlobPatterns(null)).toEqual([]);
expect(compileGlobPatterns(undefined)).toEqual([]);
expect(compileGlobPatterns("")).toEqual([]);
expect(compileGlobPatterns(" ")).toEqual([]);
});

it("returns single pattern for simple glob", () => {
expect(compileGlobPatterns("**/*.ts")).toEqual(["**/*.ts"]);
});

it("expands brace patterns", () => {
expect(compileGlobPatterns("{**/*.ts,**/*.js}")).toEqual([
"**/*.ts",
"**/*.js",
]);
});
});

describe("matchesAnyGlob", () => {
it("returns false for empty matchers", () => {
expect(matchesAnyGlob([], "foo.ts")).toBe(false);
});

it("matches with compiled matchers", () => {
const matchers = compileGlobMatchers("**/*.ts");
expect(matchesAnyGlob(matchers, "src/foo.ts")).toBe(true);
expect(matchesAnyGlob(matchers, "src/foo.js")).toBe(false);
});

it("matches default exclude globs", () => {
const matchers = compileGlobMatchers("{**/.git,**/node_modules}");
expect(matchesAnyGlob(matchers, "node_modules")).toBe(true);
expect(matchesAnyGlob(matchers, "packages/foo/node_modules")).toBe(true);
expect(matchesAnyGlob(matchers, ".git")).toBe(true);
expect(matchesAnyGlob(matchers, "src/index.ts")).toBe(false);
});
});

describe("directoryMayContainMatches", () => {
it("returns true for empty patterns", () => {
expect(directoryMayContainMatches("src", [])).toBe(true);
});

it("returns true when directory matches static prefix", () => {
expect(directoryMayContainMatches("src", ["src/**/*.ts"])).toBe(true);
expect(directoryMayContainMatches("src/deep", ["src/**/*.ts"])).toBe(true);
});

it("returns false when directory diverges from prefix", () => {
expect(directoryMayContainMatches("dist", ["src/**/*.ts"])).toBe(false);
});

it("returns true for patterns without static prefix", () => {
expect(directoryMayContainMatches("anything", ["**/*.ts"])).toBe(true);
});
});
Loading
Loading