Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ Send Annotations → feedback sent to agent session
| `/api/image` | GET | Serve image by path query param |
| `/api/upload` | POST | Upload image, returns `{ path, originalName }` |
| `/api/obsidian/vaults`| GET | Detect available Obsidian vaults |
| `/api/reference/obsidian/files` | GET | List vault markdown files as nested tree (`?vaultPath=<path>`) |
| `/api/reference/obsidian/doc` | GET | Read a vault markdown file (`?vaultPath=<path>&path=<file>`) |
| `/api/plan/vscode-diff` | POST | Open diff in VS Code (body: baseVersion) |
| `/api/doc` | GET | Serve linked .md/.mdx file (`?path=<path>`) |

Expand Down
67 changes: 64 additions & 3 deletions packages/editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import { deriveImageName } from '@plannotator/ui/components/AttachmentsButton';
import { useSidebar } from '@plannotator/ui/hooks/useSidebar';
import { usePlanDiff, type VersionInfo } from '@plannotator/ui/hooks/usePlanDiff';
import { useLinkedDoc } from '@plannotator/ui/hooks/useLinkedDoc';
import { useVaultBrowser } from '@plannotator/ui/hooks/useVaultBrowser';
import { isVaultBrowserEnabled } from '@plannotator/ui/utils/obsidian';
import { SidebarTabs } from '@plannotator/ui/components/sidebar/SidebarTabs';
import { SidebarContainer } from '@plannotator/ui/components/sidebar/SidebarContainer';
import { PlanDiffViewer } from '@plannotator/ui/components/plan-diff/PlanDiffViewer';
Expand Down Expand Up @@ -435,6 +437,59 @@ const App: React.FC = () => {
viewerRef, sidebar,
});

// Obsidian vault browser
const vaultBrowser = useVaultBrowser();

const showVaultTab = useMemo(() => isVaultBrowserEnabled(), [uiPrefs]);
const vaultPath = useMemo(() => {
if (!showVaultTab) return '';
const settings = getObsidianSettings();
return getEffectiveVaultPath(settings);
}, [showVaultTab, uiPrefs]);

// Clear active file when vault browser is disabled
useEffect(() => {
if (!showVaultTab) vaultBrowser.setActiveFile(null);
}, [showVaultTab]);

// Auto-fetch vault tree when vault tab is first opened
useEffect(() => {
if (sidebar.activeTab === 'vault' && showVaultTab && vaultPath && vaultBrowser.tree.length === 0 && !vaultBrowser.isLoading) {
vaultBrowser.fetchTree(vaultPath);
}
}, [sidebar.activeTab, showVaultTab, vaultPath]);

const buildVaultDocUrl = React.useCallback(
(vp: string) => (path: string) =>
`/api/reference/obsidian/doc?vaultPath=${encodeURIComponent(vp)}&path=${encodeURIComponent(path)}`,
[]
);

// Vault file selection: open via linked doc system with vault endpoint
const handleVaultFileSelect = React.useCallback((relativePath: string) => {
linkedDocHook.open(relativePath, buildVaultDocUrl(vaultPath));
vaultBrowser.setActiveFile(relativePath);
}, [vaultPath, linkedDocHook, vaultBrowser, buildVaultDocUrl]);

// Route linked doc opens through vault endpoint when viewing a vault file
const handleOpenLinkedDoc = React.useCallback((docPath: string) => {
if (vaultBrowser.activeFile && vaultPath) {
linkedDocHook.open(docPath, buildVaultDocUrl(vaultPath));
} else {
linkedDocHook.open(docPath);
}
}, [vaultBrowser.activeFile, vaultPath, linkedDocHook, buildVaultDocUrl]);

// Wrap linked doc back to also clear vault active file
const handleLinkedDocBack = React.useCallback(() => {
linkedDocHook.back();
vaultBrowser.setActiveFile(null);
}, [linkedDocHook, vaultBrowser]);

const handleVaultFetchTree = React.useCallback(() => {
vaultBrowser.fetchTree(vaultPath);
}, [vaultBrowser, vaultPath]);

