mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-19 09:27:29 +02:00
Speed up note switcher and sync window scaling
This commit is contained in:
@@ -462,11 +462,19 @@ pub fn set_line_height(state: State<'_, AppState>, height: f64) -> Result<(), St
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_ui_scale(state: State<'_, AppState>, scale: f64) -> Result<(), String> {
|
||||
pub fn set_ui_scale(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
scale: f64,
|
||||
) -> Result<(), String> {
|
||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
config.ui_scale = Some(scale);
|
||||
save_app_config(&config)?;
|
||||
Ok(())
|
||||
drop(config);
|
||||
|
||||
use tauri::Emitter;
|
||||
app.emit("ui-scale-changed", scale)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -799,6 +807,26 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result<Vec<NoteTitleEn
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_note_switcher_titles(
|
||||
state: State<'_, AppState>,
|
||||
recent_paths: Vec<String>,
|
||||
) -> Result<Vec<NoteTitleEntry>, String> {
|
||||
let vault_path = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?
|
||||
.active_vault
|
||||
.clone()
|
||||
.ok_or_else(|| "No active vault".to_string())?;
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
operations::get_note_switcher_titles(&vault_path, &recent_paths)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
}
|
||||
|
||||
// ── Graph ──
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1502,10 +1530,18 @@ pub fn set_general_settings(
|
||||
// ── Quick Access ──
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_quick_access(state: State<'_, AppState>) -> Result<Vec<NoteEntry>, String> {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
|
||||
operations::get_quick_access_notes(vault_path)
|
||||
pub async fn get_quick_access(state: State<'_, AppState>) -> Result<Vec<NoteEntry>, String> {
|
||||
let vault_path = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?
|
||||
.active_vault
|
||||
.clone()
|
||||
.ok_or_else(|| "No active vault".to_string())?;
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || operations::get_quick_access_notes(&vault_path))
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -158,6 +158,7 @@ pub fn run() {
|
||||
commands::move_note,
|
||||
commands::get_all_tags,
|
||||
commands::get_all_note_titles,
|
||||
commands::get_note_switcher_titles,
|
||||
commands::get_graph_data,
|
||||
commands::get_tasks,
|
||||
commands::set_task_done,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, TrashContents, TrashNotebookEntry, VaultState};
|
||||
use crate::types::{
|
||||
NoteContent, NoteEntry, NoteMeta, NoteTitleEntry, NotebookEntry, TrashContents,
|
||||
TrashNotebookEntry, VaultState,
|
||||
};
|
||||
use crate::vault::frontmatter;
|
||||
use chrono::{DateTime, Local, Locale, Utc};
|
||||
use rayon::prelude::*;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use uuid::Uuid;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
@@ -1279,6 +1283,59 @@ pub fn remove_quick_access(vault_path: &str, note_relative: &str) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const NOTE_SWITCHER_RECENT_LIMIT: usize = 6;
|
||||
|
||||
pub fn get_note_switcher_titles(
|
||||
vault_path: &str,
|
||||
recent_paths: &[String],
|
||||
) -> Result<Vec<NoteTitleEntry>, String> {
|
||||
let vault_root = Path::new(vault_path);
|
||||
if !vault_root.is_dir() {
|
||||
return Err("Vault path does not exist".to_string());
|
||||
}
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let mut titles = Vec::with_capacity(NOTE_SWITCHER_RECENT_LIMIT);
|
||||
|
||||
for requested_path in recent_paths {
|
||||
if titles.len() >= NOTE_SWITCHER_RECENT_LIMIT {
|
||||
break;
|
||||
}
|
||||
|
||||
let path = Path::new(requested_path);
|
||||
let Ok(relative) = path.strip_prefix(vault_root) else {
|
||||
continue;
|
||||
};
|
||||
let safe_relative = !relative.as_os_str().is_empty()
|
||||
&& relative.components().all(|component| match component {
|
||||
Component::Normal(name) => !is_hidden(Path::new(name)),
|
||||
Component::CurDir => true,
|
||||
_ => false,
|
||||
});
|
||||
if !safe_relative
|
||||
|| path.extension().and_then(|extension| extension.to_str()) != Some("md")
|
||||
|| !path.is_file()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let relative_path = relative.to_path_buf();
|
||||
if !seen.insert(relative_path.clone()) {
|
||||
continue;
|
||||
}
|
||||
let Ok(entry) = read_note_entry_fast(path, vault_root) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
titles.push(NoteTitleEntry {
|
||||
title: entry.meta.title,
|
||||
path: relative_path.to_string_lossy().replace('\\', "/"),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(titles)
|
||||
}
|
||||
|
||||
pub fn get_quick_access_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String> {
|
||||
let list = load_quick_access(vault_path)?;
|
||||
let vault_root = Path::new(vault_path);
|
||||
@@ -1287,7 +1344,7 @@ pub fn get_quick_access_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String
|
||||
for relative in &list {
|
||||
let full_path = vault_root.join(relative);
|
||||
if full_path.exists() {
|
||||
if let Ok(entry) = read_note_entry(&full_path, vault_root) {
|
||||
if let Ok(entry) = read_note_entry_fast(&full_path, vault_root) {
|
||||
notes.push(entry);
|
||||
}
|
||||
}
|
||||
@@ -1309,7 +1366,10 @@ pub fn sanitize_filename(name: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{duplicate_note, helixnotes_dir, load_notebook_icons, set_notebook_icon};
|
||||
use super::{
|
||||
duplicate_note, get_note_switcher_titles, helixnotes_dir, load_notebook_icons,
|
||||
set_notebook_icon,
|
||||
};
|
||||
use std::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -1333,6 +1393,70 @@ mod tests {
|
||||
fs::remove_dir_all(vault).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_only_requested_note_switcher_titles() {
|
||||
let vault =
|
||||
std::env::temp_dir().join(format!("helixnotes-note-switcher-test-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
|
||||
let mut note_paths = Vec::new();
|
||||
for index in 0..8 {
|
||||
let path = vault.join(format!("Note {index}.md"));
|
||||
fs::write(
|
||||
&path,
|
||||
format!("---\ntitle: Note {index}\n---\n\nNote {index} body.\n"),
|
||||
)
|
||||
.unwrap();
|
||||
note_paths.push(path);
|
||||
}
|
||||
fs::write(
|
||||
vault.join("Unrelated.md"),
|
||||
"---\ntitle: Unrelated\n---\n\nMust not be loaded.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let outside =
|
||||
std::env::temp_dir().join(format!("helixnotes-outside-note-{}.md", Uuid::new_v4()));
|
||||
fs::write(&outside, "---\ntitle: Outside\n---\n").unwrap();
|
||||
|
||||
let requested = vec![
|
||||
note_paths[0].to_string_lossy().into_owned(),
|
||||
note_paths[0].to_string_lossy().into_owned(),
|
||||
vault.join("Missing.md").to_string_lossy().into_owned(),
|
||||
outside.to_string_lossy().into_owned(),
|
||||
note_paths[1].to_string_lossy().into_owned(),
|
||||
note_paths[2].to_string_lossy().into_owned(),
|
||||
note_paths[3].to_string_lossy().into_owned(),
|
||||
note_paths[4].to_string_lossy().into_owned(),
|
||||
note_paths[5].to_string_lossy().into_owned(),
|
||||
note_paths[6].to_string_lossy().into_owned(),
|
||||
note_paths[7].to_string_lossy().into_owned(),
|
||||
];
|
||||
|
||||
let vault_path = vault.to_string_lossy();
|
||||
let titles = get_note_switcher_titles(&vault_path, &requested).unwrap();
|
||||
assert_eq!(titles.len(), 6);
|
||||
assert_eq!(
|
||||
titles
|
||||
.iter()
|
||||
.map(|entry| entry.path.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"Note 0.md",
|
||||
"Note 1.md",
|
||||
"Note 2.md",
|
||||
"Note 3.md",
|
||||
"Note 4.md",
|
||||
"Note 5.md",
|
||||
]
|
||||
);
|
||||
assert!(titles.iter().all(|entry| entry.title != "Unrelated"));
|
||||
assert!(titles.iter().all(|entry| entry.title != "Outside"));
|
||||
|
||||
fs::remove_dir_all(vault).unwrap();
|
||||
fs::remove_file(outside).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicates_note_content_and_assigns_unique_identity_and_name() {
|
||||
let vault =
|
||||
|
||||
@@ -179,6 +179,12 @@ export async function getAllNoteTitles(): Promise<NoteTitleEntry[]> {
|
||||
return invoke("get_all_note_titles");
|
||||
}
|
||||
|
||||
export async function getNoteSwitcherTitles(
|
||||
recentPaths: string[],
|
||||
): Promise<NoteTitleEntry[]> {
|
||||
return invoke("get_note_switcher_titles", { recentPaths });
|
||||
}
|
||||
|
||||
export async function getGraphData(): Promise<{ nodes: { title: string; path: string }[]; edges: { source: number; target: number }[] }> {
|
||||
return invoke("get_graph_data");
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { activeNote, activeNotePath, appConfig, navHistory } from '$lib/stores/app';
|
||||
import { getAllNoteTitles, getQuickAccess } from '$lib/api';
|
||||
import { getNoteSwitcherTitles, getQuickAccess } from '$lib/api';
|
||||
import { openNoteWindow } from '$lib/utils/window';
|
||||
import {
|
||||
buildNoteSwitcherRequestPaths,
|
||||
buildNoteSwitcherSections,
|
||||
type NoteSwitcherNote,
|
||||
type NoteSwitcherRow,
|
||||
@@ -23,6 +24,7 @@
|
||||
let selectingPath = $state<string | null>(null);
|
||||
let sections = $state<NoteSwitcherSections>({ recent: [], quickAccess: [] });
|
||||
let loadGeneration = 0;
|
||||
let loadedVaultPath = $state<string | null | undefined>(undefined);
|
||||
|
||||
function normalizedPath(path: string): string {
|
||||
return path.replace(/\\/g, '/').replace(/\/$/, '');
|
||||
@@ -71,13 +73,15 @@
|
||||
const vaultPath = $appConfig?.active_vault;
|
||||
if (!vaultPath) {
|
||||
sections = { recent: [], quickAccess: [] };
|
||||
loadedVaultPath = null;
|
||||
loading = false;
|
||||
await focusInitialRow();
|
||||
return;
|
||||
}
|
||||
|
||||
const requestPaths = buildNoteSwitcherRequestPaths($activeNotePath, $navHistory.stack);
|
||||
const [titlesResult, quickAccessResult] = await Promise.allSettled([
|
||||
getAllNoteTitles(),
|
||||
getNoteSwitcherTitles(requestPaths),
|
||||
getQuickAccess()
|
||||
]);
|
||||
if (!open || generation !== loadGeneration) return;
|
||||
@@ -111,6 +115,11 @@
|
||||
.map((entry) => quickAccessEntryToNote(entry, vaultPath))
|
||||
.filter((entry): entry is NoteSwitcherNote => entry !== null)
|
||||
: [];
|
||||
for (const note of quickAccessNotes) {
|
||||
if (!knownNotes.some((knownNote) => normalizedPath(knownNote.path) === normalizedPath(note.path))) {
|
||||
knownNotes.push(note);
|
||||
}
|
||||
}
|
||||
|
||||
sections = buildNoteSwitcherSections({
|
||||
currentPath,
|
||||
@@ -118,22 +127,37 @@
|
||||
knownNotes,
|
||||
quickAccessNotes
|
||||
});
|
||||
loadedVaultPath = vaultPath;
|
||||
loading = false;
|
||||
await focusInitialRow();
|
||||
}
|
||||
|
||||
function refreshAfterPopoverPaint(generation: number) {
|
||||
requestAnimationFrame(() => {
|
||||
window.setTimeout(() => {
|
||||
if (!open || generation !== loadGeneration) return;
|
||||
void refreshSections(generation);
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSwitcher() {
|
||||
if (open) {
|
||||
open = false;
|
||||
loadGeneration += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const vaultPath = $appConfig?.active_vault ?? null;
|
||||
const hasCachedSections = loadedVaultPath === vaultPath;
|
||||
open = true;
|
||||
loading = true;
|
||||
loading = !hasCachedSections;
|
||||
selectingPath = null;
|
||||
sections = { recent: [], quickAccess: [] };
|
||||
if (!hasCachedSections) sections = { recent: [], quickAccess: [] };
|
||||
loadGeneration += 1;
|
||||
void refreshSections(loadGeneration);
|
||||
const generation = loadGeneration;
|
||||
if (hasCachedSections) void focusInitialRow();
|
||||
refreshAfterPopoverPaint(generation);
|
||||
}
|
||||
|
||||
async function closeSwitcher(restoreTriggerFocus: boolean) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import Editor from './Editor.svelte';
|
||||
import {
|
||||
appConfig,
|
||||
activeNote,
|
||||
activeNotePath,
|
||||
editorDirty,
|
||||
@@ -17,9 +19,11 @@
|
||||
let { notePath }: { notePath: string } = $props();
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
const appWebview = getCurrentWebview();
|
||||
const isMac = navigator.platform.startsWith('Mac');
|
||||
let editor = $state<Editor>(null!);
|
||||
let unlistenFileChange: (() => void) | null = null;
|
||||
let unlistenUiScale: (() => void) | null = null;
|
||||
let maximized = $state(false);
|
||||
let loadError = $state<string | null>(null);
|
||||
|
||||
@@ -67,7 +71,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function applyUiScale(scale: number) {
|
||||
try {
|
||||
await appWebview.setZoom(scale);
|
||||
} catch (e) {
|
||||
console.error('Failed to apply interface scale to note window:', e);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
unlistenUiScale = await listen<number>('ui-scale-changed', (event) => {
|
||||
void applyUiScale(event.payload);
|
||||
});
|
||||
await applyUiScale($appConfig?.ui_scale ?? 1);
|
||||
|
||||
try {
|
||||
const content = await readNote(notePath);
|
||||
$activeNote = content;
|
||||
@@ -99,6 +116,7 @@
|
||||
|
||||
onDestroy(() => {
|
||||
unlistenFileChange?.();
|
||||
unlistenUiScale?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -28,6 +28,27 @@ function pathKey(path: string): string {
|
||||
return path.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
export function buildNoteSwitcherRequestPaths(
|
||||
currentPath: string | null,
|
||||
historyPaths: readonly string[]
|
||||
): string[] {
|
||||
const paths: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const addPath = (path: string | null) => {
|
||||
if (!path) return;
|
||||
const key = pathKey(path);
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
paths.push(path);
|
||||
};
|
||||
|
||||
addPath(currentPath);
|
||||
for (let index = historyPaths.length - 1; index >= 0; index -= 1) {
|
||||
addPath(historyPaths[index]);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function folderLabel(relativePath: string): string {
|
||||
const parts = relativePath.replace(/\\/g, '/').split('/').filter(Boolean);
|
||||
parts.pop();
|
||||
@@ -66,9 +87,9 @@ export function buildNoteSwitcherSections({
|
||||
recent.push(toRow(note, currentPathKey));
|
||||
};
|
||||
|
||||
addRecent(currentPath);
|
||||
for (let index = historyPaths.length - 1; index >= 0 && recent.length < limit; index -= 1) {
|
||||
addRecent(historyPaths[index]);
|
||||
for (const path of buildNoteSwitcherRequestPaths(currentPath, historyPaths)) {
|
||||
if (recent.length >= limit) break;
|
||||
addRecent(path);
|
||||
}
|
||||
|
||||
const quickAccess: NoteSwitcherRow[] = [];
|
||||
|
||||
Reference in New Issue
Block a user