Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e5b380f8c | ||
|
|
e76919ab5c | ||
|
|
84ab90fd3f | ||
|
|
87b191eb63 | ||
|
|
0def9d6d6d | ||
|
|
e708d8ee63 | ||
|
|
461001442a | ||
|
|
6b5e923e8e | ||
|
|
289606a746 | ||
|
|
9c53b3b594 |
@@ -2,7 +2,7 @@
|
||||
"name": "helixnotes",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
@@ -1850,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "helixnotes"
|
||||
version = "1.3.3"
|
||||
version = "1.3.4"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1860,6 +1860,7 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"notify",
|
||||
"objc2-app-kit",
|
||||
"png 0.17.16",
|
||||
"quick-xml 0.36.2",
|
||||
"rayon",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "helixnotes"
|
||||
version = "1.3.3"
|
||||
version = "1.3.4"
|
||||
description = "Local markdown note-taking app"
|
||||
authors = ["HelixNotes"]
|
||||
license = "AGPL-3.0-or-later"
|
||||
@@ -42,6 +42,9 @@ quick-xml = "0.36"
|
||||
urlencoding = "2"
|
||||
sha2 = "0.10"
|
||||
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]
|
||||
tauri-plugin-ios-vault-access = { path = "plugins/ios-vault-access" }
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 3.5 KiB |
|
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"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
<color name="ic_launcher_background">#626ED4</color>
|
||||
</resources>
|
||||
@@ -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 |
@@ -1,7 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{
|
||||
plugin::{Builder, PluginApi, PluginHandle, TauriPlugin},
|
||||
AppHandle, Manager, Runtime,
|
||||
plugin::{Builder, PluginHandle, TauriPlugin},
|
||||
Manager, Runtime,
|
||||
};
|
||||
|
||||
#[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> {
|
||||
Builder::new("ios-vault-access")
|
||||
.setup(|app, api| {
|
||||
let access = initialize(app, api)?;
|
||||
app.manage(access);
|
||||
let handle = api.register_ios_plugin(init_plugin_ios_vault_access)?;
|
||||
app.manage(IosVaultAccess(handle));
|
||||
Ok(())
|
||||
})
|
||||
.build()
|
||||
|
||||
@@ -143,7 +143,12 @@ fn open_vault_path(
|
||||
}
|
||||
|
||||
#[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)?;
|
||||
#[cfg(target_os = "ios")]
|
||||
app.ios_vault_access().release_active()?;
|
||||
@@ -155,6 +160,7 @@ pub async fn choose_external_vault(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<ExternalVaultResult>, String> {
|
||||
let _transition = state.vault_transition.lock().await;
|
||||
#[cfg(target_os = "ios")]
|
||||
{
|
||||
let picker_app = app.clone();
|
||||
@@ -194,7 +200,7 @@ pub async fn choose_external_vault(
|
||||
}
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
{
|
||||
let _ = (app, state);
|
||||
let _ = (&app, &state);
|
||||
Err("Files folders are only available on iOS.".to_string())
|
||||
}
|
||||
}
|
||||
@@ -205,6 +211,7 @@ pub async fn restore_external_vault(
|
||||
state: State<'_, AppState>,
|
||||
bookmark_id: String,
|
||||
) -> Result<ExternalVaultResult, String> {
|
||||
let _transition = state.vault_transition.lock().await;
|
||||
#[cfg(target_os = "ios")]
|
||||
{
|
||||
let resolver_app = app.clone();
|
||||
@@ -228,7 +235,7 @@ pub async fn restore_external_vault(
|
||||
}
|
||||
#[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())
|
||||
}
|
||||
}
|
||||
@@ -240,39 +247,34 @@ pub async fn remove_vault(
|
||||
path: String,
|
||||
bookmark_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let target = {
|
||||
let config = state.config.lock().map_err(|error| error.to_string())?;
|
||||
let vault = if let Some(bookmark_id) = bookmark_id.as_deref() {
|
||||
config
|
||||
.vaults
|
||||
.iter()
|
||||
.find(|vault| vault.bookmark_id.as_deref() == Some(bookmark_id))
|
||||
let _transition = state.vault_transition.lock().await;
|
||||
let mut config = state.config.lock().map_err(|error| error.to_string())?;
|
||||
let target = if let Some(bookmark_id) = bookmark_id.as_deref() {
|
||||
config
|
||||
.vaults
|
||||
.iter()
|
||||
.find(|vault| vault.bookmark_id.as_deref() == Some(bookmark_id))
|
||||
} else {
|
||||
config
|
||||
.vaults
|
||||
.iter()
|
||||
.find(|vault| vault.bookmark_id.is_none() && vault.path == path)
|
||||
}
|
||||
.map(|vault| {
|
||||
let is_active = if let Some(bookmark_id) = vault.bookmark_id.as_deref() {
|
||||
config.active_bookmark_id.as_deref() == Some(bookmark_id)
|
||||
} else {
|
||||
config
|
||||
.vaults
|
||||
.iter()
|
||||
.find(|vault| vault.bookmark_id.is_none() && vault.path == path)
|
||||
config.active_bookmark_id.is_none()
|
||||
&& config.active_vault.as_deref() == Some(vault.path.as_str())
|
||||
};
|
||||
vault.map(|vault| {
|
||||
let is_active = if let Some(bookmark_id) = vault.bookmark_id.as_deref() {
|
||||
config.active_bookmark_id.as_deref() == Some(bookmark_id)
|
||||
} else {
|
||||
config.active_bookmark_id.is_none()
|
||||
&& 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 {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let old = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?
|
||||
.clone();
|
||||
let old = config.clone();
|
||||
let mut next = old.clone();
|
||||
if let Some(id) = target_bookmark.as_deref() {
|
||||
next.vaults
|
||||
@@ -289,20 +291,7 @@ pub async fn remove_vault(
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
if let Some(id) = target_bookmark.as_deref() {
|
||||
let forget_app = app.clone();
|
||||
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 {
|
||||
if let Err(error) = app.ios_vault_access().forget_bookmark(id) {
|
||||
return match save_app_config(&old) {
|
||||
Ok(()) => Err(error),
|
||||
Err(rollback_error) => Err(format!(
|
||||
@@ -317,7 +306,7 @@ pub async fn remove_vault(
|
||||
if is_active {
|
||||
clear_vault_runtime(&state)?;
|
||||
}
|
||||
*state.config.lock().map_err(|error| error.to_string())? = next;
|
||||
*config = next;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -403,11 +392,15 @@ pub fn import_custom_themes(state: State<'_, AppState>, path: String) -> Result<
|
||||
}
|
||||
|
||||
#[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())?;
|
||||
config.font_size = Some(size);
|
||||
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]
|
||||
@@ -2005,15 +1998,63 @@ pub fn test_ai_connection(app: AppHandle) -> Result<(), String> {
|
||||
|
||||
// ── 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")?;
|
||||
config
|
||||
.vaults
|
||||
.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())
|
||||
}
|
||||
|
||||
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> {
|
||||
let v = active_vault_config(config)?;
|
||||
if v.sync_provider.as_deref() != Some("webdav") {
|
||||
@@ -2043,12 +2084,8 @@ pub fn set_sync_settings(
|
||||
sync_interval_minutes: u32,
|
||||
) -> Result<(), 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 v = config
|
||||
.vaults
|
||||
.iter_mut()
|
||||
.find(|v| v.path == active)
|
||||
.ok_or_else(|| "Active vault not found in config".to_string())?;
|
||||
let active_index = active_vault_index(&config)?;
|
||||
let v = &mut config.vaults[active_index];
|
||||
v.sync_provider = provider.filter(|p| !p.is_empty());
|
||||
v.webdav_url = url.filter(|u| !u.trim().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) {
|
||||
return Ok(()); // a sync is already running
|
||||
}
|
||||
let (vault, cfg) = {
|
||||
let (vault, bookmark_id, cfg) = {
|
||||
let state = app.state::<AppState>();
|
||||
let config = match state.config.lock() {
|
||||
Ok(c) => c,
|
||||
@@ -2107,9 +2144,13 @@ pub fn sync_now(app: AppHandle) -> Result<(), String> {
|
||||
.active_vault
|
||||
.clone()
|
||||
.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 {
|
||||
Ok(vc) => vc,
|
||||
Ok(vault_config) => vault_config,
|
||||
Err(e) => {
|
||||
drop(config);
|
||||
state.syncing.store(false, Ordering::SeqCst);
|
||||
@@ -2125,8 +2166,10 @@ pub fn sync_now(app: AppHandle) -> Result<(), String> {
|
||||
Ok(summary) => {
|
||||
let ts = chrono::Utc::now().to_rfc3339();
|
||||
if let Ok(mut config) = app.state::<AppState>().config.lock() {
|
||||
if let Some(v) = config.vaults.iter_mut().find(|v| v.path == vault) {
|
||||
v.last_sync_time = Some(ts.clone());
|
||||
if let Some(vault_config) = config.vaults.iter_mut().find(|candidate| {
|
||||
vault_matches_identity(candidate, &vault, bookmark_id.as_deref())
|
||||
}) {
|
||||
vault_config.last_sync_time = Some(ts.clone());
|
||||
}
|
||||
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() {
|
||||
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_url = config.webdav_url.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_interval = config.sync_interval_minutes;
|
||||
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() {
|
||||
return false; // already migrated / has its own config
|
||||
}
|
||||
|
||||
@@ -56,6 +56,23 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::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| {
|
||||
#[cfg(target_os = "ios")]
|
||||
app.handle().plugin(tauri_plugin_ios_vault_access::init())?;
|
||||
@@ -88,13 +105,6 @@ pub fn run() {
|
||||
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
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
@@ -401,44 +411,49 @@ fn percent_decode(input: &str) -> String {
|
||||
}
|
||||
|
||||
#[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};
|
||||
|
||||
const TITLEBAR_HEIGHT: f64 = 34.0;
|
||||
const LEFT_INSET: f64 = 12.0;
|
||||
|
||||
let ns_window_ptr = match window.ns_window() {
|
||||
Ok(ptr) => ptr,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let ns_window: &NSWindow = unsafe { &*(ns_window_ptr as *const NSWindow) };
|
||||
|
||||
let close = match ns_window.standardWindowButton(NSWindowButton::CloseButton) {
|
||||
Some(btn) => btn,
|
||||
Some(button) => button,
|
||||
None => return,
|
||||
};
|
||||
let miniaturize = match ns_window.standardWindowButton(NSWindowButton::MiniaturizeButton) {
|
||||
Some(btn) => btn,
|
||||
Some(button) => button,
|
||||
None => return,
|
||||
};
|
||||
let zoom = match ns_window.standardWindowButton(NSWindowButton::ZoomButton) {
|
||||
Some(btn) => btn,
|
||||
Some(button) => button,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let superview = match close.superview() {
|
||||
Some(sv) => sv,
|
||||
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 {
|
||||
let window_height = ns_window.frame().size.height;
|
||||
if window_height <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let centered_y = (sv_height - btn_height) / 2.0;
|
||||
|
||||
for btn in [&close, &miniaturize, &zoom] {
|
||||
let mut frame = NSView::frame(btn);
|
||||
frame.origin.y = centered_y;
|
||||
btn.setFrameOrigin(frame.origin);
|
||||
let close_frame = NSView::frame(&close);
|
||||
let button_spacing = NSView::frame(&miniaturize).origin.x - close_frame.origin.x;
|
||||
for (index, button) in [&close, &miniaturize, &zoom].into_iter().enumerate() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ pub struct AppState {
|
||||
pub config: Mutex<AppConfig>,
|
||||
pub search_index: Mutex<Option<Arc<SearchIndex>>>,
|
||||
pub watcher: Mutex<Option<RecommendedWatcher>>,
|
||||
pub vault_transition: tokio::sync::Mutex<()>,
|
||||
pub importing: AtomicBool,
|
||||
pub syncing: AtomicBool,
|
||||
pub pending_open_file: Mutex<Option<String>>,
|
||||
@@ -20,6 +21,7 @@ impl AppState {
|
||||
config: Mutex::new(config),
|
||||
search_index: Mutex::new(None),
|
||||
watcher: Mutex::new(None),
|
||||
vault_transition: tokio::sync::Mutex::new(()),
|
||||
importing: AtomicBool::new(false),
|
||||
syncing: AtomicBool::new(false),
|
||||
pending_open_file: Mutex::new(None),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "HelixNotes",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.4",
|
||||
"identifier": "com.helixnotes.app",
|
||||
"build": {
|
||||
"frontendDist": "../build",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"hiddenTitle": true,
|
||||
"trafficLightPosition": {
|
||||
"x": 12,
|
||||
"y": 12
|
||||
"y": 10
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
const appWindow = getCurrentWindow();
|
||||
const isMac = navigator.platform.startsWith('Mac');
|
||||
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 { debounce } from '$lib/utils/debounce';
|
||||
import { openNoteWindow } from '$lib/utils/window';
|
||||
@@ -614,25 +614,28 @@
|
||||
// Run sidebar and note list refresh in parallel
|
||||
await Promise.all([sidebar?.refresh(), noteList?.refresh()]);
|
||||
|
||||
// Auto-cleanup orphaned attachments in the background
|
||||
setTimeout(async () => {
|
||||
if (get(editorDirty)) return; // skip if user is actively editing
|
||||
try {
|
||||
const orphans = await findOrphanedAttachments();
|
||||
if (orphans.length > 0) {
|
||||
if (get(editorDirty)) return; // re-check after async scan
|
||||
const moved = await trashOrphanedAttachments(orphans.map((o) => o.name));
|
||||
if (moved > 0) {
|
||||
const toast = document.createElement('div');
|
||||
toast.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--bg-secondary);color:var(--text-primary);padding:10px 18px;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.15);border:1px solid var(--border-color);font-size:13px;z-index:10000;opacity:0;transition:opacity .3s;pointer-events:none';
|
||||
toast.textContent = `Moved ${moved} orphaned attachment${moved > 1 ? 's' : ''} to trash`;
|
||||
document.body.appendChild(toast);
|
||||
requestAnimationFrame(() => (toast.style.opacity = '1'));
|
||||
setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, 4000);
|
||||
// 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 () => {
|
||||
if (get(editorDirty)) return; // skip if user is actively editing
|
||||
try {
|
||||
const orphans = await findOrphanedAttachments();
|
||||
if (orphans.length > 0) {
|
||||
if (get(editorDirty)) return; // re-check after async scan
|
||||
const moved = await trashOrphanedAttachments(orphans.map((o) => o.name));
|
||||
if (moved > 0) {
|
||||
const toast = document.createElement('div');
|
||||
toast.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--bg-secondary);color:var(--text-primary);padding:10px 18px;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.15);border:1px solid var(--border-color);font-size:13px;z-index:10000;opacity:0;transition:opacity .3s;pointer-events:none';
|
||||
toast.textContent = `Moved ${moved} orphaned attachment${moved > 1 ? 's' : ''} to trash`;
|
||||
document.body.appendChild(toast);
|
||||
requestAnimationFrame(() => (toast.style.opacity = '1'));
|
||||
setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, 4000);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 3000);
|
||||
} catch (_) {}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Restore the last session (view + open note) if enabled.
|
||||
if ($appConfig?.restore_last_session) {
|
||||
@@ -729,11 +732,11 @@
|
||||
|
||||
// ── WebDAV sync: global status + auto-sync triggers ──
|
||||
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 });
|
||||
const cur = get(appConfig);
|
||||
const ts = event.payload?.last_sync_time;
|
||||
if (cur && ts) appConfig.set({ ...cur, vaults: cur.vaults.map((v) => v.path === cur.active_vault ? { ...v, last_sync_time: ts } : v) });
|
||||
try {
|
||||
appConfig.set(await getAppConfig());
|
||||
} catch {}
|
||||
}));
|
||||
unlistenSync.push(await listen('sync-error', (event: any) => syncState.set({ running: false, error: event.payload?.error ?? 'Sync failed' })));
|
||||
|
||||
|
||||
@@ -1112,7 +1112,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- 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'}
|
||||
<TasksView onOpenTask={openTask} onToggleTask={onToggleTask} onSetTaskPriority={onSetTaskPriority} onSetTaskDue={onSetTaskDue} />
|
||||
{/if}
|
||||
@@ -2213,6 +2213,11 @@
|
||||
padding: 4px 8px 180px;
|
||||
}
|
||||
|
||||
.note-list.mobile .list-content.tasks-mode {
|
||||
padding-bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.note-list.mobile .note-item {
|
||||
padding: 14px 16px;
|
||||
min-height: 56px;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<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 { 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 { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
|
||||
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.
|
||||
let lastSyncVault: string | null = null;
|
||||
$effect(() => {
|
||||
const path = $appConfig?.active_vault ?? null;
|
||||
if (path === lastSyncVault) return;
|
||||
lastSyncVault = path;
|
||||
const identity = $appConfig?.active_bookmark_id
|
||||
? `bookmark:${$appConfig.active_bookmark_id}`
|
||||
: $appConfig?.active_vault
|
||||
? `path:${$appConfig.active_vault}`
|
||||
: null;
|
||||
if (identity === lastSyncVault) return;
|
||||
lastSyncVault = identity;
|
||||
const vc = activeVaultConfig($appConfig);
|
||||
syncProvider = vc?.sync_provider ?? null;
|
||||
syncUrl = vc?.webdav_url ?? '';
|
||||
@@ -337,10 +341,11 @@
|
||||
// triggers update live, without an app restart.
|
||||
if ($appConfig) {
|
||||
const cur = $appConfig;
|
||||
const active = activeVaultConfig(cur);
|
||||
$appConfig = {
|
||||
...cur,
|
||||
vaults: cur.vaults.map((v) => v.path === cur.active_vault ? {
|
||||
...v,
|
||||
vaults: cur.vaults.map((vault) => vault === active ? {
|
||||
...vault,
|
||||
sync_provider: syncProvider,
|
||||
webdav_url: syncUrl || null,
|
||||
webdav_username: syncUsername || null,
|
||||
@@ -348,7 +353,7 @@
|
||||
sync_on_open: syncOnOpen,
|
||||
sync_on_change: syncOnChange,
|
||||
sync_interval_minutes: syncIntervalMinutes,
|
||||
} : v),
|
||||
} : vault),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -379,7 +384,7 @@
|
||||
syncMessage = null;
|
||||
const unlisteners: Array<() => void> = [];
|
||||
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 parts: string[] = [];
|
||||
if (s.uploaded) parts.push(`${s.uploaded} uploaded`);
|
||||
@@ -389,10 +394,10 @@
|
||||
if (s.conflicts) parts.push(`${s.conflicts} conflict copies`);
|
||||
syncMessage = { type: 'success', text: parts.length ? `Synced: ${parts.join(', ')}.` : 'Already up to date.' };
|
||||
syncRunning = false;
|
||||
if ($appConfig && event.payload.last_sync_time) {
|
||||
const cur = $appConfig;
|
||||
const ts = event.payload.last_sync_time;
|
||||
$appConfig = { ...cur, vaults: cur.vaults.map((v) => v.path === cur.active_vault ? { ...v, last_sync_time: ts } : v) };
|
||||
if (event.payload.last_sync_time) {
|
||||
try {
|
||||
$appConfig = await getAppConfig();
|
||||
} catch {}
|
||||
}
|
||||
cleanup();
|
||||
}));
|
||||
|
||||
@@ -807,6 +807,7 @@
|
||||
class="notebook-item"
|
||||
class:active={$viewMode === 'notebook' && $activeNotebook?.relative_path === ''}
|
||||
onclick={selectUnfiled}
|
||||
style="padding-left: 4px"
|
||||
>
|
||||
<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">
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
}
|
||||
|
||||
async function openRecentVault(vault: VaultConfig) {
|
||||
if (loading) return;
|
||||
if (!isIOS || !vault.bookmark_id) {
|
||||
await openSelectedVault(vault.path);
|
||||
return;
|
||||
@@ -149,6 +150,9 @@
|
||||
}
|
||||
|
||||
async function forgetVault(vault: VaultConfig) {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const wasActive = isActiveVault(vault);
|
||||
await removeVault(vault.path, vault.bookmark_id);
|
||||
@@ -156,6 +160,8 @@
|
||||
if (wasActive) $vaultReady = false;
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,11 +278,11 @@
|
||||
<span class="recent-label">Recent</span>
|
||||
{#each recentVaults as vault}
|
||||
<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-path">{vault.bookmark_id ? `Files · ${vault.path}` : vault.path}</span>
|
||||
</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">
|
||||
<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" />
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { getAppConfig, openVault, restoreExternalVault, setFontSize } from '$lib/api';
|
||||
import { darkThemes, isIOS } from '$lib/platform';
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import VaultPicker from '$lib/components/VaultPicker.svelte';
|
||||
import AppLayout from '$lib/components/AppLayout.svelte';
|
||||
import NoteWindow from '$lib/components/NoteWindow.svelte';
|
||||
@@ -13,6 +14,7 @@
|
||||
let startupVaultError = $state('');
|
||||
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let removeEditorZoomShortcuts: (() => void) | null = null;
|
||||
let removeFontSizeListener: (() => void) | null = null;
|
||||
|
||||
const defaultEditorFontSize = 14;
|
||||
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 restoreEditorViewport = preserveEditorViewportAfterLayout(getEditorScrollSurface());
|
||||
if ($appConfig) $appConfig.font_size = nextSize;
|
||||
document.documentElement.style.setProperty('--editor-font-size', `${nextSize}px`);
|
||||
restoreEditorViewport();
|
||||
return nextSize;
|
||||
}
|
||||
|
||||
function setEditorFontSize(value: number) {
|
||||
const nextSize = applyEditorFontSize(value);
|
||||
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
|
||||
fontSizeSaveTimer = setTimeout(() => {
|
||||
setFontSize(nextSize).catch((e) => console.error('Failed to save font size:', e));
|
||||
@@ -117,6 +124,9 @@
|
||||
try {
|
||||
const config = await getAppConfig();
|
||||
$appConfig = config;
|
||||
removeFontSizeListener = await listen<number>('editor-font-size-changed', (event) => {
|
||||
if ($appConfig?.font_size !== event.payload) applyEditorFontSize(event.payload);
|
||||
});
|
||||
$theme = config.theme || 'system';
|
||||
|
||||
// Apply theme immediately to prevent flash
|
||||
@@ -133,9 +143,7 @@
|
||||
}
|
||||
|
||||
// Apply saved font settings
|
||||
if (config.font_size) {
|
||||
document.documentElement.style.setProperty('--editor-font-size', `${config.font_size}px`);
|
||||
}
|
||||
if (config.font_size) applyEditorFontSize(config.font_size);
|
||||
if (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)
|
||||
if (!noteWindowPath && config.ui_scale && config.ui_scale !== 1) {
|
||||
// Apply saved interface scale to every desktop window; no-ops gracefully elsewhere.
|
||||
if (config.ui_scale && config.ui_scale !== 1) {
|
||||
try {
|
||||
await getCurrentWebview().setZoom(config.ui_scale);
|
||||
} catch (e) {
|
||||
console.error('Failed to apply interface scale:', e);
|
||||
}
|
||||
}
|
||||
if (!noteWindowPath) removeEditorZoomShortcuts = installEditorZoomShortcuts();
|
||||
removeEditorZoomShortcuts = installEditorZoomShortcuts();
|
||||
|
||||
// Auto-open last vault if available
|
||||
if (config.active_vault) {
|
||||
@@ -240,6 +248,7 @@
|
||||
|
||||
onDestroy(() => {
|
||||
removeEditorZoomShortcuts?.();
|
||||
removeFontSizeListener?.();
|
||||
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||