diff --git a/README.md b/README.md index 1a7608c5..8af9dd7c 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ Codex Panel supports the Codex workflows that are useful from a side panel: Codex Panel makes a few Obsidian-specific adjustments instead of mirroring the terminal UI exactly: - Wikilinks in sent messages are resolved to Codex file mentions when the target file exists. The visible message text is preserved, unresolved wikilinks are left alone, and note bodies are not attached automatically. +- Markdown links in rendered messages that point to existing vault files open in Obsidian. External links and non-vault file paths keep their normal link behavior. - Forking a thread opens the fork in a new right-sidebar panel so the source thread stays visible. - Rolling back is limited to thread history; see File Changes and Rollback. - The composer sends with `Enter` by default, with `Shift+Enter` for a newline. You can switch sending to `Cmd/Ctrl+Enter` in the plugin settings. diff --git a/src/panel/markdown-file-links.ts b/src/panel/markdown-file-links.ts new file mode 100644 index 00000000..1d636038 --- /dev/null +++ b/src/panel/markdown-file-links.ts @@ -0,0 +1,65 @@ +import { TFile, type App } from "obsidian"; + +export function markdownFileLinkTarget(app: App, vaultPath: string, href: string): string | null { + const parsed = parseMarkdownFileHref(href); + if (!parsed) return null; + + const relativePath = vaultRelativePath(vaultPath, parsed.path); + if (!relativePath) return null; + + const normalized = normalizeFilePath(relativePath); + const abstractFile = app.vault.getAbstractFileByPath(normalized); + return abstractFile instanceof TFile ? `${normalized}${parsed.subpath}` : null; +} + +function parseMarkdownFileHref(href: string): { path: string; subpath: string } | null { + const trimmed = href.trim(); + if (!trimmed || isExternalHref(trimmed)) return null; + + const fragmentIndex = trimmed.indexOf("#"); + const withoutFragment = fragmentIndex === -1 ? trimmed : trimmed.slice(0, fragmentIndex); + if (!withoutFragment) return null; + + const decoded = decodeHref(withoutFragment); + const withoutLine = decoded.replace(/:(\d+)(?::\d+)?$/, ""); + const subpath = fragmentIndex === -1 ? "" : decodeHref(trimmed.slice(fragmentIndex)); + return withoutLine ? { path: withoutLine, subpath } : null; +} + +function isExternalHref(href: string): boolean { + if (isWindowsAbsolutePath(href)) return false; + return /^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith("//"); +} + +function vaultRelativePath(vaultPath: string, path: string): string | null { + const normalizedPath = normalizeFilePath(path); + const normalizedVaultPath = normalizeFilePath(vaultPath); + if (!normalizedPath || !normalizedVaultPath) return null; + + if (!isAbsolutePath(normalizedPath)) return normalizedPath; + if (normalizedPath === normalizedVaultPath) return null; + + const vaultPrefix = normalizedVaultPath.endsWith("/") ? normalizedVaultPath : `${normalizedVaultPath}/`; + return normalizedPath.startsWith(vaultPrefix) ? normalizedPath.slice(vaultPrefix.length) : null; +} + +function decodeHref(href: string): string { + try { + return decodeURI(href); + } catch { + return href; + } +} + +function normalizeFilePath(path: string): string { + const normalized = path.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/\/$/, ""); + return normalized.replace(/^\.\//, ""); +} + +function isAbsolutePath(path: string): boolean { + return path.startsWith("/") || isWindowsAbsolutePath(path); +} + +function isWindowsAbsolutePath(path: string): boolean { + return /^[a-z]:[\\/]/i.test(path); +} diff --git a/src/panel/message-renderer.ts b/src/panel/message-renderer.ts index 0cd4fdf3..1afa99eb 100644 --- a/src/panel/message-renderer.ts +++ b/src/panel/message-renderer.ts @@ -6,6 +6,7 @@ import { renderTextWithWikiLinks as renderInlineWikiLinks } from "../ui/dom"; import { messageRenderBlocks, syncMessageRenderBlocks } from "../ui/message-stream"; import { bottomScrollTop, captureScrollAnchor, isNearScrollBottom, restoreScrollAnchor } from "../ui/scroll"; import type { TurnDiffViewState } from "../ui/turn-diff"; +import { markdownFileLinkTarget } from "./markdown-file-links"; import { isRollbackCandidateItem, rollbackCandidateFromItems } from "./rollback"; import type { PanelState } from "./state"; @@ -89,6 +90,7 @@ export class PanelMessageRenderer { const sourcePath = this.options.app.workspace.getActiveFile()?.path ?? ""; void MarkdownRenderer.render(this.options.app, text, parent, sourcePath, this.options.owner).then(() => { this.bindRenderedWikiLinks(parent, sourcePath); + this.bindRenderedMarkdownFileLinks(parent, sourcePath); this.scrollMarkdownMessageIntoPinnedBottom(parent); }); } @@ -117,6 +119,20 @@ export class PanelMessageRenderer { }); } + private bindRenderedMarkdownFileLinks(parent: HTMLElement, sourcePath: string): void { + parent.querySelectorAll("a[href]:not(.internal-link)").forEach((link) => { + const href = link.getAttribute("href") ?? ""; + const target = markdownFileLinkTarget(this.options.app, this.options.vaultPath, href); + if (!target) return; + + link.addClass("codex-panel__filelink"); + link.onclick = (event) => { + event.preventDefault(); + void this.options.app.workspace.openLinkText(target, sourcePath, false); + }; + }); + } + private renderTextWithWikiLinks(parent: HTMLElement, text: string): void { renderInlineWikiLinks(parent, text, (target) => { const sourcePath = this.options.app.workspace.getActiveFile()?.path ?? ""; diff --git a/tests/display-model.test.ts b/tests/display-model.test.ts index 767d17d0..e74c92cc 100644 --- a/tests/display-model.test.ts +++ b/tests/display-model.test.ts @@ -11,6 +11,7 @@ import { upsertDisplayItem, } from "../src/display/stream-updates"; import { normalizeProposedPlanMarkdown, planProgressDisplayItem } from "../src/display/plan"; +import { pathRelativeToRoot } from "../src/display/paths"; import { createAutoReviewResultItem, createReviewResultItem } from "../src/display/review"; import { executionState } from "../src/display/state"; import { displayItemFromThreadItem, displayItemsFromTurns } from "../src/display/thread-items"; @@ -354,6 +355,29 @@ describe("display model", () => { }); }); + it("summarizes Windows paths relative to the command cwd", () => { + const item: ThreadItem = { + type: "commandExecution", + id: "cmd-1", + command: "rg target C:\\Vault\\src\\display", + cwd: "C:\\Vault", + processId: null, + source: "agent", + status: "completed", + commandActions: [{ type: "search", command: "rg", query: "target", path: "C:\\Vault\\src\\display" }], + aggregatedOutput: "search results", + exitCode: 0, + durationMs: 10, + }; + + expect(displayItemFromThreadItem(item, "t1")).toMatchObject({ + kind: "command", + actionLabel: "search", + text: "target in src/display", + state: "completed", + }); + }); + it("labels parsed file listing commands and summarizes their path", () => { const item: ThreadItem = { type: "commandExecution", @@ -1028,6 +1052,11 @@ describe("display model", () => { expect(assistantBlock).toMatchObject({ item: { editedFiles: ["/tmp/outside.txt", "src/main.ts", "styles.css"] } }); }); + it("keeps Windows sibling paths outside the workspace root", () => { + expect(pathRelativeToRoot("C:\\Vault\\project\\src\\main.ts", "C:\\Vault\\project")).toBe("src/main.ts"); + expect(pathRelativeToRoot("C:\\Vault\\project-other\\src\\main.ts", "C:\\Vault\\project")).toBe("C:/Vault/project-other/src/main.ts"); + }); + it("detects failed command state", () => { expect( executionState({ diff --git a/tests/markdown-file-links.test.ts b/tests/markdown-file-links.test.ts new file mode 100644 index 00000000..3ea2fe39 --- /dev/null +++ b/tests/markdown-file-links.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { TFile, type App } from "obsidian"; + +import { markdownFileLinkTarget } from "../src/panel/markdown-file-links"; + +describe("markdown file links", () => { + it("resolves absolute vault paths", () => { + const app = appFixture(["docs/Guide.md"]); + + expect(markdownFileLinkTarget(app, "/Users/showhey/Vault", "/Users/showhey/Vault/docs/Guide.md")).toBe("docs/Guide.md"); + }); + + it("strips Codex line suffixes from absolute vault paths", () => { + const app = appFixture(["src/main.ts"]); + + expect(markdownFileLinkTarget(app, "/Users/showhey/Vault", "/Users/showhey/Vault/src/main.ts:12")).toBe("src/main.ts"); + expect(markdownFileLinkTarget(app, "/Users/showhey/Vault", "/Users/showhey/Vault/src/main.ts:12:4")).toBe("src/main.ts"); + }); + + it("keeps markdown fragments on vault file links", () => { + const app = appFixture(["docs/foo.md", "src/main.ts"]); + + expect(markdownFileLinkTarget(app, "/Users/showhey/Vault", "/Users/showhey/Vault/docs/foo.md#Heading")).toBe("docs/foo.md#Heading"); + expect(markdownFileLinkTarget(app, "/Users/showhey/Vault", "/Users/showhey/Vault/src/main.ts:12#L12")).toBe("src/main.ts#L12"); + }); + + it("resolves relative markdown links when the file exists", () => { + const app = appFixture(["docs/foo.md"]); + + expect(markdownFileLinkTarget(app, "/Users/showhey/Vault", "docs/foo.md")).toBe("docs/foo.md"); + }); + + it("resolves Windows absolute vault paths", () => { + const app = appFixture(["docs/Guide.md", "src/main.ts"]); + + expect(markdownFileLinkTarget(app, "C:\\Users\\showhey\\Vault", "C:\\Users\\showhey\\Vault\\docs\\Guide.md")).toBe("docs/Guide.md"); + expect(markdownFileLinkTarget(app, "C:/Users/showhey/Vault", "C:/Users/showhey/Vault/src/main.ts:12")).toBe("src/main.ts"); + }); + + it("leaves external and non-vault links untouched", () => { + const app = appFixture(["docs/foo.md"]); + + expect(markdownFileLinkTarget(app, "/Users/showhey/Vault", "https://example.com/docs/foo.md")).toBeNull(); + expect(markdownFileLinkTarget(app, "/Users/showhey/Vault", "mailto:test@example.com")).toBeNull(); + expect(markdownFileLinkTarget(app, "/Users/showhey/Vault", "/Users/showhey/Other/docs/foo.md")).toBeNull(); + expect(markdownFileLinkTarget(app, "C:/Users/showhey/Vault", "C:/Users/showhey/Other/docs/foo.md")).toBeNull(); + }); +}); + +function appFixture(paths: string[]): App { + const files = new Map(paths.map((path) => [path, tFile(path)])); + return { + vault: { + getAbstractFileByPath: (path: string) => files.get(path) ?? null, + }, + } as unknown as App; +} + +function tFile(path: string): TFile { + const basename = path.split("/").pop()?.replace(/\.md$/, "") ?? path; + return Object.assign(new TFile(), { path, basename }); +}