feat: v0.2.0 — command palette, full-text search, note management, editor improvements, graph filtering

- Add search_vault and rename_note Rust backend commands
- Add CommandPalette component with fuzzy search and keyboard nav (Ctrl+K)
- Add ContextMenu component with rename/delete actions
- Add global keyboard shortcuts (Ctrl+N/G/D)
- Rebuild Sidebar with debounced full-text search results
- Add Editor heading scaling (h1-h3), interactive task checkboxes, inline code
- Add GraphView filter bar (folder dropdown, min links slider)
- Add 440+ lines of CSS for all new components
- Update CHANGELOG.md with v0.2.0 entry
This commit is contained in:
enzotar 2026-03-07 08:40:16 -08:00
parent b03237f4c2
commit 2041798048
10 changed files with 1107 additions and 27 deletions

View file

@ -1,21 +1,37 @@
# Changelog
All notable changes to Graph Notes will be documented in this file.
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] — 2026-03-07
## [0.2.0] - 2026-03-07
### Added
- **Tauri v2 Desktop App** — Local-first note-taking with full filesystem access via `tauri-plugin-fs`
- **Contenteditable Editor** — Rich inline editing with `[[wikilink]]` token chips (compact pills that unwrap on backspace/delete)
- **Wikilink Autocomplete** — Type `[[` to fuzzy-search and link notes; creates new notes if no match found
- **Force-Directed Graph View** — Canvas-based visualization with semantic zoom (circles → rounded-rect cards with note previews)
- **Graph Interactions** — Single-click animates zoom to node, double-click opens note, drag to reposition nodes
- **shadcn-Inspired Design System** — Zinc-based neutrals, purple accent gradients, focus rings, spring transitions
- **Sidebar** — Recursive file tree with search, collapsible folders, active-state indicators, note count badge
- **Backlinks Panel** — Lists all notes linking to current page with highlighted context snippets
- **Markdown Preview** — Toggle between edit and rendered preview modes with inline wikilink rendering
- **Daily Notes** — Auto-generated daily journal entries accessible from sidebar shortcut
- **Auto-Save** — Debounced 500ms save on every keystroke
- **Custom Scrollbars** — Minimal 5px scrollbars matching the dark theme
- **Command Palette** (`Ctrl+K`): Fuzzy search notes and run commands with keyboard navigation (↑↓ Enter Esc)
- **Keyboard Shortcuts**: `Ctrl+N` new note, `Ctrl+G` graph view, `Ctrl+D` daily note
- **Full-Text Search**: Rust-powered vault content search with context snippets, displayed in sidebar
- **Note Rename**: Right-click context menu in sidebar for renaming notes with automatic wikilink updates across vault
- **Note Delete**: Context menu delete with confirmation dialog
- **Editor: Heading Scaling**: H1H3 headings render at proportional sizes in edit mode
- **Editor: Task Lists**: Interactive checkboxes for `- [ ]` / `- [x]` syntax, clickable to toggle
- **Editor: Inline Code**: Backtick-quoted text styled with monospace font and accent color
- **Editor: Markdown Preview**: Styled headings, code blocks, and checkbox rendering in preview mode
- **Graph Filtering**: Filter graph by folder and minimum link count with a dedicated filter bar
### Changed
- Sidebar search upgraded to show both filename matches and content search results
- Graph view header now reflects filtered node/edge counts
## [0.1.0] - 2025-06-01
### Added
- Tauri 2 desktop application with React 19 + Vite 7
- Contenteditable editor with inline wikilink tokens
- Wikilink autocomplete dropdown (`[[` trigger)
- Force-directed graph view with semantic zoom (circles → cards)
- Sidebar with file tree, search filtering, and quick actions
- Backlinks panel with context snippets
- Daily notes with auto-creation
- Auto-save with debounced writes
- Custom CSS design system (dark theme, glassmorphism, purple accents)

View file