// Track active section for TOC highlighting
const headingCount = useMemo(() => blocks.filter(b => b.type === 'heading').length, [blocks]);
const activeSection = useActiveSection(containerRef, headingCount);
Expand Down Expand Up @@ -1199,6 +1254,7 @@ const App: React.FC = () => {
activeTab={sidebar.activeTab}
onToggleTab={sidebar.toggleTab}
hasDiff={planDiff.hasPreviousVersion}
showVaultTab={showVaultTab}
className="hidden lg:flex"
/>
)}
Expand All @@ -1216,7 +1272,12 @@ const App: React.FC = () => {
activeSection={activeSection}
onTocNavigate={handleTocNavigate}
linkedDocFilepath={linkedDocHook.filepath}
onLinkedDocBack={linkedDocHook.isActive ? linkedDocHook.back : undefined}
onLinkedDocBack={linkedDocHook.isActive ? handleLinkedDocBack : undefined}
showVaultTab={showVaultTab}
vaultPath={vaultPath}
vaultBrowser={vaultBrowser}
onVaultSelectFile={handleVaultFileSelect}
onVaultFetchTree={handleVaultFetchTree}
versionInfo={versionInfo}
versions={planDiff.versions}
projectPlans={planDiff.projectPlans}
Expand Down Expand Up @@ -1280,8 +1341,8 @@ const App: React.FC = () => {
onPlanDiffToggle={() => setIsPlanDiffActive(!isPlanDiffActive)}
hasPreviousVersion={!linkedDocHook.isActive && planDiff.hasPreviousVersion}
showDemoBadge={!isApiMode && !isLoadingShared && !isSharedSession}
onOpenLinkedDoc={linkedDocHook.open}
linkedDocInfo={linkedDocHook.isActive ? { filepath: linkedDocHook.filepath!, onBack: linkedDocHook.back } : null}
onOpenLinkedDoc={handleOpenLinkedDoc}
linkedDocInfo={linkedDocHook.isActive ? { filepath: linkedDocHook.filepath!, onBack: handleLinkedDocBack, label: vaultBrowser.activeFile ? 'Vault File' : undefined } : null}
/>
)}
</div>
Expand Down
133 changes: 132 additions & 1 deletion packages/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* PLANNOTATOR_ORIGIN - Origin identifier ("claude-code" or "opencode")
*/

import { mkdirSync } from "fs";
import { mkdirSync, existsSync, statSync } from "fs";
import { resolve } from "path";
import { isRemoteSession, getServerPort } from "./remote";
import { openBrowser } from "./browser";
Expand Down Expand Up @@ -350,6 +350,84 @@ export async function startPlannotatorServer(
return Response.json({ vaults });
}

// API: List Obsidian vault files as a tree
if (url.pathname === "/api/reference/obsidian/files" && req.method === "GET") {
const vaultPath = url.searchParams.get("vaultPath");
if (!vaultPath) {
return Response.json({ error: "Missing vaultPath parameter" }, { status: 400 });
}

const resolvedVault = resolve(vaultPath);
if (!existsSync(resolvedVault) || !statSync(resolvedVault).isDirectory()) {
return Response.json({ error: "Invalid vault path" }, { status: 400 });
}

try {
const glob = new Bun.Glob("**/*.md");
const files: string[] = [];
for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) {
if (match.includes(".obsidian/") || match.includes(".trash/")) continue;
files.push(match);
}
files.sort();

const tree = buildFileTree(files);
return Response.json({ tree });
} catch {
return Response.json({ error: "Failed to list vault files" }, { status: 500 });
}
}

// API: Read an Obsidian vault document
if (url.pathname === "/api/reference/obsidian/doc" && req.method === "GET") {
const vaultPath = url.searchParams.get("vaultPath");
const filePath = url.searchParams.get("path");
if (!vaultPath || !filePath) {
return Response.json({ error: "Missing vaultPath or path parameter" }, { status: 400 });
}
if (!/\.mdx?$/i.test(filePath)) {
return Response.json({ error: "Only markdown files are supported" }, { status: 400 });
}

const resolvedVault = resolve(vaultPath);
let resolvedFile = resolve(resolvedVault, filePath);

// If direct path doesn't exist and it's a bare filename, search the vault
if (!existsSync(resolvedFile) && !filePath.includes("/")) {
const glob = new Bun.Glob(`**/${filePath}`);
const matches: string[] = [];
for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) {
if (match.includes(".obsidian/") || match.includes(".trash/")) continue;
matches.push(resolve(resolvedVault, match));
}
if (matches.length === 1) {
resolvedFile = matches[0];
} else if (matches.length > 1) {
const relativePaths = matches.map((m) => m.replace(resolvedVault + "/", ""));
return Response.json(
{ error: `Ambiguous filename '${filePath}': found ${matches.length} matches`, matches: relativePaths },
{ status: 400 }
);
}
}

// Security: must be within vault
if (!resolvedFile.startsWith(resolvedVault + "/")) {
return Response.json({ error: "Access denied: path is outside vault" }, { status: 403 });
}

