mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-20 09:57:29 +02:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32ef51161d | ||
|
|
23abd0a19a | ||
|
|
5f726eb01f | ||
|
|
73f7604187 |
@@ -462,11 +462,19 @@ pub fn set_line_height(state: State<'_, AppState>, height: f64) -> Result<(), St
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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())?;
|
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
config.ui_scale = Some(scale);
|
config.ui_scale = Some(scale);
|
||||||
save_app_config(&config)?;
|
save_app_config(&config)?;
|
||||||
Ok(())
|
drop(config);
|
||||||
|
|
||||||
|
use tauri::Emitter;
|
||||||
|
app.emit("ui-scale-changed", scale)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -799,6 +807,26 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result<Vec<NoteTitleEn
|
|||||||
Ok(entries)
|
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 ──
|
// ── Graph ──
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1502,10 +1530,18 @@ pub fn set_general_settings(
|
|||||||
// ── Quick Access ──
|
// ── Quick Access ──
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_quick_access(state: State<'_, AppState>) -> Result<Vec<NoteEntry>, String> {
|
pub async 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 = state
|
||||||
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
|
.config
|
||||||
operations::get_quick_access_notes(vault_path)
|
.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]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ pub fn run() {
|
|||||||
commands::move_note,
|
commands::move_note,
|
||||||
commands::get_all_tags,
|
commands::get_all_tags,
|
||||||
commands::get_all_note_titles,
|
commands::get_all_note_titles,
|
||||||
|
commands::get_note_switcher_titles,
|
||||||
commands::get_graph_data,
|
commands::get_graph_data,
|
||||||
commands::get_tasks,
|
commands::get_tasks,
|
||||||
commands::set_task_done,
|
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 crate::vault::frontmatter;
|
||||||
use chrono::{DateTime, Local, Locale, Utc};
|
use chrono::{DateTime, Local, Locale, Utc};
|
||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
@@ -1279,6 +1283,59 @@ pub fn remove_quick_access(vault_path: &str, note_relative: &str) -> Result<(),
|
|||||||
Ok(())
|
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> {
|
pub fn get_quick_access_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String> {
|
||||||
let list = load_quick_access(vault_path)?;
|
let list = load_quick_access(vault_path)?;
|
||||||
let vault_root = Path::new(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 {
|
for relative in &list {
|
||||||
let full_path = vault_root.join(relative);
|
let full_path = vault_root.join(relative);
|
||||||
if full_path.exists() {
|
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);
|
notes.push(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1309,7 +1366,10 @@ pub fn sanitize_filename(name: &str) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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 std::fs;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -1333,6 +1393,70 @@ mod tests {
|
|||||||
fs::remove_dir_all(vault).unwrap();
|
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]
|
#[test]
|
||||||
fn duplicates_note_content_and_assigns_unique_identity_and_name() {
|
fn duplicates_note_content_and_assigns_unique_identity_and_name() {
|
||||||
let vault =
|
let vault =
|
||||||
|
|||||||
@@ -179,6 +179,12 @@ export async function getAllNoteTitles(): Promise<NoteTitleEntry[]> {
|
|||||||
return invoke("get_all_note_titles");
|
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 }[] }> {
|
export async function getGraphData(): Promise<{ nodes: { title: string; path: string }[]; edges: { source: number; target: number }[] }> {
|
||||||
return invoke("get_graph_data");
|
return invoke("get_graph_data");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
import { debounce } from '$lib/utils/debounce';
|
import { debounce } from '$lib/utils/debounce';
|
||||||
import { encryptSecretText, decryptSecretText, readSecretTitle } from '$lib/utils/secrets';
|
import { encryptSecretText, decryptSecretText, readSecretTitle } from '$lib/utils/secrets';
|
||||||
import { WrapSelectedText } from '$lib/editor/extensions/wrapSelectedText';
|
import { WrapSelectedText } from '$lib/editor/extensions/wrapSelectedText';
|
||||||
|
import { CodeBlockInputScroll } from '$lib/editor/extensions/codeBlockInputScroll';
|
||||||
import { calloutGroup, calloutIcon, calloutLabel, CALLOUT_MENU, transformCalloutBlockquotes, serializeCallout } from '$lib/editor/callouts';
|
import { calloutGroup, calloutIcon, calloutLabel, CALLOUT_MENU, transformCalloutBlockquotes, serializeCallout } from '$lib/editor/callouts';
|
||||||
import { wrapTextareaSelection } from '$lib/editor/source/selectionPairs';
|
import { wrapTextareaSelection } from '$lib/editor/source/selectionPairs';
|
||||||
import { convertListNode, type MixedListName } from '$lib/editor/mixedLists';
|
import { convertListNode, type MixedListName } from '$lib/editor/mixedLists';
|
||||||
@@ -4396,6 +4397,7 @@
|
|||||||
TextStyle,
|
TextStyle,
|
||||||
Color,
|
Color,
|
||||||
CodeBlockLowlight.configure({ lowlight, enableTabIndentation: true, defaultLanguage: 'text' }),
|
CodeBlockLowlight.configure({ lowlight, enableTabIndentation: true, defaultLanguage: 'text' }),
|
||||||
|
CodeBlockInputScroll,
|
||||||
CodeBlockLanguageSelect,
|
CodeBlockLanguageSelect,
|
||||||
CopyButtonExtension,
|
CopyButtonExtension,
|
||||||
MermaidRenderer,
|
MermaidRenderer,
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { showInfo } from '$lib/stores/app';
|
import { showInfo } from '$lib/stores/app';
|
||||||
import { getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
|
||||||
import type { OrphanAttachment } from '$lib/api';
|
|
||||||
import { openUrl } from '$lib/api';
|
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 {
|
import {
|
||||||
ACTIONS,
|
ACTIONS,
|
||||||
keybindings,
|
keybindings,
|
||||||
@@ -84,13 +81,8 @@
|
|||||||
if (capturingId === id) stopCapture();
|
if (capturingId === id) stopCapture();
|
||||||
}
|
}
|
||||||
|
|
||||||
let stats = $state<VaultStats | null>(null);
|
|
||||||
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
|
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
|
||||||
let appVersion = $state('...');
|
let appVersion = $state('...');
|
||||||
let orphans = $state<OrphanAttachment[] | null>(null);
|
|
||||||
let scanning = $state(false);
|
|
||||||
let trashing = $state(false);
|
|
||||||
const orphanTotal = $derived((orphans ?? []).reduce((a, o) => a + o.size, 0));
|
|
||||||
|
|
||||||
getVersion().then(v => appVersion = v).catch(() => appVersion = '0.0.0');
|
getVersion().then(v => appVersion = v).catch(() => appVersion = '0.0.0');
|
||||||
|
|
||||||
@@ -103,38 +95,7 @@
|
|||||||
openUrl(url).catch(console.error);
|
openUrl(url).catch(console.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if ($showInfo) {
|
|
||||||
getVaultStats().then((s) => { stats = s; }).catch(console.error);
|
|
||||||
} else {
|
|
||||||
stats = null;
|
|
||||||
orphans = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function scanOrphans() {
|
|
||||||
scanning = true;
|
|
||||||
try { orphans = await findOrphanedAttachments(); }
|
|
||||||
catch (e) { console.error(e); }
|
|
||||||
finally { scanning = false; }
|
|
||||||
}
|
|
||||||
async function trashOrphans() {
|
|
||||||
if (!orphans || orphans.length === 0) return;
|
|
||||||
trashing = true;
|
|
||||||
try {
|
|
||||||
await trashOrphanedAttachments(orphans.map((o) => o.name));
|
|
||||||
orphans = null;
|
|
||||||
getVaultStats().then((sv) => { stats = sv; }).catch(console.error);
|
|
||||||
} catch (e) { console.error(e); }
|
|
||||||
finally { trashing = false; }
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $showInfo}
|
{#if $showInfo}
|
||||||
@@ -176,49 +137,7 @@
|
|||||||
<p class="app-version">v{appVersion}</p>
|
<p class="app-version">v{appVersion}</p>
|
||||||
<p class="app-description">A local markdown note-taking app.</p>
|
<p class="app-description">A local markdown note-taking app.</p>
|
||||||
|
|
||||||
{#if stats}
|
|
||||||
<div class="info-stats">
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label">Notes</span>
|
|
||||||
<span class="stat-value">{stats.total_notes}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label">Attachments</span>
|
|
||||||
<span class="stat-value">{stats.total_attachments}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label">Notes size</span>
|
|
||||||
<span class="stat-value">{formatSize(stats.notes_size)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-row">
|
|
||||||
<span class="stat-label">Attachments size</span>
|
|
||||||
<span class="stat-value">{formatSize(stats.attachments_size)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-row stat-total">
|
|
||||||
<span class="stat-label">Total vault size</span>
|
|
||||||
<span class="stat-value">{formatSize(stats.total_size)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if stats}
|
|
||||||
<div class="info-cleanup">
|
|
||||||
{#if orphans === null}
|
|
||||||
<button class="cleanup-btn" onclick={scanOrphans} disabled={scanning}>{#if scanning}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Scanning…{:else}Find orphaned attachments{/if}</button>
|
|
||||||
{:else if orphans.length === 0}
|
|
||||||
<p class="cleanup-msg">No orphaned attachments. Nothing to clean up.</p>
|
|
||||||
{:else}
|
|
||||||
<p class="cleanup-msg">{orphans.length} {orphans.length === 1 ? 'attachment is' : 'attachments are'} not referenced by any note ({formatSize(orphanTotal)}).</p>
|
|
||||||
<div class="cleanup-list">
|
|
||||||
{#each orphans as o}
|
|
||||||
<div class="cleanup-row"><span class="cleanup-name" title={o.name}>{o.name}</span><span class="cleanup-size">{formatSize(o.size)}</span></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<button class="cleanup-btn" onclick={trashOrphans} disabled={trashing}>{#if trashing}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Moving…{:else}Move {orphans.length} to Trash{/if}</button>
|
|
||||||
<p class="cleanup-hint">Moved to the vault's trash folder (recoverable), not permanently deleted.</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="info-credits">
|
<div class="info-credits">
|
||||||
<p>Created by <strong>Yuri Karamian</strong></p>
|
<p>Created by <strong>Yuri Karamian</strong></p>
|
||||||
@@ -417,39 +336,6 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-stats {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 20px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-light);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 7px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-total {
|
|
||||||
border-top: 1px solid var(--border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-total .stat-value {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-credits {
|
.info-credits {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
@@ -481,71 +367,6 @@
|
|||||||
.info-link:hover {
|
.info-link:hover {
|
||||||
background: var(--accent-light);
|
background: var(--accent-light);
|
||||||
}
|
}
|
||||||
.info-cleanup {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.cleanup-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 7px 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.cleanup-btn:hover:not(:disabled) { background: var(--bg-hover); }
|
|
||||||
.cleanup-btn:disabled { opacity: 0.6; cursor: default; }
|
|
||||||
.cleanup-msg {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.cleanup-list {
|
|
||||||
width: 100%;
|
|
||||||
max-height: 140px;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-light);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
.cleanup-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 5px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.cleanup-name {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.cleanup-size {
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.cleanup-hint {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.spinner-icon {
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcuts-section {
|
.shortcuts-section {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import { activeNote, activeNotePath, appConfig, navHistory } from '$lib/stores/app';
|
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 { openNoteWindow } from '$lib/utils/window';
|
||||||
import {
|
import {
|
||||||
|
buildNoteSwitcherRequestPaths,
|
||||||
buildNoteSwitcherSections,
|
buildNoteSwitcherSections,
|
||||||
type NoteSwitcherNote,
|
type NoteSwitcherNote,
|
||||||
type NoteSwitcherRow,
|
type NoteSwitcherRow,
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
let selectingPath = $state<string | null>(null);
|
let selectingPath = $state<string | null>(null);
|
||||||
let sections = $state<NoteSwitcherSections>({ recent: [], quickAccess: [] });
|
let sections = $state<NoteSwitcherSections>({ recent: [], quickAccess: [] });
|
||||||
let loadGeneration = 0;
|
let loadGeneration = 0;
|
||||||
|
let loadedVaultPath = $state<string | null | undefined>(undefined);
|
||||||
|
|
||||||
function normalizedPath(path: string): string {
|
function normalizedPath(path: string): string {
|
||||||
return path.replace(/\\/g, '/').replace(/\/$/, '');
|
return path.replace(/\\/g, '/').replace(/\/$/, '');
|
||||||
@@ -71,13 +73,15 @@
|
|||||||
const vaultPath = $appConfig?.active_vault;
|
const vaultPath = $appConfig?.active_vault;
|
||||||
if (!vaultPath) {
|
if (!vaultPath) {
|
||||||
sections = { recent: [], quickAccess: [] };
|
sections = { recent: [], quickAccess: [] };
|
||||||
|
loadedVaultPath = null;
|
||||||
loading = false;
|
loading = false;
|
||||||
await focusInitialRow();
|
await focusInitialRow();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestPaths = buildNoteSwitcherRequestPaths($activeNotePath, $navHistory.stack);
|
||||||
const [titlesResult, quickAccessResult] = await Promise.allSettled([
|
const [titlesResult, quickAccessResult] = await Promise.allSettled([
|
||||||
getAllNoteTitles(),
|
getNoteSwitcherTitles(requestPaths),
|
||||||
getQuickAccess()
|
getQuickAccess()
|
||||||
]);
|
]);
|
||||||
if (!open || generation !== loadGeneration) return;
|
if (!open || generation !== loadGeneration) return;
|
||||||
@@ -111,6 +115,11 @@
|
|||||||
.map((entry) => quickAccessEntryToNote(entry, vaultPath))
|
.map((entry) => quickAccessEntryToNote(entry, vaultPath))
|
||||||
.filter((entry): entry is NoteSwitcherNote => entry !== null)
|
.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({
|
sections = buildNoteSwitcherSections({
|
||||||
currentPath,
|
currentPath,
|
||||||
@@ -118,22 +127,37 @@
|
|||||||
knownNotes,
|
knownNotes,
|
||||||
quickAccessNotes
|
quickAccessNotes
|
||||||
});
|
});
|
||||||
|
loadedVaultPath = vaultPath;
|
||||||
loading = false;
|
loading = false;
|
||||||
await focusInitialRow();
|
await focusInitialRow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshAfterPopoverPaint(generation: number) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (!open || generation !== loadGeneration) return;
|
||||||
|
void refreshSections(generation);
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function toggleSwitcher() {
|
function toggleSwitcher() {
|
||||||
if (open) {
|
if (open) {
|
||||||
open = false;
|
open = false;
|
||||||
loadGeneration += 1;
|
loadGeneration += 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const vaultPath = $appConfig?.active_vault ?? null;
|
||||||
|
const hasCachedSections = loadedVaultPath === vaultPath;
|
||||||
open = true;
|
open = true;
|
||||||
loading = true;
|
loading = !hasCachedSections;
|
||||||
selectingPath = null;
|
selectingPath = null;
|
||||||
sections = { recent: [], quickAccess: [] };
|
if (!hasCachedSections) sections = { recent: [], quickAccess: [] };
|
||||||
loadGeneration += 1;
|
loadGeneration += 1;
|
||||||
void refreshSections(loadGeneration);
|
const generation = loadGeneration;
|
||||||
|
if (hasCachedSections) void focusInitialRow();
|
||||||
|
refreshAfterPopoverPaint(generation);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function closeSwitcher(restoreTriggerFocus: boolean) {
|
async function closeSwitcher(restoreTriggerFocus: boolean) {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import Editor from './Editor.svelte';
|
import Editor from './Editor.svelte';
|
||||||
import {
|
import {
|
||||||
|
appConfig,
|
||||||
activeNote,
|
activeNote,
|
||||||
activeNotePath,
|
activeNotePath,
|
||||||
editorDirty,
|
editorDirty,
|
||||||
@@ -17,9 +19,11 @@
|
|||||||
let { notePath }: { notePath: string } = $props();
|
let { notePath }: { notePath: string } = $props();
|
||||||
|
|
||||||
const appWindow = getCurrentWindow();
|
const appWindow = getCurrentWindow();
|
||||||
|
const appWebview = getCurrentWebview();
|
||||||
const isMac = navigator.platform.startsWith('Mac');
|
const isMac = navigator.platform.startsWith('Mac');
|
||||||
let editor = $state<Editor>(null!);
|
let editor = $state<Editor>(null!);
|
||||||
let unlistenFileChange: (() => void) | null = null;
|
let unlistenFileChange: (() => void) | null = null;
|
||||||
|
let unlistenUiScale: (() => void) | null = null;
|
||||||
let maximized = $state(false);
|
let maximized = $state(false);
|
||||||
let loadError = $state<string | null>(null);
|
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 () => {
|
onMount(async () => {
|
||||||
|
unlistenUiScale = await listen<number>('ui-scale-changed', (event) => {
|
||||||
|
void applyUiScale(event.payload);
|
||||||
|
});
|
||||||
|
await applyUiScale($appConfig?.ui_scale ?? 1);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await readNote(notePath);
|
const content = await readNote(notePath);
|
||||||
$activeNote = content;
|
$activeNote = content;
|
||||||
@@ -99,6 +116,7 @@
|
|||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
unlistenFileChange?.();
|
unlistenFileChange?.();
|
||||||
|
unlistenUiScale?.();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { showSettings, theme, resolvedTheme, appConfig, platformIsMobile, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
|
import { showSettings, theme, resolvedTheme, appConfig, platformIsMobile, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
|
||||||
import { setTheme, setSystemThemes, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes } from '$lib/api';
|
import { setTheme, setSystemThemes, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes, getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||||
import { darkThemes, isMobile, isAndroid } from '$lib/platform';
|
import { darkThemes, isMobile, isAndroid } from '$lib/platform';
|
||||||
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
|
import { open as openDialog, save as saveDialog } 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 { getCurrentWebview } from '@tauri-apps/api/webview';
|
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||||
import { openUrl } from '$lib/api';
|
import { openUrl } from '$lib/api';
|
||||||
import type { ImportResult, BackupEntry, CustomTheme, CustomThemeColors, StartupView } from '$lib/types';
|
import type { OrphanAttachment } from '$lib/api';
|
||||||
|
import type { ImportResult, BackupEntry, CustomTheme, CustomThemeColors, StartupView, VaultStats } from '$lib/types';
|
||||||
import { normalizeStartupView } from '$lib/utils/startup-view';
|
import { normalizeStartupView } from '$lib/utils/startup-view';
|
||||||
|
|
||||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
||||||
|
|
||||||
type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'ai' | 'sync' | 'updates';
|
type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'maintenance' | 'ai' | 'sync' | 'updates';
|
||||||
let activeTab = $state<Tab>('styling');
|
let activeTab = $state<Tab>('styling');
|
||||||
|
|
||||||
// Updates state
|
// Updates state
|
||||||
@@ -116,6 +117,67 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vault maintenance state
|
||||||
|
let vaultStats = $state<VaultStats | null>(null);
|
||||||
|
let vaultStatsError = $state(false);
|
||||||
|
let orphanedAttachments = $state<OrphanAttachment[] | null>(null);
|
||||||
|
let scanningOrphanedAttachments = $state(false);
|
||||||
|
let trashingOrphanedAttachments = $state(false);
|
||||||
|
const orphanedAttachmentTotal = $derived((orphanedAttachments ?? []).reduce((total, attachment) => total + attachment.size, 0));
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!$showSettings) {
|
||||||
|
vaultStats = null;
|
||||||
|
vaultStatsError = false;
|
||||||
|
orphanedAttachments = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activeTab === 'maintenance') void loadVaultStats();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadVaultStats() {
|
||||||
|
vaultStatsError = false;
|
||||||
|
try {
|
||||||
|
vaultStats = await getVaultStats();
|
||||||
|
} catch (error) {
|
||||||
|
vaultStats = null;
|
||||||
|
vaultStatsError = true;
|
||||||
|
console.error('Failed to load vault statistics:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVaultSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanOrphanedAttachments() {
|
||||||
|
scanningOrphanedAttachments = true;
|
||||||
|
try {
|
||||||
|
orphanedAttachments = await findOrphanedAttachments();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
scanningOrphanedAttachments = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trashFoundOrphanedAttachments() {
|
||||||
|
if (!orphanedAttachments || orphanedAttachments.length === 0) return;
|
||||||
|
trashingOrphanedAttachments = true;
|
||||||
|
try {
|
||||||
|
await trashOrphanedAttachments(orphanedAttachments.map((attachment) => attachment.name));
|
||||||
|
orphanedAttachments = null;
|
||||||
|
void loadVaultStats();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
trashingOrphanedAttachments = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Backup state
|
// Backup state
|
||||||
let backups = $state<BackupEntry[]>([]);
|
let backups = $state<BackupEntry[]>([]);
|
||||||
let backupLoading = $state(false);
|
let backupLoading = $state(false);
|
||||||
@@ -1114,6 +1176,12 @@
|
|||||||
Backup
|
Backup
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
<button class="tab-btn" class:active={activeTab === 'maintenance'} onclick={() => activeTab = 'maintenance'}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
Maintenance
|
||||||
|
</button>
|
||||||
<button class="tab-btn" class:active={activeTab === 'ai'} onclick={() => activeTab = 'ai'}>
|
<button class="tab-btn" class:active={activeTab === 'ai'} onclick={() => activeTab = 'ai'}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M12 8V4l-2-2"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M9 13v2"/><path d="M15 13v2"/>
|
<path d="M12 8V4l-2-2"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M9 13v2"/><path d="M15 13v2"/>
|
||||||
@@ -1291,6 +1359,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
{#if !isMobile}
|
{#if !isMobile}
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Performance</h3>
|
<h3>Performance</h3>
|
||||||
@@ -1339,6 +1408,78 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{:else if activeTab === 'maintenance'}
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Vault Overview</h3>
|
||||||
|
{#if vaultStats}
|
||||||
|
<div class="maintenance-stats">
|
||||||
|
<div class="maintenance-stat-row">
|
||||||
|
<span class="maintenance-stat-label">Notes</span>
|
||||||
|
<span class="maintenance-stat-value">{vaultStats.total_notes}</span>
|
||||||
|
</div>
|
||||||
|
<div class="maintenance-stat-row">
|
||||||
|
<span class="maintenance-stat-label">Attachments</span>
|
||||||
|
<span class="maintenance-stat-value">{vaultStats.total_attachments}</span>
|
||||||
|
</div>
|
||||||
|
<div class="maintenance-stat-row">
|
||||||
|
<span class="maintenance-stat-label">Notes size</span>
|
||||||
|
<span class="maintenance-stat-value">{formatVaultSize(vaultStats.notes_size)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="maintenance-stat-row">
|
||||||
|
<span class="maintenance-stat-label">Attachments size</span>
|
||||||
|
<span class="maintenance-stat-value">{formatVaultSize(vaultStats.attachments_size)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="maintenance-stat-row maintenance-stat-total">
|
||||||
|
<span class="maintenance-stat-label">Total vault size</span>
|
||||||
|
<span class="maintenance-stat-value">{formatVaultSize(vaultStats.total_size)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if vaultStatsError}
|
||||||
|
<p class="setting-desc">Vault statistics could not be loaded.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="setting-desc">Loading vault statistics…</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Orphaned Attachments</h3>
|
||||||
|
<div class="vault-maintenance">
|
||||||
|
<p class="setting-desc">Find files in the vault's attachments folder that are not referenced by any note.</p>
|
||||||
|
{#if orphanedAttachments === null}
|
||||||
|
<button class="import-btn cleanup-action" onclick={scanOrphanedAttachments} disabled={scanningOrphanedAttachments}>
|
||||||
|
{#if scanningOrphanedAttachments}
|
||||||
|
<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>
|
||||||
|
Scanning…
|
||||||
|
{:else}
|
||||||
|
Find orphaned attachments
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{:else if orphanedAttachments.length === 0}
|
||||||
|
<p class="cleanup-message">No orphaned attachments. Nothing to clean up.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="cleanup-message">{orphanedAttachments.length} {orphanedAttachments.length === 1 ? 'attachment is' : 'attachments are'} not referenced by any note ({formatVaultSize(orphanedAttachmentTotal)}).</p>
|
||||||
|
<div class="cleanup-list">
|
||||||
|
{#each orphanedAttachments as attachment}
|
||||||
|
<div class="cleanup-row">
|
||||||
|
<span class="cleanup-name" title={attachment.name}>{attachment.name}</span>
|
||||||
|
<span class="cleanup-size">{formatVaultSize(attachment.size)}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<button class="import-btn cleanup-action" onclick={trashFoundOrphanedAttachments} disabled={trashingOrphanedAttachments}>
|
||||||
|
{#if trashingOrphanedAttachments}
|
||||||
|
<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>
|
||||||
|
Moving…
|
||||||
|
{:else}
|
||||||
|
Move {orphanedAttachments.length} to Trash
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<p class="cleanup-hint">Moved to the vault's trash folder (recoverable), not permanently deleted.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{:else if activeTab === 'editor'}
|
{:else if activeTab === 'editor'}
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
@@ -2962,6 +3103,91 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.maintenance-stats {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maintenance-stat-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 7px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maintenance-stat-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.maintenance-stat-value {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.maintenance-stat-total {
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.maintenance-stat-total .maintenance-stat-value {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-maintenance {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cleanup-action {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cleanup-message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cleanup-list {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 140px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cleanup-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cleanup-name {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cleanup-size {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cleanup-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
.spinner-icon {
|
.spinner-icon {
|
||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Extension } from '@tiptap/core';
|
||||||
|
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||||
|
|
||||||
|
const CODE_FENCE_PATTERN = /^(?:```|~~~)(?:[a-z]+)?$/;
|
||||||
|
|
||||||
|
function isInputRulesPlugin(plugin: Plugin) {
|
||||||
|
return 'isInputRules' in plugin.spec && plugin.spec.isInputRules === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCodeBlockInputScrollPlugin() {
|
||||||
|
return new Plugin({
|
||||||
|
key: new PluginKey('codeBlockInputScroll'),
|
||||||
|
appendTransaction(transactions, oldState, newState) {
|
||||||
|
const inputRulesPlugin = oldState.plugins.find(isInputRulesPlugin);
|
||||||
|
const wasKeyboardInputRule =
|
||||||
|
inputRulesPlugin !== undefined &&
|
||||||
|
transactions.some((transaction) => {
|
||||||
|
const metadata: unknown = transaction.getMeta(inputRulesPlugin);
|
||||||
|
|
||||||
|
return (
|
||||||
|
typeof metadata === 'object' &&
|
||||||
|
metadata !== null &&
|
||||||
|
'transform' in metadata &&
|
||||||
|
metadata.transform === transaction &&
|
||||||
|
'text' in metadata &&
|
||||||
|
metadata.text === '\n'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const { selection } = oldState;
|
||||||
|
const wasFenceParagraph =
|
||||||
|
selection.empty &&
|
||||||
|
selection.$from.parent.type.name === 'paragraph' &&
|
||||||
|
CODE_FENCE_PATTERN.test(selection.$from.parent.textContent);
|
||||||
|
const isCodeBlock = newState.selection.$from.parent.type.name === 'codeBlock';
|
||||||
|
|
||||||
|
return wasKeyboardInputRule && wasFenceParagraph && isCodeBlock
|
||||||
|
? newState.tr.scrollIntoView()
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CodeBlockInputScroll = Extension.create({
|
||||||
|
name: 'codeBlockInputScroll',
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [createCodeBlockInputScrollPlugin()];
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -28,6 +28,27 @@ function pathKey(path: string): string {
|
|||||||
return path.replace(/\\/g, '/');
|
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 {
|
function folderLabel(relativePath: string): string {
|
||||||
const parts = relativePath.replace(/\\/g, '/').split('/').filter(Boolean);
|
const parts = relativePath.replace(/\\/g, '/').split('/').filter(Boolean);
|
||||||
parts.pop();
|
parts.pop();
|
||||||
@@ -66,9 +87,9 @@ export function buildNoteSwitcherSections({
|
|||||||
recent.push(toRow(note, currentPathKey));
|
recent.push(toRow(note, currentPathKey));
|
||||||
};
|
};
|
||||||
|
|
||||||
addRecent(currentPath);
|
for (const path of buildNoteSwitcherRequestPaths(currentPath, historyPaths)) {
|
||||||
for (let index = historyPaths.length - 1; index >= 0 && recent.length < limit; index -= 1) {
|
if (recent.length >= limit) break;
|
||||||
addRecent(historyPaths[index]);
|
addRecent(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
const quickAccess: NoteSwitcherRow[] = [];
|
const quickAccess: NoteSwitcherRow[] = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user