Compare commits

...
9 Commits
65 changed files with 3442 additions and 483 deletions
+7 -7
View File
@@ -1,7 +1,7 @@
# HelixNotes # HelixNotes
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE) [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
[![Latest Release](https://img.shields.io/badge/release-v1.2.5-green)](https://codeberg.org/ArkHost/HelixNotes/releases) [![Latest Release](https://img.shields.io/badge/release-v1.2.8-green)](https://codeberg.org/ArkHost/HelixNotes/releases)
[![Website](https://img.shields.io/badge/web-helixnotes.com-purple)](https://helixnotes.com) [![Website](https://img.shields.io/badge/web-helixnotes.com-purple)](https://helixnotes.com)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS%20%7C%20Android-lightgrey)]() [![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS%20%7C%20Android-lightgrey)]()
@@ -10,7 +10,7 @@ A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
Your notes are stored as standard Markdown files on your local filesystem. Your notes are stored as standard Markdown files on your local filesystem.
No cloud, no lock-in. No cloud, no lock-in.
## Download (v1.2.5) ## Download (v1.2.8)
### Linux ### Linux
@@ -28,23 +28,23 @@ curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/k
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed) #### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
[Download AppImage](https://download.helixnotes.com/releases/v1.2.5/HelixNotes_1.2.5_amd64.AppImage) [Download AppImage](https://download.helixnotes.com/releases/v1.2.8/HelixNotes_1.2.8_amd64.AppImage)
#### .deb (manual) #### .deb (manual)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.5/HelixNotes_1.2.5_amd64.deb) (Ubuntu 22.04+) [Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.8/HelixNotes_1.2.8_amd64.deb) (Ubuntu 22.04+)
### Windows ### Windows
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.5/HelixNotes_1.2.5_x64-setup.exe) (Windows 10/11) [Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.8/HelixNotes_1.2.8_x64-setup.exe) (Windows 10/11)
### macOS ### macOS
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.5/HelixNotes_1.2.5_x64.dmg) (Intel, runs on Apple Silicon via Rosetta) [Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.8/HelixNotes_1.2.8_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
### Android ### Android
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.5/HelixNotes_1.2.5_android.apk) [Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.8/HelixNotes_1.2.8_android.apk)
--- ---
+3 -2
View File
@@ -2,7 +2,7 @@
"name": "helixnotes", "name": "helixnotes",
"private": true, "private": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"version": "1.2.6", "version": "1.2.9",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -65,6 +65,7 @@
"markdown-it-mark": "^4.0.0", "markdown-it-mark": "^4.0.0",
"markdown-it-sub": "^2.0.0", "markdown-it-sub": "^2.0.0",
"markdown-it-sup": "^2.0.0", "markdown-it-sup": "^2.0.0",
"markdown-it-task-lists": "^2.1.1" "markdown-it-task-lists": "^2.1.1",
"mermaid": "^11.14.0"
} }
} }
+937
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1850,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "helixnotes" name = "helixnotes"
version = "1.2.5" version = "1.2.9"
dependencies = [ dependencies = [
"arboard", "arboard",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "helixnotes" name = "helixnotes"
version = "1.2.5" version = "1.2.9"
description = "Local markdown note-taking app" description = "Local markdown note-taking app"
authors = ["HelixNotes"] authors = ["HelixNotes"]
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1023 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 735 B

After

Width:  |  Height:  |  Size: 667 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

-1
View File
@@ -190,7 +190,6 @@ async fn stream_anthropic(
} }
} }
// Stream ended — send done
let _ = app.emit( let _ = app.emit(
"ai-stream", "ai-stream",
AiStreamEvent { AiStreamEvent {
+1 -1
View File
@@ -207,7 +207,7 @@ pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String>
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
// Keep hidden dirs (.helixnotes) we'll handle attachments separately // Keep hidden dirs (.helixnotes) - we'll handle attachments separately
if name.starts_with('.') { if name.starts_with('.') {
continue; continue;
} }
+124 -28
View File
@@ -11,7 +11,7 @@ use tauri::{AppHandle, Manager, State};
pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> Result<(), String> { pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> Result<(), String> {
operations::ensure_vault_structure(&path)?; operations::ensure_vault_structure(&path)?;
// Initialize search index rebuild in background on Android (FUSE is slow) // Initialize search index - rebuild in background on Android (FUSE is slow)
let search = std::sync::Arc::new(SearchIndex::new(&path)?); let search = std::sync::Arc::new(SearchIndex::new(&path)?);
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
{ {
@@ -257,6 +257,14 @@ pub fn save_note(
drop(config); drop(config);
operations::save_note(&path, &meta, &body)?; operations::save_note(&path, &meta, &body)?;
// Re-index note so search picks up changes immediately
if let Ok(search_guard) = state.search_index.lock() {
if let Some(ref search) = *search_guard {
let _ = search.index_note(&path);
}
}
Ok(()) Ok(())
} }
@@ -422,9 +430,14 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?; let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let vault = std::path::Path::new(vault_path); let vault = std::path::Path::new(vault_path);
// Pass 1: collect all notes with titles (fast title extraction, no YAML parsing) // Pass 1: collect ALL notes as nodes (no title deduplication - every note must appear)
let mut graph_nodes = Vec::new(); let mut graph_nodes = Vec::new();
let mut title_to_idx: HashMap<String, usize> = HashMap::new(); // title → list of node indices (one title can map to multiple notes in different folders)
let mut title_to_idxs: HashMap<String, Vec<usize>> = HashMap::new();
// vault-relative path without extension → idx (for [[subfolder/note]] style links)
let mut relpath_to_idx: HashMap<String, usize> = HashMap::new();
// absolute path → idx (for fast active-note lookup)
let mut path_to_idx: HashMap<String, usize> = HashMap::new();
let mut seen_paths: HashSet<String> = HashSet::new(); let mut seen_paths: HashSet<String> = HashSet::new();
let mut contents: Vec<String> = Vec::new(); let mut contents: Vec<String> = Vec::new();
@@ -454,12 +467,18 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
path.file_stem().unwrap_or_default().to_string_lossy().to_string() path.file_stem().unwrap_or_default().to_string_lossy().to_string()
}); });
// Deduplicate by title — skip if we already have a node with this title
let title_lower = title.to_lowercase();
if title_to_idx.contains_key(&title_lower) { continue; }
let idx = graph_nodes.len(); let idx = graph_nodes.len();
title_to_idx.insert(title_lower, idx); let title_lower = title.to_lowercase();
title_to_idxs.entry(title_lower).or_default().push(idx);
path_to_idx.insert(path_str.clone(), idx);
// Also index by vault-relative path without extension (e.g. "subfolder/note name")
if let Ok(rel) = path.strip_prefix(vault) {
let rel_no_ext = rel.with_extension("");
let rel_lower = rel_no_ext.to_string_lossy().to_lowercase();
relpath_to_idx.entry(rel_lower).or_insert(idx);
}
graph_nodes.push(crate::types::GraphNode { graph_nodes.push(crate::types::GraphNode {
title, title,
path: path_str, path: path_str,
@@ -468,8 +487,22 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
} }
// Pass 2: extract edges from wiki-links (inline scan, no regex) // Pass 2: extract edges from wiki-links (inline scan, no regex)
let mut edges = Vec::new(); // edge_map: (src, tgt) → index in edges vec, used to detect reverse links
let mut edge_set: HashSet<(usize, usize)> = HashSet::new(); let mut edges: Vec<crate::types::GraphEdge> = Vec::new();
let mut edge_map: HashMap<(usize, usize), usize> = HashMap::new();
let add_edge = |edges: &mut Vec<crate::types::GraphEdge>, edge_map: &mut HashMap<(usize, usize), usize>, src: usize, tgt: usize| {
if src == tgt { return; }
if edge_map.contains_key(&(src, tgt)) { return; } // exact duplicate
if let Some(&rev_idx) = edge_map.get(&(tgt, src)) {
// Reverse direction already exists - mark it as bidirectional
edges[rev_idx].bidirectional = true;
} else {
let idx = edges.len();
edge_map.insert((src, tgt), idx);
edges.push(crate::types::GraphEdge { source: src, target: tgt, bidirectional: false });
}
};
for (source_idx, body) in contents.iter().enumerate() { for (source_idx, body) in contents.iter().enumerate() {
let bytes = body.as_bytes(); let bytes = body.as_bytes();
@@ -490,11 +523,21 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
let link = link.split('^').next().unwrap_or(link); let link = link.split('^').next().unwrap_or(link);
let link = link.trim().to_lowercase(); let link = link.trim().to_lowercase();
if let Some(&target_idx) = title_to_idx.get(&link) { // 1. Try vault-relative path match (e.g. "arkhost/note name")
if target_idx != source_idx { if let Some(&target_idx) = relpath_to_idx.get(&link) {
let key = if source_idx < target_idx { (source_idx, target_idx) } else { (target_idx, source_idx) }; add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
if edge_set.insert(key) { } else if let Some(targets) = title_to_idxs.get(&link) {
edges.push(crate::types::GraphEdge { source: source_idx, target: target_idx }); // 2. Title match - connect to all notes with this title
for &target_idx in targets {
add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
}
} else if link.contains('/') {
// 3. Path-based ref: try last segment as title fallback
if let Some(seg) = link.rsplit('/').next() {
if let Some(targets) = title_to_idxs.get(seg) {
for &target_idx in targets {
add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
}
} }
} }
} }
@@ -685,6 +728,32 @@ pub fn copy_image_to_clipboard(_path: String) -> Result<(), String> {
Err("Clipboard image copy not supported on Android".to_string()) Err("Clipboard image copy not supported on Android".to_string())
} }
/// Copy PNG bytes directly to the system clipboard.
#[cfg(not(target_os = "android"))]
#[tauri::command]
pub fn copy_png_to_clipboard(data: Vec<u8>) -> Result<(), String> {
let img = image::load_from_memory(&data)
.map_err(|e| format!("Failed to decode image: {}", e))?;
let rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
let img_data = arboard::ImageData {
width: w as usize,
height: h as usize,
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
};
let mut clipboard = arboard::Clipboard::new()
.map_err(|e| format!("Clipboard init failed: {}", e))?;
clipboard.set_image(img_data)
.map_err(|e| format!("Failed to set clipboard image: {}", e))?;
Ok(())
}
#[cfg(target_os = "android")]
#[tauri::command]
pub fn copy_png_to_clipboard(_data: Vec<u8>) -> Result<(), String> {
Err("Clipboard image copy not supported on Android".to_string())
}
// ── Attachments ── // ── Attachments ──
#[tauri::command] #[tauri::command]
@@ -1362,40 +1431,67 @@ fn resolve_wiki_ref(
reference.to_string() reference.to_string()
} }
// ── Open file with system default handler ── // ── Open file/URL with system default handler ──
#[tauri::command] fn xdg_open(arg: &str) -> Result<(), String> {
pub fn open_file(path: String) -> Result<(), String> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
std::process::Command::new("xdg-open") let mut cmd = std::process::Command::new("xdg-open");
.arg(&path) cmd.arg(arg);
.spawn() // Clear AppImage environment so child processes find host binaries
.map_err(|e| format!("Failed to open {}: {}", path, e))?; // (e.g. gio-launch-desktop on GNOME)
if std::env::var("APPIMAGE").is_ok() {
cmd.env_remove("LD_LIBRARY_PATH")
.env_remove("LD_PRELOAD")
.env_remove("GIO_LAUNCHED_DESKTOP_FILE")
.env_remove("GIO_LAUNCHED_DESKTOP_FILE_PID");
if let Ok(original_path) = std::env::var("PATH_ORIG") {
cmd.env("PATH", original_path);
}
}
cmd.spawn()
.map_err(|e| format!("Failed to open {}: {}", arg, e))?;
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
std::process::Command::new("open") std::process::Command::new("open")
.arg(&path) .arg(arg)
.spawn() .spawn()
.map_err(|e| format!("Failed to open {}: {}", path, e))?; .map_err(|e| format!("Failed to open {}: {}", arg, e))?;
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
std::process::Command::new("cmd") std::process::Command::new("cmd")
.args(["/C", "start", "", &path]) .args(["/C", "start", "", arg])
.spawn() .spawn()
.map_err(|e| format!("Failed to open {}: {}", path, e))?; .map_err(|e| format!("Failed to open {}: {}", arg, e))?;
} }
Ok(()) Ok(())
} }
#[tauri::command]
pub fn open_file(path: String) -> Result<(), String> {
xdg_open(&path)
}
#[tauri::command]
pub fn open_url(url: String) -> Result<(), String> {
xdg_open(&url)
}
#[tauri::command] #[tauri::command]
pub fn copy_file_to(source: String, destination: String) -> Result<(), String> { pub fn copy_file_to(source: String, destination: String) -> Result<(), String> {
std::fs::copy(&source, &destination).map_err(|e| format!("Failed to copy file: {}", e))?; std::fs::copy(&source, &destination).map_err(|e| format!("Failed to copy file: {}", e))?;
Ok(()) Ok(())
} }
#[tauri::command]
pub fn write_bytes_to(destination: String, data: Vec<u8>) -> Result<(), String> {
std::fs::write(&destination, &data).map_err(|e| format!("Failed to write file: {}", e))?;
Ok(())
}
// ── Backup ── // ── Backup ──
#[tauri::command] #[tauri::command]
@@ -1654,10 +1750,10 @@ pub fn ai_ask(
let mut system_prompt = "You are a helpful writing assistant inside a note-taking app called HelixNotes. \ let mut system_prompt = "You are a helpful writing assistant inside a note-taking app called HelixNotes. \
You help users improve, rewrite, summarize, and transform their text. \ You help users improve, rewrite, summarize, and transform their text. \
Return ONLY the resulting text no explanations, no markdown code fences, no preamble. \ Return ONLY the resulting text - no explanations, no markdown code fences, no preamble. \
Preserve the original language of the text unless specifically asked to translate. \ Preserve the original language of the text unless specifically asked to translate. \
Preserve any markdown formatting (bold, italic, links, images, tables, etc.) unless the user asks to change it. \ Preserve any markdown formatting (bold, italic, links, images, tables, etc.) unless the user asks to change it. \
If the text contains placeholders like __MEDIA_0__, __MEDIA_1__, etc., keep them exactly as they are in their original positions they represent images and embedded files.".to_string(); If the text contains placeholders like __MEDIA_0__, __MEDIA_1__, etc., keep them exactly as they are in their original positions - they represent images and embedded files.".to_string();
if let Some(ref style) = writing_style { if let Some(ref style) = writing_style {
system_prompt.push_str(&format!( system_prompt.push_str(&format!(
+1 -1
View File
@@ -157,7 +157,7 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
.filter(|p| p.extension().map_or(false, |ext| ext == "md")) .filter(|p| p.extension().map_or(false, |ext| ext == "md"))
.collect(); .collect();
// Sort by name (timestamps sort lexicographically) newest last // Sort by name (timestamps sort lexicographically) - newest last
files.sort(); files.sort();
if files.len() as u32 > max { if files.len() as u32 > max {
+3
View File
@@ -137,7 +137,10 @@ pub fn run() {
commands::get_vault_stats, commands::get_vault_stats,
commands::import_obsidian, commands::import_obsidian,
commands::open_file, commands::open_file,
commands::open_url,
commands::copy_file_to, commands::copy_file_to,
commands::write_bytes_to,
commands::copy_png_to_clipboard,
commands::create_backup, commands::create_backup,
commands::list_backups, commands::list_backups,
commands::restore_backup, commands::restore_backup,
+32 -9
View File
@@ -9,9 +9,9 @@ use tantivy::collector::TopDocs;
use tantivy::directory::MmapDirectory; use tantivy::directory::MmapDirectory;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
use tantivy::directory::RamDirectory; use tantivy::directory::RamDirectory;
use tantivy::query::QueryParser; use tantivy::query::{BooleanQuery, FuzzyTermQuery, Occur, PhrasePrefixQuery, Query};
use tantivy::schema::*; use tantivy::schema::*;
use tantivy::{Index, IndexWriter, TantivyDocument}; use tantivy::{Index, IndexWriter, TantivyDocument, Term};
use walkdir::WalkDir; use walkdir::WalkDir;
pub struct SearchIndex { pub struct SearchIndex {
@@ -152,14 +152,37 @@ impl SearchIndex {
let reader = self.index.reader().map_err(|e| e.to_string())?; let reader = self.index.reader().map_err(|e| e.to_string())?;
let searcher = reader.searcher(); let searcher = reader.searcher();
let query_parser = QueryParser::for_index( let fields = vec![self.title_field, self.body_field, self.tags_field];
&self.index, let terms: Vec<String> = query_str
vec![self.title_field, self.body_field, self.tags_field], .split_whitespace()
); .map(|t| t.to_lowercase())
.collect();
let query = query_parser // For each search term, create prefix + fuzzy queries across all fields (OR),
.parse_query(query_str) // then AND all terms together
.map_err(|e| e.to_string())?; let term_queries: Vec<(Occur, Box<dyn Query>)> = terms
.iter()
.map(|term| {
let field_queries: Vec<(Occur, Box<dyn Query>)> = fields
.iter()
.flat_map(|&field| {
let prefix: Box<dyn Query> = Box::new(PhrasePrefixQuery::new(
vec![Term::from_field_text(field, term)],
));
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
Term::from_field_text(field, term),
1,
true,
));
vec![(Occur::Should, prefix), (Occur::Should, fuzzy)]
})
.collect();
let combined: Box<dyn Query> = Box::new(BooleanQuery::new(field_queries));
(Occur::Must, combined)
})
.collect();
let query = BooleanQuery::new(term_queries);
let top_docs = searcher let top_docs = searcher
.search(&query, &TopDocs::with_limit(limit)) .search(&query, &TopDocs::with_limit(limit))
+7
View File
@@ -199,6 +199,10 @@ pub struct VaultState {
pub sidebar_collapsed: bool, pub sidebar_collapsed: bool,
#[serde(default)] #[serde(default)]
pub collapsed_notebooks: Vec<String>, pub collapsed_notebooks: Vec<String>,
#[serde(default)]
pub notebook_sort_mode: String,
#[serde(default)]
pub notebook_order: std::collections::HashMap<String, i32>,
} }
impl Default for VaultState { impl Default for VaultState {
@@ -209,6 +213,8 @@ impl Default for VaultState {
notelist_width: 280.0, notelist_width: 280.0,
sidebar_collapsed: false, sidebar_collapsed: false,
collapsed_notebooks: Vec::new(), collapsed_notebooks: Vec::new(),
notebook_sort_mode: String::new(),
notebook_order: std::collections::HashMap::new(),
} }
} }
} }
@@ -285,4 +291,5 @@ pub struct GraphNode {
pub struct GraphEdge { pub struct GraphEdge {
pub source: usize, pub source: usize,
pub target: usize, pub target: usize,
pub bidirectional: bool,
} }
+2 -2
View File
@@ -61,7 +61,7 @@ pub fn parse_note(raw: &str, filename: &str) -> (NoteMeta, String) {
.and_then(|s| parse_date_flexible(&s)) .and_then(|s| parse_date_flexible(&s))
.unwrap_or_else(Utc::now); .unwrap_or_else(Utc::now);
// Don't generate UUID on read empty string signals "no ID yet" // Don't generate UUID on read - empty string signals "no ID yet"
let id = fm.id.unwrap_or_default(); let id = fm.id.unwrap_or_default();
let meta = NoteMeta { let meta = NoteMeta {
@@ -309,7 +309,7 @@ fn strip_html_and_markdown(input: &str) -> String {
continue; continue;
} }
// Markdown links: [text](url) keep the text, skip the URL // Markdown links: [text](url) - keep the text, skip the URL
if ch == '[' { if ch == '[' {
let mut link_text = String::new(); let mut link_text = String::new();
let mut depth = 1; let mut depth = 1;
+1 -2
View File
@@ -281,7 +281,7 @@ fn read_note_entry(path: &Path, vault_root: &Path) -> Result<NoteEntry, String>
/// uses filesystem timestamps for dates. No preview text. /// uses filesystem timestamps for dates. No preview text.
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
fn read_note_entry_metadata_only(path: &Path, vault_root: &Path) -> Result<NoteEntry, String> { fn read_note_entry_metadata_only(path: &Path, vault_root: &Path) -> Result<NoteEntry, String> {
// Read first 2KB enough for frontmatter with tags, title, pinned // Read first 2KB - enough for frontmatter with tags, title, pinned
let mut file = fs::File::open(path).map_err(|e| e.to_string())?; let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
let mut buf = vec![0u8; 2048]; let mut buf = vec![0u8; 2048];
let bytes_read = file.read(&mut buf).map_err(|e| e.to_string())?; let bytes_read = file.read(&mut buf).map_err(|e| e.to_string())?;
@@ -917,7 +917,6 @@ pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
notes.push(note); notes.push(note);
} }
} else if path.is_dir() { } else if path.is_dir() {
// Deleted notebook — count .md files inside
let note_count = WalkDir::new(&path) let note_count = WalkDir::new(&path)
.min_depth(1) .min_depth(1)
.into_iter() .into_iter()
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes", "productName": "HelixNotes",
"version": "1.2.6", "version": "1.2.9",
"identifier": "com.helixnotes.app", "identifier": "com.helixnotes.app",
"build": { "build": {
"frontendDist": "../build", "frontendDist": "../build",
+15
View File
@@ -267,6 +267,10 @@ export async function openFile(path: string): Promise<void> {
return invoke("open_file", { path }); return invoke("open_file", { path });
} }
export async function openUrl(url: string): Promise<void> {
return invoke("open_url", { url });
}
export async function copyFileTo( export async function copyFileTo(
source: string, source: string,
destination: string, destination: string,
@@ -274,6 +278,17 @@ export async function copyFileTo(
return invoke("copy_file_to", { source, destination }); return invoke("copy_file_to", { source, destination });
} }
export async function writeBytesTo(
destination: string,
data: Uint8Array,
): Promise<void> {
return invoke("write_bytes_to", { destination, data: Array.from(data) });
}
export async function copyPngToClipboard(data: Uint8Array): Promise<void> {
return invoke("copy_png_to_clipboard", { data: Array.from(data) });
}
// ── Backup ── // ── Backup ──
export async function createBackup(): Promise<void> { export async function createBackup(): Promise<void> {
+68 -39
View File
@@ -34,13 +34,19 @@
tags, tags,
notes, notes,
viewMode, viewMode,
activeTag,
updateAvailable as globalUpdateAvailable, updateAvailable as globalUpdateAvailable,
settingsTab settingsTab,
navHistory,
viewerNote,
notebookSortMode,
notebookOrder
} from '$lib/stores/app'; } from '$lib/stores/app';
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac'); const isMac = navigator.platform.startsWith('Mac');
const isMobile = /android|ios/i.test(navigator.userAgent); const isMobile = /android|ios/i.test(navigator.userAgent);
const isAndroid = /android/i.test(navigator.userAgent);
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme } from '$lib/api'; import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme } from '$lib/api';
import { debounce } from '$lib/utils/debounce'; import { debounce } from '$lib/utils/debounce';
import { openNoteWindow } from '$lib/utils/window'; import { openNoteWindow } from '$lib/utils/window';
@@ -58,9 +64,7 @@
let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false); let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false);
let backupInterval: ReturnType<typeof setInterval> | null = null; let backupInterval: ReturnType<typeof setInterval> | null = null;
let navigatingFromHistory = false;
let noteHistory: string[] = [];
let noteHistoryIndex = -1;
function parseFrequencyMs(freq: string): number { function parseFrequencyMs(freq: string): number {
switch (freq) { switch (freq) {
@@ -91,42 +95,51 @@
// Track note navigation in history stack // Track note navigation in history stack
$effect(() => { $effect(() => {
const path = $activeNotePath; const path = $activeNotePath;
if (navigatingFromHistory) { if (path) navHistory.push(path);
navigatingFromHistory = false;
return;
}
if (path) {
// Trim forward history and push
noteHistory = [...noteHistory.slice(0, noteHistoryIndex + 1), path];
noteHistoryIndex = noteHistory.length - 1;
}
}); });
function navigateHistory(direction: -1 | 1) { function navigateHistory(direction: -1 | 1) {
const newIndex = noteHistoryIndex + direction; const path = navHistory.go(direction);
if (newIndex < 0 || newIndex >= noteHistory.length) return; if (!path) return;
const path = noteHistory[newIndex];
noteHistoryIndex = newIndex;
navigatingFromHistory = true;
readNote(path).then((content) => { readNote(path).then((content) => {
editor?.flushSave(); editor?.flushSave();
$activeNote = content; $activeNote = content;
$activeNotePath = path; $activeNotePath = path;
$editorDirty = false; $editorDirty = false;
editor?.loadNote(path, content.content); editor?.loadNote(path, content.content);
}).catch(() => { }).catch(() => {});
// Note may have been deleted, ignore
});
} }
async function handleOpenFile(filePath: string) { async function handleOpenFile(filePath: string) {
if (!filePath || !filePath.endsWith('.md')) return; if (!filePath || !filePath.endsWith('.md')) return;
const config = get(appConfig); const config = get(appConfig);
const vaultRoot = config?.active_vault; const vaultRoot = config?.active_vault;
if (!vaultRoot || !filePath.startsWith(vaultRoot)) return; const isExternal = !vaultRoot || !filePath.startsWith(vaultRoot + '/');
// External .md → viewer mode (skipped on Android)
if (isExternal) {
if (isAndroid) return;
try {
const content = await readNote(filePath);
editor?.flushSave();
$viewerNote = { path: filePath, content: content.content };
$activeNote = content;
$activeNotePath = filePath;
$editorDirty = false;
$readOnly = true;
$focusMode = true;
editor?.loadNote(filePath, content.content);
} catch (e) {
console.error('Failed to open external file:', e);
}
return;
}
// Vault file: normal flow
try { try {
const content = await readNote(filePath); const content = await readNote(filePath);
editor?.flushSave(); editor?.flushSave();
$viewerNote = null;
$activeNote = content; $activeNote = content;
$activeNotePath = filePath; $activeNotePath = filePath;
$editorDirty = false; $editorDirty = false;
@@ -142,7 +155,9 @@
sidebar_width: $sidebarWidth, sidebar_width: $sidebarWidth,
notelist_width: $notelistWidth, notelist_width: $notelistWidth,
sidebar_collapsed: $sidebarCollapsed, sidebar_collapsed: $sidebarCollapsed,
collapsed_notebooks: $collapsedNotebooks collapsed_notebooks: $collapsedNotebooks,
notebook_sort_mode: $notebookSortMode,
notebook_order: $notebookOrder
}; };
try { try {
await saveVaultState(state); await saveVaultState(state);
@@ -160,6 +175,8 @@
} }
function handleNoteSelected(path: string, content: string) { function handleNoteSelected(path: string, content: string) {
// Selecting a real vault note exits viewer mode
$viewerNote = null;
editor?.loadNote(path, content); editor?.loadNote(path, content);
if (isMobile) $mobileView = 'editor'; if (isMobile) $mobileView = 'editor';
} }
@@ -210,14 +227,13 @@
} }
const targetDepth = view === 'sidebar' ? 0 : view === 'notelist' ? 1 : 2; const targetDepth = view === 'sidebar' ? 0 : view === 'notelist' ? 1 : 2;
if (targetDepth > historyDepth) { if (targetDepth > historyDepth) {
// Forward navigation push entries for each level skipped // Forward navigation - push entries for each level skipped
for (let d = historyDepth + 1; d <= targetDepth; d++) { for (let d = historyDepth + 1; d <= targetDepth; d++) {
const v = d === 1 ? 'notelist' : 'editor'; const v = d === 1 ? 'notelist' : 'editor';
history.pushState({ mobileView: v, depth: d }, ''); history.pushState({ mobileView: v, depth: d }, '');
} }
historyDepth = targetDepth; historyDepth = targetDepth;
} else if (targetDepth < historyDepth) { } else if (targetDepth < historyDepth) {
// Programmatic back (e.g. mobileBack button) — go back in history
const steps = historyDepth - targetDepth; const steps = historyDepth - targetDepth;
historyDepth = targetDepth; historyDepth = targetDepth;
navFromPopstate = true; // suppress the popstate that history.go triggers navFromPopstate = true; // suppress the popstate that history.go triggers
@@ -252,6 +268,7 @@
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey; const mod = e.ctrlKey || e.metaKey;
const code = e.code;
if (e.altKey && e.key === 'ArrowLeft') { if (e.altKey && e.key === 'ArrowLeft') {
e.preventDefault(); e.preventDefault();
navigateHistory(-1); navigateHistory(-1);
@@ -262,11 +279,12 @@
navigateHistory(1); navigateHistory(1);
return; return;
} }
if (mod && !e.shiftKey && e.key === 'n') { if (mod && !e.shiftKey && code === 'KeyN') {
e.preventDefault(); e.preventDefault();
createAndFocusNote(); createAndFocusNote();
return;
} }
if (mod && e.shiftKey && e.key === 'N') { if (mod && e.shiftKey && code === 'KeyN') {
e.preventDefault(); e.preventDefault();
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const next = isDark ? 'light' : 'dark'; const next = isDark ? 'light' : 'dark';
@@ -274,12 +292,12 @@
setTheme(next); setTheme(next);
return; return;
} }
if (mod && e.shiftKey && e.key === 'F') { if (mod && e.shiftKey && code === 'KeyF') {
e.preventDefault(); e.preventDefault();
$showSearch = true; $showSearch = true;
return; return;
} }
if (mod && !e.shiftKey && e.key === 'f') { if (mod && !e.shiftKey && code === 'KeyF') {
e.preventDefault(); e.preventDefault();
if ($activeNotePath) { if ($activeNotePath) {
editor?.openNoteSearch(); editor?.openNoteSearch();
@@ -288,23 +306,25 @@
} }
return; return;
} }
if (mod && e.key === 'p') { if (mod && !e.shiftKey && code === 'KeyP') {
e.preventDefault(); e.preventDefault();
$showCommandPalette = true; $showCommandPalette = true;
return;
} }
if (mod && e.key === 's') { if (mod && !e.shiftKey && code === 'KeyS') {
e.preventDefault(); e.preventDefault();
editor?.forceSave(); editor?.forceSave();
return;
} }
if (mod && e.key === 'k' && !e.shiftKey && $activeNotePath && !$sourceMode) { if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
e.preventDefault(); e.preventDefault();
editor?.addLinkFromToolbar(); editor?.addLinkFromToolbar();
} }
if (mod && e.shiftKey && e.key === 'M') { if (mod && e.shiftKey && code === 'KeyM') {
e.preventDefault(); e.preventDefault();
$sourceMode = !$sourceMode; $sourceMode = !$sourceMode;
} }
if (mod && e.shiftKey && e.key === 'W') { if (mod && e.shiftKey && code === 'KeyW') {
e.preventDefault(); e.preventDefault();
if ($activeNotePath && $activeNote) { if ($activeNotePath && $activeNote) {
openNoteWindow($activeNotePath, $activeNote.meta.title); openNoteWindow($activeNotePath, $activeNote.meta.title);
@@ -315,13 +335,14 @@
$sidebarCollapsed = !$sidebarCollapsed; $sidebarCollapsed = !$sidebarCollapsed;
return; return;
} }
if (mod && e.shiftKey && e.key === 'E') { if (mod && e.shiftKey && code === 'KeyE') {
e.preventDefault(); e.preventDefault();
$focusMode = !$focusMode; $focusMode = !$focusMode;
return; return;
} }
if (mod && e.shiftKey && e.key === 'R') { if (mod && e.shiftKey && code === 'KeyR') {
e.preventDefault(); e.preventDefault();
if ($viewerNote) return; // viewer mode is always read-only
$readOnly = !$readOnly; $readOnly = !$readOnly;
return; return;
} }
@@ -361,6 +382,12 @@
persistState(); persistState();
}); });
$effect(() => {
$notebookSortMode;
$notebookOrder;
persistState();
});
onMount(async () => { onMount(async () => {
let lastNotePath: string | null = null; let lastNotePath: string | null = null;
try { try {
@@ -369,6 +396,8 @@
$notelistWidth = state.notelist_width; $notelistWidth = state.notelist_width;
$sidebarCollapsed = state.sidebar_collapsed; $sidebarCollapsed = state.sidebar_collapsed;
$collapsedNotebooks = state.collapsed_notebooks ?? []; $collapsedNotebooks = state.collapsed_notebooks ?? [];
$notebookSortMode = state.notebook_sort_mode === 'manual' ? 'manual' : 'alphabetical';
$notebookOrder = state.notebook_order ?? {};
lastNotePath = state.last_open_note ?? null; lastNotePath = state.last_open_note ?? null;
} catch (_) {} } catch (_) {}
@@ -489,7 +518,7 @@
{#if $mobileView === 'sidebar'} {#if $mobileView === 'sidebar'}
HelixNotes HelixNotes
{:else} {:else}
{$activeNotebook?.name || 'All Notes'} {#if $viewMode === 'notebook'}{$activeNotebook?.name ?? 'Notebook'}{:else if $viewMode === 'tag'}#{$activeTag}{:else if $viewMode === 'quickaccess'}Quick Access{:else if $viewMode === 'daily'}Daily Notes{:else if $viewMode === 'trash'}Trash{:else}All Notes{/if}
{/if} {/if}
</span> </span>
{#if $globalUpdateAvailable && $mobileView === 'sidebar'} {#if $globalUpdateAvailable && $mobileView === 'sidebar'}
@@ -593,7 +622,7 @@
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} /> <Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
</div> </div>
<div class="mobile-panel" class:active={$mobileView === 'notelist'}> <div class="mobile-panel" class:active={$mobileView === 'notelist'}>
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} /> <NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => editor?.focusTitle()} />
<button class="mobile-fab" onclick={createAndFocusNote} title="New Note"> <button class="mobile-fab" onclick={createAndFocusNote} title="New Note">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M12 5v14M5 12h14" /> <path d="M12 5v14M5 12h14" />
@@ -658,7 +687,7 @@
{/if} {/if}
<div class="notelist-panel" style="width: {$notelistWidth}px"> <div class="notelist-panel" style="width: {$notelistWidth}px">
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} /> <NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => editor?.focusTitle()} />
</div> </div>
<ResizeHandle onResize={handleNotelistResize} /> <ResizeHandle onResize={handleNotelistResize} />
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { showInfo, appConfig } from '$lib/stores/app'; import { showInfo, appConfig } from '$lib/stores/app';
import { getVaultStats } from '$lib/api'; import { getVaultStats } from '$lib/api';
import { openUrl } from '@tauri-apps/plugin-opener'; import { openUrl } from '$lib/api';
import { getVersion } from '@tauri-apps/api/app'; import { getVersion } from '@tauri-apps/api/app';
import type { VaultStats } from '$lib/types'; import type { VaultStats } from '$lib/types';
+50 -3
View File
@@ -36,12 +36,14 @@
} from '$lib/api'; } from '$lib/api';
import { formatRelativeTime } from '$lib/utils/time'; import { formatRelativeTime } from '$lib/utils/time';
import { openNoteWindow } from '$lib/utils/window'; import { openNoteWindow } from '$lib/utils/window';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import type { NoteEntry, TrashNotebookEntry, SortMode } from '$lib/types'; import type { NoteEntry, TrashNotebookEntry, SortMode } from '$lib/types';
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {} }: { let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {} }: {
onNoteSelected?: (path: string, content: string) => void; onNoteSelected?: (path: string, content: string) => void;
onNoteMoved?: () => void; onNoteMoved?: () => void;
onBeforeNoteSwitch?: () => void; onBeforeNoteSwitch?: () => void;
onNoteCreated?: () => void;
} = $props(); } = $props();
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
@@ -230,6 +232,15 @@
if (listContainer) listContainer.scrollTop = 0; if (listContainer) listContainer.scrollTop = 0;
}); });
// Invalidate quickaccess cache when starred notes change (e.g. from Editor star toggle)
$effect(() => {
$quickAccessPaths;
noteCache.delete('quickaccess');
if ($viewMode === 'quickaccess') {
refresh();
}
});
// Track container height via ResizeObserver // Track container height via ResizeObserver
$effect(() => { $effect(() => {
if (!listContainer) return; if (!listContainer) return;
@@ -369,6 +380,7 @@
noteCache.clear(); noteCache.clear();
await refresh(); await refresh();
await selectNote(entry); await selectNote(entry);
onNoteCreated();
} catch (e) { } catch (e) {
console.error('Failed to create note:', e); console.error('Failed to create note:', e);
} }
@@ -703,7 +715,7 @@
return; return;
} }
if (e.shiftKey) { if (e.shiftKey) {
// Range select use lastClickedPath or active note as anchor // Range select - use lastClickedPath or active note as anchor
const anchor = lastClickedPath || $activeNotePath; const anchor = lastClickedPath || $activeNotePath;
if (!anchor) { selectNote(note); return; } if (!anchor) { selectNote(note); return; }
const paths = $sortedNotes.map(n => n.path); const paths = $sortedNotes.map(n => n.path);
@@ -720,7 +732,6 @@
} }
return; return;
} }
// Normal click — clear selection, open note
clearSelection(); clearSelection();
selectNote(note); selectNote(note);
} }
@@ -1090,6 +1101,11 @@
<path d="M12 17v5"/><path d="M9 2h6l-1 7h4l-2 4H8l-2-4h4L9 2z"/> <path d="M12 17v5"/><path d="M9 2h6l-1 7h4l-2 4H8l-2-4h4L9 2z"/>
</svg> </svg>
{/if} {/if}
{#if $viewMode !== 'quickaccess' && $quickAccessPaths.includes(note.relative_path)}
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="quickaccess-icon">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
{/if}
{note.meta.title} {note.meta.title}
</span> </span>
{#if getNotebookPath(note)} {#if getNotebookPath(note)}
@@ -1104,6 +1120,11 @@
<path d="M12 17v5"/><path d="M9 2h6l-1 7h4l-2 4H8l-2-4h4L9 2z"/> <path d="M12 17v5"/><path d="M9 2h6l-1 7h4l-2 4H8l-2-4h4L9 2z"/>
</svg> </svg>
{/if} {/if}
{#if $viewMode !== 'quickaccess' && $quickAccessPaths.includes(note.relative_path)}
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="quickaccess-icon">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
{/if}
{note.meta.title} {note.meta.title}
</div> </div>
<div class="note-preview">{note.preview}</div> <div class="note-preview">{note.preview}</div>
@@ -1290,6 +1311,14 @@
</svg> </svg>
Open in New Window Open in New Window
</button> </button>
{#if !isMobile}
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; try { await revealItemInDir(n.path); } catch (e) { console.error('Failed to reveal in file manager:', e); } }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
Show in File Manager
</button>
{/if}
<button onclick={() => startRename(contextMenu!.note)}> <button onclick={() => startRename(contextMenu!.note)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 3a2.85 2.85 0 114 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/> <path d="M17 3a2.85 2.85 0 114 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>
@@ -1412,6 +1441,19 @@
padding: 4px; padding: 4px;
} }
.list-content::-webkit-scrollbar {
width: 8px;
}
.list-content::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 4px;
}
.list-content::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
.note-item { .note-item {
display: block; display: block;
width: 100%; width: 100%;
@@ -1608,6 +1650,11 @@
flex-shrink: 0; flex-shrink: 0;
} }
.quickaccess-icon {
color: var(--text-accent);
flex-shrink: 0;
}
.note-preview { .note-preview {
font-size: 0.85em; font-size: 0.85em;
color: var(--text-tertiary); color: var(--text-tertiary);
+3 -2
View File
@@ -67,11 +67,12 @@
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey; const mod = e.ctrlKey || e.metaKey;
if (mod && e.key === 's') { if (mod && !e.shiftKey && e.code === 'KeyS') {
e.preventDefault(); e.preventDefault();
editor?.forceSave(); editor?.forceSave();
return;
} }
if (mod && e.shiftKey && e.key === 'M') { if (mod && e.shiftKey && e.code === 'KeyM') {
e.preventDefault(); e.preventDefault();
$sourceMode = !$sourceMode; $sourceMode = !$sourceMode;
} }
+17 -2
View File
@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile } from '$lib/stores/app'; import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode } from '$lib/stores/app';
import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection } from '$lib/api'; import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection } from '$lib/api';
import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { getVersion } from '@tauri-apps/api/app'; import { getVersion } from '@tauri-apps/api/app';
import { openUrl } from '@tauri-apps/plugin-opener'; import { openUrl } from '$lib/api';
import type { ImportResult, BackupEntry } from '$lib/types'; import type { ImportResult, BackupEntry } from '$lib/types';
const isMobile = /android|ios/i.test(navigator.userAgent); const isMobile = /android|ios/i.test(navigator.userAgent);
@@ -599,6 +599,20 @@
</label> </label>
</div> </div>
<div class="settings-section">
<h3>Notebooks</h3>
<label class="setting-toggle">
<span class="setting-label">
<span class="setting-name">Sort order</span>
<span class="setting-desc">Alphabetical (default) or manual. Drag notebooks above/below each other to reorder.</span>
</span>
</label>
<div class="setting-options" style="margin-top: 8px;">
<button class="option-btn" class:active={$notebookSortMode === 'alphabetical'} onclick={() => { $notebookSortMode = 'alphabetical'; }}>Alphabetical</button>
<button class="option-btn" class:active={$notebookSortMode === 'manual'} onclick={() => { $notebookSortMode = 'manual'; }}>Manual</button>
</div>
</div>
<div class="settings-section"> <div class="settings-section">
<h3>Time Format</h3> <h3>Time Format</h3>
<div class="setting-options"> <div class="setting-options">
@@ -717,6 +731,7 @@
</label> </label>
</div> </div>
{#if !isMobile} {#if !isMobile}
<div class="settings-section"> <div class="settings-section">
<h3>PDF Preview</h3> <h3>PDF Preview</h3>
+139 -8
View File
@@ -16,7 +16,9 @@
appConfig, appConfig,
quickAccessPaths, quickAccessPaths,
collapsedNotebooks, collapsedNotebooks,
rootNoteCount rootNoteCount,
notebookSortMode,
notebookOrder
} from '$lib/stores/app'; } from '$lib/stores/app';
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, getNotes, countRootNotes } from '$lib/api'; import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, getNotes, countRootNotes } from '$lib/api';
import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { open as openDialog } from '@tauri-apps/plugin-dialog';
@@ -36,7 +38,23 @@
let newNotebookName = $state(''); let newNotebookName = $state('');
let showNewNotebook = $state(false); let showNewNotebook = $state(false);
let dropTargetPath = $state<string | null>(null); let dropTargetPath = $state<string | null>(null);
let dropPosition = $state<'above' | 'into' | 'below' | null>(null);
let draggedNotebookPath = $state<string | null>(null); let draggedNotebookPath = $state<string | null>(null);
// Apply manual ordering recursively when in 'manual' sort mode.
// In 'alphabetical' mode the Rust scanner already sorts, so we pass through unchanged.
function sortNotebooksTree(tree: NotebookEntry[]): NotebookEntry[] {
if ($notebookSortMode !== 'manual') return tree;
const order = $notebookOrder;
const sorted = [...tree].sort((a, b) => {
const oa = order[a.path] ?? Number.MAX_SAFE_INTEGER;
const ob = order[b.path] ?? Number.MAX_SAFE_INTEGER;
if (oa !== ob) return oa - ob;
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
return sorted.map(nb => ({ ...nb, children: sortNotebooksTree(nb.children) }));
}
let sortedNotebooks = $derived(sortNotebooksTree($notebooks));
let contextMenu = $state<{ x: number; y: number; notebook: NotebookEntry } | null>(null); let contextMenu = $state<{ x: number; y: number; notebook: NotebookEntry } | null>(null);
let trashContextMenu = $state<{ x: number; y: number } | null>(null); let trashContextMenu = $state<{ x: number; y: number } | null>(null);
let tagsCollapsed = $state(true); let tagsCollapsed = $state(true);
@@ -50,6 +68,7 @@
} }
} }
export async function refresh() { export async function refresh() {
try { try {
if (isMobile) { if (isMobile) {
@@ -264,6 +283,94 @@
} }
} }
function findSiblingsOfPath(tree: NotebookEntry[], parentPath: string, vaultRoot: string): NotebookEntry[] {
if (parentPath === vaultRoot) return tree;
for (const nb of tree) {
if (nb.path === parentPath) return nb.children;
if (nb.children.length > 0) {
const found = findSiblingsOfPath(nb.children, parentPath, vaultRoot);
if (found.length > 0) return found;
}
}
return [];
}
async function handleNotebookReorder(srcPath: string, targetPath: string, position: 'above' | 'below') {
dropTargetPath = null;
dropPosition = null;
draggedNotebookPath = null;
if (srcPath === targetPath) return;
if (targetPath.startsWith(srcPath + '/')) return; // can't move into descendant
const vaultRoot = $appConfig?.active_vault;
if (!vaultRoot) return;
const srcParent = srcPath.substring(0, srcPath.lastIndexOf('/'));
const targetParent = targetPath.substring(0, targetPath.lastIndexOf('/'));
let actualSrcPath = srcPath;
try {
// Step 1: if different parents, move src into target's parent first
if (srcParent !== targetParent) {
const oldName = srcPath.split('/').pop() || '';
const newPath = targetParent === vaultRoot
? vaultRoot + '/' + oldName
: targetParent + '/' + oldName;
await moveNotebook(srcPath, targetParent);
// Migrate paths in collapsedNotebooks
$collapsedNotebooks = $collapsedNotebooks.map(p => {
if (p === srcPath) return newPath;
if (p.startsWith(srcPath + '/')) return newPath + p.slice(srcPath.length);
return p;
});
// Migrate paths in notebookOrder
const migrated: Record<string, number> = {};
for (const [p, v] of Object.entries($notebookOrder)) {
if (p === srcPath) migrated[newPath] = v;
else if (p.startsWith(srcPath + '/')) migrated[newPath + p.slice(srcPath.length)] = v;
else migrated[p] = v;
}
$notebookOrder = migrated;
// Update active notebook/note
if ($activeNotebook?.path === srcPath || $activeNotebook?.path.startsWith(srcPath + '/')) {
selectAllNotes();
}
if ($activeNotePath && $activeNotePath.startsWith(srcPath + '/')) {
const newNotePath = newPath + $activeNotePath.slice(srcPath.length);
$activeNotePath = newNotePath;
$activeNote = await readNote(newNotePath);
}
$notebookIcons = await getNotebookIcons();
await refresh();
actualSrcPath = newPath;
}
// Step 2: reorder within target's parent
const siblings = findSiblingsOfPath($notebooks, targetParent, vaultRoot);
const sortedSiblings = sortNotebooksTree(siblings);
const without = sortedSiblings.filter(s => s.path !== actualSrcPath);
const targetIdx = without.findIndex(s => s.path === targetPath);
if (targetIdx === -1) return;
const insertIdx = position === 'above' ? targetIdx : targetIdx + 1;
const newSequence = [
...without.slice(0, insertIdx).map(s => s.path),
actualSrcPath,
...without.slice(insertIdx).map(s => s.path),
];
// Assign sequential positions to all siblings (only within this parent)
const newOrder = { ...$notebookOrder };
newSequence.forEach((path, idx) => {
newOrder[path] = idx;
});
$notebookOrder = newOrder;
// Auto-enable manual mode (the act of reordering implies user wants this)
if ($notebookSortMode !== 'manual') $notebookSortMode = 'manual';
} catch (e) {
console.error('Failed to reorder notebook:', e);
}
}
function startRename(nb: NotebookEntry) { function startRename(nb: NotebookEntry) {
contextMenu = null; contextMenu = null;
editingNotebook = nb.path; editingNotebook = nb.path;
@@ -490,7 +597,7 @@
class:active={$viewMode === 'notebook' && $activeNotebook?.relative_path === ''} class:active={$viewMode === 'notebook' && $activeNotebook?.relative_path === ''}
onclick={selectUnfiled} onclick={selectUnfiled}
> >
<span class="chevron-spacer"></span> <span style="width:13px;flex-shrink:0"></span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.6"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.6">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" /> <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" /> <polyline points="14 2 14 8 20 8" />
@@ -498,7 +605,7 @@
<span class="notebook-name">Unfiled Notes <span class="notebook-count">{$rootNoteCount}</span></span> <span class="notebook-name">Unfiled Notes <span class="notebook-count">{$rootNoteCount}</span></span>
</button> </button>
{/if} {/if}
{#each $notebooks as nb (nb.path)} {#each sortedNotebooks as nb (nb.path)}
{@render notebookItem(nb, 0)} {@render notebookItem(nb, 0)}
{/each} {/each}
</div> </div>
@@ -648,13 +755,15 @@
<button <button
class="notebook-item" class="notebook-item"
class:active={$viewMode === 'notebook' && $activeNotebook?.path === nb.path} class:active={$viewMode === 'notebook' && $activeNotebook?.path === nb.path}
class:drop-target={dropTargetPath === nb.path} class:drop-target={dropTargetPath === nb.path && dropPosition === 'into'}
class:drop-above={dropTargetPath === nb.path && dropPosition === 'above'}
class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'}
style="padding-left: {8 + depth * 16}px" style="padding-left: {8 + depth * 16}px"
draggable="true" draggable="true"
onclick={() => selectNotebook(nb)} onclick={() => selectNotebook(nb)}
oncontextmenu={(e) => onContextMenu(e, nb)} oncontextmenu={(e) => onContextMenu(e, nb)}
ondragstart={(e) => { draggedNotebookPath = nb.path; e.dataTransfer!.setData('text/plain', nb.path); e.dataTransfer!.effectAllowed = 'move'; }} ondragstart={(e) => { draggedNotebookPath = nb.path; e.dataTransfer!.setData('text/plain', nb.path); e.dataTransfer!.effectAllowed = 'move'; }}
ondragend={() => { draggedNotebookPath = null; }} ondragend={() => { draggedNotebookPath = null; dropPosition = null; }}
ondragover={(e) => { ondragover={(e) => {
e.preventDefault(); e.preventDefault();
if (draggedNotebookPath) { if (draggedNotebookPath) {
@@ -663,17 +772,33 @@
e.dataTransfer!.dropEffect = 'none'; e.dataTransfer!.dropEffect = 'none';
return; return;
} }
// Prevent no-op (already in this parent) }
// Decide drop position from cursor Y within the row: top 30% = above, bottom 30% = below, middle = into
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const relY = (e.clientY - rect.top) / rect.height;
let pos: 'above' | 'into' | 'below';
if (draggedNotebookPath && relY < 0.3) pos = 'above';
else if (draggedNotebookPath && relY > 0.7) pos = 'below';
else pos = 'into';
// For 'into', reject if already in that parent (no-op)
if (pos === 'into' && draggedNotebookPath) {
const parentDir = draggedNotebookPath.substring(0, draggedNotebookPath.lastIndexOf('/')); const parentDir = draggedNotebookPath.substring(0, draggedNotebookPath.lastIndexOf('/'));
if (parentDir === nb.path) { e.dataTransfer!.dropEffect = 'none'; return; } if (parentDir === nb.path) { e.dataTransfer!.dropEffect = 'none'; return; }
} }
e.dataTransfer!.dropEffect = 'move'; e.dataTransfer!.dropEffect = 'move';
dropTargetPath = nb.path; dropTargetPath = nb.path;
dropPosition = pos;
}} }}
ondragleave={() => { if (dropTargetPath === nb.path) dropTargetPath = null; }} ondragleave={() => { if (dropTargetPath === nb.path) { dropTargetPath = null; dropPosition = null; } }}
ondrop={(e) => { ondrop={(e) => {
e.preventDefault();
const pos = dropPosition;
if (draggedNotebookPath) { if (draggedNotebookPath) {
handleNotebookDrop(e, nb.path); if (pos === 'above' || pos === 'below') {
handleNotebookReorder(draggedNotebookPath, nb.path, pos);
} else {
handleNotebookDrop(e, nb.path);
}
} else { } else {
handleNoteDrop(e, nb); handleNoteDrop(e, nb);
} }
@@ -908,6 +1033,12 @@
outline-offset: -2px; outline-offset: -2px;
border-radius: 6px; border-radius: 6px;
} }
.notebook-item.drop-above {
box-shadow: 0 -2px 0 0 var(--accent);
}
.notebook-item.drop-below {
box-shadow: 0 2px 0 0 var(--accent);
}
.chevron { .chevron {
display: flex; display: flex;
+2 -2
View File
@@ -31,7 +31,7 @@
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.closest('.titlebar-controls') || target.closest('.titlebar-actions')) return; if (target.closest('.titlebar-controls') || target.closest('.titlebar-actions')) return;
// Don't start dragging near window edges let Tauri handle resize // Don't start dragging near window edges - let Tauri handle resize
if (!maximized) { if (!maximized) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
if (e.clientY - rect.top < RESIZE_EDGE || e.clientX - rect.left < RESIZE_EDGE) return; if (e.clientY - rect.top < RESIZE_EDGE || e.clientX - rect.left < RESIZE_EDGE) return;
@@ -39,7 +39,6 @@
const now = Date.now(); const now = Date.now();
if (now - lastMouseDown < 300) { if (now - lastMouseDown < 300) {
// Double-click detected — maximize/restore
appWindow.toggleMaximize(); appWindow.toggleMaximize();
lastMouseDown = 0; lastMouseDown = 0;
return; return;
@@ -221,6 +220,7 @@
color: var(--text-primary); color: var(--text-primary);
} }
.switch-vault-btn.active { .switch-vault-btn.active {
background: color-mix(in srgb, var(--accent) 18%, transparent); background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--accent); color: var(--accent);
+1 -1
View File
@@ -154,7 +154,7 @@
<span class="vault-path-hint">{fullPath}</span> <span class="vault-path-hint">{fullPath}</span>
</div> </div>
{:else} {:else}
<p class="description">Your notes are stored as standard Markdown (.md) files. Pick any folder existing .md files will be recognized automatically.</p> <p class="description">Your notes are stored as standard Markdown (.md) files. Pick any folder - existing .md files will be recognized automatically.</p>
{/if} {/if}
{#if error} {#if error}
+51 -2
View File
@@ -1,4 +1,4 @@
import { writable, derived } from "svelte/store"; import { writable, derived, get } from "svelte/store";
import type { import type {
AppConfig, AppConfig,
NoteEntry, NoteEntry,
@@ -29,6 +29,13 @@ export const notebookIcons = writable<Record<string, string>>({});
export const quickAccessPaths = writable<string[]>([]); export const quickAccessPaths = writable<string[]>([]);
export const collapsedNotebooks = writable<string[]>([]); export const collapsedNotebooks = writable<string[]>([]);
// Notebook sort: 'alphabetical' (default) or 'manual' (drag-to-reorder, persisted in notebookOrder).
export const notebookSortMode = writable<"alphabetical" | "manual">(
"alphabetical",
);
// Map from notebook absolute path → ordinal position (lower = earlier). Only consulted when notebookSortMode === 'manual'.
export const notebookOrder = writable<Record<string, number>>({});
// Data // Data
export const notebooks = writable<NotebookEntry[]>([]); export const notebooks = writable<NotebookEntry[]>([]);
export const rootNoteCount = writable<number>(0); export const rootNoteCount = writable<number>(0);
@@ -39,6 +46,13 @@ export const activeNotePath = writable<string | null>(null);
export const activeNotebook = writable<NotebookEntry | null>(null); export const activeNotebook = writable<NotebookEntry | null>(null);
export const activeTag = writable<string | null>(null); export const activeTag = writable<string | null>(null);
// External-file viewer mode: set when an .md file outside the active vault is opened.
// While set, the editor is forced read-only, autosave is suppressed, and a banner
// offering "Import to vault" / "Close" is shown.
export const viewerNote = writable<{ path: string; content: string } | null>(
null,
);
// Mobile state // Mobile state
export const mobileView = writable<"sidebar" | "notelist" | "editor">( export const mobileView = writable<"sidebar" | "notelist" | "editor">(
"sidebar", "sidebar",
@@ -71,7 +85,7 @@ export async function checkForUpdate() {
updateObj.set(update); updateObj.set(update);
} }
} catch { } catch {
// Silent fail don't disrupt app startup // Silent fail - don't disrupt app startup
} }
} }
@@ -106,6 +120,35 @@ export async function checkForUpdateMobile() {
} }
} }
// Note navigation history
interface NavHistoryState { stack: string[]; index: number; skipping: boolean; }
function createNavHistory() {
const store = writable<NavHistoryState>({ stack: [], index: -1, skipping: false });
return {
subscribe: store.subscribe,
push(path: string) {
store.update(s => {
if (s.skipping) return { ...s, skipping: false };
const trimmed = s.stack.slice(0, s.index + 1);
return { stack: [...trimmed, path], index: trimmed.length, skipping: false };
});
},
go(direction: -1 | 1): string | null {
let target: string | null = null;
store.update(s => {
const newIdx = s.index + direction;
if (newIdx < 0 || newIdx >= s.stack.length) return s;
target = s.stack[newIdx];
return { ...s, index: newIdx, skipping: true };
});
return target;
},
};
}
export const navHistory = createNavHistory();
export const canGoBack = derived(navHistory, $h => $h.index > 0);
export const canGoForward = derived(navHistory, $h => $h.index < $h.stack.length - 1);
// Derived // Derived
export const sortedNotes = derived( export const sortedNotes = derived(
[notes, sortMode, viewMode], [notes, sortMode, viewMode],
@@ -145,6 +188,8 @@ export const vaultState = derived(
notelistWidth, notelistWidth,
sidebarCollapsed, sidebarCollapsed,
collapsedNotebooks, collapsedNotebooks,
notebookSortMode,
notebookOrder,
], ],
([ ([
$activeNotePath, $activeNotePath,
@@ -152,6 +197,8 @@ export const vaultState = derived(
$notelistWidth, $notelistWidth,
$sidebarCollapsed, $sidebarCollapsed,
$collapsedNotebooks, $collapsedNotebooks,
$notebookSortMode,
$notebookOrder,
]) => { ]) => {
return { return {
last_open_note: $activeNotePath, last_open_note: $activeNotePath,
@@ -159,6 +206,8 @@ export const vaultState = derived(
notelist_width: $notelistWidth, notelist_width: $notelistWidth,
sidebar_collapsed: $sidebarCollapsed, sidebar_collapsed: $sidebarCollapsed,
collapsed_notebooks: $collapsedNotebooks, collapsed_notebooks: $collapsedNotebooks,
notebook_sort_mode: $notebookSortMode,
notebook_order: $notebookOrder,
} satisfies VaultState; } satisfies VaultState;
}, },
); );
+2
View File
@@ -86,6 +86,8 @@ export interface VaultState {
notelist_width: number; notelist_width: number;
sidebar_collapsed: boolean; sidebar_collapsed: boolean;
collapsed_notebooks: string[]; collapsed_notebooks: string[];
notebook_sort_mode?: string;
notebook_order?: Record<string, number>;
} }
export interface SearchResult { export interface SearchResult {
+2 -3
View File
@@ -2,8 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import '../app.css'; import '../app.css';
import { theme, appConfig, activeNote, activeNotePath, installType, checkForUpdate, checkForUpdateMobile } from '$lib/stores/app'; import { theme, appConfig, activeNote, activeNotePath, installType, checkForUpdate, checkForUpdateMobile } from '$lib/stores/app';
import { openUrl } from '@tauri-apps/plugin-opener'; import { openFile, openUrl, readNote, getInstallType } from '$lib/api';
import { openFile, readNote, getInstallType } from '$lib/api';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
let { children } = $props(); let { children } = $props();
@@ -68,7 +67,7 @@
} }
} }
} }
// Internal .md note link navigate within the app // Internal .md note link - navigate within the app
if (absPath.endsWith('.md')) { if (absPath.endsWith('.md')) {
readNote(absPath).then((content) => { readNote(absPath).then((content) => {
activeNote.set({ ...content, content: content.content }); activeNote.set({ ...content, content: content.content });