Compare commits

..
10 Commits
22 changed files with 245 additions and 149 deletions
+1 -1
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.3.3", "version": "1.3.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+2 -1
View File
@@ -1850,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "helixnotes" name = "helixnotes"
version = "1.3.3" version = "1.3.4"
dependencies = [ dependencies = [
"arboard", "arboard",
"chrono", "chrono",
@@ -1860,6 +1860,7 @@ dependencies = [
"image", "image",
"log", "log",
"notify", "notify",
"objc2-app-kit",
"png 0.17.16", "png 0.17.16",
"quick-xml 0.36.2", "quick-xml 0.36.2",
"rayon", "rayon",
+4 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "helixnotes" name = "helixnotes"
version = "1.3.3" version = "1.3.4"
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"
@@ -42,6 +42,9 @@ quick-xml = "0.36"
urlencoding = "2" urlencoding = "2"
sha2 = "0.10" sha2 = "0.10"
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
[target.'cfg(target_os = "macos")'.dependencies]
objc2-app-kit = "0.3.2"
[target.'cfg(target_os = "ios")'.dependencies] [target.'cfg(target_os = "ios")'.dependencies]
tauri-plugin-ios-vault-access = { path = "plugins/ios-vault-access" } tauri-plugin-ios-vault-access = { path = "plugins/ios-vault-access" }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_background">#fff</color> <color name="ic_launcher_background">#626ED4</color>
</resources> </resources>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(85.333 85.333) scale(0.666667)">
<line x1="178.45584" y1="178.45584" x2="333.54416" y2="333.54416" stroke="#ffffff" stroke-width="25" stroke-opacity="0.5" stroke-linecap="round"/>
<line x1="337.53708" y1="174.46292" x2="174.46294" y2="337.53708" stroke="#ffffff" stroke-width="25" stroke-opacity="0.5" stroke-linecap="round"/>
<circle cx="153" cy="153" r="36" fill="#ffffff"/>
<circle cx="359" cy="153" r="36" fill="#ffffff"/>
<circle cx="153" cy="359" r="36" fill="#ffffff"/>
<circle cx="359" cy="359" r="36" fill="#ffffff"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 706 B

+4 -12
View File
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::{ use tauri::{
plugin::{Builder, PluginApi, PluginHandle, TauriPlugin}, plugin::{Builder, PluginHandle, TauriPlugin},
AppHandle, Manager, Runtime, Manager, Runtime,
}; };
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@@ -73,19 +73,11 @@ impl<R: Runtime, T: Manager<R>> IosVaultAccessExt<R> for T {
} }
} }
fn initialize<R: Runtime>(
_app: &AppHandle<R>,
api: PluginApi<R, ()>,
) -> tauri::Result<IosVaultAccess<R>> {
let handle = api.register_ios_plugin(init_plugin_ios_vault_access)?;
Ok(IosVaultAccess(handle))
}
pub fn init<R: Runtime>() -> TauriPlugin<R> { pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("ios-vault-access") Builder::new("ios-vault-access")
.setup(|app, api| { .setup(|app, api| {
let access = initialize(app, api)?; let handle = api.register_ios_plugin(init_plugin_ios_vault_access)?;
app.manage(access); app.manage(IosVaultAccess(handle));
Ok(()) Ok(())
}) })
.build() .build()
+90 -47
View File
@@ -143,7 +143,12 @@ fn open_vault_path(
} }
#[tauri::command] #[tauri::command]
pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> Result<(), String> { pub async fn open_vault(
app: AppHandle,
state: State<'_, AppState>,
path: String,
) -> Result<(), String> {
let _transition = state.vault_transition.lock().await;
open_vault_path(app.clone(), &state, path, None)?; open_vault_path(app.clone(), &state, path, None)?;
#[cfg(target_os = "ios")] #[cfg(target_os = "ios")]
app.ios_vault_access().release_active()?; app.ios_vault_access().release_active()?;
@@ -155,6 +160,7 @@ pub async fn choose_external_vault(
app: AppHandle, app: AppHandle,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Option<ExternalVaultResult>, String> { ) -> Result<Option<ExternalVaultResult>, String> {
let _transition = state.vault_transition.lock().await;
#[cfg(target_os = "ios")] #[cfg(target_os = "ios")]
{ {
let picker_app = app.clone(); let picker_app = app.clone();
@@ -194,7 +200,7 @@ pub async fn choose_external_vault(
} }
#[cfg(not(target_os = "ios"))] #[cfg(not(target_os = "ios"))]
{ {
let _ = (app, state); let _ = (&app, &state);
Err("Files folders are only available on iOS.".to_string()) Err("Files folders are only available on iOS.".to_string())
} }
} }
@@ -205,6 +211,7 @@ pub async fn restore_external_vault(
state: State<'_, AppState>, state: State<'_, AppState>,
bookmark_id: String, bookmark_id: String,
) -> Result<ExternalVaultResult, String> { ) -> Result<ExternalVaultResult, String> {
let _transition = state.vault_transition.lock().await;
#[cfg(target_os = "ios")] #[cfg(target_os = "ios")]
{ {
let resolver_app = app.clone(); let resolver_app = app.clone();
@@ -228,7 +235,7 @@ pub async fn restore_external_vault(
} }
#[cfg(not(target_os = "ios"))] #[cfg(not(target_os = "ios"))]
{ {
let _ = (app, state, bookmark_id); let _ = (&app, &state, &bookmark_id);
Err("Files folders are only available on iOS.".to_string()) Err("Files folders are only available on iOS.".to_string())
} }
} }
@@ -240,9 +247,9 @@ pub async fn remove_vault(
path: String, path: String,
bookmark_id: Option<String>, bookmark_id: Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
let target = { let _transition = state.vault_transition.lock().await;
let config = state.config.lock().map_err(|error| error.to_string())?; let mut config = state.config.lock().map_err(|error| error.to_string())?;
let vault = if let Some(bookmark_id) = bookmark_id.as_deref() { let target = if let Some(bookmark_id) = bookmark_id.as_deref() {
config config
.vaults .vaults
.iter() .iter()
@@ -252,8 +259,8 @@ pub async fn remove_vault(
.vaults .vaults
.iter() .iter()
.find(|vault| vault.bookmark_id.is_none() && vault.path == path) .find(|vault| vault.bookmark_id.is_none() && vault.path == path)
}; }
vault.map(|vault| { .map(|vault| {
let is_active = if let Some(bookmark_id) = vault.bookmark_id.as_deref() { let is_active = if let Some(bookmark_id) = vault.bookmark_id.as_deref() {
config.active_bookmark_id.as_deref() == Some(bookmark_id) config.active_bookmark_id.as_deref() == Some(bookmark_id)
} else { } else {
@@ -261,18 +268,13 @@ pub async fn remove_vault(
&& config.active_vault.as_deref() == Some(vault.path.as_str()) && config.active_vault.as_deref() == Some(vault.path.as_str())
}; };
(vault.path.clone(), vault.bookmark_id.clone(), is_active) (vault.path.clone(), vault.bookmark_id.clone(), is_active)
}) });
};
let Some((target_path, target_bookmark, is_active)) = target else { let Some((target_path, target_bookmark, is_active)) = target else {
return Ok(()); return Ok(());
}; };
let old = state let old = config.clone();
.config
.lock()
.map_err(|error| error.to_string())?
.clone();
let mut next = old.clone(); let mut next = old.clone();
if let Some(id) = target_bookmark.as_deref() { if let Some(id) = target_bookmark.as_deref() {
next.vaults next.vaults
@@ -289,20 +291,7 @@ pub async fn remove_vault(
#[cfg(target_os = "ios")] #[cfg(target_os = "ios")]
if let Some(id) = target_bookmark.as_deref() { if let Some(id) = target_bookmark.as_deref() {
let forget_app = app.clone(); if let Err(error) = app.ios_vault_access().forget_bookmark(id) {
let id_to_forget = id.to_string();
let forget_result = match tauri::async_runtime::spawn_blocking(move || {
forget_app
.ios_vault_access()
.forget_bookmark(&id_to_forget)
})
.await
{
Ok(result) => result,
Err(error) => Err(error.to_string()),
};
if let Err(error) = forget_result {
return match save_app_config(&old) { return match save_app_config(&old) {
Ok(()) => Err(error), Ok(()) => Err(error),
Err(rollback_error) => Err(format!( Err(rollback_error) => Err(format!(
@@ -317,7 +306,7 @@ pub async fn remove_vault(
if is_active { if is_active {
clear_vault_runtime(&state)?; clear_vault_runtime(&state)?;
} }
*state.config.lock().map_err(|error| error.to_string())? = next; *config = next;
Ok(()) Ok(())
} }
@@ -403,11 +392,15 @@ pub fn import_custom_themes(state: State<'_, AppState>, path: String) -> Result<
} }
#[tauri::command] #[tauri::command]
pub fn set_font_size(state: State<'_, AppState>, size: u32) -> Result<(), String> { pub fn set_font_size(app: AppHandle, state: State<'_, AppState>, size: u32) -> 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.font_size = Some(size); config.font_size = Some(size);
save_app_config(&config)?; save_app_config(&config)?;
Ok(()) drop(config);
use tauri::Emitter;
app.emit("editor-font-size-changed", size)
.map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
@@ -2005,15 +1998,63 @@ pub fn test_ai_connection(app: AppHandle) -> Result<(), String> {
// ── Sync (WebDAV) ── // ── Sync (WebDAV) ──
fn active_vault_config(config: &AppConfig) -> Result<&VaultConfig, String> { fn vault_matches_identity(
vault: &VaultConfig,
path: &str,
bookmark_id: Option<&str>,
) -> bool {
if let Some(bookmark_id) = bookmark_id {
vault.bookmark_id.as_deref() == Some(bookmark_id)
} else {
vault.bookmark_id.is_none() && vault.path == path
}
}
fn active_vault_index(config: &AppConfig) -> Result<usize, String> {
let active = config.active_vault.as_deref().ok_or("No active vault")?; let active = config.active_vault.as_deref().ok_or("No active vault")?;
config config
.vaults .vaults
.iter() .iter()
.find(|v| v.path == active) .position(|vault| {
vault_matches_identity(vault, active, config.active_bookmark_id.as_deref())
})
.ok_or_else(|| "Active vault not found in config".to_string()) .ok_or_else(|| "Active vault not found in config".to_string())
} }
fn active_vault_config(config: &AppConfig) -> Result<&VaultConfig, String> {
Ok(&config.vaults[active_vault_index(config)?])
}
#[cfg(test)]
mod vault_identity_tests {
use super::*;
#[test]
fn bookmark_identity_disambiguates_vaults_with_the_same_path() {
let mut config = AppConfig::default();
config.active_vault = Some("/same/path".to_string());
config.vaults = vec![
VaultConfig {
path: "/same/path".to_string(),
name: "Local".to_string(),
..Default::default()
},
VaultConfig {
path: "/same/path".to_string(),
name: "Files".to_string(),
bookmark_id: Some("bookmark".to_string()),
..Default::default()
},
];
config.active_bookmark_id = Some("bookmark".to_string());
assert_eq!(active_vault_config(&config).unwrap().name, "Files");
config.active_bookmark_id = None;
assert_eq!(active_vault_config(&config).unwrap().name, "Local");
}
}
fn sync_config_from(config: &AppConfig) -> Result<crate::sync::WebdavConfig, String> { fn sync_config_from(config: &AppConfig) -> Result<crate::sync::WebdavConfig, String> {
let v = active_vault_config(config)?; let v = active_vault_config(config)?;
if v.sync_provider.as_deref() != Some("webdav") { if v.sync_provider.as_deref() != Some("webdav") {
@@ -2043,12 +2084,8 @@ pub fn set_sync_settings(
sync_interval_minutes: u32, sync_interval_minutes: u32,
) -> Result<(), String> { ) -> 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())?;
let active = config.active_vault.clone().ok_or("No active vault")?; let active_index = active_vault_index(&config)?;
let v = config let v = &mut config.vaults[active_index];
.vaults
.iter_mut()
.find(|v| v.path == active)
.ok_or_else(|| "Active vault not found in config".to_string())?;
v.sync_provider = provider.filter(|p| !p.is_empty()); v.sync_provider = provider.filter(|p| !p.is_empty());
v.webdav_url = url.filter(|u| !u.trim().is_empty()); v.webdav_url = url.filter(|u| !u.trim().is_empty());
v.webdav_username = username.filter(|u| !u.is_empty()); v.webdav_username = username.filter(|u| !u.is_empty());
@@ -2094,7 +2131,7 @@ pub fn sync_now(app: AppHandle) -> Result<(), String> {
if app.state::<AppState>().syncing.swap(true, Ordering::SeqCst) { if app.state::<AppState>().syncing.swap(true, Ordering::SeqCst) {
return Ok(()); // a sync is already running return Ok(()); // a sync is already running
} }
let (vault, cfg) = { let (vault, bookmark_id, cfg) = {
let state = app.state::<AppState>(); let state = app.state::<AppState>();
let config = match state.config.lock() { let config = match state.config.lock() {
Ok(c) => c, Ok(c) => c,
@@ -2107,9 +2144,13 @@ pub fn sync_now(app: AppHandle) -> Result<(), String> {
.active_vault .active_vault
.clone() .clone()
.ok_or_else(|| "No active vault".to_string()) .ok_or_else(|| "No active vault".to_string())
.and_then(|v| sync_config_from(&config).map(|c| (v, c))); .and_then(|vault| {
sync_config_from(&config).map(|cfg| {
(vault, config.active_bookmark_id.clone(), cfg)
})
});
match gathered { match gathered {
Ok(vc) => vc, Ok(vault_config) => vault_config,
Err(e) => { Err(e) => {
drop(config); drop(config);
state.syncing.store(false, Ordering::SeqCst); state.syncing.store(false, Ordering::SeqCst);
@@ -2125,8 +2166,10 @@ pub fn sync_now(app: AppHandle) -> Result<(), String> {
Ok(summary) => { Ok(summary) => {
let ts = chrono::Utc::now().to_rfc3339(); let ts = chrono::Utc::now().to_rfc3339();
if let Ok(mut config) = app.state::<AppState>().config.lock() { if let Ok(mut config) = app.state::<AppState>().config.lock() {
if let Some(v) = config.vaults.iter_mut().find(|v| v.path == vault) { if let Some(vault_config) = config.vaults.iter_mut().find(|candidate| {
v.last_sync_time = Some(ts.clone()); vault_matches_identity(candidate, &vault, bookmark_id.as_deref())
}) {
vault_config.last_sync_time = Some(ts.clone());
} }
let _ = save_app_config(&config); let _ = save_app_config(&config);
} }
@@ -2265,7 +2308,7 @@ fn migrate_global_sync_to_vault(config: &mut AppConfig) -> bool {
if config.sync_provider.is_none() && config.webdav_url.is_none() { if config.sync_provider.is_none() && config.webdav_url.is_none() {
return false; return false;
} }
let Some(active) = config.active_vault.clone() else { return false; }; let Ok(active_index) = active_vault_index(config) else { return false; };
let g_provider = config.sync_provider.clone(); let g_provider = config.sync_provider.clone();
let g_url = config.webdav_url.clone(); let g_url = config.webdav_url.clone();
let g_user = config.webdav_username.clone(); let g_user = config.webdav_username.clone();
@@ -2274,7 +2317,7 @@ fn migrate_global_sync_to_vault(config: &mut AppConfig) -> bool {
let g_on_change = config.sync_on_change; let g_on_change = config.sync_on_change;
let g_interval = config.sync_interval_minutes; let g_interval = config.sync_interval_minutes;
let g_last = config.last_sync_time.clone(); let g_last = config.last_sync_time.clone();
let Some(v) = config.vaults.iter_mut().find(|v| v.path == active) else { return false; }; let v = &mut config.vaults[active_index];
if v.sync_provider.is_some() || v.webdav_url.is_some() { if v.sync_provider.is_some() || v.webdav_url.is_some() {
return false; // already migrated / has its own config return false; // already migrated / has its own config
} }
+40 -25
View File
@@ -56,6 +56,23 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.on_page_load(|webview, payload| {
#[cfg(target_os = "macos")]
if webview.label() == "main"
&& matches!(
payload.event(),
tauri::webview::PageLoadEvent::Finished
)
{
let window = webview.window();
let window_on_main = window.clone();
let _ = window.run_on_main_thread(move || {
fix_macos_traffic_lights(&window_on_main);
});
}
#[cfg(not(target_os = "macos"))]
let _ = (webview, payload);
})
.setup(move |app| { .setup(move |app| {
#[cfg(target_os = "ios")] #[cfg(target_os = "ios")]
app.handle().plugin(tauri_plugin_ios_vault_access::init())?; app.handle().plugin(tauri_plugin_ios_vault_access::init())?;
@@ -88,13 +105,6 @@ pub fn run() {
setup_tray(app)?; setup_tray(app)?;
} }
#[cfg(target_os = "macos")]
{
if let Some(window) = app.get_webview_window("main") {
fix_macos_traffic_lights(&window);
}
}
// Check CLI args for a .md file path on initial launch // Check CLI args for a .md file path on initial launch
#[cfg(desktop)] #[cfg(desktop)]
{ {
@@ -401,44 +411,49 @@ fn percent_decode(input: &str) -> String {
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn fix_macos_traffic_lights(window: &tauri::WebviewWindow) { fn fix_macos_traffic_lights(window: &tauri::Window) {
use objc2_app_kit::{NSView, NSWindow, NSWindowButton}; use objc2_app_kit::{NSView, NSWindow, NSWindowButton};
const TITLEBAR_HEIGHT: f64 = 34.0;
const LEFT_INSET: f64 = 12.0;
let ns_window_ptr = match window.ns_window() { let ns_window_ptr = match window.ns_window() {
Ok(ptr) => ptr, Ok(ptr) => ptr,
Err(_) => return, Err(_) => return,
}; };
let ns_window: &NSWindow = unsafe { &*(ns_window_ptr as *const NSWindow) }; let ns_window: &NSWindow = unsafe { &*(ns_window_ptr as *const NSWindow) };
let close = match ns_window.standardWindowButton(NSWindowButton::CloseButton) { let close = match ns_window.standardWindowButton(NSWindowButton::CloseButton) {
Some(btn) => btn, Some(button) => button,
None => return, None => return,
}; };
let miniaturize = match ns_window.standardWindowButton(NSWindowButton::MiniaturizeButton) { let miniaturize = match ns_window.standardWindowButton(NSWindowButton::MiniaturizeButton) {
Some(btn) => btn, Some(button) => button,
None => return, None => return,
}; };
let zoom = match ns_window.standardWindowButton(NSWindowButton::ZoomButton) { let zoom = match ns_window.standardWindowButton(NSWindowButton::ZoomButton) {
Some(btn) => btn, Some(button) => button,
None => return, None => return,
}; };
let superview = match close.superview() { let window_height = ns_window.frame().size.height;
Some(sv) => sv, if window_height <= 0.0 {
None => return,
};
let sv_height = NSView::frame(&superview).size.height;
let btn_height = NSView::frame(&close).size.height;
if sv_height <= 0.0 || btn_height <= 0.0 {
return; return;
} }
let centered_y = (sv_height - btn_height) / 2.0;
for btn in [&close, &miniaturize, &zoom] { let close_frame = NSView::frame(&close);
let mut frame = NSView::frame(btn); let button_spacing = NSView::frame(&miniaturize).origin.x - close_frame.origin.x;
frame.origin.y = centered_y; for (index, button) in [&close, &miniaturize, &zoom].into_iter().enumerate() {
btn.setFrameOrigin(frame.origin); let button_superview = match unsafe { button.superview() } {
Some(view) => view,
None => return,
};
let mut frame = NSView::frame(button);
let top_inset = ((TITLEBAR_HEIGHT - frame.size.height) / 2.0).max(0.0);
let mut target = frame.origin;
target.y = window_height - top_inset - frame.size.height;
frame.origin.y = button_superview.convertPoint_fromView(target, None).y;
frame.origin.x = LEFT_INSET + index as f64 * button_spacing;
button.setFrameOrigin(frame.origin);
} }
} }
+2
View File
@@ -9,6 +9,7 @@ pub struct AppState {
pub config: Mutex<AppConfig>, pub config: Mutex<AppConfig>,
pub search_index: Mutex<Option<Arc<SearchIndex>>>, pub search_index: Mutex<Option<Arc<SearchIndex>>>,
pub watcher: Mutex<Option<RecommendedWatcher>>, pub watcher: Mutex<Option<RecommendedWatcher>>,
pub vault_transition: tokio::sync::Mutex<()>,
pub importing: AtomicBool, pub importing: AtomicBool,
pub syncing: AtomicBool, pub syncing: AtomicBool,
pub pending_open_file: Mutex<Option<String>>, pub pending_open_file: Mutex<Option<String>>,
@@ -20,6 +21,7 @@ impl AppState {
config: Mutex::new(config), config: Mutex::new(config),
search_index: Mutex::new(None), search_index: Mutex::new(None),
watcher: Mutex::new(None), watcher: Mutex::new(None),
vault_transition: tokio::sync::Mutex::new(()),
importing: AtomicBool::new(false), importing: AtomicBool::new(false),
syncing: AtomicBool::new(false), syncing: AtomicBool::new(false),
pending_open_file: Mutex::new(None), pending_open_file: Mutex::new(None),
+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.3.3", "version": "1.3.4",
"identifier": "com.helixnotes.app", "identifier": "com.helixnotes.app",
"build": { "build": {
"frontendDist": "../build", "frontendDist": "../build",
+1 -1
View File
@@ -9,7 +9,7 @@
"hiddenTitle": true, "hiddenTitle": true,
"trafficLightPosition": { "trafficLightPosition": {
"x": 12, "x": 12,
"y": 12 "y": 10
} }
} }
] ]
+9 -6
View File
@@ -61,7 +61,7 @@
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac'); const isMac = navigator.platform.startsWith('Mac');
const isMobile = $derived($platformIsMobile); const isMobile = $derived($platformIsMobile);
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, setTaskDone, setTaskPriority, setTaskDue, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api'; import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, getAppConfig, setTaskDone, setTaskPriority, setTaskDue, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
import { darkThemes, isAndroid } from '$lib/platform'; import { darkThemes, isAndroid } from '$lib/platform';
import { debounce } from '$lib/utils/debounce'; import { debounce } from '$lib/utils/debounce';
import { openNoteWindow } from '$lib/utils/window'; import { openNoteWindow } from '$lib/utils/window';
@@ -614,7 +614,9 @@
// Run sidebar and note list refresh in parallel // Run sidebar and note list refresh in parallel
await Promise.all([sidebar?.refresh(), noteList?.refresh()]); await Promise.all([sidebar?.refresh(), noteList?.refresh()]);
// Auto-cleanup orphaned attachments in the background // Full-vault attachment scans cause navigation stalls on mobile storage.
// Mobile users can run the same cleanup explicitly from the Info panel.
if (!isMobile) {
setTimeout(async () => { setTimeout(async () => {
if (get(editorDirty)) return; // skip if user is actively editing if (get(editorDirty)) return; // skip if user is actively editing
try { try {
@@ -633,6 +635,7 @@
} }
} catch (_) {} } catch (_) {}
}, 3000); }, 3000);
}
// Restore the last session (view + open note) if enabled. // Restore the last session (view + open note) if enabled.
if ($appConfig?.restore_last_session) { if ($appConfig?.restore_last_session) {
@@ -729,11 +732,11 @@
// ── WebDAV sync: global status + auto-sync triggers ── // ── WebDAV sync: global status + auto-sync triggers ──
unlistenSync.push(await listen('sync-progress', () => syncState.set({ running: true, error: null }))); unlistenSync.push(await listen('sync-progress', () => syncState.set({ running: true, error: null })));
unlistenSync.push(await listen('sync-done', (event: any) => { unlistenSync.push(await listen('sync-done', async () => {
syncState.set({ running: false, error: null }); syncState.set({ running: false, error: null });
const cur = get(appConfig); try {
const ts = event.payload?.last_sync_time; appConfig.set(await getAppConfig());
if (cur && ts) appConfig.set({ ...cur, vaults: cur.vaults.map((v) => v.path === cur.active_vault ? { ...v, last_sync_time: ts } : v) }); } catch {}
})); }));
unlistenSync.push(await listen('sync-error', (event: any) => syncState.set({ running: false, error: event.payload?.error ?? 'Sync failed' }))); unlistenSync.push(await listen('sync-error', (event: any) => syncState.set({ running: false, error: event.payload?.error ?? 'Sync failed' })));
+6 -1
View File
@@ -1112,7 +1112,7 @@
{/if} {/if}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="list-content" bind:this={listContainer} onscroll={onListScroll} ondblclick={handleListDoubleClick}> <div class="list-content" class:tasks-mode={$viewMode === 'tasks'} bind:this={listContainer} onscroll={onListScroll} ondblclick={handleListDoubleClick}>
{#if $viewMode === 'tasks'} {#if $viewMode === 'tasks'}
<TasksView onOpenTask={openTask} onToggleTask={onToggleTask} onSetTaskPriority={onSetTaskPriority} onSetTaskDue={onSetTaskDue} /> <TasksView onOpenTask={openTask} onToggleTask={onToggleTask} onSetTaskPriority={onSetTaskPriority} onSetTaskDue={onSetTaskDue} />
{/if} {/if}
@@ -2213,6 +2213,11 @@
padding: 4px 8px 180px; padding: 4px 8px 180px;
} }
.note-list.mobile .list-content.tasks-mode {
padding-bottom: 0;
overflow: hidden;
}
.note-list.mobile .note-item { .note-list.mobile .note-item {
padding: 14px 16px; padding: 14px 16px;
min-height: 56px; min-height: 56px;
+17 -12
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { showSettings, theme, appConfig, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app'; import { showSettings, theme, appConfig, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes } from '$lib/api'; import { setTheme, 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 { 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';
@@ -318,9 +318,13 @@
// Re-seed the sync form when the active vault changes, so Settings reflects the current vault. // Re-seed the sync form when the active vault changes, so Settings reflects the current vault.
let lastSyncVault: string | null = null; let lastSyncVault: string | null = null;
$effect(() => { $effect(() => {
const path = $appConfig?.active_vault ?? null; const identity = $appConfig?.active_bookmark_id
if (path === lastSyncVault) return; ? `bookmark:${$appConfig.active_bookmark_id}`
lastSyncVault = path; : $appConfig?.active_vault
? `path:${$appConfig.active_vault}`
: null;
if (identity === lastSyncVault) return;
lastSyncVault = identity;
const vc = activeVaultConfig($appConfig); const vc = activeVaultConfig($appConfig);
syncProvider = vc?.sync_provider ?? null; syncProvider = vc?.sync_provider ?? null;
syncUrl = vc?.webdav_url ?? ''; syncUrl = vc?.webdav_url ?? '';
@@ -337,10 +341,11 @@
// triggers update live, without an app restart. // triggers update live, without an app restart.
if ($appConfig) { if ($appConfig) {
const cur = $appConfig; const cur = $appConfig;
const active = activeVaultConfig(cur);
$appConfig = { $appConfig = {
...cur, ...cur,
vaults: cur.vaults.map((v) => v.path === cur.active_vault ? { vaults: cur.vaults.map((vault) => vault === active ? {
...v, ...vault,
sync_provider: syncProvider, sync_provider: syncProvider,
webdav_url: syncUrl || null, webdav_url: syncUrl || null,
webdav_username: syncUsername || null, webdav_username: syncUsername || null,
@@ -348,7 +353,7 @@
sync_on_open: syncOnOpen, sync_on_open: syncOnOpen,
sync_on_change: syncOnChange, sync_on_change: syncOnChange,
sync_interval_minutes: syncIntervalMinutes, sync_interval_minutes: syncIntervalMinutes,
} : v), } : vault),
}; };
} }
} }
@@ -379,7 +384,7 @@
syncMessage = null; syncMessage = null;
const unlisteners: Array<() => void> = []; const unlisteners: Array<() => void> = [];
const cleanup = () => { unlisteners.forEach((u) => u()); }; const cleanup = () => { unlisteners.forEach((u) => u()); };
unlisteners.push(await listen<{ success: boolean; summary?: { uploaded?: number; downloaded?: number; deleted_local?: number; deleted_remote?: number; conflicts?: number }; last_sync_time?: string }>('sync-done', (event) => { unlisteners.push(await listen<{ success: boolean; summary?: { uploaded?: number; downloaded?: number; deleted_local?: number; deleted_remote?: number; conflicts?: number }; last_sync_time?: string }>('sync-done', async (event) => {
const s = event.payload.summary ?? {}; const s = event.payload.summary ?? {};
const parts: string[] = []; const parts: string[] = [];
if (s.uploaded) parts.push(`${s.uploaded} uploaded`); if (s.uploaded) parts.push(`${s.uploaded} uploaded`);
@@ -389,10 +394,10 @@
if (s.conflicts) parts.push(`${s.conflicts} conflict copies`); if (s.conflicts) parts.push(`${s.conflicts} conflict copies`);
syncMessage = { type: 'success', text: parts.length ? `Synced: ${parts.join(', ')}.` : 'Already up to date.' }; syncMessage = { type: 'success', text: parts.length ? `Synced: ${parts.join(', ')}.` : 'Already up to date.' };
syncRunning = false; syncRunning = false;
if ($appConfig && event.payload.last_sync_time) { if (event.payload.last_sync_time) {
const cur = $appConfig; try {
const ts = event.payload.last_sync_time; $appConfig = await getAppConfig();
$appConfig = { ...cur, vaults: cur.vaults.map((v) => v.path === cur.active_vault ? { ...v, last_sync_time: ts } : v) }; } catch {}
} }
cleanup(); cleanup();
})); }));
+1
View File
@@ -807,6 +807,7 @@
class="notebook-item" class="notebook-item"
class:active={$viewMode === 'notebook' && $activeNotebook?.relative_path === ''} class:active={$viewMode === 'notebook' && $activeNotebook?.relative_path === ''}
onclick={selectUnfiled} onclick={selectUnfiled}
style="padding-left: 4px"
> >
<span style="width:14px;flex-shrink:0"></span> <span style="width:14px;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">
+8 -2
View File
@@ -130,6 +130,7 @@
} }
async function openRecentVault(vault: VaultConfig) { async function openRecentVault(vault: VaultConfig) {
if (loading) return;
if (!isIOS || !vault.bookmark_id) { if (!isIOS || !vault.bookmark_id) {
await openSelectedVault(vault.path); await openSelectedVault(vault.path);
return; return;
@@ -149,6 +150,9 @@
} }
async function forgetVault(vault: VaultConfig) { async function forgetVault(vault: VaultConfig) {
if (loading) return;
loading = true;
error = '';
try { try {
const wasActive = isActiveVault(vault); const wasActive = isActiveVault(vault);
await removeVault(vault.path, vault.bookmark_id); await removeVault(vault.path, vault.bookmark_id);
@@ -156,6 +160,8 @@
if (wasActive) $vaultReady = false; if (wasActive) $vaultReady = false;
} catch (e) { } catch (e) {
error = String(e); error = String(e);
} finally {
loading = false;
} }
} }
@@ -272,11 +278,11 @@
<span class="recent-label">Recent</span> <span class="recent-label">Recent</span>
{#each recentVaults as vault} {#each recentVaults as vault}
<div class="vault-row"> <div class="vault-row">
<button class="vault-item" class:current={isActiveVault(vault)} onclick={() => openRecentVault(vault)}> <button class="vault-item" class:current={isActiveVault(vault)} onclick={() => openRecentVault(vault)} disabled={loading}>
<span class="vault-name">{vault.name}{#if isActiveVault(vault)}<span class="vault-current-badge">Current</span>{/if}</span> <span class="vault-name">{vault.name}{#if isActiveVault(vault)}<span class="vault-current-badge">Current</span>{/if}</span>
<span class="vault-path">{vault.bookmark_id ? `Files · ${vault.path}` : vault.path}</span> <span class="vault-path">{vault.bookmark_id ? `Files · ${vault.path}` : vault.path}</span>
</button> </button>
<button class="vault-remove" title="Remove from list" aria-label="Remove from list" onclick={() => forgetVault(vault)}> <button class="vault-remove" title="Remove from list" aria-label="Remove from list" onclick={() => forgetVault(vault)} disabled={loading}>
<svg width="12" height="12" viewBox="0 0 10 10"> <svg width="12" height="12" viewBox="0 0 10 10">
<line x1="1.5" y1="1.5" x2="8.5" y2="8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" /> <line x1="1.5" y1="1.5" x2="8.5" y2="8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<line x1="8.5" y1="1.5" x2="1.5" y2="8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" /> <line x1="8.5" y1="1.5" x2="1.5" y2="8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
+16 -7
View File
@@ -4,6 +4,7 @@
import { getAppConfig, openVault, restoreExternalVault, setFontSize } from '$lib/api'; import { getAppConfig, openVault, restoreExternalVault, setFontSize } from '$lib/api';
import { darkThemes, isIOS } from '$lib/platform'; import { darkThemes, isIOS } from '$lib/platform';
import { getCurrentWebview } from '@tauri-apps/api/webview'; import { getCurrentWebview } from '@tauri-apps/api/webview';
import { listen } from '@tauri-apps/api/event';
import VaultPicker from '$lib/components/VaultPicker.svelte'; import VaultPicker from '$lib/components/VaultPicker.svelte';
import AppLayout from '$lib/components/AppLayout.svelte'; import AppLayout from '$lib/components/AppLayout.svelte';
import NoteWindow from '$lib/components/NoteWindow.svelte'; import NoteWindow from '$lib/components/NoteWindow.svelte';
@@ -13,6 +14,7 @@
let startupVaultError = $state(''); let startupVaultError = $state('');
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null; let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
let removeEditorZoomShortcuts: (() => void) | null = null; let removeEditorZoomShortcuts: (() => void) | null = null;
let removeFontSizeListener: (() => void) | null = null;
const defaultEditorFontSize = 14; const defaultEditorFontSize = 14;
const minEditorFontSize = 10; const minEditorFontSize = 10;
@@ -56,12 +58,17 @@
}; };
} }
function setEditorFontSize(value: number) { function applyEditorFontSize(value: number) {
const nextSize = Math.max(minEditorFontSize, Math.min(maxEditorFontSize, Math.round(value))); const nextSize = Math.max(minEditorFontSize, Math.min(maxEditorFontSize, Math.round(value)));
const restoreEditorViewport = preserveEditorViewportAfterLayout(getEditorScrollSurface()); const restoreEditorViewport = preserveEditorViewportAfterLayout(getEditorScrollSurface());
if ($appConfig) $appConfig.font_size = nextSize; if ($appConfig) $appConfig.font_size = nextSize;
document.documentElement.style.setProperty('--editor-font-size', `${nextSize}px`); document.documentElement.style.setProperty('--editor-font-size', `${nextSize}px`);
restoreEditorViewport(); restoreEditorViewport();
return nextSize;
}
function setEditorFontSize(value: number) {
const nextSize = applyEditorFontSize(value);
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer); if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
fontSizeSaveTimer = setTimeout(() => { fontSizeSaveTimer = setTimeout(() => {
setFontSize(nextSize).catch((e) => console.error('Failed to save font size:', e)); setFontSize(nextSize).catch((e) => console.error('Failed to save font size:', e));
@@ -117,6 +124,9 @@
try { try {
const config = await getAppConfig(); const config = await getAppConfig();
$appConfig = config; $appConfig = config;
removeFontSizeListener = await listen<number>('editor-font-size-changed', (event) => {
if ($appConfig?.font_size !== event.payload) applyEditorFontSize(event.payload);
});
$theme = config.theme || 'system'; $theme = config.theme || 'system';
// Apply theme immediately to prevent flash // Apply theme immediately to prevent flash
@@ -133,9 +143,7 @@
} }
// Apply saved font settings // Apply saved font settings
if (config.font_size) { if (config.font_size) applyEditorFontSize(config.font_size);
document.documentElement.style.setProperty('--editor-font-size', `${config.font_size}px`);
}
if (config.line_height) { if (config.line_height) {
document.documentElement.style.setProperty('--editor-line-height', String(config.line_height)); document.documentElement.style.setProperty('--editor-line-height', String(config.line_height));
} }
@@ -193,15 +201,15 @@
} }
} }
// Apply saved interface scale (desktop main window only; no-ops gracefully elsewhere) // Apply saved interface scale to every desktop window; no-ops gracefully elsewhere.
if (!noteWindowPath && config.ui_scale && config.ui_scale !== 1) { if (config.ui_scale && config.ui_scale !== 1) {
try { try {
await getCurrentWebview().setZoom(config.ui_scale); await getCurrentWebview().setZoom(config.ui_scale);
} catch (e) { } catch (e) {
console.error('Failed to apply interface scale:', e); console.error('Failed to apply interface scale:', e);
} }
} }
if (!noteWindowPath) removeEditorZoomShortcuts = installEditorZoomShortcuts(); removeEditorZoomShortcuts = installEditorZoomShortcuts();
// Auto-open last vault if available // Auto-open last vault if available
if (config.active_vault) { if (config.active_vault) {
@@ -240,6 +248,7 @@
onDestroy(() => { onDestroy(() => {
removeEditorZoomShortcuts?.(); removeEditorZoomShortcuts?.();
removeFontSizeListener?.();
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer); if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
}); });
</script> </script>