diff --git a/CLAUDE.md b/CLAUDE.md index 8167c1e..2b776fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ Two-crate workspace (`resolver = "2"`, edition 2021) plus a Tauri app: ### On-disk format -Workspaces are plain folders. Each task list is a subfolder containing `.listdata.json` (metadata/ordering) and one `.md` file per task. The workspace root has `.metadata.json` for list ordering. +Workspaces are plain folders. Each task list is a subfolder containing `.listdata.json` (metadata/ordering) and one `.md` file per task. The workspace root has `.onyx-workspace.json` for list ordering and workspace detection. ### Tauri GUI structure @@ -59,10 +59,14 @@ The GUI uses Svelte 5 runes mode (`$state`, `$derived`, `$effect`, `$props()`). - **Main panel header**: Hamburger + window controls in top bar; list name (large, bold) + kebab below divider (matching task detail layout). Kebab has Rename, Group by due date, Delete completed, Delete list. - **New task**: FAB button opens bottom toast sheet (outside sliding container for fixed positioning). -### Current state (2026-04-03) +### Development phase + +Pre-alpha. No users, no released builds, no data to migrate. Breaking changes to on-disk formats, config structure, or sync conventions are free — do not add migration logic. + +### Current state (2026-04-05) - **Phase 1** (Core + CLI): Complete -- **Phase 2** (WebDAV sync): Complete — CLI + GUI sync working, auto-creates `Onyx/` subfolder on remote +- **Phase 2** (WebDAV sync): In progress — reworking to let users browse and pick a remote folder instead of hardcoding `Onyx/` subfolder - **Phase 3** (GUI MVP): Complete - **Phase 4** (Mobile): Tauri Android cfg-gated, needs `tauri android init` + build diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index db230ae..c2d343a 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -451,10 +451,118 @@ fn set_workspace_theme( s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string()) } +/// A remote folder entry returned to the frontend. +#[derive(Debug, Serialize, Deserialize)] +struct RemoteFolderEntry { + name: String, + is_workspace: bool, +} + +/// Summary of a list inside a remote workspace. +#[derive(Debug, Serialize, Deserialize)] +struct RemoteListInfo { + name: String, + task_count: usize, +} + +#[tauri::command] +async fn list_remote_folder( + url: String, + username: String, + password: String, + path: String, +) -> Result, String> { + let client = onyx_core::webdav::WebDavClient::new(&url, &username, &password) + .map_err(|e| e.to_string())?; + let entries = client.list_files(&path).await.map_err(|e| e.to_string())?; + + let mut folders = Vec::new(); + for entry in entries { + if !entry.is_dir { continue; } + // Check if this folder contains .onyx-workspace.json + let sub_path = if path.is_empty() { + entry.path.clone() + } else { + format!("{}/{}", path.trim_end_matches('/'), entry.path) + }; + let sub_files = client.list_files(&sub_path).await.unwrap_or_default(); + let is_workspace = sub_files.iter().any(|f| !f.is_dir && f.path == ".onyx-workspace.json"); + folders.push(RemoteFolderEntry { + name: entry.path, + is_workspace, + }); + } + + Ok(folders) +} + +#[tauri::command] +async fn inspect_remote_workspace( + url: String, + username: String, + password: String, + path: String, +) -> Result, String> { + let client = onyx_core::webdav::WebDavClient::new(&url, &username, &password) + .map_err(|e| e.to_string())?; + let entries = client.list_files(&path).await.map_err(|e| e.to_string())?; + + let mut lists = Vec::new(); + for entry in entries { + if !entry.is_dir { continue; } + let list_path = if path.is_empty() { + entry.path.clone() + } else { + format!("{}/{}", path.trim_end_matches('/'), entry.path) + }; + let files = client.list_files(&list_path).await.unwrap_or_default(); + let has_listdata = files.iter().any(|f| !f.is_dir && f.path == ".listdata.json"); + if has_listdata { + let task_count = files.iter().filter(|f| !f.is_dir && f.path.ends_with(".md")).count(); + lists.push(RemoteListInfo { + name: entry.path, + task_count, + }); + } + } + + Ok(lists) +} + +#[tauri::command] +async fn create_remote_workspace( + url: String, + username: String, + password: String, + path: String, +) -> Result<(), String> { + let client = onyx_core::webdav::WebDavClient::new(&url, &username, &password) + .map_err(|e| e.to_string())?; + if !path.is_empty() { + client.ensure_dir(&path).await.map_err(|e| e.to_string())?; + } + // Upload an empty .onyx-workspace.json + let metadata = serde_json::json!({ + "version": 1, + "list_order": [], + "last_opened_list": null, + }); + let file_path = if path.is_empty() { + ".onyx-workspace.json".to_string() + } else { + format!("{}/{}", path.trim_end_matches('/'), ".onyx-workspace.json") + }; + client.put_file(&file_path, serde_json::to_string_pretty(&metadata).unwrap().into_bytes()) + .await + .map_err(|e| e.to_string())?; + Ok(()) +} + #[tauri::command] fn add_webdav_workspace( name: String, webdav_url: String, + webdav_path: String, username: String, password: String, state: State<'_, Mutex>, @@ -467,6 +575,7 @@ fn add_webdav_workspace( let mut ws = WorkspaceConfig::new(managed_dir); ws.mode = WorkspaceMode::Webdav; ws.webdav_url = Some(webdav_url.clone()); + ws.webdav_path = Some(webdav_path); s.config.add_workspace(name.clone(), ws); s.config.set_current_workspace(name).map_err(|e| e.to_string())?; @@ -529,12 +638,17 @@ async fn sync_workspace( mode: String, state: State<'_, Mutex>, ) -> Result { - // Step 1: read config + // Step 1: read config — combine base URL with the user-chosen remote path 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")?) + let base = ws.webdav_url.clone().ok_or("No WebDAV URL configured")?; + let full = match &ws.webdav_path { + Some(p) if !p.is_empty() => format!("{}/{}", base.trim_end_matches('/'), p.trim_matches('/')), + _ => base, + }; + (ws.path.clone(), full) }; // Step 2: load credentials @@ -686,6 +800,9 @@ pub fn run() { set_webdav_config, set_workspace_theme, add_webdav_workspace, + list_remote_folder, + inspect_remote_workspace, + create_remote_workspace, store_credentials, load_credentials, test_webdav_connection, diff --git a/apps/tauri/src/lib/screens/SetupScreen.svelte b/apps/tauri/src/lib/screens/SetupScreen.svelte index 3d29cbd..9ab3658 100644 --- a/apps/tauri/src/lib/screens/SetupScreen.svelte +++ b/apps/tauri/src/lib/screens/SetupScreen.svelte @@ -13,14 +13,38 @@ const isWindows = currentPlatform === "windows"; const isMobile = currentPlatform === "android" || currentPlatform === "ios"; + // ── Shared state ────────────────────────────────────────────────── let mode = $state<"local" | "webdav" | null>(isMobile ? "webdav" : null); let name = $state(""); let path = $state(""); + + // ── WebDAV state ────────────────────────────────────────────────── let webdavUrl = $state(""); let webdavUser = $state(""); let webdavPass = $state(""); let testStatus = $state<"idle" | "testing" | "ok" | "fail">("idle"); + // WebDAV step: "connect" → "browse" → "preview" | "create" + let webdavStep = $state<"connect" | "browse" | "preview" | "create">("connect"); + let browsePath = $state([]); // stack of folder names for navigation + let browseLoading = $state(false); + let browseEntries = $state<{ name: string; is_workspace: boolean }[]>([]); + let browseError = $state(null); + + // Workspace preview state + let previewName = $state(""); + let previewLists = $state<{ name: string; task_count: number }[]>([]); + let previewLoading = $state(false); + + // Create workspace state + let createName = $state(""); + let creating = $state(false); + + // ── Derived ─────────────────────────────────────────────────────── + let currentBrowsePath = $derived(browsePath.join("/")); + + // ── Local workspace handlers ────────────────────────────────────── + async function pickFolder() { const selected = await open({ directory: true, multiple: false }); if (selected) path = selected as string; @@ -42,6 +66,8 @@ await app.addWorkspace(wsName, folder); } + // ── WebDAV handlers ─────────────────────────────────────────────── + async function testConnection() { testStatus = "testing"; try { @@ -56,11 +82,107 @@ } } - async function handleCreateWebdav() { - if (!name.trim() || !webdavUrl.trim()) return; - await app.addWebdavWorkspace(name.trim(), webdavUrl.trim(), webdavUser, webdavPass); + async function connectAndBrowse() { + testStatus = "testing"; + try { + await invoke("test_webdav_connection", { + url: webdavUrl, + username: webdavUser, + password: webdavPass, + }); + testStatus = "ok"; + webdavStep = "browse"; + browsePath = []; + await loadFolder(); + } catch { + testStatus = "fail"; + } } + async function loadFolder() { + browseLoading = true; + browseError = null; + try { + browseEntries = await invoke("list_remote_folder", { + url: webdavUrl, + username: webdavUser, + password: webdavPass, + path: currentBrowsePath, + }); + } catch (e) { + browseError = String(e); + browseEntries = []; + } finally { + browseLoading = false; + } + } + + async function navigateInto(folder: { name: string; is_workspace: boolean }) { + if (folder.is_workspace) { + previewName = folder.name; + previewLoading = true; + webdavStep = "preview"; + try { + const wsPath = currentBrowsePath + ? `${currentBrowsePath}/${folder.name}` + : folder.name; + previewLists = await invoke("inspect_remote_workspace", { + url: webdavUrl, + username: webdavUser, + password: webdavPass, + path: wsPath, + }); + } catch (e) { + browseError = String(e); + webdavStep = "browse"; + } finally { + previewLoading = false; + } + } else { + browsePath = [...browsePath, folder.name]; + await loadFolder(); + } + } + + function navigateUp() { + browsePath = browsePath.slice(0, -1); + loadFolder(); + } + + async function openExistingWorkspace() { + const wsPath = currentBrowsePath + ? `${currentBrowsePath}/${previewName}` + : previewName; + await app.addWebdavWorkspace(previewName, webdavUrl.trim(), wsPath, webdavUser, webdavPass); + } + + function startCreate() { + createName = ""; + webdavStep = "create"; + } + + async function handleCreateWebdav() { + if (!createName.trim()) return; + creating = true; + try { + const wsPath = currentBrowsePath + ? `${currentBrowsePath}/${createName.trim()}` + : createName.trim(); + await invoke("create_remote_workspace", { + url: webdavUrl, + username: webdavUser, + password: webdavPass, + path: wsPath, + }); + await app.addWebdavWorkspace(createName.trim(), webdavUrl.trim(), wsPath, webdavUser, webdavPass); + } catch (e) { + browseError = String(e); + creating = false; + } + } + + // ── Window dragging ─────────────────────────────────────────────── + function handleDrag(e: MouseEvent) { if (e.button !== 0) return; if ((e.target as HTMLElement).closest("button, input")) return; @@ -75,6 +197,22 @@ webdavUser = ""; webdavPass = ""; testStatus = "idle"; + webdavStep = "connect"; + browsePath = []; + browseEntries = []; + browseError = null; + } + + function webdavBack() { + if (webdavStep === "preview" || webdavStep === "create") { + webdavStep = "browse"; + } else if (webdavStep === "browse") { + webdavStep = "connect"; + browsePath = []; + browseEntries = []; + } else { + goBack(); + } } @@ -214,27 +352,17 @@ {/if} - {:else} - + {:else if webdavStep === "connect"} +