@ -33,6 +33,14 @@ pub struct GraphEdge {
pub target: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SearchResult {
pub path: String,
pub name: String,
pub context: String,
pub line_number: usize,
}
fn normalize_note_name(name: &str) -> String {
name.trim().to_lowercase()
}
@ -180,6 +188,122 @@ fn build_graph(vault_path: String) -> Result<GraphData, String> {
Ok(GraphData { nodes, edges })
}
#[tauri::command]
fn search_vault(vault_path: String, query: String) -> Result<Vec<SearchResult>, String> {
let vault = Path::new(&vault_path);
if !vault.exists() {
return Err("Vault path does not exist".to_string());
}
let query_lower = query.to_lowercase();
let mut results: Vec<SearchResult> = Vec::new();
for entry in WalkDir::new(vault)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "md"))
{
if let Ok(content) = fs::read_to_string(entry.path()) {
for (i, line) in content.lines().enumerate() {
if line.to_lowercase().contains(&query_lower) {
let rel_path = entry.path().strip_prefix(vault).unwrap_or(entry.path());
let name = rel_path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
// Build context: the matching line, trimmed
let context = line.trim().to_string();
let context_display = if context.len() > 120 {
format!("{}", &context[..120])
} else {
context
};
results.push(SearchResult {
path: rel_path.to_string_lossy().to_string(),
name,
context: context_display,
line_number: i + 1,
});
// Max 3 results per file
if results.iter().filter(|r| r.path == rel_path.to_string_lossy().to_string()).count() >= 3 {
break;
}
}
}
}
}
// Cap total results
results.truncate(50);
Ok(results)
}
#[tauri::command]
fn rename_note(vault_path: String, old_path: String, new_name: String) -> Result<String, String> {
let vault = Path::new(&vault_path);
let old_full = vault.join(&old_path);
if !old_full.is_file() {
return Err("Note not found".to_string());
}
// Compute new path (same directory, new name)
let parent = Path::new(&old_path).parent().unwrap_or(Path::new(""));
let new_file = format!("{}.md", new_name.trim());
let new_rel = if parent == Path::new("") {
new_file.clone()
} else {
format!("{}/{}", parent.to_string_lossy(), new_file)
};
let new_full = vault.join(&new_rel);
if new_full.exists() {
return Err("A note with that name already exists".to_string());
}
// Rename the file
fs::rename(&old_full, &new_full).map_err(|e| format!("Failed to rename: {}", e))?;
// Update wikilinks across vault: [[old_name]] → [[new_name]]
let old_stem = Path::new(&old_path)
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let new_stem = new_name.trim().to_string();
if old_stem != new_stem {
let link_re = Regex::new(&format!(
r"\[\[{}(\|[^\]]+)?\]\]",
regex::escape(&old_stem)
)).map_err(|e| e.to_string())?;
for entry in WalkDir::new(vault)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "md"))
{
if let Ok(content) = fs::read_to_string(entry.path()) {
let updated = link_re.replace_all(&content, |caps: &regex::Captures| {
match caps.get(1) {
Some(alias) => format!("[[{}{}", new_stem, alias.as_str()),
None => format!("[[{}]]", new_stem),
}
}).to_string();
if updated != content {
let _ = fs::write(entry.path(), &updated);
}
}
}
}
Ok(new_rel)
}
#[tauri::command]
fn get_or_create_daily(vault_path: String) -> Result<String, String> {
let today = Local::now().format("%Y-%m-%d").to_string();
@ -250,6 +374,8 @@ pub fn run() {
write_note,
delete_note,
build_graph,
search_vault,
rename_note,
get_or_create_daily,
get_vault_path,
set_vault_path,

View file

@ -4,6 +4,7 @@ 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 {
listNotes,
readNote,
@ -41,6 +42,7 @@ export default function App() {
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
@ -96,6 +98,28 @@ export default function App() {
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) {
@ -212,6 +236,7 @@ export default function App() {
<Route path="/daily" element={<DailyView />} />
<Route path="/graph" element={<GraphView />} />
</Routes>
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
</div>
</VaultContext.Provider>
);

View file

@ -0,0 +1,186 @@
import { useState, useEffect, useRef, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useVault } from "../App";
import type { NoteEntry } from "../lib/commands";
interface Command {
id: string;
label: string;
icon: string;
action: () => void;
section: "notes" | "commands";
}
export function CommandPalette({ open, onClose }: { open: boolean; onClose: () => void }) {
const [query, setQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const { notes, vaultPath, refreshNotes } = useVault();
// Flatten notes for search
const allNotes = useMemo(() => flattenEntries(notes), [notes]);
// Build command list
const commands: Command[] = useMemo(() => {
const cmds: Command[] = [
{ id: "cmd-new", label: "New Note", icon: "✏️", section: "commands", action: () => {
onClose();
const name = prompt("Note name:");
if (name?.trim()) {
import("../lib/commands").then(({ writeNote }) => {
writeNote(vaultPath, `${name.trim()}.md`, `# ${name.trim()}\n\n`).then(() => {
refreshNotes();
navigate(`/note/${encodeURIComponent(`${name.trim()}.md`)}`);
});
});
}
}},
{ id: "cmd-daily", label: "Open Daily Note", icon: "📅", section: "commands", action: () => { onClose(); navigate("/daily"); }},
{ id: "cmd-graph", label: "Open Graph View", icon: "🔮", section: "commands", action: () => { onClose(); navigate("/graph"); }},
];
// Add notes as commands
for (const note of allNotes) {
cmds.push({
id: `note-${note.path}`,
label: note.name,
icon: "📄",
section: "notes",
action: () => { onClose(); navigate(`/note/${encodeURIComponent(note.path)}`); },
});
}
return cmds;
}, [allNotes, navigate, onClose, vaultPath, refreshNotes]);
// Filter by query
const filtered = useMemo(() => {
if (!query.trim()) return commands;
const q = query.toLowerCase();
return commands.filter((c) => c.label.toLowerCase().includes(q));
}, [commands, query]);
// Reset selection on filter change
useEffect(() => { setSelectedIndex(0); }, [filtered]);
// Focus input on open
useEffect(() => {
if (open) {
setQuery("");
setSelectedIndex(0);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
// Scroll selected item into view
useEffect(() => {
if (listRef.current) {
const item = listRef.current.children[selectedIndex] as HTMLElement;
item?.scrollIntoView({ block: "nearest" });
}
}, [selectedIndex]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
filtered[selectedIndex]?.action();
} else if (e.key === "Escape") {
onClose();
}
};
if (!open) return null;
// Group by section
const noteResults = filtered.filter((c) => c.section === "notes");
const cmdResults = filtered.filter((c) => c.section === "commands");
const ordered = [...cmdResults, ...noteResults];
return (
<div className="palette-overlay" onClick={onClose}>
<div className="palette" onClick={(e) => e.stopPropagation()} onKeyDown={handleKeyDown}>
<div className="palette-input-wrap">
<svg className="palette-search-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
ref={inputRef}
type="text"
className="palette-input"
placeholder="Search notes & commands..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<kbd className="palette-kbd">esc</kbd>
</div>
<div className="palette-list" ref={listRef}>
{ordered.length === 0 && (
<div className="palette-empty">No results found</div>
)}
{cmdResults.length > 0 && (
<div className="palette-section-label">Commands</div>
)}
{cmdResults.map((cmd) => {
const globalIdx = ordered.indexOf(cmd);
return (
<button
key={cmd.id}
className={`palette-item ${globalIdx === selectedIndex ? "selected" : ""}`}
onClick={cmd.action}
onMouseEnter={() => setSelectedIndex(globalIdx)}
>
<span className="palette-item-icon">{cmd.icon}</span>
<span className="palette-item-label">{cmd.label}</span>
</button>
);
})}
{noteResults.length > 0 && (
<div className="palette-section-label">Notes</div>
)}
{noteResults.map((cmd) => {
const globalIdx = ordered.indexOf(cmd);
return (
<button
key={cmd.id}
className={`palette-item ${globalIdx === selectedIndex ? "selected" : ""}`}
onClick={cmd.action}
onMouseEnter={() => setSelectedIndex(globalIdx)}
>
<span className="palette-item-icon">{cmd.icon}</span>
<span className="palette-item-label">{cmd.label}</span>
</button>
);
})}
</div>
<div className="palette-footer">
<span> navigate</span>
<span> open</span>
<span>esc close</span>
</div>
</div>
</div>
);
}
function flattenEntries(entries: NoteEntry[]): { name: string; path: string }[] {
const result: { name: string; path: string }[] = [];
for (const e of entries) {
if (e.is_dir && e.children) {
result.push(...flattenEntries(e.children));
} else if (!e.is_dir) {
result.push({ name: e.name, path: e.path });
}
}
return result;
}

View file

@ -0,0 +1,75 @@
import { useState, useEffect, useRef, useCallback } from "react";
interface MenuItem {
label: string;
icon: string;
action: () => void;
danger?: boolean;
}
interface ContextMenuProps {
items: MenuItem[];
position: { x: number; y: number } | null;
onClose: () => void;
}
export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!position) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
};
const escHandler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
document.addEventListener("mousedown", handler);
document.addEventListener("keydown", escHandler);
return () => {
document.removeEventListener("mousedown", handler);
document.removeEventListener("keydown", escHandler);
};
}, [position, onClose]);
if (!position) return null;
return (
<div
ref={ref}
className="context-menu"
style={{ top: position.y, left: position.x }}
>
{items.map((item, i) => (
<button
key={i}
className={`context-menu-item ${item.danger ? "danger" : ""}`}
onClick={() => { item.action(); onClose(); }}
>
<span className="context-menu-icon">{item.icon}</span>
<span>{item.label}</span>
</button>
))}
</div>
);
}
/* Hook: useContextMenu */
export function useContextMenu() {
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
const [menuTarget, setMenuTarget] = useState<string | null>(null);
const openMenu = useCallback((e: React.MouseEvent, target: string) => {
e.preventDefault();
e.stopPropagation();
setMenuPos({ x: e.clientX, y: e.clientY });
setMenuTarget(target);
}, []);
const closeMenu = useCallback(() => {
setMenuPos(null);
setMenuTarget(null);
}, []);
return { menuPos, menuTarget, openMenu, closeMenu };
}

View file

@ -294,8 +294,36 @@ export function Editor() {
const linkTarget = target.dataset.target;
if (linkTarget) navigateToNote(linkTarget);
}
// Task checkbox toggle
if (target.dataset.task === "true") {
e.preventDefault();
const raw = extractRaw();
// Find the line with this checkbox and toggle it
const lines = raw.split("\n");
// Find button's parent div to locate which task line this is
const parentDiv = target.closest(".task-line");
if (parentDiv) {
const allTaskDivs = Array.from(ceRef.current?.querySelectorAll(".task-line") || []);
const taskIdx = allTaskDivs.indexOf(parentDiv);
let taskCount = 0;
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(/^(\s*-\s+)\[([ xX])\](\s+.*)$/);
if (match) {
if (taskCount === taskIdx) {
const checked = match[2].toLowerCase() === "x";
lines[i] = `${match[1]}[${checked ? " " : "x"}]${match[3]}`;
break;
}
taskCount++;
}
}
const newRaw = lines.join("\n");
saveContent(newRaw);
renderToDOM(newRaw);
}
}
},
[navigateToNote]
[navigateToNote, extractRaw, saveContent, renderToDOM]
);
// ── Preview click handler ──
@ -383,7 +411,7 @@ export function Editor() {
<div className="flex-1 overflow-y-auto relative">
{isPreview ? (
<div
className="prose prose-invert max-w-none px-8 py-6"
className="markdown-preview prose prose-invert max-w-none px-8 py-6"
style={{
color: "var(--text-primary)",
lineHeight: 1.8,
@ -550,6 +578,39 @@ function markdownToTokenHTML(raw: string): string {
}
);
// Process lines for headings, task lists, and code blocks
const lines = escaped.split("\n");
const processed = lines.map((line) => {
// Heading scaling (h1-h3)
const headingMatch = line.match(/^(#{1,3})\s+(.*)$/);
if (headingMatch) {
const level = headingMatch[1].length;
return `<div data-heading="${level}">${line}</div>`;
}
// Task list items: - [ ] or - [x]
const taskMatch = line.match(/^(\s*)-\s+\[([ xX])\]\s+(.*)$/);
if (taskMatch) {
const indent = taskMatch[1];
const checked = taskMatch[2].toLowerCase() === "x";
const text = taskMatch[3];
const checkboxClass = checked ? "editor-checkbox checked" : "editor-checkbox";
const checkMark = checked ? "✓" : "";
const lineClass = checked ? "task-line completed" : "task-line";
return `<div class="${lineClass}">${indent}<button class="${checkboxClass}" contenteditable="false" data-task="true">${checkMark}</button>${text}</div>`;
}
return line;
});
escaped = processed.join("\n");
// Inline code: `code`
escaped = escaped.replace(
/`([^`\n]+?)`/g,
'<span class="editor-code-inline">$1</span>'
);
// Convert newlines to <br>
escaped = escaped.replace(/\n/g, "<br>");

View file

@ -38,6 +38,8 @@ export function GraphView() {
const { vaultPath } = useVault();
const navigate = useNavigate();
const [graphData, setGraphData] = useState<GraphData | null>(null);
const [folderFilter, setFolderFilter] = useState<string>("all");
const [minLinks, setMinLinks] = useState<number>(0);
useEffect(() => {
if (!vaultPath) return;
@ -54,6 +56,29 @@ export function GraphView() {
);
}
// Extract unique folders
const folders = Array.from(
new Set(graphData.nodes.map((n) => {
const parts = n.path.split("/");
return parts.length > 1 ? parts.slice(0, -1).join("/") : "(root)";
}))
).sort();
// Filter nodes
const filteredNodes = graphData.nodes.filter((node) => {
const folder = node.path.includes("/")
? node.path.split("/").slice(0, -1).join("/")
: "(root)";
if (folderFilter !== "all" && folder !== folderFilter) return false;
if (node.link_count < minLinks) return false;
return true;
});
const nodeIds = new Set(filteredNodes.map((n) => n.id));
const filteredEdges = graphData.edges.filter(
(e) => nodeIds.has(e.source) && nodeIds.has(e.target)
);
const filteredData: GraphData = { nodes: filteredNodes, edges: filteredEdges };
return (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="graph-header">
@ -61,16 +86,38 @@ export function GraphView() {
<h2 style={{ fontSize: 14, fontWeight: 600, color: "var(--text-primary)" }}>
🔮 Graph View
</h2>
<span className="badge badge-purple">{graphData.nodes.length} notes</span>
<span className="badge badge-muted">{graphData.edges.length} links</span>
<span className="badge badge-purple">{filteredNodes.length} notes</span>
<span className="badge badge-muted">{filteredEdges.length} links</span>
</div>
<div style={{ fontSize: 10, color: "var(--text-muted)" }}>
Scroll to zoom · Click to focus · Double-click to open
</div>
</div>
{/* ── Filter Bar ── */}
<div className="graph-filter-bar">
<label>
Folder
<select value={folderFilter} onChange={(e) => setFolderFilter(e.target.value)}>
<option value="all">All</option>
{folders.map((f) => (
<option key={f} value={f}>{f}</option>
))}
</select>
</label>
<label>
Min links: {minLinks}
<input
type="range"
min={0}
max={Math.max(5, ...graphData.nodes.map((n) => n.link_count))}
value={minLinks}
onChange={(e) => setMinLinks(Number(e.target.value))}
/>
</label>
</div>
<div className="flex-1 relative">
<ForceGraph
graphData={graphData}
graphData={filteredData}
onNodeClick={(path) => navigate(`/note/${encodeURIComponent(path)}`)}
/>
</div>

View file

@ -1,21 +1,47 @@
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect, useCallback } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useVault } from "../App";
import { writeNote } from "../lib/commands";
import type { NoteEntry } from "../lib/commands";
import { writeNote, searchVault, renameNote, deleteNote } from "../lib/commands";
import type { NoteEntry, SearchResult } from "../lib/commands";
import { ContextMenu, useContextMenu } from "./ContextMenu";
export function Sidebar() {
const { notes, vaultPath, refreshNotes } = useVault();
const [search, setSearch] = useState("");
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const { menuPos, menuTarget, openMenu, closeMenu } = useContextMenu();
const filteredNotes = useMemo(() => {
if (!search.trim()) return notes;
return filterNotes(notes, search.toLowerCase());
}, [notes, search]);
// Full-text search with debounce
useEffect(() => {
if (!search.trim() || search.trim().length < 2) {
setSearchResults([]);
return;
}
setIsSearching(true);
const timer = setTimeout(async () => {
try {
const results = await searchVault(vaultPath, search.trim());
setSearchResults(results);
} catch {
setSearchResults([]);
} finally {
setIsSearching(false);
}
}, 300);
return () => clearTimeout(timer);
}, [search, vaultPath]);
const handleCreateNote = async () => {
const name = prompt("Note name:");
if (!name?.trim()) return;
@ -34,6 +60,42 @@ export function Sidebar() {
});
};
const handleRename = useCallback(async (notePath: string) => {
const oldName = notePath.replace(/\.md$/, "").split("/").pop() || "";
const newName = prompt("Rename note:", oldName);
if (!newName?.trim() || newName.trim() === oldName) return;
try {
const newPath = await renameNote(vaultPath, notePath, newName.trim());
await refreshNotes();
// Navigate to renamed note
navigate(`/note/${encodeURIComponent(newPath)}`);
} catch (e) {
alert(`Rename failed: ${e}`);
}
}, [vaultPath, refreshNotes, navigate]);
const handleDelete = useCallback(async (notePath: string) => {
const name = notePath.replace(/\.md$/, "").split("/").pop() || notePath;
if (!confirm(`Delete "${name}"? This cannot be undone.`)) return;
try {
await deleteNote(vaultPath, notePath);
await refreshNotes();
// Navigate away if we deleted the current note
if (location.pathname === `/note/${encodeURIComponent(notePath)}`) {
navigate("/");
}
} catch (e) {
alert(`Delete failed: ${e}`);
}
}, [vaultPath, refreshNotes, navigate, location]);
const contextMenuItems = menuTarget ? [
{ label: "Rename", icon: "✏️", action: () => handleRename(menuTarget) },
{ label: "Delete", icon: "🗑️", action: () => handleDelete(menuTarget), danger: true },
] : [];
const hasContentResults = search.trim().length >= 2 && searchResults.length > 0;
return (
<aside className="sidebar">
{/* ── Brand + Search ── */}
@ -45,7 +107,7 @@ export function Sidebar() {
<button
className="sidebar-new-btn"
onClick={handleCreateNote}
title="New note"
title="New note (Ctrl+N)"
>
+
</button>
@ -58,7 +120,7 @@ export function Sidebar() {
</svg>
<input
type="text"
placeholder="Search notes..."
placeholder="Search notes & content..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
@ -83,6 +145,31 @@ export function Sidebar() {
</button>
</div>
{/* ── Content Search Results ── */}
{hasContentResults && (
<div className="sidebar-search-results">
<div className="sidebar-section-label">
<span>Content Matches</span>
<span className="badge badge-purple">{searchResults.length}</span>
</div>
{searchResults.slice(0, 10).map((result, i) => (
<button
key={`${result.path}-${result.line_number}-${i}`}
className="search-result-item"
onClick={() => navigate(`/note/${encodeURIComponent(result.path)}`)}
>
<span className="search-result-name">📄 {result.name}</span>
<span className="search-result-context">{result.context}</span>
</button>
))}
</div>
)}
{isSearching && search.trim().length >= 2 && (
<div style={{ padding: "8px 16px", fontSize: 11, color: "var(--text-muted)" }}>
Searching...
</div>
)}
{/* ── File Tree ── */}
<div className="sidebar-tree">
<div className="sidebar-section-label">
@ -93,6 +180,7 @@ export function Sidebar() {
entries={filteredNotes}
collapsed={collapsed}
onToggle={toggleFolder}
onContextMenu={openMenu}
depth={0}
/>
{filteredNotes.length === 0 && (
@ -101,17 +189,20 @@ export function Sidebar() {
</p>
)}
</div>
<ContextMenu items={contextMenuItems} position={menuPos} onClose={closeMenu} />
</aside>
);
}
/* ── Recursive File Tree ────────────────────────────────────── */
function NoteTree({
entries, collapsed, onToggle, depth,
entries, collapsed, onToggle, onContextMenu, depth,
}: {
entries: NoteEntry[];
collapsed: Set<string>;
onToggle: (path: string) => void;
onContextMenu: (e: React.MouseEvent, target: string) => void;
depth: number;
}) {
const navigate = useNavigate();
@ -141,7 +232,7 @@ function NoteTree({
<span className="tree-item-label" style={{ fontWeight: 500 }}>{entry.name}</span>
</button>
{!isCollapsed && entry.children && (
<NoteTree entries={entry.children} collapsed={collapsed} onToggle={onToggle} depth={depth + 1} />
<NoteTree entries={entry.children} collapsed={collapsed} onToggle={onToggle} onContextMenu={onContextMenu} depth={depth + 1} />
)}
</li>
);
@ -153,6 +244,7 @@ function NoteTree({
className={`tree-item ${isActive ? "active" : ""}`}
style={{ paddingLeft: `${14 + depth * 16}px` }}
onClick={() => navigate(`/note/${encodeURIComponent(entry.path)}`)}
onContextMenu={(e) => onContextMenu(e, entry.path)}
>
<span className="tree-item-icon">📄</span>
<span className="tree-item-label">{entry.name.replace(/\.md$/, "")}</span>

View file

@ -925,4 +925,441 @@ body,
.animate-slide-right {
animation: slideInRight 200ms ease forwards;
}
/*
v0.2 Additions
*/
/* ── Command Palette ──────────────────────────────────────── */
.palette-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(8px);
display: flex;
justify-content: center;
padding-top: 20vh;
animation: fadeIn 100ms ease;
}
.palette {
width: 520px;
max-height: 440px;
background: var(--bg-elevated);
border: 1px solid var(--border-secondary);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg), 0 0 60px rgba(139, 92, 246, 0.08);
display: flex;
flex-direction: column;
overflow: hidden;
animation: dropdownIn 150ms ease forwards;
}
.palette-input-wrap {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px;
border-bottom: 1px solid var(--border-primary);
}
.palette-search-icon {
width: 18px;
height: 18px;
color: var(--text-muted);
flex-shrink: 0;
}
.palette-input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-size: 14px;
color: var(--text-primary);
font-family: 'Inter', sans-serif;
}
.palette-input::placeholder {
color: var(--text-muted);
}
.palette-kbd {
font-size: 10px;
color: var(--text-muted);
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-xs);
padding: 2px 6px;
font-family: 'JetBrains Mono', monospace;
}
.palette-list {
flex: 1;
overflow-y: auto;
padding: 6px 0;
}
.palette-section-label {
padding: 8px 16px 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.palette-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 16px;
font-size: 13px;
color: var(--text-secondary);
background: transparent;
border: none;
cursor: pointer;
transition: all var(--transition-fast);
text-align: left;
}
.palette-item:hover,
.palette-item.selected {
background: var(--accent-purple-glow);
color: var(--text-primary);
}
.palette-item.selected {
background: var(--accent-purple-dim);
border-left: 2px solid var(--accent-purple);
}
.palette-item-icon {
font-size: 15px;
flex-shrink: 0;
width: 22px;
display: flex;
align-items: center;
justify-content: center;
}
.palette-item-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.palette-empty {
padding: 24px 16px;
text-align: center;
font-size: 12px;
color: var(--text-muted);
}
.palette-footer {
display: flex;
gap: 16px;
padding: 8px 16px;
border-top: 1px solid var(--border-primary);
font-size: 10px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
/* ── Context Menu ─────────────────────────────────────────── */
.context-menu {
position: fixed;
z-index: 1001;
min-width: 160px;
background: var(--bg-elevated);
border: 1px solid var(--border-secondary);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
padding: 4px;
animation: dropdownIn 100ms ease forwards;
}
.context-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 12px;
font-size: 12px;
color: var(--text-secondary);
background: transparent;
border: none;
border-radius: var(--radius-xs);
cursor: pointer;
transition: all var(--transition-fast);
text-align: left;
}
.context-menu-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.context-menu-item.danger:hover {
background: rgba(244, 63, 94, 0.12);
color: var(--accent-rose);
}
.context-menu-icon {
font-size: 13px;
width: 18px;
display: flex;
align-items: center;
justify-content: center;
}
/* ── Search Results in Sidebar ────────────────────────────── */
.sidebar-search-results {
border-bottom: 1px solid var(--border-primary);
max-height: 220px;
overflow-y: auto;
}
.search-result-item {
display: flex;
flex-direction: column;
gap: 3px;
width: 100%;
padding: 7px 16px;
background: transparent;
border: none;
cursor: pointer;
transition: background var(--transition-fast);
text-align: left;
}
.search-result-item:hover {
background: var(--bg-hover);
}
.search-result-name {
font-size: 12px;
font-weight: 500;
color: var(--accent-purple);
}
.search-result-context {
font-size: 10px;
color: var(--text-muted);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Editor: Heading Scaling ──────────────────────────────── */
.editor-ce [data-heading="1"] {
font-size: 1.75em;
font-weight: 700;
line-height: 1.3;
margin-bottom: 0.2em;
color: var(--text-primary);
}
.editor-ce [data-heading="2"] {
font-size: 1.35em;
font-weight: 600;
line-height: 1.35;
margin-bottom: 0.15em;
color: var(--text-primary);
}
.editor-ce [data-heading="3"] {
font-size: 1.15em;
font-weight: 600;
line-height: 1.4;
margin-bottom: 0.1em;
color: var(--text-secondary);
}
/* ── Editor: Checkbox Tasks ───────────────────────────────── */
.editor-checkbox {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border: 2px solid var(--border-secondary);
border-radius: var(--radius-xs);
margin-right: 6px;
cursor: pointer;
transition: all var(--transition-fast);
vertical-align: middle;
flex-shrink: 0;
background: transparent;
padding: 0;
font-size: 10px;
}
.editor-checkbox.checked {
background: var(--accent-purple-bright);
border-color: var(--accent-purple-bright);
color: white;
}
.editor-checkbox:hover {
border-color: var(--accent-purple);
box-shadow: 0 0 0 3px var(--accent-purple-subtle);
}
.task-line.completed {
text-decoration: line-through;
opacity: 0.5;
}
/* ── Editor: Code Blocks ──────────────────────────────────── */
.editor-code-inline {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-xs);
padding: 1px 5px;
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 0.88em;
color: var(--accent-amber);
}
.editor-code-block {
display: block;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
padding: 12px 16px;
margin: 8px 0;
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 12px;
line-height: 1.6;
color: var(--text-secondary);
overflow-x: auto;
white-space: pre;
}
/* ── Markdown Preview: headings ───────────────────────────── */
.markdown-preview h1 {
font-size: 1.8em;
font-weight: 700;
margin: 1em 0 0.5em;
color: var(--text-primary);
border-bottom: 1px solid var(--border-primary);
padding-bottom: 0.3em;
}
.markdown-preview h2 {
font-size: 1.4em;
font-weight: 600;
margin: 0.8em 0 0.4em;
color: var(--text-primary);
}
.markdown-preview h3 {
font-size: 1.15em;
font-weight: 600;
margin: 0.6em 0 0.3em;
color: var(--text-secondary);
}
/* ── Markdown Preview: code ───────────────────────────────── */
.markdown-preview code {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-xs);
padding: 1px 5px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.88em;
color: var(--accent-amber);
}
.markdown-preview pre {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
padding: 14px 18px;
margin: 10px 0;
overflow-x: auto;
}
.markdown-preview pre code {
background: none;
border: none;
padding: 0;
font-size: 12px;
line-height: 1.6;
color: var(--text-secondary);
}
/* ── Markdown Preview: checkboxes ─────────────────────────── */
.markdown-preview input[type="checkbox"] {
appearance: none;
width: 14px;
height: 14px;
border: 2px solid var(--border-secondary);
border-radius: 3px;
margin-right: 6px;
vertical-align: middle;
cursor: pointer;
position: relative;
}
.markdown-preview input[type="checkbox"]:checked {
background: var(--accent-purple-bright);
border-color: var(--accent-purple-bright);
}
.markdown-preview input[type="checkbox"]:checked::after {
content: '✓';
position: absolute;
top: -2px;
left: 1px;
font-size: 10px;
color: white;
}
/* ── Graph Filter Bar ─────────────────────────────────────── */
.graph-filter-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 0 20px;
height: 40px;
border-bottom: 1px solid var(--border-primary);
background: var(--bg-secondary);
flex-shrink: 0;
}
.graph-filter-bar label {
font-size: 11px;
color: var(--text-muted);
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
}
.graph-filter-bar select,
.graph-filter-bar input[type="range"] {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: 11px;
padding: 3px 8px;
outline: none;
}
.graph-filter-bar select:focus,
.graph-filter-bar input[type="range"]:focus {
border-color: var(--border-focus);
}
.graph-filter-bar input[type="range"] {
width: 80px;
cursor: pointer;
accent-color: var(--accent-purple);
}

View file

@ -25,6 +25,13 @@ export interface GraphData {
edges: GraphEdge[];
}
export interface SearchResult {
path: string;
name: string;
context: string;
line_number: number;
}
/* ── Commands ───────────────────────────────────────────────── */
export async function listNotes(vaultPath: string): Promise<NoteEntry[]> {
return invoke<NoteEntry[]>("list_notes", { vaultPath });
@ -46,6 +53,14 @@ export async function buildGraph(vaultPath: string): Promise<GraphData> {
return invoke<GraphData>("build_graph", { vaultPath });
}
export async function searchVault(vaultPath: string, query: string): Promise<SearchResult[]> {
return invoke<SearchResult[]>("search_vault", { vaultPath, query });
}
export async function renameNote(vaultPath: string, oldPath: string, newName: string): Promise<string> {
return invoke<string>("rename_note", { vaultPath, oldPath, newName });
}
export async function getOrCreateDaily(vaultPath: string): Promise<string> {
return invoke<string>("get_or_create_daily", { vaultPath });
}