try {
const file = Bun.file(resolvedFile);
if (!(await file.exists())) {
return Response.json({ error: `File not found: ${filePath}` }, { status: 404 });
}
const markdown = await file.text();
return Response.json({ markdown, filepath: resolvedFile });
} catch {
return Response.json({ error: "Failed to read file" }, { status: 500 });
}
}

// API: Get available agents (OpenCode only)
if (url.pathname === "/api/agents") {
if (!options.opencodeClient) {
Expand Down Expand Up @@ -572,3 +650,56 @@ export async function handleServerReady(
await openBrowser(url);
}
}

// --- Vault file tree helpers ---

export interface VaultNode {
name: string;
path: string; // relative path within vault
type: "file" | "folder";
children?: VaultNode[];
}

/**
* Build a nested file tree from a sorted list of relative paths.
* Folders are sorted before files at each level.
*/
function buildFileTree(relativePaths: string[]): VaultNode[] {
const root: VaultNode[] = [];

for (const filePath of relativePaths) {
const parts = filePath.split("/");
let current = root;
let pathSoFar = "";

for (let i = 0; i < parts.length; i++) {
const part = parts[i];
pathSoFar = pathSoFar ? `${pathSoFar}/${part}` : part;
const isFile = i === parts.length - 1;

let node = current.find((n) => n.name === part && n.type === (isFile ? "file" : "folder"));
if (!node) {
node = { name: part, path: pathSoFar, type: isFile ? "file" : "folder" };
if (!isFile) node.children = [];
current.push(node);
}
if (!isFile) {
current = node.children!;
}
}
}

// Sort: folders first (alphabetical), then files (alphabetical)
const sortNodes = (nodes: VaultNode[]) => {
nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
return a.name.localeCompare(b.name);
});
for (const node of nodes) {
if (node.children) sortNodes(node.children);
}
};
sortNodes(root);

return root;
}
23 changes: 23 additions & 0 deletions packages/ui/components/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,29 @@ tags: [plan, ...]
---`}
</pre>
</div>

<div className="border-t border-border/30 pt-3">
<div className="flex items-center justify-between">
<div>
<div className="text-xs font-medium">Vault Browser</div>
<div className="text-[10px] text-muted-foreground">
Browse and annotate vault files from the sidebar
</div>
</div>
<button
role="switch"
aria-checked={obsidian.vaultBrowserEnabled}
onClick={() => handleObsidianChange({ vaultBrowserEnabled: !obsidian.vaultBrowserEnabled })}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${
obsidian.vaultBrowserEnabled ? 'bg-primary' : 'bg-muted'
}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
obsidian.vaultBrowserEnabled ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
</div>
</div>
)}
</div>
Expand Down
38 changes: 36 additions & 2 deletions packages/ui/components/Viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ interface ViewerProps {
repoInfo?: { display: string; branch?: string } | null;
stickyActions?: boolean;
onOpenLinkedDoc?: (path: string) => void;
linkedDocInfo?: { filepath: string; onBack: () => void } | null;
linkedDocInfo?: { filepath: string; onBack: () => void; label?: string } | null;
// Plan diff props
planDiffStats?: { additions: number; deletions: number; modifications: number } | null;
isPlanDiffActive?: boolean;
Expand Down Expand Up @@ -694,7 +694,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
plan
</button>
<span className="px-1.5 py-0.5 bg-primary/10 text-primary/80 rounded">
Linked File
{linkedDocInfo.label || 'Linked File'}
</span>
<span
className="px-1.5 py-0.5 bg-muted/50 text-muted-foreground rounded truncate max-w-[200px]"
Expand Down Expand Up @@ -933,6 +933,40 @@ const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string)
continue;
}

// Wikilinks: [[filename]] or [[filename|display text]]
match = remaining.match(/^\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/);
if (match) {
const target = match[1].trim();
const display = match[2]?.trim() || target;
const targetPath = /\.mdx?$/i.test(target) ? target : `${target}.md`;

if (onOpenLinkedDoc) {
parts.push(
<a
key={key++}
href={targetPath}
onClick={(e) => {
e.preventDefault();
onOpenLinkedDoc(targetPath);
}}
className="text-primary underline underline-offset-2 hover:text-primary/80 inline-flex items-center gap-1 cursor-pointer"
title={`Open: ${target}`}
>
{display}
<svg className="w-3 h-3 opacity-50 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
</a>
);
} else {
parts.push(
<span key={key++} className="text-primary">{display}</span>
);
}
remaining = remaining.slice(match[0].length);
continue;
}

// Links: [text](url)
match = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
if (match) {
Expand Down
Loading