diff --git a/.gitignore b/.gitignore index d92d85c..a8b18a6 100644 --- a/.gitignore +++ b/.gitignore @@ -29,5 +29,9 @@ src-tauri/target/ # Worktrees .worktrees/ +# Superpowers brainstorm sessions +.superpowers/ + # Docs (superpowers plans/specs) docs/ +.claude/worktrees/ diff --git a/plugins/backlinks/init.lua b/plugins/backlinks/init.lua new file mode 100644 index 0000000..9130d14 --- /dev/null +++ b/plugins/backlinks/init.lua @@ -0,0 +1,5 @@ +local prism = require("prism") + +prism.on("file:opened", function(payload) + prism.log("Backlinks: file opened - " .. (payload.path or "")) +end) diff --git a/plugins/backlinks/plugin.toml b/plugins/backlinks/plugin.toml new file mode 100644 index 0000000..15bbe1a --- /dev/null +++ b/plugins/backlinks/plugin.toml @@ -0,0 +1,8 @@ +[plugin] +name = "backlinks" +version = "0.1.0" +description = "Show files that link to the current note" +author = "Prism" + +[ui] +sidebar = true diff --git a/plugins/outline/init.lua b/plugins/outline/init.lua new file mode 100644 index 0000000..eb8c212 --- /dev/null +++ b/plugins/outline/init.lua @@ -0,0 +1,5 @@ +local prism = require("prism") + +prism.on("file:opened", function(payload) + prism.log("Outline: file opened - " .. (payload.path or "")) +end) diff --git a/plugins/outline/plugin.toml b/plugins/outline/plugin.toml new file mode 100644 index 0000000..6964f09 --- /dev/null +++ b/plugins/outline/plugin.toml @@ -0,0 +1,8 @@ +[plugin] +name = "outline" +version = "0.1.0" +description = "Table of contents for the current note" +author = "Prism" + +[ui] +sidebar = true diff --git a/src-tauri/src/commands/files.rs b/src-tauri/src/commands/files.rs index ba1bb55..e08b79c 100644 --- a/src-tauri/src/commands/files.rs +++ b/src-tauri/src/commands/files.rs @@ -132,10 +132,21 @@ pub fn toggle_todo( } #[tauri::command] -pub fn write_file(path: String, content: String, config: State<'_, Mutex>) -> Result<(), String> { +pub fn write_file( + path: String, + content: String, + config: State<'_, Mutex>, + lua_runtime: State<'_, Mutex>, +) -> Result<(), String> { let config = config.lock().map_err(|e| e.to_string())?; let full_path = config.vault_path().join(&path); - fs::write(&full_path, content).map_err(|e| format!("Failed to write {}: {}", path, e)) + fs::write(&full_path, &content).map_err(|e| format!("Failed to write {}: {}", path, e))?; + + if let Ok(runtime) = lua_runtime.lock() { + runtime.dispatch("file:saved", Some(serde_json::json!({ "path": path }))); + } + + Ok(()) } #[tauri::command] diff --git a/src-tauri/src/commands/frecency.rs b/src-tauri/src/commands/frecency.rs new file mode 100644 index 0000000..211a6d1 --- /dev/null +++ b/src-tauri/src/commands/frecency.rs @@ -0,0 +1,132 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; +use tauri::State; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FrecencyEntry { + pub count: u32, + pub last_opened: i64, + pub score: f64, +} + +pub struct FrecencyStore { + entries: RwLock>, + file_path: RwLock, + dirty: RwLock, +} + +impl FrecencyStore { + pub fn new() -> Self { + Self { + entries: RwLock::new(HashMap::new()), + file_path: RwLock::new(PathBuf::new()), + dirty: RwLock::new(false), + } + } + + pub fn init(&self, vault_path: &str) { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + vault_path.hash(&mut hasher); + let hash = hasher.finish(); + + let config_dir = dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("prism"); + let _ = std::fs::create_dir_all(&config_dir); + + let path = config_dir.join(format!("frecency-{:x}.json", hash)); + + if path.exists() { + if let Ok(data) = std::fs::read_to_string(&path) { + if let Ok(entries) = serde_json::from_str::>(&data) + { + *self.entries.write().unwrap() = entries; + } + } + } + + *self.file_path.write().unwrap() = path; + } + + pub fn record_open(&self, path: &str) { + let now = chrono::Utc::now().timestamp(); + let mut entries = self.entries.write().unwrap(); + let entry = entries.entry(path.to_string()).or_insert(FrecencyEntry { + count: 0, + last_opened: 0, + score: 0.0, + }); + entry.count += 1; + entry.last_opened = now; + entry.score = self.compute_score(entry.count, entry.last_opened, now); + *self.dirty.write().unwrap() = true; + } + + pub fn flush(&self) { + let dirty = *self.dirty.read().unwrap(); + if !dirty { + return; + } + let file_path = self.file_path.read().unwrap().clone(); + if file_path.as_os_str().is_empty() { + return; + } + let entries = self.entries.read().unwrap(); + if let Ok(json) = serde_json::to_string_pretty(&*entries) { + let _ = std::fs::write(&file_path, json); + } + *self.dirty.write().unwrap() = false; + } + + pub fn get_scores(&self) -> HashMap { + let now = chrono::Utc::now().timestamp(); + let entries = self.entries.read().unwrap(); + entries + .iter() + .map(|(path, entry)| { + ( + path.clone(), + self.compute_score(entry.count, entry.last_opened, now), + ) + }) + .collect() + } + + pub fn get_recent(&self, limit: usize) -> Vec { + let entries = self.entries.read().unwrap(); + let mut sorted: Vec<_> = entries.iter().collect(); + sorted.sort_by(|a, b| b.1.last_opened.cmp(&a.1.last_opened)); + sorted.into_iter().take(limit).map(|(k, _)| k.clone()).collect() + } + + fn compute_score(&self, count: u32, last_opened: i64, now: i64) -> f64 { + let days_ago = ((now - last_opened) as f64) / 86400.0; + // Halve score every 7 days + let decay = 0.5_f64.powf(days_ago / 7.0); + (count as f64) * decay + } +} + +#[tauri::command] +pub fn record_file_open(path: String, frecency: State<'_, Arc>) { + frecency.record_open(&path); + frecency.flush(); +} + +#[tauri::command] +pub fn get_frecency_scores( + frecency: State<'_, Arc>, +) -> HashMap { + frecency.get_scores() +} + +#[tauri::command] +pub fn get_recent_files( + limit: usize, + frecency: State<'_, Arc>, +) -> Vec { + frecency.get_recent(limit) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index fcb8a4b..5b3c04f 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod config; pub mod editor; pub mod favorites; pub mod files; +pub mod frecency; pub mod graph; pub mod images; pub mod logger; diff --git a/src-tauri/src/commands/search.rs b/src-tauri/src/commands/search.rs index d852dfe..ac87699 100644 --- a/src-tauri/src/commands/search.rs +++ b/src-tauri/src/commands/search.rs @@ -1,12 +1,11 @@ -use crate::config::PrismConfig; use nucleo_matcher::{ pattern::{CaseMatching, Normalization, Pattern}, Config, Matcher, }; use serde::Serialize; use std::fs; -use std::path::Path; -use std::sync::Mutex; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; use tauri::State; #[derive(Debug, Clone, Serialize)] @@ -43,20 +42,53 @@ fn collect_md_files(dir: &Path, vault_root: &Path, out: &mut Vec<(String, String } } +pub struct FileIndex { + entries: RwLock>, + vault_path: RwLock, +} + +impl FileIndex { + pub fn new() -> Self { + Self { + entries: RwLock::new(Vec::new()), + vault_path: RwLock::new(PathBuf::new()), + } + } + + pub fn refresh(&self, vault_path: &Path) { + { + let mut vp = self.vault_path.write().unwrap(); + *vp = vault_path.to_path_buf(); + } + let mut files = Vec::new(); + collect_md_files(vault_path, vault_path, &mut files); + let mut entries = self.entries.write().unwrap(); + *entries = files; + } + + pub fn refresh_current(&self) { + let vp = self.vault_path.read().unwrap().clone(); + if vp.as_os_str().is_empty() { + return; + } + self.refresh(&vp); + } + + pub fn get_entries(&self) -> Vec<(String, String)> { + self.entries.read().unwrap().clone() + } +} + #[tauri::command] pub fn fuzzy_search( query: String, - config: State<'_, Mutex>, + file_index: State<'_, Arc>, ) -> Result, String> { if query.is_empty() { return Ok(vec![]); } - let config = config.lock().map_err(|e| e.to_string())?; - let vault = config.vault_path(); - - let mut files: Vec<(String, String)> = Vec::new(); - collect_md_files(&vault, &vault, &mut files); + let files = file_index.get_entries(); let mut matcher = Matcher::new(Config::DEFAULT.match_paths()); let pattern = Pattern::parse(&query, CaseMatching::Ignore, Normalization::Smart); @@ -121,17 +153,13 @@ pub struct VaultSearchMatch { #[tauri::command] pub fn vault_search( query: String, - config: State<'_, Mutex>, + file_index: State<'_, Arc>, ) -> Result, String> { if query.is_empty() { return Ok(vec![]); } - let config = config.lock().map_err(|e| e.to_string())?; - let vault = config.vault_path(); - - let mut files: Vec<(String, String)> = Vec::new(); - collect_md_files(&vault, &vault, &mut files); + let files = file_index.get_entries(); let query_lower = query.to_lowercase(); let mut results: Vec = Vec::new(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7980f6f..41d42ba 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,10 +4,12 @@ mod plugins; mod theme; mod watcher; +use commands::frecency::FrecencyStore; +use commands::search::FileIndex; use config::PrismConfig; use log::{error, info}; use plugins::lua_runtime::LuaRuntime; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use tauri::Emitter; use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut}; @@ -136,11 +138,13 @@ pub fn run() { } }); }) + .manage(Arc::new(FileIndex::new())) + .manage(Arc::new(FrecencyStore::new())) .manage(Mutex::new(config)) .manage(Mutex::new(plugin_manager)) .manage(Mutex::new(lua_runtime)) .setup(move |app| { - use tauri::Manager; + use tauri::{Listener, Manager}; let config = app.state::>(); let config = config.lock().unwrap(); @@ -180,6 +184,24 @@ pub fn run() { let config_path = PrismConfig::config_path(); let handle = app.handle().clone(); + // Initialize file index cache + let file_index = app.state::>(); + if vault_path.exists() { + file_index.refresh(&vault_path); + } + + // Initialize frecency store + let frecency = app.state::>(); + frecency.init(&vault_path.to_string_lossy()); + + // Wire file watcher to refresh file index on changes + { + let file_index = Arc::clone(file_index.inner()); + app.listen("file-changed", move |_| { + file_index.refresh_current(); + }); + } + if vault_path.exists() { let watcher = watcher::start_watcher(handle, &vault_path, &config_path) .expect("Failed to start file watcher"); @@ -259,6 +281,9 @@ pub fn run() { commands::plugins::clean_plugins, commands::plugins::plugin_emit, commands::logger::log_message, + commands::frecency::record_file_open, + commands::frecency::get_frecency_scores, + commands::frecency::get_recent_files, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/animated-overlay.tsx b/src/components/animated-overlay.tsx new file mode 100644 index 0000000..4bd8cb2 --- /dev/null +++ b/src/components/animated-overlay.tsx @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useState, type ReactNode } from "react"; + +interface AnimatedOverlayProps { + visible: boolean; + children: ReactNode; +} + +export function AnimatedOverlay({ visible, children }: AnimatedOverlayProps) { + const [mounted, setMounted] = useState(false); + const [closing, setClosing] = useState(false); + + useEffect(() => { + if (visible) { + setMounted(true); + setClosing(false); + } else if (mounted) { + setClosing(true); + } + }, [visible, mounted]); + + const handleAnimationEnd = useCallback(() => { + if (closing) { + setMounted(false); + setClosing(false); + } + }, [closing]); + + if (!mounted) return null; + + return ( +
+ {children} +
+ ); +} diff --git a/src/components/content-skeleton.tsx b/src/components/content-skeleton.tsx new file mode 100644 index 0000000..e99f757 --- /dev/null +++ b/src/components/content-skeleton.tsx @@ -0,0 +1,13 @@ +import { memo } from "react"; + +export const ContentSkeleton = memo(function ContentSkeleton() { + const widths = [75, 90, 60, 85, 45, 80, 70, 55]; + return ( +
+
+ {widths.map((w, i) => ( +
+ ))} +
+ ); +}); diff --git a/src/components/search/file-finder.tsx b/src/components/search/file-finder.tsx index 5ba3581..f110dab 100644 --- a/src/components/search/file-finder.tsx +++ b/src/components/search/file-finder.tsx @@ -10,14 +10,27 @@ interface FileFinderProps { export function FileFinder({ onSelect, onClose }: FileFinderProps) { const [query, setQuery] = useState(""); const [results, setResults] = useState([]); + const [recentFiles, setRecentFiles] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); const inputRef = useRef(null); const listRef = useRef(null); useEffect(() => { inputRef.current?.focus(); + commands.getRecentFiles(10).then((paths) => { + setRecentFiles( + paths.map((p) => ({ + path: p, + name: p.replace(/\.md$/, "").split("/").pop() || p, + score: 0, + context: null, + })), + ); + }).catch(() => {}); }, []); + const displayResults = query ? results : recentFiles; + useEffect(() => { const list = listRef.current; if (!list) return; @@ -32,32 +45,32 @@ export function FileFinder({ onSelect, onClose }: FileFinderProps) { } const timeout = setTimeout(() => { commands.fuzzySearch(query).then(setResults); - }, 100); + }, 30); return () => clearTimeout(timeout); }, [query]); useEffect(() => { setSelectedIndex(0); - }, [results]); + }, [results, recentFiles]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "ArrowDown" || (e.key === "j" && e.ctrlKey)) { e.preventDefault(); - setSelectedIndex((i) => Math.min(i + 1, results.length - 1)); + setSelectedIndex((i) => Math.min(i + 1, displayResults.length - 1)); } else if (e.key === "ArrowUp" || (e.key === "k" && e.ctrlKey)) { e.preventDefault(); setSelectedIndex((i) => Math.max(i - 1, 0)); - } else if (e.key === "Enter" && results[selectedIndex]) { + } else if (e.key === "Enter" && displayResults[selectedIndex]) { e.preventDefault(); - onSelect(results[selectedIndex].path); + onSelect(displayResults[selectedIndex].path); onClose(); } else if (e.key === "Escape") { e.preventDefault(); onClose(); } }, - [results, selectedIndex, onSelect, onClose], + [displayResults, selectedIndex, onSelect, onClose], ); return ( @@ -92,7 +105,15 @@ export function FileFinder({ onSelect, onClose }: FileFinderProps) {
    - {results.map((r, i) => ( + {!query && displayResults.length > 0 && ( +
  • + Recent +
  • + )} + {displayResults.map((r, i) => (
  • { + if (visible) { + setMounted(true); + setClosing(false); + } else if (mounted) { + setClosing(true); + } + }, [visible, mounted]); + + const handleAnimationEnd = useCallback(() => { + if (closing) { + setMounted(false); + setClosing(false); + } + }, [closing]); + + if (!mounted) return null; + + return ( +
    + {children} +
    + ); +} diff --git a/src/components/sidebar/file-tree.tsx b/src/components/sidebar/file-tree.tsx index 86b493c..a9515ab 100644 --- a/src/components/sidebar/file-tree.tsx +++ b/src/components/sidebar/file-tree.tsx @@ -254,7 +254,12 @@ export const FileTree = memo(function FileTree({ tabIndex={0} onKeyDown={handleKeyDown} > -
      +
      +
      +
        {items.map((item, idx) => ( ))}
      +
      {pendingTrash && (
      { + if (!state.saveFlash) return; + setFlash(true); + const timer = setTimeout(() => setFlash(false), 300); + return () => clearTimeout(timer); + }, [state.saveFlash]); return (
      {mode} @@ -38,7 +50,9 @@ export const StatusBar = memo(function StatusBar({ filePath, content }: StatusBa ))} {filePath && {wordCount}w} {state.keySequence && ( - {state.keySequence} + + {state.keySequence} + )}
      diff --git a/src/components/toast.tsx b/src/components/toast.tsx index 9b7ab87..4ab5451 100644 --- a/src/components/toast.tsx +++ b/src/components/toast.tsx @@ -59,8 +59,8 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { } const variantColors: Record = { - success: "var(--prism-syntax-string)", - error: "var(--prism-syntax-variable)", + success: "#a6e3a1", + error: "#f38ba8", info: "var(--prism-accent)", }; @@ -104,7 +104,7 @@ function ToastItem({ return (
      - {toast.message} +
      {toast.message}
      +
      ); } diff --git a/src/components/which-key.tsx b/src/components/which-key.tsx new file mode 100644 index 0000000..7b73263 --- /dev/null +++ b/src/components/which-key.tsx @@ -0,0 +1,52 @@ +import { memo, useEffect, useState } from "react"; +import type { KeyContinuation } from "@/hooks/use-shortcuts"; + +interface WhichKeyProps { + continuations: KeyContinuation[]; + keySequence: string; +} + +export const WhichKey = memo(function WhichKey({ continuations, keySequence }: WhichKeyProps) { + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (continuations.length === 0) { + setVisible(false); + return; + } + const timer = setTimeout(() => setVisible(true), 200); + return () => clearTimeout(timer); + }, [continuations]); + + if (!visible || continuations.length === 0) return null; + + return ( +
      + +
      + {keySequence}... +
      +
      + {continuations.map((c) => ( +
      + {c.key} + {c.label} +
      + ))} +
      +
      + ); +}); diff --git a/src/hooks/use-shortcuts.ts b/src/hooks/use-shortcuts.ts index 898a33e..a9a6b64 100644 --- a/src/hooks/use-shortcuts.ts +++ b/src/hooks/use-shortcuts.ts @@ -1,13 +1,41 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { ReaderAction } from "@/lib/reader-state"; export type ShortcutMaps = Record void>>; +export interface KeyContinuation { + key: string; + label: string; +} + +function getContinuations( + prefix: string, + maps: Record void>, + actionLabels: Record, +): KeyContinuation[] { + const results: KeyContinuation[] = []; + for (const [binding] of Object.entries(maps)) { + if (binding.startsWith(prefix + " ") && binding !== prefix) { + const rest = binding.slice(prefix.length + 1); + const nextKey = rest.split(" ")[0]; + const label = actionLabels[binding] ?? binding; + if (!results.some((r) => r.key === nextKey)) { + results.push({ key: nextKey, label }); + } + } + } + return results; +} + export function useShortcuts( maps: ShortcutMaps, currentMode: string, dispatch: React.Dispatch, + actionLabels?: Record, ) { + const [continuations, setContinuations] = useState([]); + const actionLabelsRef = useRef(actionLabels ?? {}); + actionLabelsRef.current = actionLabels ?? {}; const mapsRef = useRef(maps); mapsRef.current = maps; const modeRef = useRef(currentMode); @@ -44,6 +72,7 @@ export function useShortcuts( } sequenceRef.current = ""; setKeySequence(""); + setContinuations([]); return; } @@ -68,6 +97,7 @@ export function useShortcuts( handler(); sequenceRef.current = ""; setKeySequence(""); + setContinuations([]); return; } } @@ -90,6 +120,7 @@ export function useShortcuts( seqHandler(); sequenceRef.current = ""; setKeySequence(""); + setContinuations([]); return; } @@ -100,17 +131,26 @@ export function useShortcuts( singleHandler(); sequenceRef.current = ""; setKeySequence(""); + setContinuations([]); return; } + // Compute continuations from both mode and global maps + const allBindings = { ...maps.global, ...maps[mode] }; + const conts = getContinuations(seq, allBindings, actionLabelsRef.current); + setContinuations(conts); + timeoutRef.current = setTimeout(() => { sequenceRef.current = ""; setKeySequence(""); - }, 500); + setContinuations([]); + }, conts.length > 0 ? 2000 : 500); } } window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [isEditorFocused, setKeySequence]); + + return { continuations }; } diff --git a/src/hooks/use-vault.ts b/src/hooks/use-vault.ts index 1bf2ffe..8c3e70a 100644 --- a/src/hooks/use-vault.ts +++ b/src/hooks/use-vault.ts @@ -27,6 +27,7 @@ export function useVault() { try { const md = await commands.readFile(path); setContent(md); + commands.recordFileOpen(path).catch(() => {}); } catch (e) { log.error("Failed to read file:", e); setContent(""); diff --git a/src/index.css b/src/index.css index a119edf..426c1e5 100644 --- a/src/index.css +++ b/src/index.css @@ -21,6 +21,12 @@ --font-mono: "JetBrains Mono", monospace; --font-sans: "Inter", system-ui, sans-serif; + + --transition-fast: 120ms; + --transition-normal: 200ms; + --transition-slow: 300ms; + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); } body { @@ -35,6 +41,9 @@ body { main { position: relative; + font-family: var(--font-sans); + font-size: 16px; + line-height: 1.7; } ::-webkit-scrollbar { @@ -51,6 +60,67 @@ main { background: var(--prism-muted); } +button, a, [role="button"] { + transition: opacity var(--transition-fast) var(--ease-out), + color var(--transition-fast) var(--ease-out), + background-color var(--transition-fast) var(--ease-out); +} + +:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--prism-accent); + border-radius: 2px; + transition: box-shadow var(--transition-fast) var(--ease-out); +} + +body, main, footer, header, nav, +button, a, code, pre, +[data-tauri-drag-region] { + transition: color var(--transition-slow) var(--ease-in-out), + background-color var(--transition-slow) var(--ease-in-out), + border-color var(--transition-slow) var(--ease-in-out); +} + +@keyframes overlay-in { + from { opacity: 0; transform: scale(0.98); } + to { opacity: 1; transform: scale(1); } +} +@keyframes overlay-out { + from { opacity: 1; transform: scale(1); } + to { opacity: 0; transform: scale(0.98); } +} +@keyframes slide-in-left { + from { transform: translateX(-100%); } + to { transform: translateX(0); } +} +@keyframes slide-out-left { + from { transform: translateX(0); } + to { transform: translateX(-100%); } +} +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes fade-out { + from { opacity: 1; } + to { opacity: 0; } +} + +.tree-children { + display: grid; + grid-template-rows: 1fr; + opacity: 1; + transition: grid-template-rows var(--transition-fast) var(--ease-out), + opacity var(--transition-fast) var(--ease-out); +} +.tree-children[data-collapsed="true"] { + grid-template-rows: 0fr; + opacity: 0; +} +.tree-children > div { + overflow: hidden; +} + /* highlight.js syntax highlighting - themed via CSS variables */ .hljs { color: var(--prism-fg); background: var(--prism-code-bg); } .hljs-keyword, .hljs-selector-tag, .hljs-built_in, .hljs-deletion { color: var(--prism-syntax-keyword); } @@ -66,3 +136,159 @@ main { .hljs-attribute { color: var(--prism-syntax-function); } .hljs-emphasis { font-style: italic; } .hljs-strong { font-weight: bold; } + +/* Markdown typography */ +main > .markdown-body { + max-width: 72ch; +} + +main h1, main h2, main h3, main h4, main h5, main h6 { + color: var(--prism-heading); + font-family: var(--font-sans); + line-height: 1.3; + margin-top: 1.5em; + margin-bottom: 0.5em; +} + +main h1 { font-size: 1.8em; font-weight: 700; } +main h2 { font-size: 1.4em; font-weight: 600; border-bottom: 1px solid var(--prism-border); padding-bottom: 0.3em; } +main h3 { font-size: 1.15em; font-weight: 600; } +main h4 { font-size: 1em; font-weight: 600; } +main h5, main h6 { font-size: 0.9em; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; } + +main h1:first-child { margin-top: 0; } + +main p { margin-bottom: 1em; } + +main a { + color: var(--prism-accent); + text-decoration: none; + transition: text-decoration-color var(--transition-fast) var(--ease-out); +} +main a:hover { + text-decoration: underline; + text-underline-offset: 2px; +} + +/* Code blocks */ +main pre { + background: var(--prism-code-bg); + border-left: 3px solid var(--prism-accent); + border-radius: 6px; + padding: 1em 1.25em; + margin: 1em 0; + overflow-x: auto; + font-family: var(--font-mono); + font-size: 0.9em; + line-height: 1.5; +} + +main code { + font-family: var(--font-mono); + font-size: 0.88em; +} + +main :not(pre) > code { + background: var(--prism-code-bg); + padding: 2px 6px; + border-radius: 4px; +} + +/* Blockquotes */ +main blockquote { + border-left: 3px solid var(--prism-muted); + padding-left: 1em; + margin: 1em 0; + color: var(--prism-muted); + font-style: italic; +} +main blockquote p { margin-bottom: 0.5em; } + +/* Lists */ +main ul, main ol { + padding-left: 1.5em; + margin: 0.5em 0 1em; +} +main li { margin-bottom: 0.25em; } +main li::marker { color: var(--prism-muted); } + +/* Task lists */ +main .task-list-item { + list-style: none; + margin-left: -1.5em; + padding-left: 0; +} +main .task-list-item input[type="checkbox"] { + accent-color: var(--prism-accent); + margin-right: 0.5em; +} + +/* Tables */ +main table { + width: 100%; + border-collapse: collapse; + margin: 1em 0; + font-size: 0.95em; +} +main th, main td { + border: 1px solid var(--prism-border); + padding: 0.5em 0.75em; + text-align: left; +} +main th { + background: var(--prism-code-bg); + font-weight: 600; +} +main tr:nth-child(even) { + background: color-mix(in srgb, var(--prism-code-bg) 50%, transparent); +} + +/* Horizontal rule */ +main hr { + border: none; + border-top: 1px solid var(--prism-border); + margin: 2em auto; + width: 50%; +} + +/* Images */ +main img { + max-width: 100%; + border-radius: 6px; + margin: 1em 0; +} + +/* Strong and emphasis */ +main strong { color: var(--prism-fg); font-weight: 600; } +main em { font-style: italic; } + +/* Skeleton loading */ +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} +.skeleton-line { + height: 14px; + border-radius: 4px; + background: linear-gradient(90deg, var(--prism-border) 25%, var(--prism-selection) 50%, var(--prism-border) 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + margin-bottom: 12px; +} + +@keyframes shrink-width { + from { width: 100%; } + to { width: 0%; } +} + +.file-tree-cursor { + position: absolute; + left: 4px; + right: 4px; + height: 32px; + background: var(--prism-selection); + border-radius: 4px; + transition: top 120ms cubic-bezier(0.16, 1, 0.3, 1); + pointer-events: none; + z-index: 0; +} diff --git a/src/lib/reader-state.ts b/src/lib/reader-state.ts index c124576..3d77f0a 100644 --- a/src/lib/reader-state.ts +++ b/src/lib/reader-state.ts @@ -5,6 +5,7 @@ export interface ReaderState { overlay: Overlay; editorOpen: boolean; keySequence: string; + saveFlash: boolean; } export type ReaderAction = @@ -13,13 +14,15 @@ export type ReaderAction = | { type: "CLOSE_OVERLAY" } | { type: "OPEN_EDITOR" } | { type: "CLOSE_EDITOR" } - | { type: "SET_KEY_SEQUENCE"; keySequence: string }; + | { type: "SET_KEY_SEQUENCE"; keySequence: string } + | { type: "SAVE_FLASH" }; export const initialReaderState: ReaderState = { sidebarVisible: false, overlay: "none", editorOpen: false, keySequence: "", + saveFlash: false, }; export function readerReducer(state: ReaderState, action: ReaderAction): ReaderState { @@ -36,6 +39,8 @@ export function readerReducer(state: ReaderState, action: ReaderAction): ReaderS return { ...state, editorOpen: false }; case "SET_KEY_SEQUENCE": return { ...state, keySequence: action.keySequence }; + case "SAVE_FLASH": + return { ...state, saveFlash: !state.saveFlash }; default: return state; } diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 3471a03..e27ce93 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -43,6 +43,9 @@ export const commands = { pluginEmit: (event: string, data?: unknown) => invoke("plugin_emit", { event, data }), getDebugFlag: () => invoke("get_debug_flag"), logMessage: (level: string, message: string) => invoke("log_message", { level, message }), + recordFileOpen: (path: string) => invoke("record_file_open", { path }), + getRecentFiles: (limit: number) => invoke("get_recent_files", { limit }), + getFrecencyScores: () => invoke>("get_frecency_scores"), }; export function onFileChanged(callback: (path: string) => void): Promise { diff --git a/src/routes/index.tsx b/src/routes/index.tsx index d447989..a907b48 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -18,8 +18,12 @@ import { QuickCapture } from "@/components/quick-capture"; import { TemplatePickerDialog } from "@/components/template-picker"; import { LinkGraph } from "@/components/link-graph"; import { ThemePicker } from "@/components/theme-picker"; +import { AnimatedOverlay } from "@/components/animated-overlay"; +import { SidebarPanel } from "@/components/sidebar-panel"; +import { WhichKey } from "@/components/which-key"; import { PluginErrorBoundary } from "@/components/plugin-panel"; import { loadPluginBundle, type PluginUI } from "@/lib/plugin-loader"; +import { ContentSkeleton } from "@/components/content-skeleton"; import { useToast } from "@/components/toast"; import { commands } from "@/lib/tauri"; import { getCurrentWindow } from "@tauri-apps/api/window"; @@ -38,7 +42,7 @@ export const Route = createFileRoute("/")( }); function ReaderView() { - const { files, currentPath, content, openFile, closeFile, refreshFiles, setContent } = + const { files, currentPath, content, loading, openFile, closeFile, refreshFiles, setContent } = useVault(); const { config, shortcuts, favorites, pluginCommands, toggleFavorite } = usePrism(); const { state, dispatch, readerRef } = useReader(); @@ -179,6 +183,11 @@ function ReaderView() { dispatch({ type: "CLOSE_EDITOR" }); }, [dispatch]); + const handleEditorSave = useCallback((text: string) => { + setContent(text); + dispatch({ type: "SAVE_FLASH" }); + }, [setContent, dispatch]); + const createFile = useCallback( (path: string) => { openFile(path).then(() => { @@ -548,17 +557,56 @@ function ReaderView() { setVault, ]); + const actionLabels = useMemo(() => { + const labels: Record = {}; + const sc = shortcuts ?? { global: {}, render: {} }; + const labelMap: Record = { + "find-file": "Find File", + "toggle-sidebar": "Toggle Sidebar", + "command-palette": "Command Palette", + "new-file": "New File", + "filter-tags": "Filter Tags", + "quick-capture": "Quick Capture", + "new-from-template": "New from Template", + "link-graph": "Link Graph", + "cycle-theme": "Switch Theme", + "vault-search": "Search Vault", + "set-vault": "Set Vault", + "daily-note": "Daily Note", + "close-overlay": "Close Overlay", + "page-down": "Page Down", + "page-up": "Page Up", + "quit": "Quit", + "scroll-down": "Scroll Down", + "scroll-up": "Scroll Up", + "scroll-left": "Scroll Left", + "scroll-right": "Scroll Right", + "goto-top": "Go to Top", + "goto-bottom": "Go to Bottom", + "open-editor": "Open Editor", + "toggle-todo": "Toggle Todo", + "search-in-file": "Search in File", + "trash-file": "Trash File", + }; + for (let i = 1; i <= 9; i++) { + labelMap[`favorite-${i}`] = `Favorite ${i}`; + } + for (const [actionId, binding] of Object.entries(sc.global)) { + labels[binding] = labelMap[actionId] ?? actionId; + } + for (const [actionId, binding] of Object.entries(sc.render)) { + labels[binding] = labelMap[actionId] ?? actionId; + } + return labels; + }, [shortcuts]); + const currentMode = state.editorOpen ? "editor" : state.sidebarVisible ? "sidebar" : "render"; - useShortcuts(shortcutMaps, currentMode, dispatch); + const { continuations } = useShortcuts(shortcutMaps, currentMode, dispatch, actionLabels); return ( <>
      - {state.sidebarVisible && ( -
      +
      dispatch({ type: "TOGGLE_SIDEBAR" })} /> -
      - )} +
      {state.editorOpen && currentPath && content != null ? ( ) : ( @@ -621,7 +668,9 @@ function ReaderView() { ref={readerRef} onScroll={saveScrollPosition} > - {content ? ( + {loading ? ( + + ) : content ? ( ) : (
      - {state.overlay === "file-finder" && ( + dispatch({ type: "SET_OVERLAY", overlay: "none" })} + onSelect={(path) => { openFile(path); dispatch({ type: "CLOSE_OVERLAY" }); }} + onClose={() => dispatch({ type: "CLOSE_OVERLAY" })} /> - )} - {state.overlay === "palette" && ( + + dispatch({ type: "SET_OVERLAY", overlay: "none" })} + onClose={() => dispatch({ type: "CLOSE_OVERLAY" })} /> - )} - {state.overlay === "new-file" && ( + + dispatch({ type: "SET_OVERLAY", overlay: "none" })} + onClose={() => dispatch({ type: "CLOSE_OVERLAY" })} /> - )} - {state.overlay === "rename" && currentPath && ( - dispatch({ type: "SET_OVERLAY", overlay: "none" })} - /> - )} - {state.overlay === "tags" && ( + + + {currentPath && ( + dispatch({ type: "CLOSE_OVERLAY" })} + /> + )} + + { openFile(path); - dispatch({ type: "SET_OVERLAY", overlay: "none" }); + dispatch({ type: "CLOSE_OVERLAY" }); }} - onClose={() => dispatch({ type: "SET_OVERLAY", overlay: "none" })} + onClose={() => dispatch({ type: "CLOSE_OVERLAY" })} /> - )} - {state.overlay === "capture" && ( + + dispatch({ type: "SET_OVERLAY", overlay: "none" })} + onClose={() => dispatch({ type: "CLOSE_OVERLAY" })} /> - )} - {state.overlay === "template" && ( + + dispatch({ type: "SET_OVERLAY", overlay: "none" })} + onClose={() => dispatch({ type: "CLOSE_OVERLAY" })} /> - )} - {state.overlay === "vault-search" && ( + + dispatch({ type: "SET_OVERLAY", overlay: "none" })} + onSelect={(path) => { openFile(path); dispatch({ type: "CLOSE_OVERLAY" }); }} + onClose={() => dispatch({ type: "CLOSE_OVERLAY" })} /> - )} - {state.overlay === "theme" && ( + + dispatch({ type: "SET_OVERLAY", overlay: "none" })} + onClose={() => dispatch({ type: "CLOSE_OVERLAY" })} /> - )} - {state.overlay === "graph" && ( + + dispatch({ type: "SET_OVERLAY", overlay: "none" })} + onSelect={(path) => { openFile(path); dispatch({ type: "CLOSE_OVERLAY" }); }} + onClose={() => dispatch({ type: "CLOSE_OVERLAY" })} /> - )} + + {pendingTrash && (