diff --git a/CHANGELOG.md b/CHANGELOG.md index 19d98b3..862714e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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**: H1–H3 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) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 45cbc58..58bc634 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { Ok(GraphData { nodes, edges }) } +#[tauri::command] +fn search_vault(vault_path: String, query: String) -> Result, 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 = 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 { + 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: ®ex::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 { 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, diff --git a/src/App.tsx b/src/App.tsx index 265f652..9b6ee40 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(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() { } /> } /> + setPaletteOpen(false)} /> ); diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx new file mode 100644 index 0000000..ea1bd3f --- /dev/null +++ b/src/components/CommandPalette.tsx @@ -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(null); + const listRef = useRef(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 ( +
+
e.stopPropagation()} onKeyDown={handleKeyDown}> +
+ + + + + setQuery(e.target.value)} + /> + esc +
+ +
+ {ordered.length === 0 && ( +
No results found
+ )} + {cmdResults.length > 0 && ( +
Commands
+ )} + {cmdResults.map((cmd) => { + const globalIdx = ordered.indexOf(cmd); + return ( + + ); + })} + {noteResults.length > 0 && ( +
Notes
+ )} + {noteResults.map((cmd) => { + const globalIdx = ordered.indexOf(cmd); + return ( + + ); + })} +
+ +
+ ↑↓ navigate + ↵ open + esc close +
+
+
+ ); +} + +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; +} diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx new file mode 100644 index 0000000..11b17c8 --- /dev/null +++ b/src/components/ContextMenu.tsx @@ -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(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 ( +
+ {items.map((item, i) => ( + + ))} +
+ ); +} + +/* Hook: useContextMenu */ +export function useContextMenu() { + const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null); + const [menuTarget, setMenuTarget] = useState(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 }; +} diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 9b04020..ec3d0a0 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -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() {
{isPreview ? (
{ + // Heading scaling (h1-h3) + const headingMatch = line.match(/^(#{1,3})\s+(.*)$/); + if (headingMatch) { + const level = headingMatch[1].length; + return `
${line}
`; + } + + // 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 `
${indent}${text}
`; + } + + return line; + }); + + escaped = processed.join("\n"); + + // Inline code: `code` + escaped = escaped.replace( + /`([^`\n]+?)`/g, + '$1' + ); + // Convert newlines to
escaped = escaped.replace(/\n/g, "
"); diff --git a/src/components/GraphView.tsx b/src/components/GraphView.tsx index fbb5794..a082e19 100644 --- a/src/components/GraphView.tsx +++ b/src/components/GraphView.tsx @@ -38,6 +38,8 @@ export function GraphView() { const { vaultPath } = useVault(); const navigate = useNavigate(); const [graphData, setGraphData] = useState(null); + const [folderFilter, setFolderFilter] = useState("all"); + const [minLinks, setMinLinks] = useState(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 (
@@ -61,16 +86,38 @@ export function GraphView() {

🔮 Graph View

- {graphData.nodes.length} notes - {graphData.edges.length} links + {filteredNodes.length} notes + {filteredEdges.length} links
Scroll to zoom · Click to focus · Double-click to open
+ {/* ── Filter Bar ── */} +
+ + +
navigate(`/note/${encodeURIComponent(path)}`)} />
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index d92fe35..28758fc 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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>(new Set()); + const [searchResults, setSearchResults] = useState([]); + 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 (
+ {/* ── Content Search Results ── */} + {hasContentResults && ( +
+
+ Content Matches + {searchResults.length} +
+ {searchResults.slice(0, 10).map((result, i) => ( + + ))} +
+ )} + {isSearching && search.trim().length >= 2 && ( +
+ Searching... +
+ )} + {/* ── File Tree ── */}
@@ -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() {

)}
+ + ); } /* ── Recursive File Tree ────────────────────────────────────── */ function NoteTree({ - entries, collapsed, onToggle, depth, + entries, collapsed, onToggle, onContextMenu, depth, }: { entries: NoteEntry[]; collapsed: Set; onToggle: (path: string) => void; + onContextMenu: (e: React.MouseEvent, target: string) => void; depth: number; }) { const navigate = useNavigate(); @@ -141,7 +232,7 @@ function NoteTree({ {entry.name} {!isCollapsed && entry.children && ( - + )} ); @@ -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)} > 📄 {entry.name.replace(/\.md$/, "")} diff --git a/src/index.css b/src/index.css index 9d345ab..eb05b8c 100644 --- a/src/index.css +++ b/src/index.css @@ -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); } \ No newline at end of file diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 0bc82f3..f93774a 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -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 { return invoke("list_notes", { vaultPath }); @@ -46,6 +53,14 @@ export async function buildGraph(vaultPath: string): Promise { return invoke("build_graph", { vaultPath }); } +export async function searchVault(vaultPath: string, query: string): Promise { + return invoke("search_vault", { vaultPath, query }); +} + +export async function renameNote(vaultPath: string, oldPath: string, newName: string): Promise { + return invoke("rename_note", { vaultPath, oldPath, newName }); +} + export async function getOrCreateDaily(vaultPath: string): Promise { return invoke("get_or_create_daily", { vaultPath }); }