notes/src/App.tsx
enzotar d174c7f26d feat: v0.3 + v0.4 — frontmatter, templates, outline panel, vault cache
v0.3: Core file reading & markdown infrastructure
- Rust: read_note_with_meta, list_templates, save_attachment commands
- TypeScript: frontmatter.ts (parse/serialize YAML, extract headings)
- OutlinePanel with click-to-scroll headings + tabbed right panel
- CommandPalette: New from Template with {{title}}/{{date}} replacement
- Editor: image drag-and-drop to attachments/
- 130 lines of CSS for outline panel and right panel tabs

v0.4: File reading & caching
- Rust: VaultCache (cache.rs) with mtime-based invalidation
- Rewrote read_note, read_note_with_meta, build_graph, search_vault to use cache
- init_vault_cache (eager scan on startup), get_cache_stats commands
- Frontend LRU noteCache (capacity 20, stale-while-revalidate)
- notify crate added for filesystem watching foundation
2026-03-07 09:54:08 -08:00

369 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback, createContext, useContext } from "react";
import { Routes, Route, useNavigate, useParams } from "react-router-dom";
import { Sidebar } from "./components/Sidebar";
import { Editor } from "./components/Editor";
import { Backlinks } from "./components/Backlinks";
import { GraphView } from "./components/GraphView";
import { CommandPalette } from "./components/CommandPalette";
import { OutlinePanel } from "./components/OutlinePanel";
import {
listNotes,
readNote,
writeNote,
getVaultPath,
setVaultPath,
ensureVault,
initVaultCache,
getOrCreateDaily,
type NoteEntry,
} from "./lib/commands";
import { extractWikilinks, type BacklinkEntry } from "./lib/wikilinks";
import { noteCache } from "./lib/noteCache";
/* ── Vault Context ──────────────────────────────────────────── */
interface VaultContextType {
vaultPath: string;
notes: NoteEntry[];
refreshNotes: () => Promise<void>;
currentNote: string | null;
setCurrentNote: (path: string | null) => void;
noteContent: string;
setNoteContent: (content: string) => void;
backlinks: BacklinkEntry[];
navigateToNote: (name: string) => void;
}
const VaultContext = createContext<VaultContextType>(null!);
export const useVault = () => useContext(VaultContext);
/* ── Main App ───────────────────────────────────────────────── */
export default function App() {
const [vaultPath, setVaultPathState] = useState<string>("");
const [notes, setNotes] = useState<NoteEntry[]>([]);
const [currentNote, setCurrentNote] = useState<string | null>(null);
const [noteContent, setNoteContent] = useState<string>("");
const [backlinks, setBacklinks] = useState<BacklinkEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [paletteOpen, setPaletteOpen] = useState(false);
const navigate = useNavigate();
// Initialize vault
useEffect(() => {
(async () => {
try {
console.log("[GraphNotes] Initializing vault...");
let path: string | null = null;
try {
path = await getVaultPath();
console.log("[GraphNotes] Stored vault path:", path);
} catch (e) {
console.warn("[GraphNotes] getVaultPath failed:", e);
}
if (!path) {
// Default to the project's vault directory
path = "/home/amir/code/notes/vault";
console.log("[GraphNotes] Using default vault path:", path);
try {
await setVaultPath(path);
} catch (e) {
console.warn("[GraphNotes] setVaultPath failed:", e);
}
}
try {
await ensureVault(path);
} catch (e) {
console.warn("[GraphNotes] ensureVault failed:", e);
}
// Initialize the Rust-side vault cache (eagerly scan all notes)
try {
const count = await initVaultCache(path);
console.log(`[GraphNotes] Cache initialized: ${count} notes`);
} catch (e) {
console.warn("[GraphNotes] initVaultCache failed:", e);
}
setVaultPathState(path);
console.log("[GraphNotes] Vault ready at:", path);
setLoading(false);
} catch (e) {
console.error("[GraphNotes] Init failed:", e);
setError(String(e));
setLoading(false);
}
})();
}, []);
// Load notes when vault is ready
const refreshNotes = useCallback(async () => {
if (!vaultPath) return;
const entries = await listNotes(vaultPath);
setNotes(entries);
}, [vaultPath]);
useEffect(() => {
if (vaultPath) refreshNotes();
}, [vaultPath, refreshNotes]);
// Global keyboard shortcuts
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key === "k") { e.preventDefault(); setPaletteOpen((v) => !v); }
else if (mod && e.key === "n") {
e.preventDefault(); setPaletteOpen(false);
const name = prompt("Note name:");
if (name?.trim()) {
writeNote(vaultPath, `${name.trim()}.md`, `# ${name.trim()}\n\n`).then(() => {
refreshNotes();
navigate(`/note/${encodeURIComponent(`${name.trim()}.md`)}`);
});
}
}
else if (mod && e.key === "g") { e.preventDefault(); setPaletteOpen(false); navigate("/graph"); }
else if (mod && e.key === "d") { e.preventDefault(); setPaletteOpen(false); navigate("/daily"); }
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [vaultPath, navigate, refreshNotes]);
// Build backlinks for current note
useEffect(() => {
if (!vaultPath || !currentNote || !notes.length) {
setBacklinks([]);
return;
}
(async () => {
const currentName = currentNote
.replace(/\.md$/, "")
.split("/")
.pop()
?.toLowerCase();
if (!currentName) return;
// Read all notes and find backlinks
const allPaths = flattenNotes(notes);
const entries: BacklinkEntry[] = [];
for (const notePath of allPaths) {
if (notePath === currentNote) continue;
try {
const content = await readNote(vaultPath, notePath);
const links = extractWikilinks(content);
for (const link of links) {
if (link.target.toLowerCase() === currentName) {
const lines = content.split("\n");
const contextLine =
lines.find((l) => l.includes(link.raw)) || "";
entries.push({
sourcePath: notePath,
sourceName: notePath.replace(/\.md$/, "").split("/").pop() || notePath,
context: contextLine.trim().substring(0, 200),
});
}
}
} catch {
// Skip unreadable notes
}
}
setBacklinks(entries);
})();
}, [vaultPath, currentNote, notes]);
const navigateToNote = useCallback(
(name: string) => {
// Find the note by name (case-insensitive)
const allPaths = flattenNotes(notes);
const match = allPaths.find(
(p) =>
p
.replace(/\.md$/, "")
.split("/")
.pop()
?.toLowerCase() === name.toLowerCase()
);
if (match) {
navigate(`/note/${encodeURIComponent(match)}`);
} else {
// Create new note
const newPath = `${name}.md`;
writeNote(vaultPath, newPath, `# ${name}\n\n`).then(() => {
refreshNotes();
navigate(`/note/${encodeURIComponent(newPath)}`);
});
}
},
[notes, vaultPath, navigate, refreshNotes]
);
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-center animate-fade-in">
<div className="text-4xl mb-4">📝</div>
<p className="text-[var(--text-secondary)]">Loading vault...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-center animate-fade-in max-w-md">
<div className="text-4xl mb-4"></div>
<p className="text-[var(--text-primary)] font-semibold mb-2">Failed to load vault</p>
<p className="text-[var(--text-muted)] text-sm">{error}</p>
</div>
</div>
);
}
return (
<VaultContext.Provider
value={{
vaultPath,
notes,
refreshNotes,
currentNote,
setCurrentNote,
noteContent,
setNoteContent,
backlinks,
navigateToNote,
}}
>
<div className="flex h-screen w-screen overflow-hidden">
<Sidebar />
<Routes>
<Route path="/" element={<WelcomeScreen />} />
<Route path="/note/:path" element={<NoteView />} />
<Route path="/daily" element={<DailyView />} />
<Route path="/graph" element={<GraphView />} />
</Routes>
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
</div>
</VaultContext.Provider>
);
}
/* ── Note View ──────────────────────────────────────────────── */
function NoteView() {
const { path } = useParams<{ path: string }>();
const { vaultPath, setCurrentNote, noteContent, setNoteContent } = useVault();
const decodedPath = decodeURIComponent(path || "");
const [rightTab, setRightTab] = useState<"backlinks" | "outline">("outline");
useEffect(() => {
if (!decodedPath || !vaultPath) return;
setCurrentNote(decodedPath);
// Check frontend LRU cache first
const cached = noteCache.get(decodedPath);
if (cached !== undefined) {
setNoteContent(cached);
// Still re-validate from backend in background
readNote(vaultPath, decodedPath).then((fresh) => {
if (fresh !== cached) {
setNoteContent(fresh);
noteCache.set(decodedPath, fresh);
}
}).catch(() => {});
} else {
readNote(vaultPath, decodedPath).then((content) => {
setNoteContent(content);
noteCache.set(decodedPath, content);
}).catch(() => setNoteContent(""));
}
}, [decodedPath, vaultPath, setCurrentNote, setNoteContent]);
return (
<div className="flex flex-1 overflow-hidden">
<main className="flex-1 overflow-y-auto">
<Editor />
</main>
<aside className="right-panel">
<div className="right-panel-tabs">
<button
className={`right-panel-tab ${rightTab === "outline" ? "active" : ""}`}
onClick={() => setRightTab("outline")}
>
📑 Outline
</button>
<button
className={`right-panel-tab ${rightTab === "backlinks" ? "active" : ""}`}
onClick={() => setRightTab("backlinks")}
>
🔗 Backlinks
</button>
</div>
{rightTab === "outline" ? <OutlinePanel /> : <Backlinks />}
</aside>
</div>
);
}
/* ── Daily View ─────────────────────────────────────────────── */
function DailyView() {
const { vaultPath, refreshNotes } = useVault();
const navigate = useNavigate();
useEffect(() => {
if (!vaultPath) return;
getOrCreateDaily(vaultPath).then((dailyPath) => {
refreshNotes();
navigate(`/note/${encodeURIComponent(dailyPath)}`, { replace: true });
});
}, [vaultPath, navigate, refreshNotes]);
return (
<div className="flex-1 flex items-center justify-center">
<p className="text-[var(--text-muted)]">Creating daily note...</p>
</div>
);
}
/* ── Welcome Screen ─────────────────────────────────────────── */
function WelcomeScreen() {
const navigate = useNavigate();
return (
<div className="welcome">
<div className="welcome-card">
<div className="welcome-icon">📝</div>
<h1 className="welcome-title">Graph Notes</h1>
<p className="welcome-subtitle">
A local-first knowledge base with bidirectional linking
and graph visualization. Your notes, your data.
</p>
<div className="welcome-actions">
<button className="btn btn-primary" onClick={() => navigate("/daily")}>
📅 Today's Note
</button>
<button className="btn" onClick={() => navigate("/graph")}>
🔮 Graph View
</button>
</div>
</div>
</div>
);
}
/* ── Helpers ────────────────────────────────────────────────── */
function flattenNotes(entries: NoteEntry[]): string[] {
const paths: string[] = [];
for (const entry of entries) {
if (entry.is_dir && entry.children) {
paths.push(...flattenNotes(entry.children));
} else if (!entry.is_dir) {
paths.push(entry.path);
}
}
return paths;
}