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",
|
||||
"directories",
|
||||
"keyring",
|
||||
"log",
|
||||
"quick-xml",
|
||||
"reqwest",
|
||||
"serde",
|
||||
|
|
@ -1144,7 +1145,7 @@ dependencies = [
|
|||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1304,9 +1305,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
|
|
@ -1347,9 +1348,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
version = "0.103.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
|
|
@ -2335,18 +2336,18 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.42"
|
||||
version = "0.8.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
|
||||
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.42"
|
||||
version = "0.8.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
|
||||
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
|
|||
1
apps/tauri/src-tauri/Cargo.lock
generated
1
apps/tauri/src-tauri/Cargo.lock
generated
|
|
@ -2386,6 +2386,7 @@ dependencies = [
|
|||
"chrono",
|
||||
"directories",
|
||||
"keyring",
|
||||
"log",
|
||||
"quick-xml 0.36.2",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use tauri::{Emitter, Manager, State};
|
|||
use uuid::Uuid;
|
||||
|
||||
use onyx_core::{
|
||||
config::{AppConfig, WorkspaceConfig},
|
||||
config::{AppConfig, WorkspaceConfig, WorkspaceMode},
|
||||
models::{Task, TaskList, TaskStatus},
|
||||
repository::TaskRepository,
|
||||
sync::{self, SyncMode, SyncResult as CoreSyncResult},
|
||||
|
|
@ -31,6 +31,7 @@ static LAST_WRITE: Mutex<Option<Instant>> = Mutex::new(None);
|
|||
struct AppState {
|
||||
config: AppConfig,
|
||||
config_path: PathBuf,
|
||||
app_data_dir: PathBuf,
|
||||
repo: Option<TaskRepository>,
|
||||
}
|
||||
|
||||
|
|
@ -418,17 +419,74 @@ fn set_webdav_config(
|
|||
}
|
||||
|
||||
#[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,
|
||||
username: String,
|
||||
password: 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]
|
||||
fn load_credentials(domain: String) -> Result<(String, String), String> {
|
||||
webdav::load_credentials(&domain).map_err(|e| e.to_string())
|
||||
async fn load_credentials(domain: String) -> Result<(String, String), 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]
|
||||
|
|
@ -437,7 +495,8 @@ async fn test_webdav_connection(
|
|||
username: String,
|
||||
password: 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
|
||||
.test_connection()
|
||||
.await
|
||||
|
|
@ -447,20 +506,39 @@ async fn test_webdav_connection(
|
|||
#[tauri::command]
|
||||
async fn sync_workspace(
|
||||
workspace_name: String,
|
||||
workspace_path: String,
|
||||
webdav_url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
mode: String,
|
||||
state: State<'_, Mutex<AppState>>,
|
||||
) -> 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() {
|
||||
"push" => SyncMode::Push,
|
||||
"pull" => SyncMode::Pull,
|
||||
_ => SyncMode::Full,
|
||||
};
|
||||
let result = sync::sync_workspace(
|
||||
&PathBuf::from(&workspace_path),
|
||||
&workspace_path,
|
||||
&webdav_url,
|
||||
&username,
|
||||
&password,
|
||||
|
|
@ -470,7 +548,6 @@ async fn sync_workspace(
|
|||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Persist last_sync timestamp to config
|
||||
{
|
||||
let mut s = lock_state(&state)?;
|
||||
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_os::init())
|
||||
.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 = {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
use tauri::Manager;
|
||||
app.path().app_data_dir()
|
||||
.map_err(|e| format!("Failed to get app data dir: {}", e))?
|
||||
.join("config.json")
|
||||
}
|
||||
{ app_data_dir.join("config.json") }
|
||||
#[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 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"))]
|
||||
if let Some(path) = workspace_path {
|
||||
|
|
@ -591,6 +663,8 @@ pub fn run() {
|
|||
set_group_by_due_date,
|
||||
get_group_by_due_date,
|
||||
set_webdav_config,
|
||||
set_workspace_theme,
|
||||
add_webdav_workspace,
|
||||
store_credentials,
|
||||
load_credentials,
|
||||
test_webdav_connection,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
});
|
||||
</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="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 app.screen === "setup"}
|
||||
<SetupScreen />
|
||||
<SetupScreen cancellable={app.hasWorkspace} />
|
||||
{:else}
|
||||
<TasksScreen />
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -68,3 +68,83 @@ body {
|
|||
background-color: #242424;
|
||||
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 { 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 webdavUser = $state("");
|
||||
|
|
@ -10,19 +13,15 @@
|
|||
let testStatus = $state<"idle" | "testing" | "ok" | "fail">("idle");
|
||||
|
||||
$effect(() => {
|
||||
const ws = app.config?.current_workspace;
|
||||
if (!ws) return;
|
||||
const cfg = app.config?.workspaces[ws];
|
||||
if (cfg?.webdav_url) {
|
||||
webdavUrl = cfg.webdav_url;
|
||||
try {
|
||||
const domain = new URL(cfg.webdav_url).hostname;
|
||||
invoke<[string, string]>("load_credentials", { domain }).then(([u, p]) => {
|
||||
webdavUser = u;
|
||||
webdavPass = p;
|
||||
}).catch(() => {});
|
||||
} catch {}
|
||||
}
|
||||
if (!ws?.webdav_url) return;
|
||||
webdavUrl = ws.webdav_url;
|
||||
try {
|
||||
const domain = new URL(ws.webdav_url).hostname;
|
||||
invoke<[string, string]>("load_credentials", { domain }).then(([u, p]) => {
|
||||
webdavUser = u;
|
||||
webdavPass = p;
|
||||
}).catch(() => {});
|
||||
} catch {}
|
||||
});
|
||||
|
||||
async function testConnection() {
|
||||
|
|
@ -40,9 +39,9 @@
|
|||
}
|
||||
|
||||
async function saveWebdav() {
|
||||
if (!app.config?.current_workspace || !webdavUrl.trim()) return;
|
||||
if (!webdavUrl.trim()) return;
|
||||
await invoke("set_webdav_config", {
|
||||
workspaceName: app.config.current_workspace,
|
||||
workspaceName,
|
||||
webdavUrl: webdavUrl.trim(),
|
||||
});
|
||||
if (webdavUser && webdavPass) {
|
||||
|
|
@ -55,13 +54,12 @@
|
|||
}
|
||||
await app.loadConfig();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<header
|
||||
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
|
||||
onclick={() => onclose?.()}
|
||||
class="rounded-lg p-1.5 hover:bg-black/5 dark:hover:bg-white/10"
|
||||
|
|
@ -75,53 +73,53 @@
|
|||
</header>
|
||||
|
||||
<main class="flex-1 overflow-y-auto p-4">
|
||||
<!-- WebDAV Sync -->
|
||||
<section class="mb-6">
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide opacity-50">
|
||||
WebDAV Sync
|
||||
</h2>
|
||||
<div class="rounded-xl border border-border-light p-4 dark:border-border-dark">
|
||||
<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"
|
||||
/>
|
||||
<!-- WebDAV Sync (only for webdav workspaces) -->
|
||||
{#if isWebdav}
|
||||
<section class="mb-6">
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide opacity-50">
|
||||
WebDAV Sync
|
||||
</h2>
|
||||
<div class="rounded-xl border border-border-light p-4 dark:border-border-dark">
|
||||
<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">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"
|
||||
/>
|
||||
<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="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>
|
||||
<button
|
||||
onclick={saveWebdav}
|
||||
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"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<div class="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>
|
||||
<button
|
||||
onclick={saveWebdav}
|
||||
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"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if app.config?.current_workspace}
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<select
|
||||
value={app.syncMode}
|
||||
|
|
@ -137,11 +135,14 @@
|
|||
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"
|
||||
>
|
||||
{app.syncing ? "Syncing…" : "Sync Now"}
|
||||
{app.syncing ? "Syncing..." : "Sync Now"}
|
||||
</button>
|
||||
</div>
|
||||
{#if app.config.workspaces[app.config.current_workspace]?.last_sync}
|
||||
{@const lastSync = new Date(app.config.workspaces[app.config.current_workspace].last_sync!)}
|
||||
{#if app.error}
|
||||
<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 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">
|
||||
|
|
@ -151,27 +152,32 @@
|
|||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Theme -->
|
||||
<section>
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide opacity-50">
|
||||
Appearance
|
||||
</h2>
|
||||
<button
|
||||
onclick={() => app.toggleDarkMode()}
|
||||
class="flex w-full items-center justify-between rounded-xl border border-border-light p-4 dark:border-border-dark"
|
||||
>
|
||||
<span class="text-sm font-medium">Dark mode</span>
|
||||
<div
|
||||
class="h-6 w-11 rounded-full transition-colors {app.darkMode ? 'bg-primary' : 'bg-gray-300 dark:bg-gray-600'}"
|
||||
<div class="rounded-xl border border-border-light p-4 dark:border-border-dark">
|
||||
<label class="mb-1 block text-xs font-medium opacity-60">Theme</label>
|
||||
<select
|
||||
value={ws?.theme ?? ""}
|
||||
onchange={(e) => {
|
||||
const val = (e.target as HTMLSelectElement).value;
|
||||
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
|
||||
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'}"
|
||||
></div>
|
||||
</div>
|
||||
</button>
|
||||
<option value="">System default</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="nord">Nord</option>
|
||||
<option value="dracula">Dracula</option>
|
||||
<option value="solarized">Solarized Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="mt-8 text-center text-xs opacity-30">Tauri v2 + Svelte</p>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,25 @@
|
|||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { app } from "../stores/app.svelte";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
|
||||
let { cancellable = false }: { cancellable?: boolean } = $props();
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
const currentPlatform = platform();
|
||||
const isDesktop = currentPlatform === "linux" || 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 path = $state("");
|
||||
let webdavUrl = $state("");
|
||||
let webdavUser = $state("");
|
||||
let webdavPass = $state("");
|
||||
let testStatus = $state<"idle" | "testing" | "ok" | "fail">("idle");
|
||||
|
||||
async function pickFolder() {
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
|
|
@ -26,23 +35,63 @@
|
|||
const selected = await open({ directory: true, multiple: false });
|
||||
if (!selected) return;
|
||||
const folder = selected as string;
|
||||
// Derive workspace name from folder name
|
||||
const parts = folder.replace(/\\/g, "/").split("/");
|
||||
const wsName = parts[parts.length - 1] || "workspace";
|
||||
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) {
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest("button, input")) return;
|
||||
if (isDesktop) appWindow.startDragging();
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
mode = null;
|
||||
name = "";
|
||||
path = "";
|
||||
webdavUrl = "";
|
||||
webdavUser = "";
|
||||
webdavPass = "";
|
||||
testStatus = "idle";
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="flex h-full flex-col" onmousedown={handleDrag}>
|
||||
<!-- 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}
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#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"
|
||||
>
|
||||
<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">
|
||||
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>
|
||||
{#if mode === null}
|
||||
<!-- Step 1: Choose mode -->
|
||||
<p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark">
|
||||
How would you like to store your tasks?
|
||||
</p>
|
||||
|
||||
<!-- 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"
|
||||
onclick={() => (mode = "local")}
|
||||
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>
|
||||
</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>
|
||||
<button
|
||||
onclick={() => (mode = "webdav")}
|
||||
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"
|
||||
>
|
||||
<p class="text-sm font-semibold">WebDAV Server</p>
|
||||
<p class="mt-0.5 text-xs text-text-secondary-light dark:text-text-secondary-dark">
|
||||
Connect to a WebDAV server. The app manages local files automatically.
|
||||
</p>
|
||||
</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>
|
||||
{:else if mode === "local"}
|
||||
<!-- Step 2a: Local workspace -->
|
||||
<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>
|
||||
|
||||
<button
|
||||
onclick={handleOpen}
|
||||
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"
|
||||
>
|
||||
Open Existing Folder
|
||||
</button>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
|
||||
let showDrawer = $state(false);
|
||||
let showSettings = $state(false);
|
||||
let settingsWorkspace = $state<string | null>(null);
|
||||
let showNewList = $state(false);
|
||||
let showWorkspacePicker = $state(false);
|
||||
let workspacePickerEl = $state<HTMLDivElement | null>(null);
|
||||
|
|
@ -152,7 +153,7 @@
|
|||
clone.style.position = "absolute";
|
||||
clone.style.top = "-9999px";
|
||||
clone.style.left = "-9999px";
|
||||
if (app.darkMode) {
|
||||
if (app.isDark) {
|
||||
clone.classList.add("dark");
|
||||
clone.style.backgroundColor = "var(--color-surface-dark)";
|
||||
clone.style.color = "var(--color-text-dark)";
|
||||
|
|
@ -192,12 +193,9 @@
|
|||
showNewList = false;
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
showSettings = true;
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
showSettings = false;
|
||||
settingsWorkspace = null;
|
||||
}
|
||||
|
||||
function handleHeaderMouseDown(e: MouseEvent) {
|
||||
|
|
@ -256,7 +254,7 @@
|
|||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<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>
|
||||
</button>
|
||||
<div class="relative shrink-0" data-ws-menu>
|
||||
|
|
@ -270,6 +268,15 @@
|
|||
</button>
|
||||
{#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">
|
||||
<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
|
||||
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"
|
||||
|
|
@ -347,20 +354,6 @@
|
|||
</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>
|
||||
|
||||
<!-- 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'}"
|
||||
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>
|
||||
|
||||
|
|
|
|||
|
|
@ -20,9 +20,7 @@ let config = $state<AppConfig | null>(null);
|
|||
let lists = $state<TaskList[]>([]);
|
||||
let activeListId = $state<string | null>(null);
|
||||
let tasks = $state<Task[]>([]);
|
||||
let darkMode = $state(
|
||||
globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false,
|
||||
);
|
||||
let osDark = globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
|
||||
let syncing = $state(false);
|
||||
let syncMode = $state<"full" | "push" | "pull">("full");
|
||||
let lastSyncResult = $state<SyncResult | null>(null);
|
||||
|
|
@ -56,6 +54,16 @@ let hasWorkspace = $derived(
|
|||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
async function loadConfig() {
|
||||
|
|
@ -274,30 +282,17 @@ async function setGroupByDueDate(listId: string, enabled: boolean) {
|
|||
|
||||
async function triggerSync() {
|
||||
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;
|
||||
error = null;
|
||||
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", {
|
||||
workspaceName,
|
||||
workspacePath: ws.path,
|
||||
webdavUrl: ws.webdav_url,
|
||||
username,
|
||||
password,
|
||||
workspaceName: config.current_workspace,
|
||||
mode: syncMode,
|
||||
});
|
||||
lastSyncResult = result;
|
||||
if (result.errors.length > 0) {
|
||||
error = result.errors.join("; ");
|
||||
}
|
||||
// Reload config to pick up updated last_sync timestamp
|
||||
config = await invoke<AppConfig>("get_config");
|
||||
await loadLists();
|
||||
} catch (e) {
|
||||
|
|
@ -311,8 +306,31 @@ function setSyncMode(mode: "full" | "push" | "pull") {
|
|||
syncMode = mode;
|
||||
}
|
||||
|
||||
function toggleDarkMode() {
|
||||
darkMode = !darkMode;
|
||||
async function setTheme(theme: string | null) {
|
||||
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) {
|
||||
|
|
@ -350,8 +368,11 @@ export const app = {
|
|||
get completedTasks() {
|
||||
return completedTasks;
|
||||
},
|
||||
get darkMode() {
|
||||
return darkMode;
|
||||
get currentTheme() {
|
||||
return currentTheme;
|
||||
},
|
||||
get isDark() {
|
||||
return isDark;
|
||||
},
|
||||
get syncing() {
|
||||
return syncing;
|
||||
|
|
@ -388,7 +409,8 @@ export const app = {
|
|||
setGroupByDueDate,
|
||||
triggerSync,
|
||||
setSyncMode,
|
||||
toggleDarkMode,
|
||||
setTheme,
|
||||
addWebdavWorkspace,
|
||||
setScreen,
|
||||
clearError,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,10 +19,14 @@ export interface TaskList {
|
|||
group_by_due_date: boolean;
|
||||
}
|
||||
|
||||
export type WorkspaceMode = "local" | "webdav";
|
||||
|
||||
export interface WorkspaceConfig {
|
||||
path: string;
|
||||
mode: WorkspaceMode;
|
||||
webdav_url: string | null;
|
||||
last_sync: string | null;
|
||||
theme: string | null;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
|
|
|
|||
|
|
@ -39,7 +39,8 @@ pub fn setup(workspace_name: Option<String>) -> Result<()> {
|
|||
output::info("Testing connection...");
|
||||
|
||||
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()) {
|
||||
Ok(()) => {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ quick-xml = { workspace = true }
|
|||
tokio = { workspace = true }
|
||||
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"], optional = true }
|
||||
zeroize = "1"
|
||||
log = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.0"
|
||||
|
|
|
|||
|
|
@ -3,18 +3,35 @@ use std::path::PathBuf;
|
|||
use serde::{Deserialize, Serialize};
|
||||
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)]
|
||||
pub struct WorkspaceConfig {
|
||||
pub path: PathBuf,
|
||||
#[serde(default)]
|
||||
pub mode: WorkspaceMode,
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub webdav_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub last_sync: Option<chrono::DateTime<chrono::Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub theme: Option<String>,
|
||||
}
|
||||
|
||||
impl WorkspaceConfig {
|
||||
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 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#"{
|
||||
"workspaces": {
|
||||
"personal": { "path": "/home/user/tasks" }
|
||||
|
|
@ -243,5 +260,7 @@ mod tests {
|
|||
assert_eq!(ws.path, PathBuf::from("/home/user/tasks"));
|
||||
assert!(ws.webdav_url.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,
|
||||
on_progress: Option<ProgressCallback>,
|
||||
) -> 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 queue = OfflineQueue::load(workspace_path);
|
||||
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?;
|
||||
|
||||
// Scan local files
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
use reqwest::Client;
|
||||
use zeroize::Zeroize;
|
||||
use zeroize::Zeroizing;
|
||||
use std::time::Duration;
|
||||
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.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RemoteFileInfo {
|
||||
|
|
@ -11,29 +15,34 @@ pub struct RemoteFileInfo {
|
|||
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 {
|
||||
_client: Client,
|
||||
_base_url: String,
|
||||
_username: String,
|
||||
_password: String,
|
||||
}
|
||||
|
||||
impl Drop for WebDavClient {
|
||||
fn drop(&mut self) {
|
||||
self._password.zeroize();
|
||||
self._username.zeroize();
|
||||
}
|
||||
_username: Zeroizing<String>,
|
||||
_password: Zeroizing<String>,
|
||||
}
|
||||
|
||||
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();
|
||||
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,
|
||||
_username: username.to_string(),
|
||||
_password: password.to_string(),
|
||||
_username: Zeroizing::new(username.to_string()),
|
||||
_password: Zeroizing::new(password.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -56,7 +65,7 @@ impl WebDavClient {
|
|||
pub async fn test_connection(&self) -> Result<()> {
|
||||
let resp = self._client
|
||||
.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("Content-Type", "application/xml")
|
||||
.body(PROPFIND_BODY)
|
||||
|
|
@ -78,7 +87,7 @@ impl WebDavClient {
|
|||
let url = self.full_url(path);
|
||||
let resp = self._client
|
||||
.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("Content-Type", "application/xml")
|
||||
.body(PROPFIND_BODY)
|
||||
|
|
@ -99,7 +108,7 @@ impl WebDavClient {
|
|||
let url = self.full_url(path);
|
||||
let resp = self._client
|
||||
.get(&url)
|
||||
.basic_auth(&self._username, Some(&self._password))
|
||||
.basic_auth(self._username.as_str(), Some(self._password.as_str()))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
|
|
@ -119,7 +128,7 @@ impl WebDavClient {
|
|||
let url = self.full_url(path);
|
||||
let resp = self._client
|
||||
.put(&url)
|
||||
.basic_auth(&self._username, Some(&self._password))
|
||||
.basic_auth(self._username.as_str(), Some(self._password.as_str()))
|
||||
.body(content)
|
||||
.send()
|
||||
.await?;
|
||||
|
|
@ -136,7 +145,7 @@ impl WebDavClient {
|
|||
let url = self.full_url(path);
|
||||
let resp = self._client
|
||||
.delete(&url)
|
||||
.basic_auth(&self._username, Some(&self._password))
|
||||
.basic_auth(self._username.as_str(), Some(self._password.as_str()))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
|
|
@ -155,7 +164,7 @@ impl WebDavClient {
|
|||
let url = self.full_url(path);
|
||||
let resp = self._client
|
||||
.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()
|
||||
.await?;
|
||||
|
||||
|
|
@ -388,20 +397,27 @@ fn extract_relative_path(href: &str, base_url: &str, request_path: &str) -> Stri
|
|||
// --- Credential 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<()> {
|
||||
let service = format!("com.onyx.webdav.{}", domain);
|
||||
let scoped_service = format!("com.onyx.webdav.{}.{}", domain, username);
|
||||
|
||||
let user_entry = keyring::Entry::new(&service, "username")
|
||||
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
|
||||
user_entry.set_password(username)
|
||||
.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)))?;
|
||||
pass_entry.set_password(password)
|
||||
.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(())
|
||||
}
|
||||
|
||||
|
|
@ -413,16 +429,28 @@ pub fn store_credentials(_domain: &str, _username: &str, _password: &str) -> Res
|
|||
|
||||
#[cfg(feature = "keyring-storage")]
|
||||
/// 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 user_entry = keyring::Entry::new(&service, "username")
|
||||
.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()) {
|
||||
return Ok((user, pass));
|
||||
if let Ok(user) = user_entry.get_password() {
|
||||
// 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
|
||||
|
|
@ -430,27 +458,29 @@ pub fn load_credentials(domain: &str) -> Result<(String, String)> {
|
|||
std::env::var("ONYX_WEBDAV_USER"),
|
||||
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!(
|
||||
"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
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "keyring-storage"))]
|
||||
/// 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)) = (
|
||||
std::env::var("ONYX_WEBDAV_USER"),
|
||||
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!(
|
||||
"No credentials found for '{}'. Set ONYX_WEBDAV_USER and ONYX_WEBDAV_PASS.",
|
||||
"No credentials found for '{}'. Configure environment variables.",
|
||||
domain
|
||||
)))
|
||||
}
|
||||
|
|
@ -460,10 +490,23 @@ pub fn load_credentials(domain: &str) -> Result<(String, String)> {
|
|||
pub fn delete_credentials(domain: &str) -> Result<()> {
|
||||
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();
|
||||
}
|
||||
if let Ok(entry) = keyring::Entry::new(&service, "password") {
|
||||
if let Ok(entry) = keyring::Entry::new(&service, "username") {
|
||||
let _ = entry.delete_credential();
|
||||
}
|
||||
|
||||
|
|
@ -640,9 +683,21 @@ mod tests {
|
|||
|
||||
// --- 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]
|
||||
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("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");
|
||||
|
|
@ -650,7 +705,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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");
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue