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
369 lines
12 KiB
TypeScript
369 lines
12 KiB
TypeScript
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;
|
||
}
|