mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Open vault markdown file links
This commit is contained in:
parent
31281ae137
commit
cff33d47c9
5 changed files with 173 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
65
src/panel/markdown-file-links.ts
Normal file
65
src/panel/markdown-file-links.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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<HTMLAnchorElement>("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 ?? "";
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
62
tests/markdown-file-links.test.ts
Normal file
62
tests/markdown-file-links.test.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
Loading…
Reference in a new issue