diff --git a/apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts b/apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts index 09b48e05c51..d328440a5a5 100644 --- a/apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts +++ b/apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts @@ -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 { @@ -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); diff --git a/apps/desktop/src/main/extension-host-worker/index.ts b/apps/desktop/src/main/extension-host-worker/index.ts index 548436a7b37..f69c1f2ed3b 100644 --- a/apps/desktop/src/main/extension-host-worker/index.ts +++ b/apps/desktop/src/main/extension-host-worker/index.ts @@ -95,6 +95,7 @@ async function main() { leftUri: data.leftUri, rightUri: data.rightUri, title: data.title, + leftContent: data.leftContent, }); }); @@ -102,7 +103,7 @@ async function main() { 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()), diff --git a/apps/desktop/src/main/lib/vscode-shim/api/commands.ts b/apps/desktop/src/main/lib/vscode-shim/api/commands.ts index 45f42905889..bcf0b3d0a8e 100644 --- a/apps/desktop/src/main/lib/vscode-shim/api/commands.ts +++ b/apps/desktop/src/main/lib/vscode-shim/api/commands.ts @@ -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. @@ -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 } @@ -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": { diff --git a/apps/desktop/src/main/lib/vscode-shim/api/extension-context.ts b/apps/desktop/src/main/lib/vscode-shim/api/extension-context.ts index b46320291e6..2bac13360b5 100644 --- a/apps/desktop/src/main/lib/vscode-shim/api/extension-context.ts +++ b/apps/desktop/src/main/lib/vscode-shim/api/extension-context.ts @@ -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; @@ -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, diff --git a/apps/desktop/src/main/lib/vscode-shim/api/glob-utils.test.ts b/apps/desktop/src/main/lib/vscode-shim/api/glob-utils.test.ts new file mode 100644 index 00000000000..d1a7451b700 --- /dev/null +++ b/apps/desktop/src/main/lib/vscode-shim/api/glob-utils.test.ts @@ -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); + }); +}); diff --git a/apps/desktop/src/main/lib/vscode-shim/api/glob-utils.ts b/apps/desktop/src/main/lib/vscode-shim/api/glob-utils.ts new file mode 100644 index 00000000000..bc1c971037f --- /dev/null +++ b/apps/desktop/src/main/lib/vscode-shim/api/glob-utils.ts @@ -0,0 +1,263 @@ +import path from "node:path"; + +/** + * Minimal glob-to-regexp utilities for the VS Code workspace shim. + * Handles the subset of glob syntax that VS Code extensions commonly use: + * `*`, `**`, `?`, `[...]`, and `{a,b}` brace expansion. + */ + +export function normalizeGlobPath(value: string): string { + return value.split(path.sep).join("/"); +} + +export function escapeRegexLiteral(value: string): string { + return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); +} + +export function globToRegExp(glob: string): RegExp { + let source = "^"; + + for (let index = 0; index < glob.length; index += 1) { + const char = glob[index]; + + if (char === "\\") { + const next = glob[index + 1]; + if (next) { + source += escapeRegexLiteral(next); + index += 1; + } else { + source += "\\\\"; + } + continue; + } + + if (char === "*") { + if (glob[index + 1] === "*") { + while (glob[index + 1] === "*") { + index += 1; + } + if (glob[index + 1] === "/") { + source += "(?:.*/)?"; + index += 1; + } else { + source += ".*"; + } + } else { + source += "[^/]*"; + } + continue; + } + + if (char === "?") { + source += "[^/]"; + continue; + } + + if (char === "[") { + const closingIndex = glob.indexOf("]", index + 1); + if (closingIndex === -1) { + source += "\\["; + } else { + source += glob.slice(index, closingIndex + 1); + index = closingIndex; + } + continue; + } + + source += escapeRegexLiteral(char); + } + + source += "$"; + return new RegExp(source); +} + +export function findFirstBraceRange( + pattern: string, +): { start: number; end: number; body: string } | null { + let braceStart = -1; + let depth = 0; + + for (let index = 0; index < pattern.length; index += 1) { + const char = pattern[index]; + if (char === "\\") { + index += 1; + continue; + } + if (char === "{") { + if (depth === 0) { + braceStart = index; + } + depth += 1; + continue; + } + if (char === "}") { + if (depth === 0 || braceStart < 0) { + continue; + } + depth -= 1; + if (depth === 0) { + return { + start: braceStart, + end: index, + body: pattern.slice(braceStart + 1, index), + }; + } + } + } + + return null; +} + +export function splitBraceOptions(body: string): string[] { + const options: string[] = []; + let depth = 0; + let current = ""; + + for (let index = 0; index < body.length; index += 1) { + const char = body[index]; + if (char === "\\") { + current += char; + if (index + 1 < body.length) { + current += body[index + 1]; + index += 1; + } + continue; + } + if (char === "{") { + depth += 1; + current += char; + continue; + } + if (char === "}") { + depth = Math.max(0, depth - 1); + current += char; + continue; + } + if (char === "," && depth === 0) { + options.push(current); + current = ""; + continue; + } + current += char; + } + + options.push(current); + return options; +} + +export function expandBracePatterns(pattern: string): string[] { + const braceRange = findFirstBraceRange(pattern); + if (!braceRange) { + return [pattern]; + } + + const prefix = pattern.slice(0, braceRange.start); + const suffix = pattern.slice(braceRange.end + 1); + const options = splitBraceOptions(braceRange.body); + + return options.flatMap((option) => + expandBracePatterns(`${prefix}${option}${suffix}`), + ); +} + +export function compileGlobPatterns( + pattern: string | null | undefined, +): string[] { + if (!pattern) { + return []; + } + + const normalized = pattern.trim(); + if (!normalized) { + return []; + } + + return expandBracePatterns(normalized) + .map((entry) => normalizeGlobPath(entry.trim())) + .filter(Boolean); +} + +export function compileGlobMatchers( + pattern: string | null | undefined, +): RegExp[] { + return compileGlobPatterns(pattern).map((entry) => globToRegExp(entry)); +} + +export function matchesAnyGlob( + matchers: RegExp[], + targetPath: string, +): boolean { + if (matchers.length === 0) { + return false; + } + + const normalizedTarget = normalizeGlobPath(targetPath); + return matchers.some((matcher) => matcher.test(normalizedTarget)); +} + +export function splitGlobSegments(pattern: string): string[] { + return normalizeGlobPath(pattern) + .split("/") + .map((segment) => segment.trim()) + .filter(Boolean); +} + +export function hasGlobMeta(segment: string): boolean { + let escaped = false; + + for (const char of segment) { + if (!escaped && char === "\\") { + escaped = true; + continue; + } + if (!escaped && (char === "*" || char === "?" || char === "[")) { + return true; + } + escaped = false; + } + + return false; +} + +export function getStaticGlobPrefixSegments(pattern: string): string[] { + const prefix: string[] = []; + + for (const segment of splitGlobSegments(pattern)) { + if (segment === "**" || hasGlobMeta(segment)) { + break; + } + prefix.push(segment); + } + + return prefix; +} + +export function directoryMayContainMatches( + relativeDirectory: string, + includePatterns: string[], +): boolean { + if (includePatterns.length === 0) { + return true; + } + + const directorySegments = splitGlobSegments(relativeDirectory); + + return includePatterns.some((pattern) => { + const prefixSegments = getStaticGlobPrefixSegments(pattern); + if (prefixSegments.length === 0) { + return true; + } + + const commonLength = Math.min( + directorySegments.length, + prefixSegments.length, + ); + for (let index = 0; index < commonLength; index += 1) { + if (directorySegments[index] !== prefixSegments[index]) { + return false; + } + } + + return true; + }); +} diff --git a/apps/desktop/src/main/lib/vscode-shim/api/window.ts b/apps/desktop/src/main/lib/vscode-shim/api/window.ts index 6682f338b88..1cd3c31ef0c 100644 --- a/apps/desktop/src/main/lib/vscode-shim/api/window.ts +++ b/apps/desktop/src/main/lib/vscode-shim/api/window.ts @@ -20,6 +20,9 @@ import { type WebviewOptions, type WebviewPanel, } from "./webview"; +import { setActiveWorkspaceTextDocument } from "./workspace"; + +const QUICK_PICK_ITEM_KIND_SEPARATOR = -1; interface TextEditor { readonly document: { @@ -27,6 +30,8 @@ interface TextEditor { fileName: string; getText(range?: unknown): string; languageId: string; + isDirty?: boolean; + isUntitled?: boolean; }; readonly selection: { readonly start: { line: number; character: number }; @@ -36,6 +41,14 @@ interface TextEditor { }; readonly selections: Array; readonly viewColumn: number | undefined; + edit( + callback: (builder: { + insert( + position: { line: number; character: number }, + value: string, + ): void; + }) => void, + ): Promise; } interface Terminal { @@ -65,20 +78,25 @@ const _openFileEmitter = new EventEmitter<{ line?: number; }>(); export const onOpenFile = _openFileEmitter.event; +export function fireOpenFile(filePath: string, line?: number): void { + _openFileEmitter.fire({ filePath, line }); +} // Emits when vscode.diff is called - renderer listens to open diff viewer const _openDiffEmitter = new EventEmitter<{ leftUri: string; rightUri: string; title?: string; + leftContent?: string; }>(); export const onOpenDiff = _openDiffEmitter.event; export function fireOpenDiff( leftUri: string, rightUri: string, title?: string, + leftContent?: string, ): void { - _openDiffEmitter.fire({ leftUri, rightUri, title }); + _openDiffEmitter.fire({ leftUri, rightUri, title, leftContent }); } // IPC send function — injected from worker process so dialog calls go via main @@ -132,6 +150,7 @@ export function setActiveTextEditor( if (!filePath) { _activeTextEditor = undefined; + setActiveWorkspaceTextDocument(null); } else { const uri = Uri.file(filePath); _activeTextEditor = { @@ -146,6 +165,8 @@ export function setActiveTextEditor( } }, languageId: languageId ?? "plaintext", + isDirty: false, + isUntitled: false, }, selection: { start: { line: 0, character: 0 }, @@ -155,7 +176,48 @@ export function setActiveTextEditor( }, selections: [], viewColumn: 1, + async edit(callback) { + const inserts: Array<{ + position: { line: number; character: number }; + value: string; + }> = []; + callback({ + insert(position, value) { + inserts.push({ position, value }); + }, + }); + if (inserts.length === 0) { + return true; + } + + try { + const fs = require("node:fs") as typeof import("node:fs"); + const content = fs.readFileSync(filePath, "utf-8"); + const lines = content.split("\n"); + const sortedInserts = [...inserts].sort((left, right) => { + const lineDelta = right.position.line - left.position.line; + return lineDelta !== 0 + ? lineDelta + : right.position.character - left.position.character; + }); + + for (const insert of sortedInserts) { + const line = lines[insert.position.line] ?? ""; + lines[insert.position.line] = + line.slice(0, insert.position.character) + + insert.value + + line.slice(insert.position.character); + } + + fs.writeFileSync(filePath, lines.join("\n"), "utf-8"); + return true; + } catch (error) { + shimWarn("[vscode-shim] TextEditor.edit failed:", error); + return false; + } + }, }; + setActiveWorkspaceTextDocument(filePath, languageId); // Update visible editors _visibleTextEditors.length = 0; _visibleTextEditors.push(_activeTextEditor); @@ -274,16 +336,32 @@ export const window = { async showQuickPick( items: | string[] - | Array<{ label: string; description?: string; detail?: string }> + | Array<{ + label: string; + description?: string; + detail?: string; + kind?: number; + }> | Promise< | string[] - | Array<{ label: string; description?: string; detail?: string }> + | Array<{ + label: string; + description?: string; + detail?: string; + kind?: number; + }> >, options?: { placeHolder?: string; canPickMany?: boolean }, ): Promise { const resolved = await items; if (!resolved || resolved.length === 0) return undefined; - const labels = resolved.map((item) => + const selectableItems = resolved.filter((item) => { + return ( + typeof item === "string" || item.kind !== QUICK_PICK_ITEM_KIND_SEPARATOR + ); + }); + if (selectableItems.length === 0) return undefined; + const labels = selectableItems.map((item) => typeof item === "string" ? item : item.label, ); if (!_sendToMain) { @@ -301,7 +379,7 @@ export const window = { }); }); if (selectedIndex < 0) return undefined; - return resolved[selectedIndex]; + return selectableItems[selectedIndex]; }, async showInputBox(_options?: { @@ -361,11 +439,11 @@ export const window = { // Notify renderer to open the file in file viewer if (uri.scheme === "file" && uri.fsPath) { - _openFileEmitter.fire({ - filePath: uri.fsPath, - line: (_options as { selection?: { start?: { line?: number } } }) - ?.selection?.start?.line, - }); + fireOpenFile( + uri.fsPath, + (_options as { selection?: { start?: { line?: number } } })?.selection + ?.start?.line, + ); } // Return a minimal editor stub @@ -377,6 +455,8 @@ export const window = { return ""; }, languageId: "plaintext", + isDirty: false, + isUntitled: false, }, selection: { start: { line: 0, character: 0 }, @@ -386,6 +466,9 @@ export const window = { }, selections: [], viewColumn: 1, + async edit(_callback) { + return false; + }, }; }, diff --git a/apps/desktop/src/main/lib/vscode-shim/api/workspace.ts b/apps/desktop/src/main/lib/vscode-shim/api/workspace.ts index a259fa869f4..c93e283e7b2 100644 --- a/apps/desktop/src/main/lib/vscode-shim/api/workspace.ts +++ b/apps/desktop/src/main/lib/vscode-shim/api/workspace.ts @@ -7,6 +7,14 @@ import path from "node:path"; import { getConfiguration, onDidChangeConfiguration } from "./configuration"; import { shimLog, shimWarn } from "./debug-log"; import { Disposable, type Event, EventEmitter } from "./event-emitter"; +import { + compileGlobMatchers, + compileGlobPatterns, + directoryMayContainMatches, + globToRegExp, + matchesAnyGlob, + normalizeGlobPath, +} from "./glob-utils"; import { Uri } from "./uri"; interface WorkspaceFolder { @@ -21,6 +29,8 @@ interface TextDocument { readonly languageId: string; readonly version: number; readonly lineCount: number; + readonly isDirty: boolean; + readonly isUntitled: boolean; getText(range?: unknown): string; save(): Promise; } @@ -51,9 +61,42 @@ const _onDidChangeTextDocument = new EventEmitter(); const _onDidOpenTextDocument = new EventEmitter(); const _onDidCloseTextDocument = new EventEmitter(); const _onWillSaveTextDocument = new EventEmitter(); +const _textDocuments: TextDocument[] = []; const fileSystemProviders = new Map(); const textDocumentContentProviders = new Map(); +const DEFAULT_FIND_EXCLUDE_GLOBS = ["**/.git", "**/node_modules"]; +const FILE_TYPE = { + File: 1, + Directory: 2, + SymbolicLink: 64, +} as const; + +export async function resolveTextDocumentContent( + uri: Uri, +): Promise { + if (uri.scheme === "file") { + try { + return await fs.promises.readFile(uri.fsPath, "utf-8"); + } catch { + return undefined; + } + } + + const provider = textDocumentContentProviders.get(uri.scheme) as + | { + provideTextDocumentContent?( + uri: Uri, + ): string | undefined | Promise; + } + | undefined; + if (!provider?.provideTextDocumentContent) { + return undefined; + } + + const content = await provider.provideTextDocumentContent(uri); + return typeof content === "string" ? content : undefined; +} export function setWorkspacePath(folderPath: string): void { const oldPath = workspaceFolderPath; @@ -77,6 +120,42 @@ export function setWorkspacePath(folderPath: string): void { } } +export function setActiveWorkspaceTextDocument( + filePath: string | null, + languageId?: string, +): void { + _textDocuments.length = 0; + if (!filePath) { + return; + } + + const readContent = () => { + try { + return fs.readFileSync(filePath, "utf-8"); + } catch { + return ""; + } + }; + + const content = readContent(); + const doc: TextDocument = { + uri: Uri.file(filePath), + fileName: filePath, + languageId: languageId ?? (path.extname(filePath).slice(1) || "plaintext"), + version: 1, + lineCount: content.split("\n").length, + isDirty: false, + isUntitled: false, + getText() { + return readContent(); + }, + async save() { + return true; + }, + }; + _textDocuments.push(doc); +} + export const workspace = { get workspaceFolders(): WorkspaceFolder[] | undefined { if (!workspaceFolderPath) return undefined; @@ -98,7 +177,7 @@ export const workspace = { }, get textDocuments(): TextDocument[] { - return []; + return [..._textDocuments]; }, get name(): string | undefined { @@ -138,20 +217,20 @@ export const workspace = { }, async openTextDocument(uriOrPath: Uri | string): Promise { - const filePath = - typeof uriOrPath === "string" ? uriOrPath : uriOrPath.fsPath; - const content = fs.existsSync(filePath) - ? fs.readFileSync(filePath, "utf-8") - : ""; + const uri = typeof uriOrPath === "string" ? Uri.file(uriOrPath) : uriOrPath; + const filePath = uri.scheme === "file" ? uri.fsPath : uri.path; + const content = (await resolveTextDocumentContent(uri)) ?? ""; const lines = content.split("\n"); const ext = path.extname(filePath).slice(1); return { - uri: Uri.file(filePath), + uri, fileName: filePath, languageId: ext || "plaintext", version: 1, lineCount: lines.length, + isDirty: false, + isUntitled: false, getText(_range?: unknown) { return content; }, @@ -168,9 +247,18 @@ export const workspace = { _token?: unknown, ): Promise { if (!workspaceFolderPath) return []; + const rootPath = workspaceFolderPath; try { const results: string[] = []; - const ignorePatterns = exclude ? [exclude] : ["node_modules", ".git"]; + const includePatterns = compileGlobPatterns(include); + const includeMatchers = includePatterns.map((pattern) => + globToRegExp(pattern), + ); + const excludeMatchers = compileGlobMatchers( + exclude === undefined + ? `{${DEFAULT_FIND_EXCLUDE_GLOBS.join(",")}}` + : exclude, + ); function walkDir(dir: string, depth: number): void { if (depth > 15 || (maxResults && results.length >= maxResults)) return; @@ -181,14 +269,25 @@ export const workspace = { return; } for (const entry of entries) { - if (ignorePatterns.some((p) => entry.name.includes(p))) continue; const fullPath = path.join(dir, entry.name); + const relativePath = normalizeGlobPath( + path.relative(rootPath, fullPath), + ); + if ( + relativePath && + (matchesAnyGlob(excludeMatchers, relativePath) || + (entry.isDirectory() && + matchesAnyGlob(excludeMatchers, `${relativePath}/`))) + ) { + continue; + } if (entry.isDirectory()) { walkDir(fullPath, depth + 1); } else if (entry.isFile()) { - // Simple extension matching from include pattern (e.g., "**/*.ts") - const ext = include.match(/\*(\.\w+)$/)?.[1]; - if (!ext || entry.name.endsWith(ext)) { + if ( + includeMatchers.length === 0 || + matchesAnyGlob(includeMatchers, relativePath) + ) { results.push(fullPath); } } @@ -260,11 +359,155 @@ export const workspace = { const _onCreate = new EventEmitter(); const _onChange = new EventEmitter(); const _onDelete = new EventEmitter(); + const rootPath = workspaceFolderPath; + const includePatterns = compileGlobPatterns(_globPattern); + const includeMatchers = includePatterns.map((pattern) => + globToRegExp(pattern), + ); + const excludeMatchers = compileGlobMatchers( + `{${DEFAULT_FIND_EXCLUDE_GLOBS.join(",")}}`, + ); + const dirWatchers = new Map(); + + const matchesWatcherPath = (fullPath: string): boolean => { + if (!rootPath) { + return false; + } + const relativePath = normalizeGlobPath(path.relative(rootPath, fullPath)); + if (!relativePath || relativePath.startsWith("..")) { + return false; + } + return ( + includeMatchers.length === 0 || + matchesAnyGlob(includeMatchers, relativePath) || + matchesAnyGlob(includeMatchers, `${relativePath}/`) + ); + }; + + const shouldSkipDirectory = (directoryPath: string): boolean => { + if (!rootPath) { + return false; + } + const relativePath = normalizeGlobPath( + path.relative(rootPath, directoryPath), + ); + if (!relativePath || relativePath.startsWith("..")) { + return false; + } + return ( + matchesAnyGlob(excludeMatchers, relativePath) || + matchesAnyGlob(excludeMatchers, `${relativePath}/`) || + !directoryMayContainMatches(relativePath, includePatterns) + ); + }; + + const closeDescendantWatchers = (targetPath: string) => { + const watchedDirs = [...dirWatchers.keys()]; + for (const watchedDir of watchedDirs) { + if ( + watchedDir === targetPath || + watchedDir.startsWith(`${targetPath}${path.sep}`) + ) { + const watcher = dirWatchers.get(watchedDir); + if (!watcher) { + continue; + } + watcher.close(); + dirWatchers.delete(watchedDir); + } + } + }; + + const addDirectoryWatcher = (directoryPath: string) => { + if (dirWatchers.has(directoryPath)) { + return; + } + if (shouldSkipDirectory(directoryPath)) { + return; + } + + try { + const watcher = fs.watch(directoryPath, (eventType, filename) => { + if (!filename) { + return; + } + + const fullPath = path.join(directoryPath, filename.toString()); + const exists = fs.existsSync(fullPath); + + if (exists) { + try { + if ( + fs.statSync(fullPath).isDirectory() && + !shouldSkipDirectory(fullPath) + ) { + addDirectoryWatcher(fullPath); + } + } catch {} + } else { + closeDescendantWatchers(fullPath); + } + + if (!matchesWatcherPath(fullPath)) { + return; + } + + const uri = Uri.file(fullPath); + if (!exists) { + if (!_ignoreDeleteEvents) { + _onDelete.fire(uri); + } + return; + } + + if (eventType === "change") { + if (!_ignoreChangeEvents) { + _onChange.fire(uri); + } + return; + } + + if (!_ignoreCreateEvents) { + _onCreate.fire(uri); + } + }); + dirWatchers.set(directoryPath, watcher); + } catch (error) { + shimWarn( + `[vscode-shim] createFileSystemWatcher failed for ${directoryPath}:`, + error, + ); + return; + } + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(directoryPath, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + addDirectoryWatcher(path.join(directoryPath, entry.name)); + } + }; + + if (rootPath) { + addDirectoryWatcher(rootPath); + } + return { onDidCreate: _onCreate.event, onDidChange: _onChange.event, onDidDelete: _onDelete.event, dispose() { + for (const watcher of dirWatchers.values()) { + watcher.close(); + } + dirWatchers.clear(); _onCreate.dispose(); _onChange.dispose(); _onDelete.dispose(); @@ -371,6 +614,44 @@ export const workspace = { ): Promise { await fs.promises.copyFile(source.fsPath, target.fsPath); }, + async readDirectory(uri: Uri): Promise<[string, number][]> { + if (uri.scheme !== "file") { + const provider = fileSystemProviders.get(uri.scheme) as + | { readDirectory?(uri: Uri): Promise<[string, number][]> } + | undefined; + if (provider?.readDirectory) { + return provider.readDirectory(uri); + } + throw new Error(`No file system provider for scheme: ${uri.scheme}`); + } + const entries = await fs.promises.readdir(uri.fsPath, { + withFileTypes: true, + }); + return Promise.all( + entries.map(async (entry) => { + if (entry.isDirectory()) { + return [entry.name, FILE_TYPE.Directory] as [string, number]; + } + + if (!entry.isSymbolicLink()) { + return [entry.name, FILE_TYPE.File] as [string, number]; + } + + const entryPath = path.join(uri.fsPath, entry.name); + + try { + const stats = await fs.promises.stat(entryPath); + return [ + entry.name, + (stats.isDirectory() ? FILE_TYPE.Directory : FILE_TYPE.File) | + FILE_TYPE.SymbolicLink, + ] as [string, number]; + } catch { + return [entry.name, FILE_TYPE.SymbolicLink] as [string, number]; + } + }), + ); + }, isWritableFileSystem(scheme: string): boolean | undefined { return scheme === "file" ? true : undefined; }, diff --git a/apps/desktop/src/main/lib/vscode-shim/ipc-types.ts b/apps/desktop/src/main/lib/vscode-shim/ipc-types.ts index 907b1a1b714..1b59033aa41 100644 --- a/apps/desktop/src/main/lib/vscode-shim/ipc-types.ts +++ b/apps/desktop/src/main/lib/vscode-shim/ipc-types.ts @@ -40,7 +40,13 @@ export type WorkerToMainMessage = html: string | null; } | { type: "open-file"; filePath: string; line?: number } - | { type: "open-diff"; leftUri: string; rightUri: string; title?: string } + | { + type: "open-diff"; + leftUri: string; + rightUri: string; + title?: string; + leftContent?: string; + } | { type: "show-dialog"; requestId: string; diff --git a/apps/desktop/src/main/lib/vscode-shim/vscode-api.ts b/apps/desktop/src/main/lib/vscode-shim/vscode-api.ts index c74fe03de59..b52e0b58dfa 100644 --- a/apps/desktop/src/main/lib/vscode-shim/vscode-api.ts +++ b/apps/desktop/src/main/lib/vscode-shim/vscode-api.ts @@ -140,6 +140,10 @@ const CompletionItemKind = { TypeParameter: 24, } as const; const TextDocumentChangeReason = { Undo: 1, Redo: 2 } as const; +const QuickPickItemKind = { + Default: 0, + Separator: -1, +} as const; // Stub classes class Position { @@ -733,6 +737,7 @@ export function createVscodeApi(): Record { SymbolKind, CompletionItemKind, TextDocumentChangeReason, + QuickPickItemKind, }; // Proxy logger: log access to unimplemented APIs diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts index 2f1ccfb5030..9de29695750 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts @@ -1231,7 +1231,7 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ section: "vscodeExtensions", title: "VS Code Extensions", description: - "Manage VS Code extensions like Claude Code and ChatGPT running inside Superset", + "Manage VS Code extensions like Claude Code, ChatGPT, and Kimi Code running inside Superset", keywords: [ "vscode", "vs code", @@ -1239,6 +1239,7 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ "claude", "chatgpt", "codex", + "kimi", "ai", "install", "manage", diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/vscode-extensions/components/VscodeExtensionsSettings/VscodeExtensionsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/vscode-extensions/components/VscodeExtensionsSettings/VscodeExtensionsSettings.tsx index 319d6d6dca2..09b168b0e96 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/vscode-extensions/components/VscodeExtensionsSettings/VscodeExtensionsSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/vscode-extensions/components/VscodeExtensionsSettings/VscodeExtensionsSettings.tsx @@ -13,6 +13,7 @@ import { LuSparkles, LuTrash2, } from "react-icons/lu"; +import { SiOpenai } from "react-icons/si"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { INDENT_RAINBOW_DEFAULT_COLORS } from "renderer/screens/main/components/WorkspaceView/components/CodeEditor/createIndentRainbowPlugin"; import { TRAILING_SPACES_DEFAULT_COLOR } from "renderer/screens/main/components/WorkspaceView/components/CodeEditor/createTrailingSpacesPlugin"; @@ -32,7 +33,8 @@ const EXTENSION_ICONS: Record< React.ComponentType<{ className?: string }> > = { "anthropic.claude-code": LuBot, - "openai.chatgpt": LuSparkles, + "openai.chatgpt": SiOpenai, + "moonshot-ai.kimi-code": LuSparkles, }; const HEX6_COLOR_RE = /^#[0-9a-f]{6}$/i; diff --git a/apps/desktop/src/renderer/screens/main/components/VscodeExtensionButtons/VscodeExtensionButtons.tsx b/apps/desktop/src/renderer/screens/main/components/VscodeExtensionButtons/VscodeExtensionButtons.tsx index d31a31bdaeb..e3474d576bd 100644 --- a/apps/desktop/src/renderer/screens/main/components/VscodeExtensionButtons/VscodeExtensionButtons.tsx +++ b/apps/desktop/src/renderer/screens/main/components/VscodeExtensionButtons/VscodeExtensionButtons.tsx @@ -13,6 +13,7 @@ import { LuSparkles, LuSquareArrowOutUpRight, } from "react-icons/lu"; +import { SiOpenai } from "react-icons/si"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useWorkspaceId } from "renderer/screens/main/components/WorkspaceView/WorkspaceIdContext"; import { @@ -132,12 +133,21 @@ export function VscodeExtensionButtons() { {installed.some((e) => e.id === "openai.chatgpt") && ( )} + {installed.some((e) => e.id === "moonshot-ai.kimi-code") && ( + + )} ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx index 9f9ff8e561e..761b3af5ea7 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx @@ -185,6 +185,8 @@ export function FileViewerPane({ const diffCategory = fileViewer?.diffCategory; const commitHash = fileViewer?.commitHash; const oldPath = fileViewer?.oldPath; + const inlineOriginalContent = fileViewer?.inlineOriginalContent; + const inlineOriginalContentKey = fileViewer?.inlineOriginalContentKey; const initialLine = fileViewer?.initialLine; const initialColumn = fileViewer?.initialColumn; @@ -213,8 +215,16 @@ export function FileViewerPane({ diffCategory, commitHash, oldPath, + inlineOriginalContentKey, }), - [normalizedWorkspaceId, filePath, diffCategory, commitHash, oldPath], + [ + normalizedWorkspaceId, + filePath, + diffCategory, + commitHash, + oldPath, + inlineOriginalContentKey, + ], ); const documentState = useEditorDocumentsStore( (state) => state.documents[documentKey], @@ -272,6 +282,7 @@ export function FileViewerPane({ diffCategory, commitHash, oldPath, + inlineOriginalContent, }); useEffect(() => { @@ -292,6 +303,7 @@ export function FileViewerPane({ diffCategory, commitHash, oldPath, + inlineOriginalContentKey, }, { preserveDocumentState, @@ -310,6 +322,7 @@ export function FileViewerPane({ diffCategory, commitHash, oldPath, + inlineOriginalContentKey, ]); const { handleSaveFile, isSaving } = useFileSave({ diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts index 87cdb5dc610..ff75f340b80 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts @@ -23,6 +23,7 @@ interface UseFileContentParams { diffCategory?: ChangeCategory; commitHash?: string; oldPath?: string; + inlineOriginalContent?: string; } function isBinaryText(content: string): boolean { @@ -43,6 +44,7 @@ export function useFileContent({ diffCategory, commitHash, oldPath, + inlineOriginalContent, }: UseFileContentParams) { // For remote URLs (e.g. Vercel Blob), skip all IPC queries const isRemote = @@ -181,7 +183,12 @@ export function useFileContent({ oldAbsolutePath: oldPath, }, { - enabled: !isRemote && isUnstagedDiff && !!filePath && !!worktreePath, + enabled: + !isRemote && + isUnstagedDiff && + inlineOriginalContent === undefined && + !!filePath && + !!worktreePath, }, ); @@ -200,7 +207,10 @@ export function useFileContent({ const diffData = useMemo(() => { if (isGitDiff) return gitDiffData; - if (isUnstagedDiff && gitOriginal) { + if ( + isUnstagedDiff && + (inlineOriginalContent !== undefined || gitOriginal) + ) { let modifiedContent = ""; if (workingCopy) { if (workingCopy.exceededLimit) { @@ -210,7 +220,7 @@ export function useFileContent({ } } return { - original: gitOriginal.content, + original: inlineOriginalContent ?? gitOriginal?.content ?? "", modified: modifiedContent, language: detectLanguage(filePath), }; @@ -221,6 +231,7 @@ export function useFileContent({ isUnstagedDiff, gitDiffData, gitOriginal, + inlineOriginalContent, workingCopy, filePath, ]); @@ -228,7 +239,8 @@ export function useFileContent({ const isLoadingDiff = isGitDiff ? isLoadingGitDiff : isUnstagedDiff - ? isLoadingGitOriginal || isLoadingWorkingCopy + ? (inlineOriginalContent === undefined && isLoadingGitOriginal) || + isLoadingWorkingCopy : false; return { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx index 6f99fd4f667..24c9f5cdd32 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx @@ -44,6 +44,7 @@ import { LuSparkles, LuX, } from "react-icons/lu"; +import { SiOpenai } from "react-icons/si"; import { HotkeyLabel } from "renderer/hotkeys"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useWorkspaceId } from "renderer/screens/main/components/WorkspaceView/WorkspaceIdContext"; @@ -107,6 +108,10 @@ const RIGHT_SIDEBAR_TAB_METADATA: Record< }, [RightSidebarTab.Codex]: { label: "Codex", + icon: SiOpenai, + }, + [RightSidebarTab.Kimi]: { + label: "Kimi", icon: LuSparkles, }, }; @@ -300,6 +305,7 @@ export function RightSidebar({ isActive = true }: { isActive?: boolean }) { ); const showClaudeCodeTab = installedExtensionIds.has("anthropic.claude-code"); const showCodexTab = installedExtensionIds.has("openai.chatgpt"); + const showKimiTab = installedExtensionIds.has("moonshot-ai.kimi-code"); const hasProblemErrors = (workspaceDiagnostics?.summary.errorCount ?? 0) > 0; const dockerComposeFiles = dockerComposeFilesQuery.data; const isResolvingDockerVisibility = @@ -334,6 +340,9 @@ export function RightSidebar({ isActive = true }: { isActive?: boolean }) { if (tabId === RightSidebarTab.Codex) { return showCodexTab; } + if (tabId === RightSidebarTab.Kimi) { + return showKimiTab; + } return true; }) .map((tabId) => ({ @@ -349,6 +358,7 @@ export function RightSidebar({ isActive = true }: { isActive?: boolean }) { showDockerTab, showClaudeCodeTab, showCodexTab, + showKimiTab, ]); useEffect(() => { @@ -852,6 +862,24 @@ export function RightSidebar({ isActive = true }: { isActive?: boolean }) { /> )} + {showKimiTab && ( +
+ +
+ )} ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/hooks/useVscodeDiffSync.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/hooks/useVscodeDiffSync.ts index 511fd423615..fe37f8dc869 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/hooks/useVscodeDiffSync.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/hooks/useVscodeDiffSync.ts @@ -26,6 +26,9 @@ export function useVscodeDiffSync() { viewMode: "diff", diffCategory: "unstaged", useRightSidebarOpenViewWidth: true, + inlineOriginalContent: data.leftContent, + inlineOriginalContentKey: + data.leftContent !== undefined ? data.leftUri : undefined, ...(isRename ? { oldPath: data.leftUri } : {}), }); }, diff --git a/apps/desktop/src/renderer/stores/editor-state/editorCoordinator.ts b/apps/desktop/src/renderer/stores/editor-state/editorCoordinator.ts index c0980828901..591e78d6793 100644 --- a/apps/desktop/src/renderer/stores/editor-state/editorCoordinator.ts +++ b/apps/desktop/src/renderer/stores/editor-state/editorCoordinator.ts @@ -93,6 +93,8 @@ function applyFileViewerReplacement( diffCategory: options.diffCategory, commitHash: options.commitHash, oldPath: options.oldPath, + inlineOriginalContent: options.inlineOriginalContent, + inlineOriginalContentKey: options.inlineOriginalContentKey, initialLine: options.line, initialColumn: options.column, displayName: options.displayName, diff --git a/apps/desktop/src/renderer/stores/editor-state/types.ts b/apps/desktop/src/renderer/stores/editor-state/types.ts index d1d77ceccb7..b199269d39c 100644 --- a/apps/desktop/src/renderer/stores/editor-state/types.ts +++ b/apps/desktop/src/renderer/stores/editor-state/types.ts @@ -61,6 +61,7 @@ export interface FileViewerDocumentIdentity { diffCategory?: ChangeCategory; commitHash?: string; oldPath?: string; + inlineOriginalContentKey?: string; } export function resolveEditorDocumentScope( @@ -102,6 +103,7 @@ export function buildEditorDocumentKey({ diffCategory, commitHash, oldPath, + inlineOriginalContentKey, }: FileViewerDocumentIdentity): string { const scope = resolveEditorDocumentScope(diffCategory); @@ -110,6 +112,7 @@ export function buildEditorDocumentKey({ encodeDocumentKeyPart(workspaceId), encodeDocumentKeyPart(scope), encodeDocumentKeyPart(filePath), + encodeDocumentKeyPart(inlineOriginalContentKey), ].join("::"); } diff --git a/apps/desktop/src/renderer/stores/sidebar-state.ts b/apps/desktop/src/renderer/stores/sidebar-state.ts index 35fbab497fd..5f5d010f0bc 100644 --- a/apps/desktop/src/renderer/stores/sidebar-state.ts +++ b/apps/desktop/src/renderer/stores/sidebar-state.ts @@ -15,6 +15,7 @@ export enum RightSidebarTab { Databases = "databases", ClaudeCode = "claude-code", Codex = "codex", + Kimi = "kimi", } export const DEFAULT_RIGHT_SIDEBAR_TAB_ORDER = [ @@ -26,6 +27,7 @@ export const DEFAULT_RIGHT_SIDEBAR_TAB_ORDER = [ RightSidebarTab.Databases, RightSidebarTab.ClaudeCode, RightSidebarTab.Codex, + RightSidebarTab.Kimi, ] as const satisfies RightSidebarTab[]; export const DEFAULT_SIDEBAR_WIDTH = 250; diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index 2fbb43e5539..2f909d5d919 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -960,7 +960,9 @@ export const useTabsStore = create()( const isSameFile = pathsMatch(existingFileViewer.filePath, options.filePath) && existingFileViewer.diffCategory === options.diffCategory && - existingFileViewer.commitHash === options.commitHash; + existingFileViewer.commitHash === options.commitHash && + existingFileViewer.inlineOriginalContentKey === + options.inlineOriginalContentKey; if (paneDocument?.dirty && !isSameFile) { set({ @@ -1052,6 +1054,8 @@ export const useTabsStore = create()( diffCategory: options.diffCategory, commitHash: options.commitHash, oldPath: options.oldPath, + inlineOriginalContent: options.inlineOriginalContent, + inlineOriginalContentKey: options.inlineOriginalContentKey, initialLine: options.line, initialColumn: options.column, displayName: options.displayName, diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index 16c836f0902..ecede6d2869 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -82,6 +82,8 @@ export interface AddFileViewerPaneOptions { commitHash?: string; /** Canonical absolute original path for renamed files */ oldPath?: string; + inlineOriginalContent?: string; + inlineOriginalContentKey?: string; /** Line to scroll to (raw mode only) */ line?: number; /** Column to scroll to (raw mode only) */ diff --git a/apps/desktop/src/renderer/stores/tabs/utils.ts b/apps/desktop/src/renderer/stores/tabs/utils.ts index 7265db91813..c77b2599aa6 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils.ts @@ -204,6 +204,8 @@ export interface CreateFileViewerPaneOptions { fileStatus?: FileStatus; commitHash?: string; oldPath?: string; + inlineOriginalContent?: string; + inlineOriginalContentKey?: string; /** Line to scroll to (raw mode only) */ line?: number; /** Column to scroll to (raw mode only) */ @@ -234,6 +236,8 @@ export const createFileViewerPane = ( diffCategory: options.diffCategory, commitHash: options.commitHash, oldPath: options.oldPath, + inlineOriginalContent: options.inlineOriginalContent, + inlineOriginalContentKey: options.inlineOriginalContentKey, initialLine: options.line, initialColumn: options.column, displayName: options.displayName, @@ -845,11 +849,14 @@ export const updateHistoryStack = ( export const fileViewerTargetsMatch = ( fileViewer: - | Pick + | Pick< + FileViewerState, + "filePath" | "diffCategory" | "commitHash" | "inlineOriginalContentKey" + > | undefined, options: Pick< AddFileViewerPaneOptions, - "filePath" | "diffCategory" | "commitHash" + "filePath" | "diffCategory" | "commitHash" | "inlineOriginalContentKey" >, ): boolean => { if (!fileViewer) { @@ -870,7 +877,8 @@ export const fileViewerTargetsMatch = ( return ( filePathsMatch && fileViewer.diffCategory === options.diffCategory && - fileViewer.commitHash === options.commitHash + fileViewer.commitHash === options.commitHash && + fileViewer.inlineOriginalContentKey === options.inlineOriginalContentKey ); }; @@ -995,6 +1003,11 @@ export const applyFileViewerOpenOptionsToPane = ( viewMode: options.viewMode ?? fallbackViewMode, isPinned: pane.fileViewer.isPinned || (options.isPinned ?? false), oldPath: options.oldPath ?? pane.fileViewer.oldPath, + inlineOriginalContent: + options.inlineOriginalContent ?? pane.fileViewer.inlineOriginalContent, + inlineOriginalContentKey: + options.inlineOriginalContentKey ?? + pane.fileViewer.inlineOriginalContentKey, initialLine: options.line ?? pane.fileViewer.initialLine, initialColumn: options.column ?? pane.fileViewer.initialColumn, displayName: options.displayName ?? pane.fileViewer.displayName, @@ -1009,6 +1022,10 @@ export const applyFileViewerOpenOptionsToPane = ( nextFileViewer.viewMode === pane.fileViewer.viewMode && nextFileViewer.isPinned === pane.fileViewer.isPinned && nextFileViewer.oldPath === pane.fileViewer.oldPath && + nextFileViewer.inlineOriginalContent === + pane.fileViewer.inlineOriginalContent && + nextFileViewer.inlineOriginalContentKey === + pane.fileViewer.inlineOriginalContentKey && nextFileViewer.initialLine === pane.fileViewer.initialLine && nextFileViewer.initialColumn === pane.fileViewer.initialColumn && nextFileViewer.displayName === pane.fileViewer.displayName diff --git a/apps/desktop/src/shared/tabs-types.ts b/apps/desktop/src/shared/tabs-types.ts index c6e11690383..6a7aef821c8 100644 --- a/apps/desktop/src/shared/tabs-types.ts +++ b/apps/desktop/src/shared/tabs-types.ts @@ -120,6 +120,10 @@ export interface FileViewerState { commitHash?: string; /** Canonical absolute original path for renamed files */ oldPath?: string; + /** Optional inline baseline/original content for extension-driven diffs */ + inlineOriginalContent?: string; + /** Stable identity for inlineOriginalContent so pane reuse/document keys stay correct */ + inlineOriginalContentKey?: string; /** Initial line to scroll to (raw mode only, transient - applied once) */ initialLine?: number; /** Initial column to scroll to (raw mode only, transient - applied once) */