Merge pull request #25 from SteelDynamite/fix/webdav-sync-and-themes
fix/webdav-sync-and-themes
This commit is contained in:
commit
c4df1413dd
37
.claude/settings.local.json
Normal file
37
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Edit|MultiEdit|Write",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "but claude pre-tool"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Edit|MultiEdit|Write",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "but claude post-tool"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"matcher": "",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "but claude stop"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
19
Cargo.lock
generated
19
Cargo.lock
generated
|
|
@ -990,6 +990,7 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"directories",
|
"directories",
|
||||||
"keyring",
|
"keyring",
|
||||||
|
"log",
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -1144,7 +1145,7 @@ dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1304,9 +1305,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.1"
|
version = "2.1.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
|
|
@ -1347,9 +1348,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.103.9"
|
version = "0.103.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
|
|
@ -2335,18 +2336,18 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.42"
|
version = "0.8.48"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
|
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy-derive",
|
"zerocopy-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy-derive"
|
name = "zerocopy-derive"
|
||||||
version = "0.8.42"
|
version = "0.8.48"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
|
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|
|
||||||
1
apps/tauri/src-tauri/Cargo.lock
generated
1
apps/tauri/src-tauri/Cargo.lock
generated
|
|
@ -2386,6 +2386,7 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"directories",
|
"directories",
|
||||||
"keyring",
|
"keyring",
|
||||||
|
"log",
|
||||||
"quick-xml 0.36.2",
|
"quick-xml 0.36.2",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ use tauri::{Emitter, Manager, State};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use onyx_core::{
|
use onyx_core::{
|
||||||
config::{AppConfig, WorkspaceConfig},
|
config::{AppConfig, WorkspaceConfig, WorkspaceMode},
|
||||||
models::{Task, TaskList, TaskStatus},
|
models::{Task, TaskList, TaskStatus},
|
||||||
repository::TaskRepository,
|
repository::TaskRepository,
|
||||||
sync::{self, SyncMode, SyncResult as CoreSyncResult},
|
sync::{self, SyncMode, SyncResult as CoreSyncResult},
|
||||||
|
|
@ -31,6 +31,7 @@ static LAST_WRITE: Mutex<Option<Instant>> = Mutex::new(None);
|
||||||
struct AppState {
|
struct AppState {
|
||||||
config: AppConfig,
|
config: AppConfig,
|
||||||
config_path: PathBuf,
|
config_path: PathBuf,
|
||||||
|
app_data_dir: PathBuf,
|
||||||
repo: Option<TaskRepository>,
|
repo: Option<TaskRepository>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -418,17 +419,74 @@ fn set_webdav_config(
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn store_credentials(
|
fn set_workspace_theme(
|
||||||
|
workspace_name: String,
|
||||||
|
theme: Option<String>,
|
||||||
|
state: State<'_, Mutex<AppState>>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut s = lock_state(&state)?;
|
||||||
|
if let Some(ws) = s.config.workspaces.get_mut(&workspace_name) {
|
||||||
|
ws.theme = theme;
|
||||||
|
}
|
||||||
|
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn add_webdav_workspace(
|
||||||
|
name: String,
|
||||||
|
webdav_url: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
state: State<'_, Mutex<AppState>>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut s = lock_state(&state)?;
|
||||||
|
let managed_dir = s.app_data_dir.join("workspaces").join(&name);
|
||||||
|
std::fs::create_dir_all(&managed_dir).map_err(|e| e.to_string())?;
|
||||||
|
TaskRepository::init(managed_dir.clone()).map(|_| ()).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut ws = WorkspaceConfig::new(managed_dir);
|
||||||
|
ws.mode = WorkspaceMode::Webdav;
|
||||||
|
ws.webdav_url = Some(webdav_url.clone());
|
||||||
|
|
||||||
|
s.config.add_workspace(name.clone(), ws);
|
||||||
|
s.config.set_current_workspace(name).map_err(|e| e.to_string())?;
|
||||||
|
s.repo = None;
|
||||||
|
|
||||||
|
// Store credentials keyed by hostname
|
||||||
|
let domain = webdav_url
|
||||||
|
.split("://")
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|rest| rest.split('/').next())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?;
|
||||||
|
drop(s);
|
||||||
|
webdav::store_credentials(&domain, &username, &password).map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn store_credentials(
|
||||||
domain: String,
|
domain: String,
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
webdav::store_credentials(&domain, &username, &password).map_err(|e| e.to_string())
|
tokio::task::spawn_blocking(move || {
|
||||||
|
webdav::store_credentials(&domain, &username, &password).map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn load_credentials(domain: String) -> Result<(String, String), String> {
|
async fn load_credentials(domain: String) -> Result<(String, String), String> {
|
||||||
webdav::load_credentials(&domain).map_err(|e| e.to_string())
|
tokio::task::spawn_blocking(move || {
|
||||||
|
webdav::load_credentials(&domain)
|
||||||
|
.map(|(u, p)| ((*u).clone(), (*p).clone()))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -437,7 +495,8 @@ async fn test_webdav_connection(
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let client = onyx_core::webdav::WebDavClient::new(&url, &username, &password);
|
let client = onyx_core::webdav::WebDavClient::new(&url, &username, &password)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
client
|
client
|
||||||
.test_connection()
|
.test_connection()
|
||||||
.await
|
.await
|
||||||
|
|
@ -447,20 +506,39 @@ async fn test_webdav_connection(
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn sync_workspace(
|
async fn sync_workspace(
|
||||||
workspace_name: String,
|
workspace_name: String,
|
||||||
workspace_path: String,
|
|
||||||
webdav_url: String,
|
|
||||||
username: String,
|
|
||||||
password: String,
|
|
||||||
mode: String,
|
mode: String,
|
||||||
state: State<'_, Mutex<AppState>>,
|
state: State<'_, Mutex<AppState>>,
|
||||||
) -> Result<SyncResult, String> {
|
) -> Result<SyncResult, String> {
|
||||||
|
// Step 1: read config
|
||||||
|
let (workspace_path, webdav_url) = {
|
||||||
|
let s = lock_state(&state)?;
|
||||||
|
let ws = s.config.workspaces.get(&workspace_name)
|
||||||
|
.ok_or("Workspace not found")?;
|
||||||
|
(ws.path.clone(), ws.webdav_url.clone().ok_or("No WebDAV URL configured")?)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 2: load credentials
|
||||||
|
let domain = webdav_url
|
||||||
|
.split("://")
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|rest| rest.split('/').next())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let (username, password) = tokio::task::spawn_blocking(move || {
|
||||||
|
webdav::load_credentials(&domain)
|
||||||
|
.map(|(u, p)| ((*u).clone(), (*p).clone()))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())??;
|
||||||
|
|
||||||
let sync_mode = match mode.as_str() {
|
let sync_mode = match mode.as_str() {
|
||||||
"push" => SyncMode::Push,
|
"push" => SyncMode::Push,
|
||||||
"pull" => SyncMode::Pull,
|
"pull" => SyncMode::Pull,
|
||||||
_ => SyncMode::Full,
|
_ => SyncMode::Full,
|
||||||
};
|
};
|
||||||
let result = sync::sync_workspace(
|
let result = sync::sync_workspace(
|
||||||
&PathBuf::from(&workspace_path),
|
&workspace_path,
|
||||||
&webdav_url,
|
&webdav_url,
|
||||||
&username,
|
&username,
|
||||||
&password,
|
&password,
|
||||||
|
|
@ -470,7 +548,6 @@ async fn sync_workspace(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// Persist last_sync timestamp to config
|
|
||||||
{
|
{
|
||||||
let mut s = lock_state(&state)?;
|
let mut s = lock_state(&state)?;
|
||||||
if let Some(ws) = s.config.workspaces.get_mut(&workspace_name) {
|
if let Some(ws) = s.config.workspaces.get_mut(&workspace_name) {
|
||||||
|
|
@ -545,23 +622,18 @@ pub fn run() {
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_os::init())
|
.plugin(tauri_plugin_os::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
// Resolve config path: Tauri's app_data_dir on Android, directories crate on desktop
|
// Resolve app data dir and config path
|
||||||
|
let app_data_dir = app.path().app_data_dir()
|
||||||
|
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
|
||||||
let config_path = {
|
let config_path = {
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
{
|
{ app_data_dir.join("config.json") }
|
||||||
use tauri::Manager;
|
|
||||||
app.path().app_data_dir()
|
|
||||||
.map_err(|e| format!("Failed to get app data dir: {}", e))?
|
|
||||||
.join("config.json")
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
{
|
{ AppConfig::get_config_path() }
|
||||||
AppConfig::get_config_path()
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let config = AppConfig::load_from_file(&config_path).unwrap_or_default();
|
let config = AppConfig::load_from_file(&config_path).unwrap_or_default();
|
||||||
let workspace_path = config.get_current_workspace().ok().map(|(_, ws)| ws.path.clone());
|
let workspace_path = config.get_current_workspace().ok().map(|(_, ws)| ws.path.clone());
|
||||||
app.manage(Mutex::new(AppState { config, config_path, repo: None }));
|
app.manage(Mutex::new(AppState { config, config_path, app_data_dir, repo: None }));
|
||||||
|
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
if let Some(path) = workspace_path {
|
if let Some(path) = workspace_path {
|
||||||
|
|
@ -591,6 +663,8 @@ pub fn run() {
|
||||||
set_group_by_due_date,
|
set_group_by_due_date,
|
||||||
get_group_by_due_date,
|
get_group_by_due_date,
|
||||||
set_webdav_config,
|
set_webdav_config,
|
||||||
|
set_workspace_theme,
|
||||||
|
add_webdav_workspace,
|
||||||
store_credentials,
|
store_credentials,
|
||||||
load_credentials,
|
load_credentials,
|
||||||
test_webdav_connection,
|
test_webdav_connection,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={app.darkMode ? "dark" : ""}>
|
<div class={app.isDark ? "dark" : ""} data-theme={app.currentTheme ?? ""}>
|
||||||
<div class="h-screen w-screen" class:p-2={isLinux}>
|
<div class="h-screen w-screen" class:p-2={isLinux}>
|
||||||
<div
|
<div
|
||||||
class="relative h-full w-full overflow-hidden bg-surface-light text-text-light dark:bg-surface-dark dark:text-text-dark"
|
class="relative h-full w-full overflow-hidden bg-surface-light text-text-light dark:bg-surface-dark dark:text-text-dark"
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if app.screen === "setup"}
|
{#if app.screen === "setup"}
|
||||||
<SetupScreen />
|
<SetupScreen cancellable={app.hasWorkspace} />
|
||||||
{:else}
|
{:else}
|
||||||
<TasksScreen />
|
<TasksScreen />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -68,3 +68,83 @@ body {
|
||||||
background-color: #242424;
|
background-color: #242424;
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Theme overrides ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--color-primary: #2d87b8;
|
||||||
|
--color-primary-hover: #2474a0;
|
||||||
|
--color-surface-light: #ffffff;
|
||||||
|
--color-surface-dark: #ffffff;
|
||||||
|
--color-card-light: #f9fafb;
|
||||||
|
--color-card-dark: #f9fafb;
|
||||||
|
--color-text-light: #1f2937;
|
||||||
|
--color-text-dark: #1f2937;
|
||||||
|
--color-text-secondary-light: #6b7280;
|
||||||
|
--color-text-secondary-dark: #6b7280;
|
||||||
|
--color-border-light: #e5e7eb;
|
||||||
|
--color-border-dark: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--color-primary: #2d87b8;
|
||||||
|
--color-primary-hover: #2474a0;
|
||||||
|
--color-surface-light: #242424;
|
||||||
|
--color-surface-dark: #242424;
|
||||||
|
--color-card-light: #303030;
|
||||||
|
--color-card-dark: #303030;
|
||||||
|
--color-text-light: #e5e7eb;
|
||||||
|
--color-text-dark: #e5e7eb;
|
||||||
|
--color-text-secondary-light: #9ca3af;
|
||||||
|
--color-text-secondary-dark: #9ca3af;
|
||||||
|
--color-border-light: #3d3d3d;
|
||||||
|
--color-border-dark: #3d3d3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="nord"] {
|
||||||
|
--color-primary: #88c0d0;
|
||||||
|
--color-primary-hover: #7ab3c3;
|
||||||
|
--color-surface-light: #2e3440;
|
||||||
|
--color-surface-dark: #2e3440;
|
||||||
|
--color-card-light: #3b4252;
|
||||||
|
--color-card-dark: #3b4252;
|
||||||
|
--color-text-light: #eceff4;
|
||||||
|
--color-text-dark: #eceff4;
|
||||||
|
--color-text-secondary-light: #d8dee9;
|
||||||
|
--color-text-secondary-dark: #d8dee9;
|
||||||
|
--color-border-light: #434c5e;
|
||||||
|
--color-border-dark: #434c5e;
|
||||||
|
--color-danger: #bf616a;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dracula"] {
|
||||||
|
--color-primary: #bd93f9;
|
||||||
|
--color-primary-hover: #a87ef0;
|
||||||
|
--color-surface-light: #282a36;
|
||||||
|
--color-surface-dark: #282a36;
|
||||||
|
--color-card-light: #343746;
|
||||||
|
--color-card-dark: #343746;
|
||||||
|
--color-text-light: #f8f8f2;
|
||||||
|
--color-text-dark: #f8f8f2;
|
||||||
|
--color-text-secondary-light: #bfbfbf;
|
||||||
|
--color-text-secondary-dark: #bfbfbf;
|
||||||
|
--color-border-light: #44475a;
|
||||||
|
--color-border-dark: #44475a;
|
||||||
|
--color-danger: #ff5555;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="solarized"] {
|
||||||
|
--color-primary: #268bd2;
|
||||||
|
--color-primary-hover: #1e7ac0;
|
||||||
|
--color-surface-light: #002b36;
|
||||||
|
--color-surface-dark: #002b36;
|
||||||
|
--color-card-light: #073642;
|
||||||
|
--color-card-dark: #073642;
|
||||||
|
--color-text-light: #93a1a1;
|
||||||
|
--color-text-dark: #93a1a1;
|
||||||
|
--color-text-secondary-light: #657b83;
|
||||||
|
--color-text-secondary-dark: #657b83;
|
||||||
|
--color-border-light: #094959;
|
||||||
|
--color-border-dark: #094959;
|
||||||
|
--color-danger: #dc322f;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { app } from "../stores/app.svelte";
|
import { app } from "../stores/app.svelte";
|
||||||
|
|
||||||
let { onclose }: { onclose?: () => void } = $props();
|
let { onclose, workspaceName }: { onclose?: () => void; workspaceName: string } = $props();
|
||||||
|
|
||||||
|
let ws = $derived(app.config?.workspaces[workspaceName]);
|
||||||
|
let isWebdav = $derived(ws?.mode === "webdav");
|
||||||
|
|
||||||
let webdavUrl = $state("");
|
let webdavUrl = $state("");
|
||||||
let webdavUser = $state("");
|
let webdavUser = $state("");
|
||||||
|
|
@ -10,19 +13,15 @@
|
||||||
let testStatus = $state<"idle" | "testing" | "ok" | "fail">("idle");
|
let testStatus = $state<"idle" | "testing" | "ok" | "fail">("idle");
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const ws = app.config?.current_workspace;
|
if (!ws?.webdav_url) return;
|
||||||
if (!ws) return;
|
webdavUrl = ws.webdav_url;
|
||||||
const cfg = app.config?.workspaces[ws];
|
try {
|
||||||
if (cfg?.webdav_url) {
|
const domain = new URL(ws.webdav_url).hostname;
|
||||||
webdavUrl = cfg.webdav_url;
|
invoke<[string, string]>("load_credentials", { domain }).then(([u, p]) => {
|
||||||
try {
|
webdavUser = u;
|
||||||
const domain = new URL(cfg.webdav_url).hostname;
|
webdavPass = p;
|
||||||
invoke<[string, string]>("load_credentials", { domain }).then(([u, p]) => {
|
}).catch(() => {});
|
||||||
webdavUser = u;
|
} catch {}
|
||||||
webdavPass = p;
|
|
||||||
}).catch(() => {});
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function testConnection() {
|
async function testConnection() {
|
||||||
|
|
@ -40,9 +39,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveWebdav() {
|
async function saveWebdav() {
|
||||||
if (!app.config?.current_workspace || !webdavUrl.trim()) return;
|
if (!webdavUrl.trim()) return;
|
||||||
await invoke("set_webdav_config", {
|
await invoke("set_webdav_config", {
|
||||||
workspaceName: app.config.current_workspace,
|
workspaceName,
|
||||||
webdavUrl: webdavUrl.trim(),
|
webdavUrl: webdavUrl.trim(),
|
||||||
});
|
});
|
||||||
if (webdavUser && webdavPass) {
|
if (webdavUser && webdavPass) {
|
||||||
|
|
@ -55,13 +54,12 @@
|
||||||
}
|
}
|
||||||
await app.loadConfig();
|
await app.loadConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header
|
<header
|
||||||
class="flex items-center justify-between border-b border-border-light px-4 py-3 dark:border-border-dark"
|
class="flex items-center justify-between border-b border-border-light px-4 py-3 dark:border-border-dark"
|
||||||
>
|
>
|
||||||
<h1 class="text-lg font-bold">Settings</h1>
|
<h1 class="text-lg font-bold">{workspaceName} Settings</h1>
|
||||||
<button
|
<button
|
||||||
onclick={() => onclose?.()}
|
onclick={() => onclose?.()}
|
||||||
class="rounded-lg p-1.5 hover:bg-black/5 dark:hover:bg-white/10"
|
class="rounded-lg p-1.5 hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
|
|
@ -75,53 +73,53 @@
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="flex-1 overflow-y-auto p-4">
|
<main class="flex-1 overflow-y-auto p-4">
|
||||||
<!-- WebDAV Sync -->
|
<!-- WebDAV Sync (only for webdav workspaces) -->
|
||||||
<section class="mb-6">
|
{#if isWebdav}
|
||||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide opacity-50">
|
<section class="mb-6">
|
||||||
WebDAV Sync
|
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide opacity-50">
|
||||||
</h2>
|
WebDAV Sync
|
||||||
<div class="rounded-xl border border-border-light p-4 dark:border-border-dark">
|
</h2>
|
||||||
<label class="mb-1 block text-xs font-medium opacity-60">Server URL</label>
|
<div class="rounded-xl border border-border-light p-4 dark:border-border-dark">
|
||||||
<input
|
<label class="mb-1 block text-xs font-medium opacity-60">Server URL</label>
|
||||||
type="url"
|
<input
|
||||||
bind:value={webdavUrl}
|
type="url"
|
||||||
placeholder="https://dav.example.com/tasks/"
|
bind:value={webdavUrl}
|
||||||
class="mb-3 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
|
placeholder="https://dav.example.com/tasks/"
|
||||||
/>
|
class="mb-3 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
|
||||||
|
/>
|
||||||
|
|
||||||
<label class="mb-1 block text-xs font-medium opacity-60">Username</label>
|
<label class="mb-1 block text-xs font-medium opacity-60">Username</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={webdavUser}
|
bind:value={webdavUser}
|
||||||
class="mb-3 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
|
class="mb-3 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label class="mb-1 block text-xs font-medium opacity-60">Password</label>
|
<label class="mb-1 block text-xs font-medium opacity-60">Password</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
bind:value={webdavPass}
|
bind:value={webdavPass}
|
||||||
class="mb-4 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
|
class="mb-4 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onclick={testConnection}
|
onclick={testConnection}
|
||||||
disabled={!webdavUrl.trim()}
|
disabled={!webdavUrl.trim()}
|
||||||
class="rounded-lg border border-border-light px-4 py-2 text-sm font-medium hover:bg-black/5 disabled:opacity-40 dark:border-border-dark dark:hover:bg-white/10"
|
class="rounded-lg border border-border-light px-4 py-2 text-sm font-medium hover:bg-black/5 disabled:opacity-40 dark:border-border-dark dark:hover:bg-white/10"
|
||||||
>
|
>
|
||||||
{testStatus === "testing" ? "Testing…" : testStatus === "ok" ? "Connected" : testStatus === "fail" ? "Failed — Retry" : "Test Connection"}
|
{testStatus === "testing" ? "Testing..." : testStatus === "ok" ? "Connected" : testStatus === "fail" ? "Failed -- Retry" : "Test Connection"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={saveWebdav}
|
onclick={saveWebdav}
|
||||||
disabled={!webdavUrl.trim()}
|
disabled={!webdavUrl.trim()}
|
||||||
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
|
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if app.config?.current_workspace}
|
|
||||||
<div class="mt-3 flex items-center gap-2">
|
<div class="mt-3 flex items-center gap-2">
|
||||||
<select
|
<select
|
||||||
value={app.syncMode}
|
value={app.syncMode}
|
||||||
|
|
@ -137,11 +135,14 @@
|
||||||
disabled={app.syncing}
|
disabled={app.syncing}
|
||||||
class="flex-1 rounded-lg bg-primary py-2 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
|
class="flex-1 rounded-lg bg-primary py-2 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{app.syncing ? "Syncing…" : "Sync Now"}
|
{app.syncing ? "Syncing..." : "Sync Now"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if app.config.workspaces[app.config.current_workspace]?.last_sync}
|
{#if app.error}
|
||||||
{@const lastSync = new Date(app.config.workspaces[app.config.current_workspace].last_sync!)}
|
<p class="mt-1.5 text-xs text-danger">{app.error}</p>
|
||||||
|
{/if}
|
||||||
|
{#if ws?.last_sync}
|
||||||
|
{@const lastSync = new Date(ws.last_sync)}
|
||||||
{@const secsAgo = Math.floor((Date.now() - lastSync.getTime()) / 1000)}
|
{@const secsAgo = Math.floor((Date.now() - lastSync.getTime()) / 1000)}
|
||||||
{@const relTime = secsAgo < 60 ? "just now" : secsAgo < 3600 ? `${Math.floor(secsAgo / 60)}m ago` : `${Math.floor(secsAgo / 3600)}h ago`}
|
{@const relTime = secsAgo < 60 ? "just now" : secsAgo < 3600 ? `${Math.floor(secsAgo / 60)}m ago` : `${Math.floor(secsAgo / 3600)}h ago`}
|
||||||
<p class="mt-1.5 text-xs opacity-40">
|
<p class="mt-1.5 text-xs opacity-40">
|
||||||
|
|
@ -151,27 +152,32 @@
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
</section>
|
||||||
</section>
|
{/if}
|
||||||
|
|
||||||
<!-- Theme -->
|
<!-- Theme -->
|
||||||
<section>
|
<section>
|
||||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide opacity-50">
|
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide opacity-50">
|
||||||
Appearance
|
Appearance
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<div class="rounded-xl border border-border-light p-4 dark:border-border-dark">
|
||||||
onclick={() => app.toggleDarkMode()}
|
<label class="mb-1 block text-xs font-medium opacity-60">Theme</label>
|
||||||
class="flex w-full items-center justify-between rounded-xl border border-border-light p-4 dark:border-border-dark"
|
<select
|
||||||
>
|
value={ws?.theme ?? ""}
|
||||||
<span class="text-sm font-medium">Dark mode</span>
|
onchange={(e) => {
|
||||||
<div
|
const val = (e.target as HTMLSelectElement).value;
|
||||||
class="h-6 w-11 rounded-full transition-colors {app.darkMode ? 'bg-primary' : 'bg-gray-300 dark:bg-gray-600'}"
|
app.setTheme(val || null);
|
||||||
|
}}
|
||||||
|
class="w-full appearance-none rounded-lg border border-border-light bg-surface-light px-3 py-2 text-sm text-text-light outline-none focus:border-primary dark:border-border-dark dark:bg-surface-dark dark:text-text-dark"
|
||||||
>
|
>
|
||||||
<div
|
<option value="">System default</option>
|
||||||
class="h-5 w-5 translate-y-0.5 rounded-full bg-white shadow transition-transform {app.darkMode ? 'translate-x-5.5' : 'translate-x-0.5'}"
|
<option value="light">Light</option>
|
||||||
></div>
|
<option value="dark">Dark</option>
|
||||||
</div>
|
<option value="nord">Nord</option>
|
||||||
</button>
|
<option value="dracula">Dracula</option>
|
||||||
|
<option value="solarized">Solarized Dark</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p class="mt-8 text-center text-xs opacity-30">Tauri v2 + Svelte</p>
|
<p class="mt-8 text-center text-xs opacity-30">Tauri v2 + Svelte</p>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,25 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { app } from "../stores/app.svelte";
|
import { app } from "../stores/app.svelte";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { platform } from "@tauri-apps/plugin-os";
|
import { platform } from "@tauri-apps/plugin-os";
|
||||||
|
|
||||||
|
let { cancellable = false }: { cancellable?: boolean } = $props();
|
||||||
|
|
||||||
const appWindow = getCurrentWindow();
|
const appWindow = getCurrentWindow();
|
||||||
const currentPlatform = platform();
|
const currentPlatform = platform();
|
||||||
const isDesktop = currentPlatform === "linux" || currentPlatform === "windows";
|
const isDesktop = currentPlatform === "linux" || currentPlatform === "windows";
|
||||||
const isWindows = currentPlatform === "windows";
|
const isWindows = currentPlatform === "windows";
|
||||||
|
const isMobile = currentPlatform === "android" || currentPlatform === "ios";
|
||||||
|
|
||||||
|
let mode = $state<"local" | "webdav" | null>(isMobile ? "webdav" : null);
|
||||||
let name = $state("");
|
let name = $state("");
|
||||||
let path = $state("");
|
let path = $state("");
|
||||||
|
let webdavUrl = $state("");
|
||||||
|
let webdavUser = $state("");
|
||||||
|
let webdavPass = $state("");
|
||||||
|
let testStatus = $state<"idle" | "testing" | "ok" | "fail">("idle");
|
||||||
|
|
||||||
async function pickFolder() {
|
async function pickFolder() {
|
||||||
const selected = await open({ directory: true, multiple: false });
|
const selected = await open({ directory: true, multiple: false });
|
||||||
|
|
@ -26,23 +35,63 @@
|
||||||
const selected = await open({ directory: true, multiple: false });
|
const selected = await open({ directory: true, multiple: false });
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
const folder = selected as string;
|
const folder = selected as string;
|
||||||
// Derive workspace name from folder name
|
|
||||||
const parts = folder.replace(/\\/g, "/").split("/");
|
const parts = folder.replace(/\\/g, "/").split("/");
|
||||||
const wsName = parts[parts.length - 1] || "workspace";
|
const wsName = parts[parts.length - 1] || "workspace";
|
||||||
await app.addWorkspace(wsName, folder);
|
await app.addWorkspace(wsName, folder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function testConnection() {
|
||||||
|
testStatus = "testing";
|
||||||
|
try {
|
||||||
|
await invoke("test_webdav_connection", {
|
||||||
|
url: webdavUrl,
|
||||||
|
username: webdavUser,
|
||||||
|
password: webdavPass,
|
||||||
|
});
|
||||||
|
testStatus = "ok";
|
||||||
|
} catch {
|
||||||
|
testStatus = "fail";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateWebdav() {
|
||||||
|
if (!name.trim() || !webdavUrl.trim()) return;
|
||||||
|
await app.addWebdavWorkspace(name.trim(), webdavUrl.trim(), webdavUser, webdavPass);
|
||||||
|
}
|
||||||
|
|
||||||
function handleDrag(e: MouseEvent) {
|
function handleDrag(e: MouseEvent) {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
if ((e.target as HTMLElement).closest("button, input")) return;
|
if ((e.target as HTMLElement).closest("button, input")) return;
|
||||||
if (isDesktop) appWindow.startDragging();
|
if (isDesktop) appWindow.startDragging();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
mode = null;
|
||||||
|
name = "";
|
||||||
|
path = "";
|
||||||
|
webdavUrl = "";
|
||||||
|
webdavUser = "";
|
||||||
|
webdavPass = "";
|
||||||
|
testStatus = "idle";
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="flex h-full flex-col" onmousedown={handleDrag}>
|
<div class="flex h-full flex-col" onmousedown={handleDrag}>
|
||||||
<!-- Title bar area with window controls -->
|
<!-- Title bar area with window controls -->
|
||||||
<header class="flex h-11 shrink-0 items-center justify-end px-2">
|
<header class="flex h-11 shrink-0 items-center justify-between px-2">
|
||||||
|
<div>
|
||||||
|
{#if cancellable}
|
||||||
|
<button
|
||||||
|
onclick={() => app.setScreen("tasks")}
|
||||||
|
class="rounded-lg p-1.5 opacity-50 hover:bg-black/10 hover:opacity-80 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{#if isDesktop}
|
{#if isDesktop}
|
||||||
<div class="flex items-center gap-0.5">
|
<div class="flex items-center gap-0.5">
|
||||||
{#if isWindows}
|
{#if isWindows}
|
||||||
|
|
@ -72,58 +121,162 @@
|
||||||
class="w-full max-w-sm rounded-2xl bg-card-light p-8 shadow-lg dark:bg-card-dark"
|
class="w-full max-w-sm rounded-2xl bg-card-light p-8 shadow-lg dark:bg-card-dark"
|
||||||
>
|
>
|
||||||
<h1 class="mb-1 text-2xl font-bold">Onyx</h1>
|
<h1 class="mb-1 text-2xl font-bold">Onyx</h1>
|
||||||
<p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark">
|
|
||||||
Create a new workspace or open an existing one.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<label class="mb-1 block text-sm font-medium">
|
{#if mode === null}
|
||||||
Workspace name
|
<!-- Step 1: Choose mode -->
|
||||||
<input
|
<p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark">
|
||||||
type="text"
|
How would you like to store your tasks?
|
||||||
bind:value={name}
|
</p>
|
||||||
placeholder="My Tasks"
|
|
||||||
class="mt-1 mb-4 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm font-normal outline-none focus:border-primary dark:border-border-dark"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
|
||||||
<label class="mb-1 block text-sm font-medium">Folder</label>
|
|
||||||
<div class="mb-6 flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={path}
|
|
||||||
readonly
|
|
||||||
placeholder="Select a folder…"
|
|
||||||
class="min-w-0 flex-1 rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm dark:border-border-dark"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
onclick={pickFolder}
|
onclick={() => (mode = "local")}
|
||||||
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary-hover"
|
class="mb-3 w-full rounded-xl border border-border-light p-4 text-left hover:bg-black/5 dark:border-border-dark dark:hover:bg-white/10"
|
||||||
>
|
>
|
||||||
Browse
|
<p class="text-sm font-semibold">Local Folder</p>
|
||||||
|
<p class="mt-0.5 text-xs text-text-secondary-light dark:text-text-secondary-dark">
|
||||||
|
Pick a folder on your computer. Files stay local.
|
||||||
|
</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={handleCreate}
|
onclick={() => (mode = "webdav")}
|
||||||
disabled={!name.trim() || !path.trim()}
|
class="w-full rounded-xl border border-border-light p-4 text-left hover:bg-black/5 dark:border-border-dark dark:hover:bg-white/10"
|
||||||
class="w-full rounded-lg bg-primary py-2.5 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
|
>
|
||||||
>
|
<p class="text-sm font-semibold">WebDAV Server</p>
|
||||||
Create Workspace
|
<p class="mt-0.5 text-xs text-text-secondary-light dark:text-text-secondary-dark">
|
||||||
</button>
|
Connect to a WebDAV server. The app manages local files automatically.
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="my-4 flex items-center gap-3">
|
{:else if mode === "local"}
|
||||||
<div class="h-px flex-1 bg-border-light dark:bg-border-dark"></div>
|
<!-- Step 2a: Local workspace -->
|
||||||
<span class="text-xs opacity-40">or</span>
|
<p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark">
|
||||||
<div class="h-px flex-1 bg-border-light dark:bg-border-dark"></div>
|
Create a new workspace or open an existing one.
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
<button
|
<label class="mb-1 block text-sm font-medium">
|
||||||
onclick={handleOpen}
|
Workspace name
|
||||||
class="w-full rounded-lg border border-border-light py-2.5 text-sm font-medium hover:bg-black/5 dark:border-border-dark dark:hover:bg-white/10"
|
<input
|
||||||
>
|
type="text"
|
||||||
Open Existing Folder
|
bind:value={name}
|
||||||
</button>
|
placeholder="My Tasks"
|
||||||
|
class="mt-1 mb-4 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm font-normal outline-none focus:border-primary dark:border-border-dark"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
|
<label class="mb-1 block text-sm font-medium">Folder</label>
|
||||||
|
<div class="mb-6 flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={path}
|
||||||
|
readonly
|
||||||
|
placeholder="Select a folder..."
|
||||||
|
class="min-w-0 flex-1 rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm dark:border-border-dark"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onclick={pickFolder}
|
||||||
|
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary-hover"
|
||||||
|
>
|
||||||
|
Browse
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={handleCreate}
|
||||||
|
disabled={!name.trim() || !path.trim()}
|
||||||
|
class="w-full rounded-lg bg-primary py-2.5 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Create Workspace
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="my-4 flex items-center gap-3">
|
||||||
|
<div class="h-px flex-1 bg-border-light dark:bg-border-dark"></div>
|
||||||
|
<span class="text-xs opacity-40">or</span>
|
||||||
|
<div class="h-px flex-1 bg-border-light dark:bg-border-dark"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={handleOpen}
|
||||||
|
class="mb-3 w-full rounded-lg border border-border-light py-2.5 text-sm font-medium hover:bg-black/5 dark:border-border-dark dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Open Existing Folder
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if !isMobile}
|
||||||
|
<button
|
||||||
|
onclick={goBack}
|
||||||
|
class="w-full rounded-lg py-2 text-sm opacity-50 hover:opacity-80"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<!-- Step 2b: WebDAV workspace -->
|
||||||
|
<p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark">
|
||||||
|
Connect to a WebDAV server for cloud-synced tasks.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="mb-1 block text-sm font-medium">
|
||||||
|
Workspace name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={name}
|
||||||
|
placeholder="My Tasks"
|
||||||
|
class="mt-1 mb-4 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm font-normal outline-none focus:border-primary dark:border-border-dark"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="mb-1 block text-xs font-medium opacity-60">Server URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
bind:value={webdavUrl}
|
||||||
|
placeholder="https://dav.example.com/tasks/"
|
||||||
|
class="mb-3 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label class="mb-1 block text-xs font-medium opacity-60">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={webdavUser}
|
||||||
|
class="mb-3 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label class="mb-1 block text-xs font-medium opacity-60">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
bind:value={webdavPass}
|
||||||
|
class="mb-4 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="mb-4 flex gap-2">
|
||||||
|
<button
|
||||||
|
onclick={testConnection}
|
||||||
|
disabled={!webdavUrl.trim()}
|
||||||
|
class="rounded-lg border border-border-light px-4 py-2 text-sm font-medium hover:bg-black/5 disabled:opacity-40 dark:border-border-dark dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{testStatus === "testing" ? "Testing..." : testStatus === "ok" ? "Connected" : testStatus === "fail" ? "Failed -- Retry" : "Test Connection"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={handleCreateWebdav}
|
||||||
|
disabled={!name.trim() || !webdavUrl.trim()}
|
||||||
|
class="w-full rounded-lg bg-primary py-2.5 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Create Workspace
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if !isMobile}
|
||||||
|
<button
|
||||||
|
onclick={goBack}
|
||||||
|
class="mt-3 w-full rounded-lg py-2 text-sm opacity-50 hover:opacity-80"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@
|
||||||
|
|
||||||
let showDrawer = $state(false);
|
let showDrawer = $state(false);
|
||||||
let showSettings = $state(false);
|
let showSettings = $state(false);
|
||||||
|
let settingsWorkspace = $state<string | null>(null);
|
||||||
let showNewList = $state(false);
|
let showNewList = $state(false);
|
||||||
let showWorkspacePicker = $state(false);
|
let showWorkspacePicker = $state(false);
|
||||||
let workspacePickerEl = $state<HTMLDivElement | null>(null);
|
let workspacePickerEl = $state<HTMLDivElement | null>(null);
|
||||||
|
|
@ -152,7 +153,7 @@
|
||||||
clone.style.position = "absolute";
|
clone.style.position = "absolute";
|
||||||
clone.style.top = "-9999px";
|
clone.style.top = "-9999px";
|
||||||
clone.style.left = "-9999px";
|
clone.style.left = "-9999px";
|
||||||
if (app.darkMode) {
|
if (app.isDark) {
|
||||||
clone.classList.add("dark");
|
clone.classList.add("dark");
|
||||||
clone.style.backgroundColor = "var(--color-surface-dark)";
|
clone.style.backgroundColor = "var(--color-surface-dark)";
|
||||||
clone.style.color = "var(--color-text-dark)";
|
clone.style.color = "var(--color-text-dark)";
|
||||||
|
|
@ -192,12 +193,9 @@
|
||||||
showNewList = false;
|
showNewList = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSettings() {
|
|
||||||
showSettings = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeSettings() {
|
function closeSettings() {
|
||||||
showSettings = false;
|
showSettings = false;
|
||||||
|
settingsWorkspace = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleHeaderMouseDown(e: MouseEvent) {
|
function handleHeaderMouseDown(e: MouseEvent) {
|
||||||
|
|
@ -256,7 +254,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="truncate text-sm">{name}</p>
|
<p class="truncate text-sm">{name}</p>
|
||||||
<p class="truncate text-xs opacity-40">{ws?.path ?? ""}</p>
|
<p class="truncate text-xs opacity-40">{ws?.mode === "webdav" ? ws.webdav_url ?? "WebDAV" : ws?.path ?? ""}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div class="relative shrink-0" data-ws-menu>
|
<div class="relative shrink-0" data-ws-menu>
|
||||||
|
|
@ -270,6 +268,15 @@
|
||||||
</button>
|
</button>
|
||||||
{#if wsMenuName === name}
|
{#if wsMenuName === name}
|
||||||
<div class="absolute right-0 top-full z-40 mt-1 min-w-[140px] rounded-lg border border-border-light bg-surface-light py-1 shadow-lg dark:border-border-dark dark:bg-surface-dark">
|
<div class="absolute right-0 top-full z-40 mt-1 min-w-[140px] rounded-lg border border-border-light bg-surface-light py-1 shadow-lg dark:border-border-dark dark:bg-surface-dark">
|
||||||
|
<button
|
||||||
|
onclick={() => { wsMenuName = null; settingsWorkspace = name; showSettings = true; showWorkspacePicker = false; }}
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => { wsMenuName = null; confirmRemoveWorkspace = name; }}
|
onclick={() => { wsMenuName = null; confirmRemoveWorkspace = name; }}
|
||||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-danger hover:bg-black/5 dark:hover:bg-white/10"
|
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-danger hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
|
|
@ -347,20 +354,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings -->
|
|
||||||
<button
|
|
||||||
onclick={openSettings}
|
|
||||||
class="flex shrink-0 items-center gap-2 border-t border-border-light px-5 py-3 text-sm opacity-50 hover:bg-black/5 hover:opacity-80 dark:border-border-dark dark:hover:bg-white/10"
|
|
||||||
>
|
|
||||||
<svg class="h-4.5 w-4.5" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Settings
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main content panel -->
|
<!-- Main content panel -->
|
||||||
|
|
@ -632,7 +625,7 @@
|
||||||
class="relative flex h-full w-full flex-col overflow-hidden rounded-2xl bg-surface-light transition-transform duration-200 dark:bg-surface-dark {showSettings ? 'scale-100' : 'scale-95'}"
|
class="relative flex h-full w-full flex-col overflow-hidden rounded-2xl bg-surface-light transition-transform duration-200 dark:bg-surface-dark {showSettings ? 'scale-100' : 'scale-95'}"
|
||||||
style="border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 25px 60px rgba(0,0,0,0.7), 0 10px 20px rgba(0,0,0,0.5)"
|
style="border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 25px 60px rgba(0,0,0,0.7), 0 10px 20px rgba(0,0,0,0.5)"
|
||||||
>
|
>
|
||||||
<SettingsScreen onclose={closeSettings} />
|
<SettingsScreen onclose={closeSettings} workspaceName={settingsWorkspace ?? app.config?.current_workspace ?? ""} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,7 @@ let config = $state<AppConfig | null>(null);
|
||||||
let lists = $state<TaskList[]>([]);
|
let lists = $state<TaskList[]>([]);
|
||||||
let activeListId = $state<string | null>(null);
|
let activeListId = $state<string | null>(null);
|
||||||
let tasks = $state<Task[]>([]);
|
let tasks = $state<Task[]>([]);
|
||||||
let darkMode = $state(
|
let osDark = globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
|
||||||
globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false,
|
|
||||||
);
|
|
||||||
let syncing = $state(false);
|
let syncing = $state(false);
|
||||||
let syncMode = $state<"full" | "push" | "pull">("full");
|
let syncMode = $state<"full" | "push" | "pull">("full");
|
||||||
let lastSyncResult = $state<SyncResult | null>(null);
|
let lastSyncResult = $state<SyncResult | null>(null);
|
||||||
|
|
@ -56,6 +54,16 @@ let hasWorkspace = $derived(
|
||||||
Object.keys(config.workspaces).length > 0,
|
Object.keys(config.workspaces).length > 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const DARK_THEMES = new Set(["dark", "nord", "dracula", "solarized"]);
|
||||||
|
let currentTheme = $derived(
|
||||||
|
config?.current_workspace
|
||||||
|
? config.workspaces[config.current_workspace]?.theme ?? null
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
let isDark = $derived(
|
||||||
|
currentTheme ? DARK_THEMES.has(currentTheme) : osDark,
|
||||||
|
);
|
||||||
|
|
||||||
// ── Actions ──────────────────────────────────────────────────────────
|
// ── Actions ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function loadConfig() {
|
async function loadConfig() {
|
||||||
|
|
@ -274,30 +282,17 @@ async function setGroupByDueDate(listId: string, enabled: boolean) {
|
||||||
|
|
||||||
async function triggerSync() {
|
async function triggerSync() {
|
||||||
if (!config?.current_workspace) return;
|
if (!config?.current_workspace) return;
|
||||||
const workspaceName = config.current_workspace;
|
|
||||||
const ws = config.workspaces[workspaceName];
|
|
||||||
if (!ws?.webdav_url) {
|
|
||||||
error = "No WebDAV URL configured";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
syncing = true;
|
syncing = true;
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
const domain = new URL(ws.webdav_url).hostname;
|
|
||||||
const [username, password] = await invoke<[string, string]>("load_credentials", { domain });
|
|
||||||
const result = await invoke<SyncResult>("sync_workspace", {
|
const result = await invoke<SyncResult>("sync_workspace", {
|
||||||
workspaceName,
|
workspaceName: config.current_workspace,
|
||||||
workspacePath: ws.path,
|
|
||||||
webdavUrl: ws.webdav_url,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
mode: syncMode,
|
mode: syncMode,
|
||||||
});
|
});
|
||||||
lastSyncResult = result;
|
lastSyncResult = result;
|
||||||
if (result.errors.length > 0) {
|
if (result.errors.length > 0) {
|
||||||
error = result.errors.join("; ");
|
error = result.errors.join("; ");
|
||||||
}
|
}
|
||||||
// Reload config to pick up updated last_sync timestamp
|
|
||||||
config = await invoke<AppConfig>("get_config");
|
config = await invoke<AppConfig>("get_config");
|
||||||
await loadLists();
|
await loadLists();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -311,8 +306,31 @@ function setSyncMode(mode: "full" | "push" | "pull") {
|
||||||
syncMode = mode;
|
syncMode = mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleDarkMode() {
|
async function setTheme(theme: string | null) {
|
||||||
darkMode = !darkMode;
|
if (!config?.current_workspace) return;
|
||||||
|
try {
|
||||||
|
await invoke("set_workspace_theme", {
|
||||||
|
workspaceName: config.current_workspace,
|
||||||
|
theme,
|
||||||
|
});
|
||||||
|
config = await invoke<AppConfig>("get_config");
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addWebdavWorkspace(name: string, webdavUrl: string, username: string, password: string) {
|
||||||
|
try {
|
||||||
|
await invoke("add_webdav_workspace", { name, webdavUrl, username, password });
|
||||||
|
config = await invoke<AppConfig>("get_config");
|
||||||
|
await loadLists();
|
||||||
|
const ws = config?.workspaces[name];
|
||||||
|
if (ws) invoke("watch_workspace", { path: ws.path }).catch((e) => console.warn("File watcher failed:", e));
|
||||||
|
screen = "tasks";
|
||||||
|
error = null;
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setScreen(s: Screen) {
|
function setScreen(s: Screen) {
|
||||||
|
|
@ -350,8 +368,11 @@ export const app = {
|
||||||
get completedTasks() {
|
get completedTasks() {
|
||||||
return completedTasks;
|
return completedTasks;
|
||||||
},
|
},
|
||||||
get darkMode() {
|
get currentTheme() {
|
||||||
return darkMode;
|
return currentTheme;
|
||||||
|
},
|
||||||
|
get isDark() {
|
||||||
|
return isDark;
|
||||||
},
|
},
|
||||||
get syncing() {
|
get syncing() {
|
||||||
return syncing;
|
return syncing;
|
||||||
|
|
@ -388,7 +409,8 @@ export const app = {
|
||||||
setGroupByDueDate,
|
setGroupByDueDate,
|
||||||
triggerSync,
|
triggerSync,
|
||||||
setSyncMode,
|
setSyncMode,
|
||||||
toggleDarkMode,
|
setTheme,
|
||||||
|
addWebdavWorkspace,
|
||||||
setScreen,
|
setScreen,
|
||||||
clearError,
|
clearError,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,14 @@ export interface TaskList {
|
||||||
group_by_due_date: boolean;
|
group_by_due_date: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WorkspaceMode = "local" | "webdav";
|
||||||
|
|
||||||
export interface WorkspaceConfig {
|
export interface WorkspaceConfig {
|
||||||
path: string;
|
path: string;
|
||||||
|
mode: WorkspaceMode;
|
||||||
webdav_url: string | null;
|
webdav_url: string | null;
|
||||||
last_sync: string | null;
|
last_sync: string | null;
|
||||||
|
theme: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,8 @@ pub fn setup(workspace_name: Option<String>) -> Result<()> {
|
||||||
output::info("Testing connection...");
|
output::info("Testing connection...");
|
||||||
|
|
||||||
let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?;
|
let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?;
|
||||||
let client = WebDavClient::new(&url, &username, &password);
|
let client = WebDavClient::new(&url, &username, &password)
|
||||||
|
.context("Invalid WebDAV URL")?;
|
||||||
|
|
||||||
match rt.block_on(client.test_connection()) {
|
match rt.block_on(client.test_connection()) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ quick-xml = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"], optional = true }
|
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"], optional = true }
|
||||||
zeroize = "1"
|
zeroize = "1"
|
||||||
|
log = "0.4"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.0"
|
tempfile = "3.0"
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,35 @@ use std::path::PathBuf;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum WorkspaceMode {
|
||||||
|
Local,
|
||||||
|
Webdav,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WorkspaceMode {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Local
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct WorkspaceConfig {
|
pub struct WorkspaceConfig {
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mode: WorkspaceMode,
|
||||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||||
pub webdav_url: Option<String>,
|
pub webdav_url: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||||
pub last_sync: Option<chrono::DateTime<chrono::Utc>>,
|
pub last_sync: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||||
|
pub theme: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkspaceConfig {
|
impl WorkspaceConfig {
|
||||||
pub fn new(path: PathBuf) -> Self {
|
pub fn new(path: PathBuf) -> Self {
|
||||||
Self { path, webdav_url: None, last_sync: None }
|
Self { path, mode: WorkspaceMode::Local, webdav_url: None, last_sync: None, theme: None }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,7 +246,7 @@ mod tests {
|
||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
let config_path = temp_dir.path().join("config.json");
|
let config_path = temp_dir.path().join("config.json");
|
||||||
|
|
||||||
// Write old-format JSON without webdav_url or last_sync fields
|
// Write old-format JSON without webdav_url, last_sync, mode, or theme fields
|
||||||
let old_json = r#"{
|
let old_json = r#"{
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"personal": { "path": "/home/user/tasks" }
|
"personal": { "path": "/home/user/tasks" }
|
||||||
|
|
@ -243,5 +260,7 @@ mod tests {
|
||||||
assert_eq!(ws.path, PathBuf::from("/home/user/tasks"));
|
assert_eq!(ws.path, PathBuf::from("/home/user/tasks"));
|
||||||
assert!(ws.webdav_url.is_none());
|
assert!(ws.webdav_url.is_none());
|
||||||
assert!(ws.last_sync.is_none());
|
assert!(ws.last_sync.is_none());
|
||||||
|
assert_eq!(ws.mode, WorkspaceMode::Local);
|
||||||
|
assert!(ws.theme.is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -510,7 +510,28 @@ pub async fn sync_workspace(
|
||||||
mode: SyncMode,
|
mode: SyncMode,
|
||||||
on_progress: Option<ProgressCallback>,
|
on_progress: Option<ProgressCallback>,
|
||||||
) -> Result<SyncResult> {
|
) -> Result<SyncResult> {
|
||||||
let client = WebDavClient::new(webdav_url, username, password);
|
// Wrap entire sync in a hard timeout — reqwest's built-in timeout
|
||||||
|
// doesn't reliably fire on Windows native TLS when the server is unreachable.
|
||||||
|
match tokio::time::timeout(
|
||||||
|
crate::webdav::REQUEST_TIMEOUT * 2,
|
||||||
|
sync_workspace_inner(workspace_path, webdav_url, username, password, mode, on_progress),
|
||||||
|
).await {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(_) => Err(Error::WebDav("Sync timed out — server may be unreachable".into())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync_workspace_inner(
|
||||||
|
workspace_path: &Path,
|
||||||
|
webdav_url: &str,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
mode: SyncMode,
|
||||||
|
on_progress: Option<ProgressCallback>,
|
||||||
|
) -> Result<SyncResult> {
|
||||||
|
// Sync into an "Onyx" subfolder so we don't scan the user's entire cloud storage
|
||||||
|
let sync_url = format!("{}/Onyx", webdav_url.trim_end_matches('/'));
|
||||||
|
let client = WebDavClient::new(&sync_url, username, password)?;
|
||||||
let mut sync_state = SyncState::load(workspace_path);
|
let mut sync_state = SyncState::load(workspace_path);
|
||||||
let queue = OfflineQueue::load(workspace_path);
|
let queue = OfflineQueue::load(workspace_path);
|
||||||
let mut result = SyncResult::default();
|
let mut result = SyncResult::default();
|
||||||
|
|
@ -521,7 +542,8 @@ pub async fn sync_workspace(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure remote root exists
|
// Ensure remote Onyx folder exists (creates it on first sync)
|
||||||
|
client.create_dir("").await.ok();
|
||||||
client.test_connection().await?;
|
client.test_connection().await?;
|
||||||
|
|
||||||
// Scan local files
|
// Scan local files
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use zeroize::Zeroize;
|
use zeroize::Zeroizing;
|
||||||
|
use std::time::Duration;
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
|
/// Hard timeout for any WebDAV network operation.
|
||||||
|
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
/// Information about a file on the remote WebDAV server.
|
/// Information about a file on the remote WebDAV server.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RemoteFileInfo {
|
pub struct RemoteFileInfo {
|
||||||
|
|
@ -11,29 +15,34 @@ pub struct RemoteFileInfo {
|
||||||
pub last_modified: Option<String>,
|
pub last_modified: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// WebDAV client wrapping reqwest with basic auth.
|
/// WebDAV client wrapping reqwest with basic auth. Credentials are zeroized on drop.
|
||||||
pub struct WebDavClient {
|
pub struct WebDavClient {
|
||||||
_client: Client,
|
_client: Client,
|
||||||
_base_url: String,
|
_base_url: String,
|
||||||
_username: String,
|
_username: Zeroizing<String>,
|
||||||
_password: String,
|
_password: Zeroizing<String>,
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for WebDavClient {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self._password.zeroize();
|
|
||||||
self._username.zeroize();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WebDavClient {
|
impl WebDavClient {
|
||||||
pub fn new(base_url: &str, username: &str, password: &str) -> Self {
|
/// Create a new WebDAV client. Rejects non-HTTPS URLs to prevent sending credentials in plaintext.
|
||||||
|
pub fn new(base_url: &str, username: &str, password: &str) -> Result<Self> {
|
||||||
|
if !base_url.starts_with("https://") {
|
||||||
|
return Err(Error::WebDav("Refusing non-HTTPS URL: credentials would be sent in plaintext".into()));
|
||||||
|
}
|
||||||
|
Ok(Self::new_unchecked(base_url, username, password))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_unchecked(base_url: &str, username: &str, password: &str) -> Self {
|
||||||
let base_url = base_url.trim_end_matches('/').to_string();
|
let base_url = base_url.trim_end_matches('/').to_string();
|
||||||
Self {
|
Self {
|
||||||
_client: Client::new(),
|
_client: Client::builder()
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.connect_timeout(Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|_| Client::new()),
|
||||||
_base_url: base_url,
|
_base_url: base_url,
|
||||||
_username: username.to_string(),
|
_username: Zeroizing::new(username.to_string()),
|
||||||
_password: password.to_string(),
|
_password: Zeroizing::new(password.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,7 +65,7 @@ impl WebDavClient {
|
||||||
pub async fn test_connection(&self) -> Result<()> {
|
pub async fn test_connection(&self) -> Result<()> {
|
||||||
let resp = self._client
|
let resp = self._client
|
||||||
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &self._base_url)
|
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &self._base_url)
|
||||||
.basic_auth(&self._username, Some(&self._password))
|
.basic_auth(self._username.as_str(), Some(self._password.as_str()))
|
||||||
.header("Depth", "0")
|
.header("Depth", "0")
|
||||||
.header("Content-Type", "application/xml")
|
.header("Content-Type", "application/xml")
|
||||||
.body(PROPFIND_BODY)
|
.body(PROPFIND_BODY)
|
||||||
|
|
@ -78,7 +87,7 @@ impl WebDavClient {
|
||||||
let url = self.full_url(path);
|
let url = self.full_url(path);
|
||||||
let resp = self._client
|
let resp = self._client
|
||||||
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url)
|
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url)
|
||||||
.basic_auth(&self._username, Some(&self._password))
|
.basic_auth(self._username.as_str(), Some(self._password.as_str()))
|
||||||
.header("Depth", "1")
|
.header("Depth", "1")
|
||||||
.header("Content-Type", "application/xml")
|
.header("Content-Type", "application/xml")
|
||||||
.body(PROPFIND_BODY)
|
.body(PROPFIND_BODY)
|
||||||
|
|
@ -99,7 +108,7 @@ impl WebDavClient {
|
||||||
let url = self.full_url(path);
|
let url = self.full_url(path);
|
||||||
let resp = self._client
|
let resp = self._client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.basic_auth(&self._username, Some(&self._password))
|
.basic_auth(self._username.as_str(), Some(self._password.as_str()))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -119,7 +128,7 @@ impl WebDavClient {
|
||||||
let url = self.full_url(path);
|
let url = self.full_url(path);
|
||||||
let resp = self._client
|
let resp = self._client
|
||||||
.put(&url)
|
.put(&url)
|
||||||
.basic_auth(&self._username, Some(&self._password))
|
.basic_auth(self._username.as_str(), Some(self._password.as_str()))
|
||||||
.body(content)
|
.body(content)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -136,7 +145,7 @@ impl WebDavClient {
|
||||||
let url = self.full_url(path);
|
let url = self.full_url(path);
|
||||||
let resp = self._client
|
let resp = self._client
|
||||||
.delete(&url)
|
.delete(&url)
|
||||||
.basic_auth(&self._username, Some(&self._password))
|
.basic_auth(self._username.as_str(), Some(self._password.as_str()))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -155,7 +164,7 @@ impl WebDavClient {
|
||||||
let url = self.full_url(path);
|
let url = self.full_url(path);
|
||||||
let resp = self._client
|
let resp = self._client
|
||||||
.request(reqwest::Method::from_bytes(b"MKCOL").unwrap(), &url)
|
.request(reqwest::Method::from_bytes(b"MKCOL").unwrap(), &url)
|
||||||
.basic_auth(&self._username, Some(&self._password))
|
.basic_auth(self._username.as_str(), Some(self._password.as_str()))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -388,20 +397,27 @@ fn extract_relative_path(href: &str, base_url: &str, request_path: &str) -> Stri
|
||||||
// --- Credential Storage ---
|
// --- Credential Storage ---
|
||||||
|
|
||||||
#[cfg(feature = "keyring-storage")]
|
#[cfg(feature = "keyring-storage")]
|
||||||
/// Store WebDAV credentials in the platform keychain.
|
/// Store WebDAV credentials in the platform keychain. Password is scoped by domain+username
|
||||||
|
/// to prevent collisions when multiple accounts exist on the same server.
|
||||||
pub fn store_credentials(domain: &str, username: &str, password: &str) -> Result<()> {
|
pub fn store_credentials(domain: &str, username: &str, password: &str) -> Result<()> {
|
||||||
let service = format!("com.onyx.webdav.{}", domain);
|
let service = format!("com.onyx.webdav.{}", domain);
|
||||||
|
let scoped_service = format!("com.onyx.webdav.{}.{}", domain, username);
|
||||||
|
|
||||||
let user_entry = keyring::Entry::new(&service, "username")
|
let user_entry = keyring::Entry::new(&service, "username")
|
||||||
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
|
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
|
||||||
user_entry.set_password(username)
|
user_entry.set_password(username)
|
||||||
.map_err(|e| Error::Credential(format!("Failed to store username: {}", e)))?;
|
.map_err(|e| Error::Credential(format!("Failed to store username: {}", e)))?;
|
||||||
|
|
||||||
let pass_entry = keyring::Entry::new(&service, "password")
|
let pass_entry = keyring::Entry::new(&scoped_service, "password")
|
||||||
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
|
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
|
||||||
pass_entry.set_password(password)
|
pass_entry.set_password(password)
|
||||||
.map_err(|e| Error::Credential(format!("Failed to store password: {}", e)))?;
|
.map_err(|e| Error::Credential(format!("Failed to store password: {}", e)))?;
|
||||||
|
|
||||||
|
// Clean up legacy unscoped password entry if present
|
||||||
|
if let Ok(legacy) = keyring::Entry::new(&service, "password") {
|
||||||
|
let _ = legacy.delete_credential();
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -413,16 +429,28 @@ pub fn store_credentials(_domain: &str, _username: &str, _password: &str) -> Res
|
||||||
|
|
||||||
#[cfg(feature = "keyring-storage")]
|
#[cfg(feature = "keyring-storage")]
|
||||||
/// Load WebDAV credentials from the platform keychain, falling back to env vars.
|
/// Load WebDAV credentials from the platform keychain, falling back to env vars.
|
||||||
pub fn load_credentials(domain: &str) -> Result<(String, String)> {
|
pub fn load_credentials(domain: &str) -> Result<(Zeroizing<String>, Zeroizing<String>)> {
|
||||||
let service = format!("com.onyx.webdav.{}", domain);
|
let service = format!("com.onyx.webdav.{}", domain);
|
||||||
|
|
||||||
let user_entry = keyring::Entry::new(&service, "username")
|
let user_entry = keyring::Entry::new(&service, "username")
|
||||||
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
|
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
|
||||||
let pass_entry = keyring::Entry::new(&service, "password")
|
|
||||||
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
|
|
||||||
|
|
||||||
if let (Ok(user), Ok(pass)) = (user_entry.get_password(), pass_entry.get_password()) {
|
if let Ok(user) = user_entry.get_password() {
|
||||||
return Ok((user, pass));
|
// Try scoped password key first (domain+username), fall back to legacy unscoped key
|
||||||
|
let scoped_service = format!("com.onyx.webdav.{}.{}", domain, user);
|
||||||
|
let pass = keyring::Entry::new(&scoped_service, "password")
|
||||||
|
.ok()
|
||||||
|
.and_then(|e| e.get_password().ok())
|
||||||
|
.or_else(|| {
|
||||||
|
// Migration fallback: try legacy unscoped password entry
|
||||||
|
keyring::Entry::new(&service, "password")
|
||||||
|
.ok()
|
||||||
|
.and_then(|e| e.get_password().ok())
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(pass) = pass {
|
||||||
|
return Ok((Zeroizing::new(user), Zeroizing::new(pass)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to env vars for headless/CI environments
|
// Fallback to env vars for headless/CI environments
|
||||||
|
|
@ -430,27 +458,29 @@ pub fn load_credentials(domain: &str) -> Result<(String, String)> {
|
||||||
std::env::var("ONYX_WEBDAV_USER"),
|
std::env::var("ONYX_WEBDAV_USER"),
|
||||||
std::env::var("ONYX_WEBDAV_PASS"),
|
std::env::var("ONYX_WEBDAV_PASS"),
|
||||||
) {
|
) {
|
||||||
return Ok((user, pass));
|
log::warn!("Using environment variables for WebDAV credentials — prefer keyring for better security");
|
||||||
|
return Ok((Zeroizing::new(user), Zeroizing::new(pass)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(Error::Credential(format!(
|
Err(Error::Credential(format!(
|
||||||
"No credentials found for '{}'. Run 'onyx sync --setup' or set ONYX_WEBDAV_USER and ONYX_WEBDAV_PASS.",
|
"No credentials found for '{}'. Run setup or configure environment variables.",
|
||||||
domain
|
domain
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "keyring-storage"))]
|
#[cfg(not(feature = "keyring-storage"))]
|
||||||
/// Load WebDAV credentials from env vars only (keyring not available).
|
/// Load WebDAV credentials from env vars only (keyring not available).
|
||||||
pub fn load_credentials(domain: &str) -> Result<(String, String)> {
|
pub fn load_credentials(domain: &str) -> Result<(Zeroizing<String>, Zeroizing<String>)> {
|
||||||
if let (Ok(user), Ok(pass)) = (
|
if let (Ok(user), Ok(pass)) = (
|
||||||
std::env::var("ONYX_WEBDAV_USER"),
|
std::env::var("ONYX_WEBDAV_USER"),
|
||||||
std::env::var("ONYX_WEBDAV_PASS"),
|
std::env::var("ONYX_WEBDAV_PASS"),
|
||||||
) {
|
) {
|
||||||
return Ok((user, pass));
|
log::warn!("Using environment variables for WebDAV credentials — these are visible to other processes on this system");
|
||||||
|
return Ok((Zeroizing::new(user), Zeroizing::new(pass)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(Error::Credential(format!(
|
Err(Error::Credential(format!(
|
||||||
"No credentials found for '{}'. Set ONYX_WEBDAV_USER and ONYX_WEBDAV_PASS.",
|
"No credentials found for '{}'. Configure environment variables.",
|
||||||
domain
|
domain
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
@ -460,10 +490,23 @@ pub fn load_credentials(domain: &str) -> Result<(String, String)> {
|
||||||
pub fn delete_credentials(domain: &str) -> Result<()> {
|
pub fn delete_credentials(domain: &str) -> Result<()> {
|
||||||
let service = format!("com.onyx.webdav.{}", domain);
|
let service = format!("com.onyx.webdav.{}", domain);
|
||||||
|
|
||||||
if let Ok(entry) = keyring::Entry::new(&service, "username") {
|
// Load username first so we can delete the scoped password entry
|
||||||
|
let username = keyring::Entry::new(&service, "username")
|
||||||
|
.ok()
|
||||||
|
.and_then(|e| e.get_password().ok());
|
||||||
|
|
||||||
|
if let Some(user) = &username {
|
||||||
|
let scoped_service = format!("com.onyx.webdav.{}.{}", domain, user);
|
||||||
|
if let Ok(entry) = keyring::Entry::new(&scoped_service, "password") {
|
||||||
|
let _ = entry.delete_credential();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up legacy unscoped password and username entries
|
||||||
|
if let Ok(entry) = keyring::Entry::new(&service, "password") {
|
||||||
let _ = entry.delete_credential();
|
let _ = entry.delete_credential();
|
||||||
}
|
}
|
||||||
if let Ok(entry) = keyring::Entry::new(&service, "password") {
|
if let Ok(entry) = keyring::Entry::new(&service, "username") {
|
||||||
let _ = entry.delete_credential();
|
let _ = entry.delete_credential();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -640,9 +683,21 @@ mod tests {
|
||||||
|
|
||||||
// --- WebDavClient URL building ---
|
// --- WebDavClient URL building ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_rejects_http() {
|
||||||
|
let result = WebDavClient::new("http://example.com/dav", "user", "pass");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_accepts_https() {
|
||||||
|
let result = WebDavClient::new("https://example.com/dav", "user", "pass");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_full_url_building() {
|
fn test_full_url_building() {
|
||||||
let client = WebDavClient::new("http://example.com/dav/", "user", "pass");
|
let client = WebDavClient::new_unchecked("http://example.com/dav/", "user", "pass");
|
||||||
assert_eq!(client.full_url(""), "http://example.com/dav");
|
assert_eq!(client.full_url(""), "http://example.com/dav");
|
||||||
assert_eq!(client.full_url("file.md"), "http://example.com/dav/file.md");
|
assert_eq!(client.full_url("file.md"), "http://example.com/dav/file.md");
|
||||||
assert_eq!(client.full_url("My Tasks/Buy groceries.md"), "http://example.com/dav/My%20Tasks/Buy%20groceries.md");
|
assert_eq!(client.full_url("My Tasks/Buy groceries.md"), "http://example.com/dav/My%20Tasks/Buy%20groceries.md");
|
||||||
|
|
@ -650,7 +705,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_full_url_strips_leading_slash() {
|
fn test_full_url_strips_leading_slash() {
|
||||||
let client = WebDavClient::new("http://example.com/dav", "user", "pass");
|
let client = WebDavClient::new_unchecked("http://example.com/dav", "user", "pass");
|
||||||
assert_eq!(client.full_url("/file.md"), "http://example.com/dav/file.md");
|
assert_eq!(client.full_url("/file.md"), "http://example.com/dav/file.md");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue