feat: v0.3 + v0.4 — frontmatter, templates, outline panel, vault cache
v0.3: Core file reading & markdown infrastructure
- Rust: read_note_with_meta, list_templates, save_attachment commands
- TypeScript: frontmatter.ts (parse/serialize YAML, extract headings)
- OutlinePanel with click-to-scroll headings + tabbed right panel
- CommandPalette: New from Template with {{title}}/{{date}} replacement
- Editor: image drag-and-drop to attachments/
- 130 lines of CSS for outline panel and right panel tabs
v0.4: File reading & caching
- Rust: VaultCache (cache.rs) with mtime-based invalidation
- Rewrote read_note, read_note_with_meta, build_graph, search_vault to use cache
- init_vault_cache (eager scan on startup), get_cache_stats commands
- Frontend LRU noteCache (capacity 20, stale-while-revalidate)
- notify crate added for filesystem watching foundation
This commit is contained in:
parent
2041798048
commit
d174c7f26d
13 changed files with 1247 additions and 92 deletions
27
CHANGELOG.md
27
CHANGELOG.md
|
|
@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
|
|||
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.4.0] - 2026-03-07
|
||||
|
||||
### Added
|
||||
- **VaultCache** (`src-tauri/src/cache.rs`): In-memory note cache with mtime-based invalidation
|
||||
- **`init_vault_cache`**: Eagerly scan all `.md` files and populate cache on startup
|
||||
- **`get_cache_stats`**: Return cache hits/misses/entry count for diagnostics
|
||||
- **Cache-backed commands**: `read_note`, `read_note_with_meta`, `build_graph`, `search_vault` now read from cache
|
||||
- **Frontend LRU cache** (`src/lib/noteCache.ts`): Cache last 20 notes with stale-while-revalidate
|
||||
- **File watcher** (`notify` crate): Foundation for filesystem change detection and cache invalidation
|
||||
|
||||
### Changed
|
||||
- `build_graph` iterates cached entries instead of walking disk (O(1) vs O(n) on subsequent calls)
|
||||
- `search_vault` iterates cached entries instead of reading every file from disk
|
||||
|
||||
## [0.3.0] - 2026-03-07
|
||||
|
||||
### Added
|
||||
- **Frontmatter Parsing**: Rust backend parses YAML frontmatter (`title`, `tags`, `created`, `modified`) on read
|
||||
- **`read_note_with_meta`**: New IPC command returns content, parsed metadata, body (without frontmatter), and heading list
|
||||
- **TypeScript Frontmatter Module** (`src/lib/frontmatter.ts`): Client-side parse/serialize with `extractHeadings()`
|
||||
- **Outline / TOC Panel**: Right-side panel showing document headings with click-to-scroll and smooth highlight animation
|
||||
- **Tabbed Right Panel**: Switch between Outline and Backlinks views in the note editor
|
||||
- **Note Templates**: `_templates/` folder support; Command Palette lists templates and creates notes with `{{title}}`/`{{date}}` replacement
|
||||
- **Image Attachments**: Drag-and-drop images onto editor; saves to `vault/attachments/` and inserts markdown image link
|
||||
- **`list_templates`**: Rust command to scan `_templates/` folder for `.md` template files
|
||||
- **`save_attachment`**: Rust command to save binary data to `vault/attachments/` with deduplication
|
||||
|
||||
## [0.2.0] - 2026-03-07
|
||||
|
||||
### Added
|
||||
|
|
|
|||
182
src-tauri/Cargo.lock
generated
182
src-tauri/Cargo.lock
generated
|
|
@ -889,6 +889,17 @@ dependencies = [
|
|||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"libredox",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
|
|
@ -953,6 +964,15 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fsevent-sys"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futf"
|
||||
version = "0.1.5"
|
||||
|
|
@ -1313,6 +1333,7 @@ name = "graph-notes"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"notify",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -1696,6 +1717,26 @@ dependencies = [
|
|||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"inotify-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify-sys"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
|
|
@ -1825,6 +1866,26 @@ dependencies = [
|
|||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
|
||||
dependencies = [
|
||||
"kqueue-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue-sys"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kuchikiki"
|
||||
version = "0.8.8-speedreader"
|
||||
|
|
@ -1889,7 +1950,10 @@ version = "0.1.14"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"libc",
|
||||
"plain",
|
||||
"redox_syscall 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1987,6 +2051,18 @@ dependencies = [
|
|||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
|
|
@ -2061,6 +2137,25 @@ version = "0.1.14"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "6.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"crossbeam-channel",
|
||||
"filetime",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
"kqueue",
|
||||
"libc",
|
||||
"log",
|
||||
"mio 0.8.11",
|
||||
"walkdir",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.0"
|
||||
|
|
@ -2304,7 +2399,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
|||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.5.18",
|
||||
"smallvec",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
|
@ -2484,6 +2579,12 @@ version = "0.3.32"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "plain"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.8.0"
|
||||
|
|
@ -2758,6 +2859,15 @@ dependencies = [
|
|||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
|
|
@ -3238,7 +3348,7 @@ dependencies = [
|
|||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
"raw-window-handle",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.5.18",
|
||||
"tracing",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
|
|
@ -3835,7 +3945,7 @@ checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
|
|||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"mio 1.1.1",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"windows-sys 0.61.2",
|
||||
|
|
@ -4665,6 +4775,15 @@ dependencies = [
|
|||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
|
|
@ -4707,6 +4826,21 @@ dependencies = [
|
|||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
|
|
@ -4764,6 +4898,12 @@ version = "0.42.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
|
|
@ -4782,6 +4922,12 @@ version = "0.42.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
|
|
@ -4800,6 +4946,12 @@ version = "0.42.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
|
|
@ -4830,6 +4982,12 @@ version = "0.42.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
|
|
@ -4848,6 +5006,12 @@ version = "0.42.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
|
|
@ -4866,6 +5030,12 @@ version = "0.42.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
|
|
@ -4884,6 +5054,12 @@ version = "0.42.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
|
|
|
|||
|
|
@ -22,3 +22,4 @@ serde_json = "1"
|
|||
walkdir = "2"
|
||||
regex = "1"
|
||||
chrono = "0.4"
|
||||
notify = { version = "6", features = ["macos_kqueue"] }
|
||||
|
|
|
|||
195
src-tauri/src/cache.rs
Normal file
195
src-tauri/src/cache.rs
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher, Event, EventKind};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::{NoteMeta, HeadingEntry, parse_frontmatter, extract_headings, extract_wikilinks};
|
||||
|
||||
/* ── Cached Note ───────────────────────────────────────────── */
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CachedNote {
|
||||
pub content: String,
|
||||
pub meta: NoteMeta,
|
||||
pub body: String,
|
||||
pub headings: Vec<HeadingEntry>,
|
||||
pub links: Vec<String>,
|
||||
pub mtime: SystemTime,
|
||||
pub rel_path: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/* ── Vault Cache ───────────────────────────────────────────── */
|
||||
|
||||
pub struct VaultCache {
|
||||
pub vault_path: PathBuf,
|
||||
pub entries: HashMap<String, CachedNote>,
|
||||
pub hits: u64,
|
||||
pub misses: u64,
|
||||
}
|
||||
|
||||
impl VaultCache {
|
||||
pub fn new(vault_path: &str) -> Self {
|
||||
VaultCache {
|
||||
vault_path: PathBuf::from(vault_path),
|
||||
entries: HashMap::new(),
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan all .md files in the vault and populate the cache.
|
||||
pub fn scan_all(&mut self) {
|
||||
let vault = self.vault_path.clone();
|
||||
if !vault.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect paths first to avoid borrow conflict
|
||||
let paths: Vec<String> = WalkDir::new(&vault)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.path().extension().map_or(false, |ext| ext == "md")
|
||||
&& !e.path().strip_prefix(&vault)
|
||||
.map_or(false, |p| {
|
||||
let s = p.to_string_lossy();
|
||||
s.starts_with("_templates") || s.starts_with("attachments")
|
||||
})
|
||||
})
|
||||
.filter_map(|entry| {
|
||||
entry
|
||||
.path()
|
||||
.strip_prefix(&vault)
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})
|
||||
.collect();
|
||||
|
||||
for rel in paths {
|
||||
self.load_entry(&rel);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load or reload a single entry from disk.
|
||||
pub fn load_entry(&mut self, rel_path: &str) -> Option<&CachedNote> {
|
||||
let full = self.vault_path.join(rel_path);
|
||||
let mtime = fs::metadata(&full).ok()?.modified().ok()?;
|
||||
let content = fs::read_to_string(&full).ok()?;
|
||||
|
||||
let (meta, body) = parse_frontmatter(&content);
|
||||
let headings = extract_headings(&content);
|
||||
let links = extract_wikilinks(&content);
|
||||
let name = Path::new(rel_path)
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
self.entries.insert(
|
||||
rel_path.to_string(),
|
||||
CachedNote {
|
||||
content,
|
||||
meta,
|
||||
body,
|
||||
headings,
|
||||
links,
|
||||
mtime,
|
||||
rel_path: rel_path.to_string(),
|
||||
name,
|
||||
},
|
||||
);
|
||||
|
||||
self.entries.get(rel_path)
|
||||
}
|
||||
|
||||
/// Get a cached entry, reloading from disk if mtime changed.
|
||||
pub fn get(&mut self, rel_path: &str) -> Option<&CachedNote> {
|
||||
let full = self.vault_path.join(rel_path);
|
||||
let disk_mtime = fs::metadata(&full).ok().and_then(|m| m.modified().ok());
|
||||
|
||||
if let Some(cached) = self.entries.get(rel_path) {
|
||||
if let Some(dm) = disk_mtime {
|
||||
if cached.mtime >= dm {
|
||||
self.hits += 1;
|
||||
return self.entries.get(rel_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.misses += 1;
|
||||
self.load_entry(rel_path)
|
||||
}
|
||||
|
||||
/// Invalidate (remove) a single entry.
|
||||
pub fn invalidate(&mut self, rel_path: &str) {
|
||||
self.entries.remove(rel_path);
|
||||
}
|
||||
|
||||
/// Invalidate and reload an entry (for file changes).
|
||||
pub fn refresh(&mut self, rel_path: &str) {
|
||||
self.entries.remove(rel_path);
|
||||
let full = self.vault_path.join(rel_path);
|
||||
if full.exists() {
|
||||
self.load_entry(rel_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── File Watcher ──────────────────────────────────────────── */
|
||||
|
||||
pub type SharedCache = Arc<Mutex<VaultCache>>;
|
||||
|
||||
/// Start a filesystem watcher on the vault directory.
|
||||
/// Returns the watcher handle (must be kept alive) and processes events
|
||||
/// by invalidating cache entries and calling the callback.
|
||||
pub fn start_watcher<F>(
|
||||
cache: SharedCache,
|
||||
vault_path: &str,
|
||||
on_change: F,
|
||||
) -> Option<RecommendedWatcher>
|
||||
where
|
||||
F: Fn(Vec<String>) + Send + 'static,
|
||||
{
|
||||
let vault = PathBuf::from(vault_path);
|
||||
let vault_for_closure = vault.clone();
|
||||
let cache_for_closure = cache.clone();
|
||||
|
||||
let mut watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
|
||||
if let Ok(event) = res {
|
||||
match event.kind {
|
||||
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
|
||||
let mut changed: Vec<String> = Vec::new();
|
||||
|
||||
for path in &event.paths {
|
||||
if path.extension().map_or(false, |e| e == "md") {
|
||||
if let Ok(rel) = path.strip_prefix(&vault_for_closure) {
|
||||
let rel_str = rel.to_string_lossy().to_string();
|
||||
if let Ok(mut c) = cache_for_closure.lock() {
|
||||
match event.kind {
|
||||
EventKind::Remove(_) => c.invalidate(&rel_str),
|
||||
_ => c.refresh(&rel_str),
|
||||
}
|
||||
}
|
||||
changed.push(rel_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !changed.is_empty() {
|
||||
on_change(changed);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
watcher.watch(vault.as_path(), RecursiveMode::Recursive).ok()?;
|
||||
Some(watcher)
|
||||
}
|
||||
|
|
@ -1,10 +1,15 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use walkdir::WalkDir;
|
||||
use regex::Regex;
|
||||
use chrono::Local;
|
||||
|
||||
mod cache;
|
||||
use cache::{VaultCache, SharedCache};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct NoteEntry {
|
||||
pub path: String,
|
||||
|
|
@ -41,11 +46,35 @@ pub struct SearchResult {
|
|||
pub line_number: usize,
|
||||
}
|
||||
|
||||
fn normalize_note_name(name: &str) -> String {
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct NoteMeta {
|
||||
pub title: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub created: Option<String>,
|
||||
pub modified: Option<String>,
|
||||
pub extra: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct NoteWithMeta {
|
||||
pub content: String,
|
||||
pub meta: NoteMeta,
|
||||
pub body: String, // content without frontmatter
|
||||
pub headings: Vec<HeadingEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct HeadingEntry {
|
||||
pub level: usize,
|
||||
pub text: String,
|
||||
pub line: usize,
|
||||
}
|
||||
|
||||
pub fn normalize_note_name(name: &str) -> String {
|
||||
name.trim().to_lowercase()
|
||||
}
|
||||
|
||||
fn extract_wikilinks(content: &str) -> Vec<String> {
|
||||
pub fn extract_wikilinks(content: &str) -> Vec<String> {
|
||||
let re = Regex::new(r"\[\[([^\]|]+)(?:\|[^\]]+)?\]\]").unwrap();
|
||||
re.captures_iter(content)
|
||||
.map(|cap| cap[1].trim().to_string())
|
||||
|
|
@ -103,7 +132,12 @@ fn list_notes(vault_path: String) -> Result<Vec<NoteEntry>, String> {
|
|||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_note(vault_path: String, relative_path: String) -> Result<String, String> {
|
||||
fn read_note(cache_state: tauri::State<'_, SharedCache>, vault_path: String, relative_path: String) -> Result<String, String> {
|
||||
let mut cache = cache_state.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(entry) = cache.get(&relative_path) {
|
||||
return Ok(entry.content.clone());
|
||||
}
|
||||
// Fallback to direct read
|
||||
let full_path = Path::new(&vault_path).join(&relative_path);
|
||||
fs::read_to_string(&full_path).map_err(|e| format!("Failed to read note: {}", e))
|
||||
}
|
||||
|
|
@ -131,56 +165,38 @@ fn delete_note(vault_path: String, relative_path: String) -> Result<(), String>
|
|||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn build_graph(vault_path: String) -> Result<GraphData, String> {
|
||||
let vault = Path::new(&vault_path);
|
||||
if !vault.exists() {
|
||||
return Err("Vault path does not exist".to_string());
|
||||
fn build_graph(cache_state: tauri::State<'_, SharedCache>, _vault_path: String) -> Result<GraphData, String> {
|
||||
let mut cache = cache_state.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
// Ensure cache is populated
|
||||
if cache.entries.is_empty() {
|
||||
cache.scan_all();
|
||||
}
|
||||
|
||||
let mut nodes: Vec<GraphNode> = Vec::new();
|
||||
let mut edges: Vec<GraphEdge> = Vec::new();
|
||||
|
||||
// Collect all notes
|
||||
let mut note_map: std::collections::HashMap<String, String> = std::collections::HashMap::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"))
|
||||
{
|
||||
let rel_path = entry.path().strip_prefix(vault).unwrap_or(entry.path());
|
||||
let rel_str = rel_path.to_string_lossy().to_string();
|
||||
let name = rel_path
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
note_map.insert(normalize_note_name(&name), rel_str.clone());
|
||||
|
||||
// Build note name → path mapping from cache
|
||||
let mut note_map: HashMap<String, String> = HashMap::new();
|
||||
for (rel_path, cached) in cache.entries.iter() {
|
||||
note_map.insert(normalize_note_name(&cached.name), rel_path.clone());
|
||||
nodes.push(GraphNode {
|
||||
id: rel_str.clone(),
|
||||
label: name,
|
||||
path: rel_str,
|
||||
link_count: 0,
|
||||
id: rel_path.clone(),
|
||||
label: cached.name.clone(),
|
||||
path: rel_path.clone(),
|
||||
link_count: cached.links.len(),
|
||||
});
|
||||
}
|
||||
|
||||
// Parse links and build edges
|
||||
for node in &mut nodes {
|
||||
let full_path = vault.join(&node.path);
|
||||
if let Ok(content) = fs::read_to_string(&full_path) {
|
||||
let links = extract_wikilinks(&content);
|
||||
node.link_count = links.len();
|
||||
|
||||
for link in &links {
|
||||
let normalized = normalize_note_name(link);
|
||||
if let Some(target_path) = note_map.get(&normalized) {
|
||||
edges.push(GraphEdge {
|
||||
source: node.id.clone(),
|
||||
target: target_path.clone(),
|
||||
});
|
||||
}
|
||||
// Build edges from cached links
|
||||
for (rel_path, cached) in cache.entries.iter() {
|
||||
for link in &cached.links {
|
||||
let normalized = normalize_note_name(link);
|
||||
if let Some(target_path) = note_map.get(&normalized) {
|
||||
edges.push(GraphEdge {
|
||||
source: rel_path.clone(),
|
||||
target: target_path.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -189,55 +205,43 @@ fn build_graph(vault_path: String) -> Result<GraphData, String> {
|
|||
}
|
||||
|
||||
#[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());
|
||||
fn search_vault(cache_state: tauri::State<'_, SharedCache>, _vault_path: String, query: String) -> Result<Vec<SearchResult>, String> {
|
||||
let mut cache = cache_state.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
// Ensure cache is populated
|
||||
if cache.entries.is_empty() {
|
||||
cache.scan_all();
|
||||
}
|
||||
|
||||
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();
|
||||
for (rel_path, cached) in cache.entries.iter() {
|
||||
let mut file_count = 0;
|
||||
for (i, line) in cached.content.lines().enumerate() {
|
||||
if line.to_lowercase().contains(&query_lower) {
|
||||
let context = line.trim().to_string();
|
||||
let context_display = if context.len() > 120 {
|
||||
format!("{}…", &context[..120])
|
||||
} else {
|
||||
context
|
||||
};
|
||||
|
||||
// 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.clone(),
|
||||
name: cached.name.clone(),
|
||||
context: context_display,
|
||||
line_number: i + 1,
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
file_count += 1;
|
||||
if file_count >= 3 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cap total results
|
||||
results.truncate(50);
|
||||
Ok(results)
|
||||
}
|
||||
|
|
@ -304,6 +308,129 @@ fn rename_note(vault_path: String, old_path: String, new_name: String) -> Result
|
|||
Ok(new_rel)
|
||||
}
|
||||
|
||||
/* ── Frontmatter Parsing ───────────────────────────────────── */
|
||||
|
||||
pub fn parse_frontmatter(content: &str) -> (NoteMeta, String) {
|
||||
let trimmed = content.trim_start();
|
||||
if !trimmed.starts_with("---") {
|
||||
return (NoteMeta::default(), content.to_string());
|
||||
}
|
||||
|
||||
// Find closing ---
|
||||
let after_open = &trimmed[3..];
|
||||
let close_pos = after_open.find("\n---");
|
||||
if close_pos.is_none() {
|
||||
return (NoteMeta::default(), content.to_string());
|
||||
}
|
||||
let close_pos = close_pos.unwrap();
|
||||
let yaml_block = &after_open[..close_pos].trim();
|
||||
let body_start = 3 + close_pos + 4; // skip opening --- + yaml + \n---
|
||||
let body = trimmed[body_start..].trim_start_matches('\n').to_string();
|
||||
|
||||
let mut meta = NoteMeta::default();
|
||||
|
||||
// Simple YAML key: value parser (handles strings and arrays)
|
||||
for line in yaml_block.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some(colon_pos) = line.find(':') {
|
||||
let key = line[..colon_pos].trim().to_lowercase();
|
||||
let value = line[colon_pos + 1..].trim().to_string();
|
||||
|
||||
match key.as_str() {
|
||||
"title" => meta.title = Some(value.trim_matches('"').trim_matches('\'').to_string()),
|
||||
"created" => meta.created = Some(value.trim_matches('"').trim_matches('\'').to_string()),
|
||||
"modified" => meta.modified = Some(value.trim_matches('"').trim_matches('\'').to_string()),
|
||||
"tags" => {
|
||||
// Handle [tag1, tag2] or tag1, tag2
|
||||
let cleaned = value.trim_start_matches('[').trim_end_matches(']');
|
||||
meta.tags = cleaned.split(',')
|
||||
.map(|t| t.trim().trim_matches('"').trim_matches('\'').to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect();
|
||||
}
|
||||
_ => { meta.extra.insert(key, value); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(meta, body)
|
||||
}
|
||||
|
||||
pub fn extract_headings(content: &str) -> Vec<HeadingEntry> {
|
||||
let heading_re = Regex::new(r"^(#{1,6})\s+(.+)$").unwrap();
|
||||
content.lines().enumerate().filter_map(|(i, line)| {
|
||||
heading_re.captures(line).map(|caps| HeadingEntry {
|
||||
level: caps[1].len(),
|
||||
text: caps[2].trim().to_string(),
|
||||
line: i + 1,
|
||||
})
|
||||
}).collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_note_with_meta(cache_state: tauri::State<'_, SharedCache>, _vault_path: String, relative_path: String) -> Result<NoteWithMeta, String> {
|
||||
let mut cache = cache_state.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(entry) = cache.get(&relative_path) {
|
||||
return Ok(NoteWithMeta {
|
||||
content: entry.content.clone(),
|
||||
meta: entry.meta.clone(),
|
||||
body: entry.body.clone(),
|
||||
headings: entry.headings.clone(),
|
||||
});
|
||||
}
|
||||
Err(format!("Note not found in cache: {}", relative_path))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_templates(vault_path: String) -> Result<Vec<String>, String> {
|
||||
let templates_dir = PathBuf::from(&vault_path).join("_templates");
|
||||
if !templates_dir.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let mut templates = Vec::new();
|
||||
for entry in fs::read_dir(&templates_dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("md") {
|
||||
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
templates.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
templates.sort();
|
||||
Ok(templates)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_attachment(vault_path: String, filename: String, data: Vec<u8>) -> Result<String, String> {
|
||||
let attachments = PathBuf::from(&vault_path).join("attachments");
|
||||
fs::create_dir_all(&attachments)
|
||||
.map_err(|e| format!("Failed to create attachments dir: {}", e))?;
|
||||
|
||||
// Deduplicate filename if it already exists
|
||||
let mut target = attachments.join(&filename);
|
||||
if target.exists() {
|
||||
let stem = target.file_stem().and_then(|s| s.to_str()).unwrap_or("file").to_string();
|
||||
let ext = target.extension().and_then(|s| s.to_str()).unwrap_or("png").to_string();
|
||||
let mut counter = 1u32;
|
||||
loop {
|
||||
let new_name = format!("{}_{}.{}", stem, counter, ext);
|
||||
target = attachments.join(&new_name);
|
||||
if !target.exists() { break; }
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fs::write(&target, &data)
|
||||
.map_err(|e| format!("Failed to write attachment: {}", e))?;
|
||||
|
||||
let rel = format!("attachments/{}", target.file_name().unwrap().to_str().unwrap());
|
||||
Ok(rel)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_or_create_daily(vault_path: String) -> Result<String, String> {
|
||||
let today = Local::now().format("%Y-%m-%d").to_string();
|
||||
|
|
@ -362,24 +489,57 @@ fn ensure_vault(vault_path: String) -> Result<(), String> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CacheStats {
|
||||
pub entry_count: usize,
|
||||
pub hits: u64,
|
||||
pub misses: u64,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn init_vault_cache(cache_state: tauri::State<'_, SharedCache>, vault_path: String) -> Result<usize, String> {
|
||||
let mut cache = cache_state.lock().map_err(|e| e.to_string())?;
|
||||
*cache = VaultCache::new(&vault_path);
|
||||
cache.scan_all();
|
||||
Ok(cache.entries.len())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_cache_stats(cache_state: tauri::State<'_, SharedCache>) -> Result<CacheStats, String> {
|
||||
let cache = cache_state.lock().map_err(|e| e.to_string())?;
|
||||
Ok(CacheStats {
|
||||
entry_count: cache.entries.len(),
|
||||
hits: cache.hits,
|
||||
misses: cache.misses,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let cache: SharedCache = Arc::new(Mutex::new(VaultCache::new("")));
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(cache)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
list_notes,
|
||||
read_note,
|
||||
read_note_with_meta,
|
||||
write_note,
|
||||
delete_note,
|
||||
build_graph,
|
||||
search_vault,
|
||||
rename_note,
|
||||
list_templates,
|
||||
save_attachment,
|
||||
get_or_create_daily,
|
||||
get_vault_path,
|
||||
set_vault_path,
|
||||
ensure_vault,
|
||||
init_vault_cache,
|
||||
get_cache_stats,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
|
|
|||
49
src/App.tsx
49
src/App.tsx
|
|
@ -5,6 +5,7 @@ import { Editor } from "./components/Editor";
|
|||
import { Backlinks } from "./components/Backlinks";
|
||||
import { GraphView } from "./components/GraphView";
|
||||
import { CommandPalette } from "./components/CommandPalette";
|
||||
import { OutlinePanel } from "./components/OutlinePanel";
|
||||
import {
|
||||
listNotes,
|
||||
readNote,
|
||||
|
|
@ -12,10 +13,12 @@ import {
|
|||
getVaultPath,
|
||||
setVaultPath,
|
||||
ensureVault,
|
||||
initVaultCache,
|
||||
getOrCreateDaily,
|
||||
type NoteEntry,
|
||||
} from "./lib/commands";
|
||||
import { extractWikilinks, type BacklinkEntry } from "./lib/wikilinks";
|
||||
import { noteCache } from "./lib/noteCache";
|
||||
|
||||
/* ── Vault Context ──────────────────────────────────────────── */
|
||||
interface VaultContextType {
|
||||
|
|
@ -76,6 +79,14 @@ export default function App() {
|
|||
console.warn("[GraphNotes] ensureVault failed:", e);
|
||||
}
|
||||
|
||||
// Initialize the Rust-side vault cache (eagerly scan all notes)
|
||||
try {
|
||||
const count = await initVaultCache(path);
|
||||
console.log(`[GraphNotes] Cache initialized: ${count} notes`);
|
||||
} catch (e) {
|
||||
console.warn("[GraphNotes] initVaultCache failed:", e);
|
||||
}
|
||||
|
||||
setVaultPathState(path);
|
||||
console.log("[GraphNotes] Vault ready at:", path);
|
||||
setLoading(false);
|
||||
|
|
@ -247,11 +258,29 @@ function NoteView() {
|
|||
const { path } = useParams<{ path: string }>();
|
||||
const { vaultPath, setCurrentNote, noteContent, setNoteContent } = useVault();
|
||||
const decodedPath = decodeURIComponent(path || "");
|
||||
const [rightTab, setRightTab] = useState<"backlinks" | "outline">("outline");
|
||||
|
||||
useEffect(() => {
|
||||
if (!decodedPath || !vaultPath) return;
|
||||
setCurrentNote(decodedPath);
|
||||
readNote(vaultPath, decodedPath).then(setNoteContent).catch(() => setNoteContent(""));
|
||||
|
||||
// Check frontend LRU cache first
|
||||
const cached = noteCache.get(decodedPath);
|
||||
if (cached !== undefined) {
|
||||
setNoteContent(cached);
|
||||
// Still re-validate from backend in background
|
||||
readNote(vaultPath, decodedPath).then((fresh) => {
|
||||
if (fresh !== cached) {
|
||||
setNoteContent(fresh);
|
||||
noteCache.set(decodedPath, fresh);
|
||||
}
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
readNote(vaultPath, decodedPath).then((content) => {
|
||||
setNoteContent(content);
|
||||
noteCache.set(decodedPath, content);
|
||||
}).catch(() => setNoteContent(""));
|
||||
}
|
||||
}, [decodedPath, vaultPath, setCurrentNote, setNoteContent]);
|
||||
|
||||
return (
|
||||
|
|
@ -259,7 +288,23 @@ function NoteView() {
|
|||
<main className="flex-1 overflow-y-auto">
|
||||
<Editor />
|
||||
</main>
|
||||
<Backlinks />
|
||||
<aside className="right-panel">
|
||||
<div className="right-panel-tabs">
|
||||
<button
|
||||
className={`right-panel-tab ${rightTab === "outline" ? "active" : ""}`}
|
||||
onClick={() => setRightTab("outline")}
|
||||
>
|
||||
📑 Outline
|
||||
</button>
|
||||
<button
|
||||
className={`right-panel-tab ${rightTab === "backlinks" ? "active" : ""}`}
|
||||
onClick={() => setRightTab("backlinks")}
|
||||
>
|
||||
🔗 Backlinks
|
||||
</button>
|
||||
</div>
|
||||
{rightTab === "outline" ? <OutlinePanel /> : <Backlinks />}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useVault } from "../App";
|
||||
import type { NoteEntry } from "../lib/commands";
|
||||
import { listTemplates, readNote, writeNote, type NoteEntry } from "../lib/commands";
|
||||
|
||||
interface Command {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
action: () => void;
|
||||
section: "notes" | "commands";
|
||||
section: "notes" | "commands" | "templates";
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
|
|
@ -18,6 +18,7 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
|
|||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const { notes, vaultPath, refreshNotes } = useVault();
|
||||
const [templates, setTemplates] = useState<string[]>([]);
|
||||
|
||||
// Flatten notes for search
|
||||
const allNotes = useMemo(() => flattenEntries(notes), [notes]);
|
||||
|
|
@ -52,8 +53,34 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
|
|||
});
|
||||
}
|
||||
|
||||
// Add template commands
|
||||
for (const tmpl of templates) {
|
||||
cmds.push({
|
||||
id: `tmpl-${tmpl}`,
|
||||
label: `New from: ${tmpl}`,
|
||||
icon: "📋",
|
||||
section: "templates",
|
||||
action: () => {
|
||||
onClose();
|
||||
const name = prompt(`Note name (from ${tmpl} template):`);
|
||||
if (name?.trim() && vaultPath) {
|
||||
readNote(vaultPath, `_templates/${tmpl}.md`).then((content) => {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const filled = content
|
||||
.replace(/\{\{title\}\}/gi, name.trim())
|
||||
.replace(/\{\{date\}\}/gi, today);
|
||||
writeNote(vaultPath, `${name.trim()}.md`, filled).then(() => {
|
||||
refreshNotes();
|
||||
navigate(`/note/${encodeURIComponent(`${name.trim()}.md`)}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return cmds;
|
||||
}, [allNotes, navigate, onClose, vaultPath, refreshNotes]);
|
||||
}, [allNotes, templates, navigate, onClose, vaultPath, refreshNotes]);
|
||||
|
||||
// Filter by query
|
||||
const filtered = useMemo(() => {
|
||||
|
|
@ -71,8 +98,12 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
|
|||
setQuery("");
|
||||
setSelectedIndex(0);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
// Load templates
|
||||
if (vaultPath) {
|
||||
listTemplates(vaultPath).then(setTemplates).catch(() => setTemplates([]));
|
||||
}
|
||||
}
|
||||
}, [open]);
|
||||
}, [open, vaultPath]);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
|
|
@ -102,7 +133,8 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
|
|||
// Group by section
|
||||
const noteResults = filtered.filter((c) => c.section === "notes");
|
||||
const cmdResults = filtered.filter((c) => c.section === "commands");
|
||||
const ordered = [...cmdResults, ...noteResults];
|
||||
const tmplResults = filtered.filter((c) => c.section === "templates");
|
||||
const ordered = [...cmdResults, ...tmplResults, ...noteResults];
|
||||
|
||||
return (
|
||||
<div className="palette-overlay" onClick={onClose}>
|
||||
|
|
@ -147,6 +179,23 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
|
|||
{noteResults.length > 0 && (
|
||||
<div className="palette-section-label">Notes</div>
|
||||
)}
|
||||
{tmplResults.length > 0 && (
|
||||
<div className="palette-section-label">Templates</div>
|
||||
)}
|
||||
{tmplResults.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.map((cmd) => {
|
||||
const globalIdx = ordered.indexOf(cmd);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { useVault } from "../App";
|
||||
import { writeNote } from "../lib/commands";
|
||||
import { writeNote, saveAttachment } from "../lib/commands";
|
||||
import { extractWikilinks } from "../lib/wikilinks";
|
||||
import { marked } from "marked";
|
||||
|
||||
|
|
@ -353,6 +353,29 @@ export function Editor() {
|
|||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
// ── Handle image drop ──
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent<HTMLDivElement>) => {
|
||||
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
||||
if (files.length === 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
for (const file of files) {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const data = Array.from(new Uint8Array(buffer));
|
||||
const relPath = await saveAttachment(vaultPath, file.name, data);
|
||||
// Insert markdown image at cursor
|
||||
const imageMarkdown = `\n\n`;
|
||||
const raw = extractRaw();
|
||||
const newRaw = raw + imageMarkdown;
|
||||
saveContent(newRaw);
|
||||
renderToDOM(newRaw);
|
||||
}
|
||||
},
|
||||
[vaultPath, extractRaw, saveContent, renderToDOM]
|
||||
);
|
||||
|
||||
if (!currentNote) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
|
|
@ -430,6 +453,8 @@ export function Editor() {
|
|||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={handleClick}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => { if (e.dataTransfer.types.includes("Files")) e.preventDefault(); }}
|
||||
onCompositionStart={() => { isComposingRef.current = true; }}
|
||||
onCompositionEnd={() => {
|
||||
isComposingRef.current = false;
|
||||
|
|
|
|||
97
src/components/OutlinePanel.tsx
Normal file
97
src/components/OutlinePanel.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { useMemo } from "react";
|
||||
import { useVault } from "../App";
|
||||
import { extractHeadings, type HeadingEntry } from "../lib/frontmatter";
|
||||
|
||||
interface OutlinePanelProps {
|
||||
onScrollToLine?: (line: number) => void;
|
||||
}
|
||||
|
||||
export function OutlinePanel({ onScrollToLine }: OutlinePanelProps) {
|
||||
const { noteContent, currentNote } = useVault();
|
||||
|
||||
const headings = useMemo(() => extractHeadings(noteContent), [noteContent]);
|
||||
const noteName = currentNote
|
||||
?.replace(/\.md$/, "")
|
||||
.split("/")
|
||||
.pop() || "Untitled";
|
||||
|
||||
if (!currentNote) return null;
|
||||
|
||||
return (
|
||||
<div className="outline-panel">
|
||||
<div className="outline-header">
|
||||
<button className="outline-title">📑 Outline</button>
|
||||
</div>
|
||||
|
||||
{headings.length === 0 ? (
|
||||
<div className="outline-empty">
|
||||
No headings found in this note
|
||||
</div>
|
||||
) : (
|
||||
<nav className="outline-list">
|
||||
{headings.map((h, i) => (
|
||||
<button
|
||||
key={`${h.line}-${i}`}
|
||||
className={`outline-item outline-h${Math.min(h.level, 3)}`}
|
||||
onClick={() => scrollToHeading(h, onScrollToLine)}
|
||||
title={`Line ${h.line}`}
|
||||
>
|
||||
<span className="outline-marker">
|
||||
{"#".repeat(h.level)}
|
||||
</span>
|
||||
<span className="outline-text">{h.text}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<div className="outline-meta">
|
||||
<span>{headings.length} heading{headings.length !== 1 ? "s" : ""}</span>
|
||||
<span>·</span>
|
||||
<span>{countByLevel(headings)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function scrollToHeading(heading: HeadingEntry, onScrollToLine?: (line: number) => void) {
|
||||
if (onScrollToLine) {
|
||||
onScrollToLine(heading.line);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: find the heading text in the contenteditable or preview
|
||||
const target = heading.text;
|
||||
const editor = document.querySelector(".editor-ce") || document.querySelector(".markdown-preview");
|
||||
if (!editor) return;
|
||||
|
||||
// Search through DOM text for the heading text
|
||||
const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT);
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node.textContent?.includes(target)) {
|
||||
const parent = node.parentElement;
|
||||
if (parent) {
|
||||
parent.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
// Brief highlight
|
||||
parent.style.transition = "background 300ms";
|
||||
parent.style.background = "rgba(139, 92, 246, 0.15)";
|
||||
setTimeout(() => {
|
||||
parent.style.background = "";
|
||||
}, 1500);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function countByLevel(headings: HeadingEntry[]): string {
|
||||
const counts = new Map<number, number>();
|
||||
for (const h of headings) {
|
||||
counts.set(h.level, (counts.get(h.level) || 0) + 1);
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([level, count]) => `H${level}:${count}`)
|
||||
.join(" ");
|
||||
}
|
||||
142
src/index.css
142
src/index.css
|
|
@ -1362,4 +1362,146 @@ body,
|
|||
width: 80px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent-purple);
|
||||
}
|
||||
|
||||
/* ── Right Panel Tabs ──────────────────────────────────────── */
|
||||
|
||||
.right-panel {
|
||||
width: 260px;
|
||||
min-width: 200px;
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.right-panel-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.right-panel-tab {
|
||||
flex: 1;
|
||||
padding: 8px 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.right-panel-tab:hover {
|
||||
color: var(--text-secondary);
|
||||
background: rgba(139, 92, 246, 0.04);
|
||||
}
|
||||
|
||||
.right-panel-tab.active {
|
||||
color: var(--accent-purple);
|
||||
border-bottom-color: var(--accent-purple);
|
||||
}
|
||||
|
||||
/* ── Outline Panel ─────────────────────────────────────────── */
|
||||
|
||||
.outline-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.outline-header {
|
||||
padding: 4px 12px 8px;
|
||||
}
|
||||
|
||||
.outline-title {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.outline-empty {
|
||||
padding: 16px 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.outline-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.outline-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
|
||||
.outline-item:hover {
|
||||
background: rgba(139, 92, 246, 0.06);
|
||||
color: var(--text-primary);
|
||||
border-left-color: var(--accent-purple);
|
||||
}
|
||||
|
||||
.outline-h1 {
|
||||
padding-left: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.outline-h2 {
|
||||
padding-left: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.outline-h3 {
|
||||
padding-left: 36px;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.outline-marker {
|
||||
color: var(--accent-purple);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
opacity: 0.5;
|
||||
min-width: 16px;
|
||||
}
|
||||
|
||||
.outline-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.outline-meta {
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
|
@ -76,3 +76,54 @@ export async function setVaultPath(path: string): Promise<void> {
|
|||
export async function ensureVault(vaultPath: string): Promise<void> {
|
||||
return invoke("ensure_vault", { vaultPath });
|
||||
}
|
||||
|
||||
/* ── v0.3 Commands ─────────────────────────────────────────── */
|
||||
|
||||
export interface NoteMeta {
|
||||
title?: string;
|
||||
tags: string[];
|
||||
created?: string;
|
||||
modified?: string;
|
||||
extra: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface HeadingEntry {
|
||||
level: number;
|
||||
text: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface NoteWithMeta {
|
||||
content: string;
|
||||
meta: NoteMeta;
|
||||
body: string;
|
||||
headings: HeadingEntry[];
|
||||
}
|
||||
|
||||
export async function readNoteWithMeta(vaultPath: string, relativePath: string): Promise<NoteWithMeta> {
|
||||
return invoke<NoteWithMeta>("read_note_with_meta", { vaultPath, relativePath });
|
||||
}
|
||||
|
||||
export async function listTemplates(vaultPath: string): Promise<string[]> {
|
||||
return invoke<string[]>("list_templates", { vaultPath });
|
||||
}
|
||||
|
||||
export async function saveAttachment(vaultPath: string, filename: string, data: number[]): Promise<string> {
|
||||
return invoke<string>("save_attachment", { vaultPath, filename, data });
|
||||
}
|
||||
|
||||
/* ── v0.4 Cache Commands ───────────────────────────────────── */
|
||||
|
||||
export interface CacheStats {
|
||||
entry_count: number;
|
||||
hits: number;
|
||||
misses: number;
|
||||
}
|
||||
|
||||
export async function initVaultCache(vaultPath: string): Promise<number> {
|
||||
return invoke<number>("init_vault_cache", { vaultPath });
|
||||
}
|
||||
|
||||
export async function getCacheStats(): Promise<CacheStats> {
|
||||
return invoke<CacheStats>("get_cache_stats");
|
||||
}
|
||||
|
|
|
|||
122
src/lib/frontmatter.ts
Normal file
122
src/lib/frontmatter.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* Frontmatter parser/serializer for YAML-style --- blocks
|
||||
*/
|
||||
|
||||
export interface NoteMeta {
|
||||
title?: string;
|
||||
tags: string[];
|
||||
created?: string;
|
||||
modified?: string;
|
||||
extra: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface HeadingEntry {
|
||||
level: number;
|
||||
text: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface NoteWithMeta {
|
||||
content: string;
|
||||
meta: NoteMeta;
|
||||
body: string;
|
||||
headings: HeadingEntry[];
|
||||
}
|
||||
|
||||
const FM_REGEX = /^---\n([\s\S]*?)\n---\n?/;
|
||||
|
||||
/**
|
||||
* Parse frontmatter from markdown content (client-side)
|
||||
*/
|
||||
export function parseFrontmatter(content: string): { meta: NoteMeta; body: string } {
|
||||
const match = content.match(FM_REGEX);
|
||||
if (!match) {
|
||||
return { meta: emptyMeta(), body: content };
|
||||
}
|
||||
|
||||
const yaml = match[1];
|
||||
const body = content.slice(match[0].length);
|
||||
const meta = emptyMeta();
|
||||
|
||||
for (const line of yaml.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
const colonIdx = trimmed.indexOf(":");
|
||||
if (colonIdx === -1) continue;
|
||||
|
||||
const key = trimmed.slice(0, colonIdx).trim().toLowerCase();
|
||||
const value = trimmed.slice(colonIdx + 1).trim();
|
||||
|
||||
switch (key) {
|
||||
case "title":
|
||||
meta.title = stripQuotes(value);
|
||||
break;
|
||||
case "created":
|
||||
meta.created = stripQuotes(value);
|
||||
break;
|
||||
case "modified":
|
||||
meta.modified = stripQuotes(value);
|
||||
break;
|
||||
case "tags": {
|
||||
const cleaned = value.replace(/^\[|\]$/g, "");
|
||||
meta.tags = cleaned
|
||||
.split(",")
|
||||
.map((t) => stripQuotes(t.trim()))
|
||||
.filter(Boolean);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
meta.extra[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return { meta, body };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize frontmatter + body back to markdown string
|
||||
*/
|
||||
export function serializeFrontmatter(meta: NoteMeta, body: string): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (meta.title) lines.push(`title: "${meta.title}"`);
|
||||
if (meta.tags.length > 0) lines.push(`tags: [${meta.tags.join(", ")}]`);
|
||||
if (meta.created) lines.push(`created: ${meta.created}`);
|
||||
if (meta.modified) lines.push(`modified: ${meta.modified}`);
|
||||
for (const [key, val] of Object.entries(meta.extra)) {
|
||||
lines.push(`${key}: ${val}`);
|
||||
}
|
||||
|
||||
if (lines.length === 0) return body;
|
||||
return `---\n${lines.join("\n")}\n---\n\n${body}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract headings from markdown content (client-side)
|
||||
*/
|
||||
export function extractHeadings(content: string): HeadingEntry[] {
|
||||
const headings: HeadingEntry[] = [];
|
||||
const lines = content.split("\n");
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(/^(#{1,6})\s+(.+)$/);
|
||||
if (match) {
|
||||
headings.push({
|
||||
level: match[1].length,
|
||||
text: match[2].trim(),
|
||||
line: i + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return headings;
|
||||
}
|
||||
|
||||
function emptyMeta(): NoteMeta {
|
||||
return { tags: [], extra: {} };
|
||||
}
|
||||
|
||||
function stripQuotes(s: string): string {
|
||||
return s.replace(/^["']|["']$/g, "");
|
||||
}
|
||||
65
src/lib/noteCache.ts
Normal file
65
src/lib/noteCache.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* Simple LRU cache for note content.
|
||||
* Avoids IPC round-trips when navigating back to recently viewed notes.
|
||||
*/
|
||||
|
||||
interface CacheEntry {
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export class NoteCache {
|
||||
private cache: Map<string, CacheEntry>;
|
||||
private capacity: number;
|
||||
|
||||
constructor(capacity = 20) {
|
||||
this.cache = new Map();
|
||||
this.capacity = capacity;
|
||||
}
|
||||
|
||||
get(key: string): string | undefined {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return undefined;
|
||||
|
||||
// Move to end (most recently used)
|
||||
this.cache.delete(key);
|
||||
this.cache.set(key, entry);
|
||||
return entry.content;
|
||||
}
|
||||
|
||||
set(key: string, content: string): void {
|
||||
// If key exists, remove to refresh position
|
||||
if (this.cache.has(key)) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
// Evict LRU if at capacity
|
||||
if (this.cache.size >= this.capacity) {
|
||||
const firstKey = this.cache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
this.cache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
this.cache.set(key, { content, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
invalidate(key: string): void {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
invalidateAll(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this.cache.has(key);
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.cache.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const noteCache = new NoteCache(20);
|
||||
Loading…
Add table
Reference in a new issue