From 3453d406fc8ea660714c300c058057eb0be308fb Mon Sep 17 00:00:00 2001 From: Yuri Karamian Date: Mon, 23 Feb 2026 11:16:16 +0100 Subject: [PATCH] v1.1.6 - Multi-window, single-instance file handling, line height setting --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/capabilities/default.json | 4 +- src-tauri/src/commands.rs | 14 + src-tauri/src/lib.rs | 75 +++++- src-tauri/src/state.rs | 2 + src-tauri/src/types.rs | 3 + src-tauri/src/vault/frontmatter.rs | 29 ++- src-tauri/tauri.conf.json | 2 +- src/lib/api.ts | 8 + src/lib/components/AppLayout.svelte | 41 ++- src/lib/components/Editor.svelte | 45 +++- src/lib/components/NoteList.svelte | 7 + src/lib/components/NoteWindow.svelte | 326 ++++++++++++++++++++++++ src/lib/components/SettingsPanel.svelte | 42 ++- src/lib/types.ts | 1 + src/lib/utils/window.ts | 42 +++ src/routes/+page.svelte | 19 +- 19 files changed, 634 insertions(+), 32 deletions(-) create mode 100644 src/lib/components/NoteWindow.svelte create mode 100644 src/lib/utils/window.ts diff --git a/package.json b/package.json index 7fa004a..c992046 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "helixnotes", "private": true, "license": "AGPL-3.0-or-later", - "version": "1.1.5", + "version": "1.1.6", "type": "module", "scripts": { "dev": "vite dev", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7d45a17..2a968c5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1750,7 +1750,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "helixnotes" -version = "1.1.5" +version = "1.1.6" dependencies = [ "chrono", "dirs", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5c09616..0414936 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "helixnotes" -version = "1.1.5" +version = "1.1.6" description = "Local markdown note-taking app" authors = ["HelixNotes"] license = "AGPL-3.0-or-later" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 65f7196..e25a3fc 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "HelixNotes default capabilities", - "windows": ["main"], + "windows": ["main", "note-*"], "permissions": [ "core:default", "core:window:default", @@ -12,6 +12,8 @@ "core:window:allow-close", "core:window:allow-is-maximized", "core:window:allow-set-decorations", + "core:window:allow-set-title", + "core:webview:allow-create-webview-window", "dialog:default", "dialog:allow-open", "dialog:allow-save", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a6847cd..4a5a962 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -45,6 +45,12 @@ pub fn get_app_config(state: State<'_, AppState>) -> Result { Ok(config.clone()) } +#[tauri::command] +pub fn get_pending_open_file(state: State<'_, AppState>) -> Result, String> { + let mut pending = state.pending_open_file.lock().map_err(|e| e.to_string())?; + Ok(pending.take()) +} + #[tauri::command] pub fn set_theme(state: State<'_, AppState>, theme: String) -> Result<(), String> { let mut config = state.config.lock().map_err(|e| e.to_string())?; @@ -77,6 +83,14 @@ pub fn set_font_family(state: State<'_, AppState>, family: String) -> Result<(), Ok(()) } +#[tauri::command] +pub fn set_line_height(state: State<'_, AppState>, height: f64) -> Result<(), String> { + let mut config = state.config.lock().map_err(|e| e.to_string())?; + config.line_height = Some(height); + save_app_config(&config)?; + Ok(()) +} + // ── Notebooks ── #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 35c64f8..3aec366 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,7 +9,7 @@ mod vault; use state::AppState; #[allow(unused_imports)] -use tauri::Manager; +use tauri::{Emitter, Manager}; #[cfg(not(target_os = "android"))] use tauri::{ @@ -59,6 +59,28 @@ pub fn run() { setup_tray(app)?; } + // Check CLI args for a .md file path on initial launch + #[cfg(not(target_os = "android"))] + { + let file_arg = std::env::args().skip(1).find(|a| { + let a = a.trim(); + !a.starts_with('-') && a.ends_with(".md") + }); + if let Some(path) = file_arg { + let resolved = if std::path::Path::new(&path).is_absolute() { + std::path::PathBuf::from(&path) + } else { + std::env::current_dir().unwrap_or_default().join(&path) + }; + if let Some(resolved_str) = resolved.to_str() { + let app_state = app.state::(); + let _ = app_state.pending_open_file.lock().map(|mut p| { + *p = Some(resolved_str.to_string()); + }); + } + } + } + Ok(()) }) .manage(app_state) @@ -69,6 +91,7 @@ pub fn run() { commands::set_accent_color, commands::set_font_size, commands::set_font_family, + commands::set_line_height, commands::get_notebooks, commands::create_notebook, commands::rename_notebook, @@ -117,28 +140,62 @@ pub fn run() { commands::test_ai_connection, commands::ai_ask, commands::get_install_type, + commands::get_pending_open_file, ]); #[cfg(not(target_os = "android"))] { - builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, cwd| { + // Always show/focus the main window if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.unminimize(); let _ = window.set_focus(); } + + // Extract .md file path from args (args[0] is the binary) + let file_path = args.iter().skip(1).find(|arg| { + let a = arg.trim(); + !a.starts_with('-') && a.ends_with(".md") + }); + + if let Some(path) = file_path { + let resolved = if std::path::Path::new(path.as_str()).is_absolute() { + std::path::PathBuf::from(path) + } else { + std::path::Path::new(&cwd).join(path) + }; + if let Some(resolved_str) = resolved.to_str() { + let _ = app.emit("open-file", resolved_str.to_string()); + } + } })); builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); - if close_to_tray { - builder = builder.on_window_event(|window, event| { - if let tauri::WindowEvent::CloseRequested { api, .. } = event { - api.prevent_close(); - let _ = window.hide(); + builder = builder.on_window_event(move |window, event| { + match event { + tauri::WindowEvent::CloseRequested { api, .. } => { + // Only hide to tray for the main window + if close_to_tray && window.label() == "main" { + api.prevent_close(); + let _ = window.hide(); + } } - }); - } + tauri::WindowEvent::Destroyed => { + // When main window is destroyed, close all note windows + if window.label() == "main" { + let app = window.app_handle(); + for (label, win) in app.webview_windows() { + if label.starts_with("note-") { + let _ = win.close(); + } + } + } + } + _ => {} + } + }); } builder diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index fde9643..c949b94 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -9,6 +9,7 @@ pub struct AppState { pub search_index: Mutex>, pub watcher: Mutex>, pub importing: AtomicBool, + pub pending_open_file: Mutex>, } impl AppState { @@ -18,6 +19,7 @@ impl AppState { search_index: Mutex::new(None), watcher: Mutex::new(None), importing: AtomicBool::new(false), + pending_open_file: Mutex::new(None), } } } diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 8c53cef..0b3e858 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -54,6 +54,8 @@ pub struct AppConfig { #[serde(default)] pub font_family: Option, #[serde(default)] + pub line_height: Option, + #[serde(default)] pub compact_notes: bool, #[serde(default)] pub time_format: String, @@ -142,6 +144,7 @@ impl Default for AppConfig { accent_color: None, font_size: None, font_family: None, + line_height: None, compact_notes: false, time_format: "relative".to_string(), gpu_acceleration: true, diff --git a/src-tauri/src/vault/frontmatter.rs b/src-tauri/src/vault/frontmatter.rs index bf5a422..3e908f3 100644 --- a/src-tauri/src/vault/frontmatter.rs +++ b/src-tauri/src/vault/frontmatter.rs @@ -77,6 +77,29 @@ pub fn parse_note(raw: &str, filename: &str) -> (NoteMeta, String) { (meta, content) } +/// Ensure the body starts with `# Title` heading for external app compatibility. +/// If the body already starts with `# Title`, leave it as-is. +fn ensure_title_heading(body: &str, title: &str) -> String { + let trimmed = body.trim_start(); + + // Check if body already starts with `# Title` + if let Some(rest) = trimmed.strip_prefix("# ") { + let first_line_end = rest.find('\n').unwrap_or(rest.len()); + let heading_text = rest[..first_line_end].trim(); + if heading_text.eq_ignore_ascii_case(title.trim()) { + return body.to_string(); + } + } + + // Don't add heading to empty notes (new/blank notes) + if trimmed.is_empty() { + return body.to_string(); + } + + // Prepend `# Title` heading + format!("# {}\n\n{}", title, body) +} + pub fn serialize_frontmatter(meta: &NoteMeta) -> String { let tags_str = if meta.tags.is_empty() { "[]".to_string() @@ -104,7 +127,8 @@ pub fn serialize_frontmatter(meta: &NoteMeta) -> String { pub fn update_note_raw(meta: &NoteMeta, body: &str) -> String { let fm = serialize_frontmatter(meta); - format!("{}{}", fm, body) + let body_with_heading = ensure_title_heading(body, &meta.title); + format!("{}{}", fm, body_with_heading) } /// Merge NoteMeta fields into existing frontmatter, preserving unknown YAML keys. @@ -163,7 +187,8 @@ pub fn merge_frontmatter(original_raw: &str, meta: &NoteMeta, body: &str) -> Str Err(_) => return update_note_raw(meta, body), }; - format!("---\n{}---\n{}", yaml_str, body) + let body_with_heading = ensure_title_heading(body, &meta.title); + format!("---\n{}---\n{}", yaml_str, body_with_heading) } fn filename_to_title(filename: &str) -> String { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 155f1f6..96e02ef 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "HelixNotes", - "version": "1.1.5", + "version": "1.1.6", "identifier": "com.helixnotes.app", "build": { "frontendDist": "../build", diff --git a/src/lib/api.ts b/src/lib/api.ts index b64ae96..b0b91ef 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -38,6 +38,10 @@ export async function setFontFamily(family: string): Promise { return invoke("set_font_family", { family }); } +export async function setLineHeight(height: number): Promise { + return invoke("set_line_height", { height }); +} + export async function getNotebooks(): Promise { return invoke("get_notebooks"); } @@ -330,3 +334,7 @@ export async function aiAsk( export async function getInstallType(): Promise { return invoke("get_install_type"); } + +export async function getPendingOpenFile(): Promise { + return invoke("get_pending_open_file"); +} diff --git a/src/lib/components/AppLayout.svelte b/src/lib/components/AppLayout.svelte index 011fe0e..cb78d06 100644 --- a/src/lib/components/AppLayout.svelte +++ b/src/lib/components/AppLayout.svelte @@ -28,14 +28,16 @@ showInfo, showSettings, sourceMode, - mobileView + mobileView, + appConfig } from '$lib/stores/app'; const appWindow = getCurrentWindow(); const isMac = navigator.platform.startsWith('Mac'); const isMobile = /android|ios/i.test(navigator.userAgent); - import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup } from '$lib/api'; + import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile } from '$lib/api'; import { debounce } from '$lib/utils/debounce'; + import { openNoteWindow } from '$lib/utils/window'; import { get } from 'svelte/store'; import type { VaultState, FileEvent } from '$lib/types'; @@ -43,6 +45,7 @@ let noteList: NoteList; let editor: Editor; let unlistenFileChange: (() => void) | null = null; + let unlistenOpenFile: (() => void) | null = null; let backupInterval: ReturnType | null = null; let navigatingFromHistory = false; let noteHistory: string[] = []; @@ -104,6 +107,22 @@ }); } + async function handleOpenFile(filePath: string) { + if (!filePath || !filePath.endsWith('.md')) return; + const config = get(appConfig); + const vaultRoot = config?.active_vault; + if (!vaultRoot || !filePath.startsWith(vaultRoot)) return; + try { + const content = await readNote(filePath); + $activeNote = content; + $activeNotePath = filePath; + $editorDirty = false; + editor?.loadNote(filePath, content.content); + } catch (e) { + console.error('Failed to open file:', e); + } + } + const persistState = debounce(async () => { const state: VaultState = { last_open_note: null, @@ -219,6 +238,12 @@ e.preventDefault(); $sourceMode = !$sourceMode; } + if (mod && e.shiftKey && e.key === 'W') { + e.preventDefault(); + if ($activeNotePath && $activeNote) { + openNoteWindow($activeNotePath, $activeNote.meta.title); + } + } if (e.key === 'Escape') { if ($showSettings) $showSettings = false; else if ($showInfo) $showInfo = false; @@ -268,6 +293,17 @@ await noteList?.refresh(true); }); + // Listen for file open events (from single-instance or OS file associations) + unlistenOpenFile = await listen('open-file', async (event) => { + await handleOpenFile(event.payload); + }); + + // Check for pending file from first-launch CLI args + try { + const pending = await getPendingOpenFile(); + if (pending) await handleOpenFile(pending); + } catch (_) {} + // Scheduled backup: check on startup and every 5 minutes checkScheduledBackup(); backupInterval = setInterval(checkScheduledBackup, 5 * 60 * 1000); @@ -275,6 +311,7 @@ onDestroy(() => { unlistenFileChange?.(); + unlistenOpenFile?.(); if (backupInterval) clearInterval(backupInterval); }); diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index d4563b2..e2d2769 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -39,7 +39,7 @@ import { openFile, copyFileTo } from '$lib/api'; import { save as saveDialog } from '@tauri-apps/plugin-dialog'; import { activeNote, activeNotePath, appConfig, editorDirty, sourceMode, focusMode, readOnly, quickAccessPaths, notes } from '$lib/stores/app'; - import { saveNote, saveImage, saveAttachment, addQuickAccess, removeQuickAccess, getQuickAccess, getNoteVersions, getNoteVersionContent, createVersion, aiAsk, getAllNoteTitles, readNote } from '$lib/api'; + import { saveNote, saveImage, saveAttachment, addQuickAccess, removeQuickAccess, getQuickAccess, getNoteVersions, getNoteVersionContent, createVersion, aiAsk, getAllNoteTitles, readNote, renameNote } from '$lib/api'; import type { VersionEntry, AiStreamEvent, NoteTitleEntry } from '$lib/types'; import { listen } from '@tauri-apps/api/event'; import { debounce } from '$lib/utils/debounce'; @@ -2941,16 +2941,37 @@ editor?.commands.focus('start'); } }} - onchange={(e) => { - if ($activeNote) { - const newTitle = (e.target as HTMLInputElement).value; + onchange={async (e) => { + if ($activeNote && $activeNotePath) { + const newTitle = (e.target as HTMLInputElement).value.trim(); + if (!newTitle) return; + const oldPath = $activeNotePath; $activeNote.meta.title = newTitle; - // Update the note list entry so the 2nd panel reflects the change - notes.update(list => list.map(n => - n.path === $activeNotePath ? { ...n, meta: { ...n.meta, title: newTitle } } : n - )); $editorDirty = true; autoSave(); + // Rename file on disk if filename doesn't match the new title + const filename = oldPath.split('/').pop() ?? ''; + const stem = filename.replace(/\.md$/, ''); + if (stem !== newTitle) { + try { + const newPath = await renameNote(oldPath, newTitle); + $activeNotePath = newPath; + notes.update(list => list.map(n => + n.path === oldPath + ? { ...n, path: newPath, relative_path: n.relative_path.replace(/[^/]+$/, newTitle + '.md'), meta: { ...n.meta, title: newTitle } } + : n + )); + } catch (err) { + console.error('Failed to rename note file:', err); + notes.update(list => list.map(n => + n.path === oldPath ? { ...n, meta: { ...n.meta, title: newTitle } } : n + )); + } + } else { + notes.update(list => list.map(n => + n.path === oldPath ? { ...n, meta: { ...n.meta, title: newTitle } } : n + )); + } } }} /> @@ -4817,7 +4838,7 @@ color: var(--text-primary); font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; font-size: var(--editor-font-size, 14px); - line-height: 1.6; + line-height: var(--editor-line-height, 1.6); resize: none; outline: none; padding: 0; @@ -4845,7 +4866,7 @@ padding-top: 8px; font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; font-size: var(--editor-font-size, 14px); - line-height: 1.6; + line-height: var(--editor-line-height, 1.6); color: var(--text-secondary); opacity: 0.5; text-align: right; @@ -4874,7 +4895,7 @@ :global(.tiptap-wrapper .tiptap p) { margin: 0 0 0.75em; - line-height: 1.65; + line-height: var(--editor-line-height, 1.65); } :global(.tiptap-wrapper .tiptap h1) { @@ -5192,7 +5213,7 @@ :global(.tiptap-wrapper .tiptap li) { margin: 0.25em 0; - line-height: 1.65; + line-height: var(--editor-line-height, 1.65); } :global(.tiptap-wrapper .tiptap li p) { diff --git a/src/lib/components/NoteList.svelte b/src/lib/components/NoteList.svelte index e1c268a..86940cc 100644 --- a/src/lib/components/NoteList.svelte +++ b/src/lib/components/NoteList.svelte @@ -32,6 +32,7 @@ getAllTags } from '$lib/api'; import { formatRelativeTime } from '$lib/utils/time'; + import { openNoteWindow } from '$lib/utils/window'; import type { NoteEntry, SortMode } from '$lib/types'; let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {} }: { @@ -936,6 +937,12 @@ Add to Quick Access {/if} + + + + {#if !isMac} +
+ + + +
+ {/if} + + + {#if loadError} +
+

Failed to load note

+

{loadError}

+
+ {:else} +
+ +
+ {/if} + + + diff --git a/src/lib/components/SettingsPanel.svelte b/src/lib/components/SettingsPanel.svelte index d30989c..ac2442d 100644 --- a/src/lib/components/SettingsPanel.svelte +++ b/src/lib/components/SettingsPanel.svelte @@ -1,6 +1,6 @@