- Connect to a WebDAV server for cloud-synced tasks. + Connect to a WebDAV server.

- - @@ -252,22 +380,16 @@ 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" /> -
- -
+ {#if testStatus === "fail"} +

Connection failed. Check your URL and credentials.

+ {/if} {#if !isMobile} @@ -278,6 +400,148 @@ Back {/if} + + {:else if webdavStep === "browse"} + +

+ Pick a folder or create a new workspace. +

+ + +
+ {#if browsePath.length > 0} + + {/if} + /{currentBrowsePath} +
+ + +
+ {#if browseLoading} +
Loading...
+ {:else if browseError} +
{browseError}
+ {:else if browseEntries.length === 0} +
No folders found.
+ {:else} + {#each browseEntries as entry} + + {/each} + {/if} +
+ + + + + + {:else if webdavStep === "preview"} + +
+ +

{previewName}

+
+ + {#if previewLoading} +
Loading workspace...
+ {:else if previewLists.length === 0} +

No lists in this workspace yet.

+ {:else} +
+ {#each previewLists as list} +
+ {list.name} + + {list.task_count} {list.task_count === 1 ? "task" : "tasks"} + +
+ {/each} +
+ {/if} + + + + {:else if webdavStep === "create"} + +
+ +

New Workspace

+
+ +

+ Creating in: /{currentBrowsePath} +

+ + + + {#if browseError} +

{browseError}

+ {/if} + + {/if} diff --git a/apps/tauri/src/lib/stores/app.svelte.ts b/apps/tauri/src/lib/stores/app.svelte.ts index f54bbe0..e6250c2 100644 --- a/apps/tauri/src/lib/stores/app.svelte.ts +++ b/apps/tauri/src/lib/stores/app.svelte.ts @@ -329,9 +329,9 @@ async function setTheme(theme: string | null) { } } -async function addWebdavWorkspace(name: string, webdavUrl: string, username: string, password: string) { +async function addWebdavWorkspace(name: string, webdavUrl: string, webdavPath: string, username: string, password: string) { try { - await invoke("add_webdav_workspace", { name, webdavUrl, username, password }); + await invoke("add_webdav_workspace", { name, webdavUrl, webdavPath, username, password }); config = await invoke("get_config"); await loadLists(); const ws = config?.workspaces[name]; diff --git a/apps/tauri/src/lib/types.ts b/apps/tauri/src/lib/types.ts index a24855c..f8d0433 100644 --- a/apps/tauri/src/lib/types.ts +++ b/apps/tauri/src/lib/types.ts @@ -25,6 +25,7 @@ export interface WorkspaceConfig { path: string; mode: WorkspaceMode; webdav_url: string | null; + webdav_path: string | null; last_sync: string | null; theme: string | null; } diff --git a/crates/onyx-core/src/config.rs b/crates/onyx-core/src/config.rs index cf46d65..7682e72 100644 --- a/crates/onyx-core/src/config.rs +++ b/crates/onyx-core/src/config.rs @@ -24,6 +24,8 @@ pub struct WorkspaceConfig { #[serde(skip_serializing_if = "Option::is_none", default)] pub webdav_url: Option, #[serde(skip_serializing_if = "Option::is_none", default)] + pub webdav_path: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] pub last_sync: Option>, #[serde(skip_serializing_if = "Option::is_none", default)] pub theme: Option, @@ -31,7 +33,7 @@ pub struct WorkspaceConfig { impl WorkspaceConfig { pub fn new(path: PathBuf) -> Self { - Self { path, mode: WorkspaceMode::Local, webdav_url: None, last_sync: None, theme: None } + Self { path, mode: WorkspaceMode::Local, webdav_url: None, webdav_path: None, last_sync: None, theme: None } } } diff --git a/crates/onyx-core/src/storage.rs b/crates/onyx-core/src/storage.rs index fec62dc..b93f034 100644 --- a/crates/onyx-core/src/storage.rs +++ b/crates/onyx-core/src/storage.rs @@ -7,7 +7,7 @@ use uuid::Uuid; use crate::error::{Error, Result}; use crate::models::{Task, TaskList, TaskStatus}; -/// Metadata stored in root .metadata.json +/// Metadata stored in root .onyx-workspace.json #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RootMetadata { pub version: u32, @@ -124,7 +124,7 @@ impl FileSystemStorage { } fn metadata_path(&self) -> PathBuf { - self.root_path.join(".metadata.json") + self.root_path.join(".onyx-workspace.json") } fn list_dir_path(&self, list_id: Uuid) -> Result { @@ -658,7 +658,7 @@ mod tests { fn test_init_creates_metadata() { let temp_dir = TempDir::new().unwrap(); let _storage = init_storage(&temp_dir); - assert!(temp_dir.path().join(".metadata.json").exists()); + assert!(temp_dir.path().join(".onyx-workspace.json").exists()); } #[test] @@ -683,7 +683,7 @@ mod tests { let storage = init_storage(&temp_dir); // Delete the metadata file to simulate missing - fs::remove_file(temp_dir.path().join(".metadata.json")).unwrap(); + fs::remove_file(temp_dir.path().join(".onyx-workspace.json")).unwrap(); let meta = storage.read_root_metadata().unwrap(); assert_eq!(meta.version, 1); diff --git a/crates/onyx-core/src/sync.rs b/crates/onyx-core/src/sync.rs index 1f627d2..9ca6718 100644 --- a/crates/onyx-core/src/sync.rs +++ b/crates/onyx-core/src/sync.rs @@ -371,8 +371,8 @@ pub fn compute_checksum(data: &[u8]) -> String { fn is_syncable(path: &str) -> bool { let parts: Vec<&str> = path.split('/').collect(); let filename = parts.last().copied().unwrap_or(path); - // .metadata.json only at workspace root (depth 1) - if filename == ".metadata.json" { + // .onyx-workspace.json only at workspace root (depth 1) + if filename == ".onyx-workspace.json" { return parts.len() == 1; } // .listdata.json only inside a list directory (depth 2) @@ -529,9 +529,7 @@ async fn sync_workspace_inner( mode: SyncMode, on_progress: Option, ) -> Result { - // 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 client = WebDavClient::new(webdav_url, username, password)?; let mut sync_state = SyncState::load(workspace_path); let queue = OfflineQueue::load(workspace_path); let mut result = SyncResult::default(); @@ -542,8 +540,6 @@ async fn sync_workspace_inner( } }; - // Ensure remote Onyx folder exists (creates it on first sync) - client.create_dir("").await.ok(); client.test_connection().await?; // Scan local files @@ -1117,9 +1113,9 @@ mod tests { // .listdata.json inside a list dir (depth 2) assert!(is_syncable("My Tasks/.listdata.json")); assert!(!is_syncable(".listdata.json")); // root-level not valid - // .metadata.json only at root (depth 1) - assert!(is_syncable(".metadata.json")); - assert!(!is_syncable("My Tasks/.metadata.json")); // nested not valid + // .onyx-workspace.json only at root (depth 1) + assert!(is_syncable(".onyx-workspace.json")); + assert!(!is_syncable("My Tasks/.onyx-workspace.json")); // nested not valid // Non-syncable assert!(!is_syncable(".syncstate.json")); assert!(!is_syncable("random.txt")); @@ -1133,7 +1129,7 @@ mod tests { let root = temp_dir.path(); // Create a workspace-like structure - std::fs::write(root.join(".metadata.json"), "{}").unwrap(); + std::fs::write(root.join(".onyx-workspace.json"), "{}").unwrap(); std::fs::create_dir_all(root.join("My Tasks")).unwrap(); std::fs::write(root.join("My Tasks").join(".listdata.json"), "{}").unwrap(); std::fs::write(root.join("My Tasks").join("task1.md"), "# Task 1").unwrap(); @@ -1144,8 +1140,8 @@ mod tests { std::fs::write(root.join(".syncstate.json"), "{}").unwrap(); let files = scan_local_files(root).unwrap(); - assert_eq!(files.len(), 4); // .metadata.json, .listdata.json, task1.md, task2.md - assert!(files.iter().any(|f| f.path == ".metadata.json")); + assert_eq!(files.len(), 4); // .onyx-workspace.json, .listdata.json, task1.md, task2.md + assert!(files.iter().any(|f| f.path == ".onyx-workspace.json")); assert!(files.iter().any(|f| f.path == "My Tasks/.listdata.json")); assert!(files.iter().any(|f| f.path == "My Tasks/task1.md")); assert!(files.iter().any(|f| f.path == "My Tasks/task2.md")); @@ -1159,12 +1155,12 @@ mod tests { fn test_get_sync_status_no_state() { let temp_dir = TempDir::new().unwrap(); let root = temp_dir.path(); - std::fs::write(root.join(".metadata.json"), "{}").unwrap(); + std::fs::write(root.join(".onyx-workspace.json"), "{}").unwrap(); let status = get_sync_status(root).unwrap(); assert!(status.last_sync.is_none()); assert_eq!(status.tracked_files, 0); - assert_eq!(status.pending_changes, 1); // .metadata.json is new + assert_eq!(status.pending_changes, 1); // .onyx-workspace.json is new assert_eq!(status.queued_operations, 0); }