From 8df0edf163fa1e76d0cb49e9fa0c3625fbadfb0d Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Sun, 5 Apr 2026 13:07:23 -0700 Subject: [PATCH 01/25] setup gitbutler --- .claude/settings.json | 37 ------------------------------------- .claude/settings.local.json | 37 ------------------------------------- CLAUDE.md | 4 ++++ 3 files changed, 4 insertions(+), 74 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.json b/.claude/settings.json index b4b7cb5..e69de29 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,37 +0,0 @@ -{ - "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" - } - ] - } - ] - } -} diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index b4b7cb5..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "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" - } - ] - } - ] - } -} diff --git a/CLAUDE.md b/CLAUDE.md index cf557af..8167c1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,3 +104,7 @@ The GUI uses Svelte 5 runes mode (`$state`, `$derived`, `$effect`, `$props()`). ## Roadmap See `PLAN.md` for the 7-phase roadmap. Detailed API docs in `docs/API.md`, development practices in `docs/DEVELOPMENT.md`. + +## GitButler + +If you generate code or modify files, run the gitbutler update branches MCP tool. From bb735ecd4ac6c216f1f1d6580bdff80949631246 Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Sun, 5 Apr 2026 12:56:53 -0700 Subject: [PATCH 02/25] Add workspace rename and restructure settings screen Full-stack workspace rename: renames folder on disk, updates config key/path, refreshes frontend. Restructure settings screen with generic 'Workspace Settings' header, workspace name row with kebab menu (Rename + Delete). Replace per-workspace kebab dropdown in workspace list with a direct settings gear button. Remove Appearance heading and border box from theme section. Clean up unused wsMenuName state. --- apps/tauri/src-tauri/src/lib.rs | 21 ++++ .../src/lib/screens/SettingsScreen.svelte | 115 ++++++++++++++---- apps/tauri/src/lib/screens/TasksScreen.svelte | 46 ++----- apps/tauri/src/lib/stores/app.svelte.ts | 11 ++ crates/onyx-core/src/config.rs | 15 +++ 5 files changed, 148 insertions(+), 60 deletions(-) diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index 6a43333..db230ae 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -160,6 +160,26 @@ fn remove_workspace( .map_err(|e| e.to_string()) } +#[tauri::command] +fn rename_workspace( + old_name: String, + new_name: String, + state: State<'_, Mutex>, +) -> Result<(), String> { + let mut s = lock_state(&state)?; + let ws = s.config.get_workspace(&old_name) + .ok_or_else(|| format!("Workspace '{}' not found", old_name))?; + let old_path = ws.path.clone(); + let new_path = old_path.parent() + .ok_or("Workspace path has no parent directory")? + .join(&new_name); + std::fs::rename(&old_path, &new_path).map_err(|e| e.to_string())?; + s.config.rename_workspace(&old_name, new_name.clone()).map_err(|e| e.to_string())?; + s.config.workspaces.get_mut(&new_name).unwrap().path = new_path; + s.repo = None; + s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string()) +} + // ── Workspace init ─────────────────────────────────────────────────── #[tauri::command] @@ -648,6 +668,7 @@ pub fn run() { add_workspace, set_current_workspace, remove_workspace, + rename_workspace, init_workspace, get_lists, create_list, diff --git a/apps/tauri/src/lib/screens/SettingsScreen.svelte b/apps/tauri/src/lib/screens/SettingsScreen.svelte index 0257973..1d0bf38 100644 --- a/apps/tauri/src/lib/screens/SettingsScreen.svelte +++ b/apps/tauri/src/lib/screens/SettingsScreen.svelte @@ -2,7 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { app } from "../stores/app.svelte"; - let { onclose, workspaceName }: { onclose?: () => void; workspaceName: string } = $props(); + let { onclose, workspaceName, onrename, ondelete }: { onclose?: () => void; workspaceName: string; onrename?: (newName: string) => void; ondelete?: (name: string) => void } = $props(); let ws = $derived(app.config?.workspaces[workspaceName]); let isWebdav = $derived(ws?.mode === "webdav"); @@ -12,6 +12,10 @@ let webdavPass = $state(""); let testStatus = $state<"idle" | "testing" | "ok" | "fail">("idle"); + let renaming = $state(false); + let renameValue = $state(""); + let showKebab = $state(false); + $effect(() => { if (!ws?.webdav_url) return; webdavUrl = ws.webdav_url; @@ -54,12 +58,32 @@ } await app.loadConfig(); } + + function startRename() { + showKebab = false; + renaming = true; + renameValue = workspaceName; + } + + async function handleRename() { + if (!renaming) return; + renaming = false; + var trimmed = renameValue.trim(); + if (!trimmed || trimmed === workspaceName) return; + await app.renameWorkspace(workspaceName, trimmed); + onrename?.(trimmed); + } + function handleWindowClick(e: MouseEvent) { + if (showKebab && !(e.target as HTMLElement).closest("[data-settings-kebab]")) showKebab = false; + } + +
-

{workspaceName} Settings

+

Workspace Settings

+ +
+
+ {#if renaming} + { if (e.key === "Enter") handleRename(); if (e.key === "Escape") { renaming = false; } }} + onblur={handleRename} + autofocus + /> + {:else} +

{workspaceName}

+ {/if} +
+
+ + {#if showKebab} +
+ + +
+ {/if} +
+
+
{#if isWebdav} @@ -157,27 +231,22 @@
-

- Appearance -

-
- - -
+ +

Tauri v2 + Svelte

diff --git a/apps/tauri/src/lib/screens/TasksScreen.svelte b/apps/tauri/src/lib/screens/TasksScreen.svelte index a21a39a..e56a44b 100644 --- a/apps/tauri/src/lib/screens/TasksScreen.svelte +++ b/apps/tauri/src/lib/screens/TasksScreen.svelte @@ -45,14 +45,11 @@ showWorkspacePicker = false; if (showListMenu && listMenuEl && !listMenuEl.contains(e.target as Node)) showListMenu = false; - const target = e.target as HTMLElement; - if (wsMenuName && !target.closest("[data-ws-menu]")) wsMenuName = null; } let newListName = $state(""); let showCompleted = $state(false); let completedVisible = $state(false); - let wsMenuName = $state(null); let renamingListId = $state(null); let renameValue = $state(""); let showListMenu = $state(false); @@ -137,7 +134,6 @@ if (taskStack.length > 0) { closeDetail(); return; } if (showListMenu) { showListMenu = false; return; } if (showDrawer) { closeDrawer(); return; } - if (wsMenuName) { wsMenuName = null; return; } if (showWorkspacePicker) { showWorkspacePicker = false; return; } } @@ -257,38 +253,14 @@

{ws?.mode === "webdav" ? ws.webdav_url ?? "WebDAV" : ws?.path ?? ""}

-
- - {#if wsMenuName === name} -
- - -
- {/if} -
+ {/each}
@@ -625,7 +597,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)" > - + settingsWorkspace = newName} ondelete={(name) => { closeSettings(); confirmRemoveWorkspace = name; }} />
diff --git a/apps/tauri/src/lib/stores/app.svelte.ts b/apps/tauri/src/lib/stores/app.svelte.ts index a76aeb5..f54bbe0 100644 --- a/apps/tauri/src/lib/stores/app.svelte.ts +++ b/apps/tauri/src/lib/stores/app.svelte.ts @@ -109,6 +109,16 @@ async function switchWorkspace(name: string) { } } +async function renameWorkspace(oldName: string, newName: string) { + try { + await invoke("rename_workspace", { oldName, newName }); + config = await invoke("get_config"); + error = null; + } catch (e) { + error = String(e); + } +} + async function removeWorkspace(name: string) { try { await invoke("remove_workspace", { name }); @@ -393,6 +403,7 @@ export const app = { loadConfig, addWorkspace, switchWorkspace, + renameWorkspace, removeWorkspace, loadLists, loadTasks, diff --git a/crates/onyx-core/src/config.rs b/crates/onyx-core/src/config.rs index 8d22016..cf46d65 100644 --- a/crates/onyx-core/src/config.rs +++ b/crates/onyx-core/src/config.rs @@ -60,6 +60,21 @@ impl AppConfig { self.workspaces.remove(name) } + pub fn rename_workspace(&mut self, old_name: &str, new_name: String) -> Result<()> { + if !self.workspaces.contains_key(old_name) { + return Err(Error::InvalidData(format!("Workspace '{}' not found", old_name))); + } + if self.workspaces.contains_key(&new_name) { + return Err(Error::InvalidData(format!("Workspace '{}' already exists", new_name))); + } + let ws = self.workspaces.remove(old_name).unwrap(); + if self.current_workspace.as_deref() == Some(old_name) { + self.current_workspace = Some(new_name.clone()); + } + self.workspaces.insert(new_name, ws); + Ok(()) + } + pub fn get_workspace(&self, name: &str) -> Option<&WorkspaceConfig> { self.workspaces.get(name) } From 70af83ccfc4cbc12d996732f3db80dbed22434a1 Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Sun, 5 Apr 2026 12:35:59 -0700 Subject: [PATCH 03/25] Show parent path for non-WebDAV workspaces When displaying workspace paths in the tasks list, the previous code showed the full path which could include a tail segment (e.g., a filename or last folder). This change strips the last path segment for non-WebDAV workspaces so the UI presents the parent directory instead, improving clarity for users who expect to see the workspace root rather than a trailing item. Fix fallback rendering of workspace path The previous change grouped the nullish coalescing incorrectly, which caused the fallback empty string to be applied to the result of replace(...) rather than to ws?.path. This could lead to rendering 'undefined' or leaving the replace expression unresolved when ws.path is nullish. Adjust the expression so the nullish fallback applies to ws?.path, ensuring a clean empty string fallback when no path is present. --- apps/tauri/src/lib/screens/TasksScreen.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/tauri/src/lib/screens/TasksScreen.svelte b/apps/tauri/src/lib/screens/TasksScreen.svelte index e56a44b..f3949a3 100644 --- a/apps/tauri/src/lib/screens/TasksScreen.svelte +++ b/apps/tauri/src/lib/screens/TasksScreen.svelte @@ -250,7 +250,7 @@ {/if}

{name}

-

{ws?.mode === "webdav" ? ws.webdav_url ?? "WebDAV" : ws?.path ?? ""}

+

{ws?.mode === "webdav" ? ws.webdav_url ?? "WebDAV" : ws?.path?.replace(/\/[^/]+\/?$/, "") ?? ""}

{/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); } From ac789e8d5664896bf2dee8ad2f8ad320fb4b5533 Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Sun, 5 Apr 2026 14:29:30 -0700 Subject: [PATCH 05/25] Handle deleted/moved workspace folders (missing state) Detect when the current workspace folder cannot be opened and show a dedicated "missing" screen that explains the workspace was not found. Catch failures from get_lists during loadConfig, set a missingWorkspace state and switch to the missing screen, and provide forgetMissingWorkspace() to remove the missing workspace from the config and either switch to the next available workspace or fall back to the setup screen. Add UI in App.svelte to present the workspace name, explanation, and a Continue button that invokes forgetting the missing workspace. --- apps/tauri/src/App.svelte | 20 ++++++++++++- apps/tauri/src/lib/stores/app.svelte.ts | 37 ++++++++++++++++++++++++- apps/tauri/src/lib/types.ts | 2 +- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/apps/tauri/src/App.svelte b/apps/tauri/src/App.svelte index 80d38ec..9154f58 100644 --- a/apps/tauri/src/App.svelte +++ b/apps/tauri/src/App.svelte @@ -29,7 +29,25 @@ {/if} - {#if app.screen === "setup"} + {#if app.screen === "missing"} +
+
+

Workspace Not Found

+

+ The workspace {app.missingWorkspace} could not be opened. Its folder may have been moved or deleted. +

+

+ It will be removed from your workspace list. You can re-add it if the folder becomes available again. +

+ +
+
+ {:else if app.screen === "setup"} {:else} diff --git a/apps/tauri/src/lib/stores/app.svelte.ts b/apps/tauri/src/lib/stores/app.svelte.ts index e6250c2..c69d472 100644 --- a/apps/tauri/src/lib/stores/app.svelte.ts +++ b/apps/tauri/src/lib/stores/app.svelte.ts @@ -25,6 +25,7 @@ let syncing = $state(false); let syncMode = $state<"full" | "push" | "pull">("full"); let lastSyncResult = $state(null); let error = $state(null); +let missingWorkspace = $state(null); // ── Derived ────────────────────────────────────────────────────────── @@ -70,8 +71,18 @@ async function loadConfig() { try { config = await invoke("get_config"); if (hasWorkspace) { + // Try loading lists — if the workspace path is gone, get_lists will fail + lists = []; + try { + lists = await invoke("get_lists"); + } catch { + missingWorkspace = config!.current_workspace; + screen = "missing"; + return; + } + if (lists.length > 0 && !activeListId) activeListId = lists[0].id; + if (activeListId) await loadTasks(); screen = "tasks"; - await loadLists(); } else { screen = "setup"; } @@ -343,6 +354,26 @@ async function addWebdavWorkspace(name: string, webdavUrl: string, webdavPath: s } } +async function forgetMissingWorkspace() { + if (!missingWorkspace) return; + await removeWorkspace(missingWorkspace); + missingWorkspace = null; + config = await invoke("get_config"); + if (hasWorkspace) { + // Switch to the next available workspace + const nextName = Object.keys(config!.workspaces)[0]; + if (nextName) { + await switchWorkspace(nextName); + screen = "tasks"; + return; + } + } + screen = "setup"; + lists = []; + tasks = []; + activeListId = null; +} + function setScreen(s: Screen) { screen = s; } @@ -399,6 +430,9 @@ export const app = { get hasWorkspace() { return hasWorkspace; }, + get missingWorkspace() { + return missingWorkspace; + }, getSubtasks, loadConfig, addWorkspace, @@ -422,6 +456,7 @@ export const app = { setSyncMode, setTheme, addWebdavWorkspace, + forgetMissingWorkspace, setScreen, clearError, }; diff --git a/apps/tauri/src/lib/types.ts b/apps/tauri/src/lib/types.ts index f8d0433..0bb1cab 100644 --- a/apps/tauri/src/lib/types.ts +++ b/apps/tauri/src/lib/types.ts @@ -44,4 +44,4 @@ export interface SyncResult { errors: string[]; } -export type Screen = "setup" | "tasks" | "settings"; +export type Screen = "setup" | "tasks" | "settings" | "missing"; From 095ac8fa97c24cef26fbd151f0ec5261e021a4fd Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Sun, 5 Apr 2026 14:37:58 -0700 Subject: [PATCH 06/25] Parallelize workspace detection in folder listing Speed up initial folder listing by checking each subfolder for the .onyx-workspace.json marker in parallel instead of sequentially. This uses futures::future::join_all to run multiple PROPFIND/list_files calls concurrently, reducing latency when detecting workspaces across many subdirectories. Also add the futures 0.3 dependency to the Tauri Cargo.toml and lockfile so the async utilities are available. --- apps/tauri/src-tauri/Cargo.lock | 18 ++++++++++++++++++ apps/tauri/src-tauri/Cargo.toml | 1 + apps/tauri/src-tauri/src/lib.rs | 31 ++++++++++++++++--------------- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/apps/tauri/src-tauri/Cargo.lock b/apps/tauri/src-tauri/Cargo.lock index 01abbb0..0eb22b5 100644 --- a/apps/tauri/src-tauri/Cargo.lock +++ b/apps/tauri/src-tauri/Cargo.lock @@ -909,6 +909,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -916,6 +931,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -970,6 +986,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -2403,6 +2420,7 @@ name = "onyx-tasks" version = "0.1.0" dependencies = [ "chrono", + "futures", "notify", "notify-debouncer-mini", "onyx-core", diff --git a/apps/tauri/src-tauri/Cargo.toml b/apps/tauri/src-tauri/Cargo.toml index 9c474eb..bee7aec 100644 --- a/apps/tauri/src-tauri/Cargo.toml +++ b/apps/tauri/src-tauri/Cargo.toml @@ -25,6 +25,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" onyx-core = { path = "../../../crates/onyx-core", default-features = false } tokio = { version = "1", features = ["full"] } +futures = "0.3" uuid = { version = "1", features = ["serde", "v4"] } chrono = { version = "0.4", features = ["serde"] } notify = { version = "7", optional = true } diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index c2d343a..8a3ad72 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -476,22 +476,23 @@ async fn list_remote_folder( .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 dir_entries: Vec<_> = entries.into_iter().filter(|e| e.is_dir).collect(); + + // Check all subfolders for .onyx-workspace.json in parallel + let sub_paths: Vec<_> = dir_entries.iter().map(|entry| { + if path.is_empty() { entry.path.clone() } + else { format!("{}/{}", path.trim_end_matches('/'), entry.path) } + }).collect(); + let checks: Vec<_> = sub_paths.iter().map(|sp| { + client.list_files(sp) + }).collect(); + let results: Vec<_> = futures::future::join_all(checks).await + .into_iter().map(|r| r.unwrap_or_default()).collect(); + + let folders = dir_entries.into_iter().zip(results).map(|(entry, sub_files)| { let is_workspace = sub_files.iter().any(|f| !f.is_dir && f.path == ".onyx-workspace.json"); - folders.push(RemoteFolderEntry { - name: entry.path, - is_workspace, - }); - } + RemoteFolderEntry { name: entry.path, is_workspace } + }).collect(); Ok(folders) } From a709df609fc7b431daba4a1199057201df5ab685 Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Sun, 5 Apr 2026 14:49:55 -0700 Subject: [PATCH 07/25] Prevent workspace name collisions Adding explicit duplicate-name checks to the Tauri commands that create workspaces prevents a new workspace from silently overwriting an existing one. The change returns an error if a workspace with the given name already exists. Also set sane defaults in the setup UI: default workspace name to "Onyx" for both local and WebDAV flows, default the local folder to the user's Documents directory via documentDir(), and ensure the create flow resets the name to the "Onyx" default when starting creation. --- apps/tauri/src-tauri/src/lib.rs | 6 ++++++ apps/tauri/src/lib/screens/SetupScreen.svelte | 9 ++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index 8a3ad72..b8b059b 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -120,6 +120,9 @@ fn add_workspace( state: State<'_, Mutex>, ) -> Result<(), String> { let mut s = lock_state(&state)?; + if s.config.workspaces.contains_key(&name) { + return Err(format!("A workspace named '{}' already exists", name)); + } let ws = WorkspaceConfig::new(PathBuf::from(&path)); s.config.add_workspace(name.clone(), ws); s.config @@ -569,6 +572,9 @@ fn add_webdav_workspace( state: State<'_, Mutex>, ) -> Result<(), String> { let mut s = lock_state(&state)?; + if s.config.workspaces.contains_key(&name) { + return Err(format!("A workspace named '{}' already exists", name)); + } 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())?; diff --git a/apps/tauri/src/lib/screens/SetupScreen.svelte b/apps/tauri/src/lib/screens/SetupScreen.svelte index 9ab3658..6976740 100644 --- a/apps/tauri/src/lib/screens/SetupScreen.svelte +++ b/apps/tauri/src/lib/screens/SetupScreen.svelte @@ -1,5 +1,6 @@ @@ -226,7 +226,7 @@ onclick={() => (showWorkspacePicker = !showWorkspacePicker)} class="flex items-center gap-1.5 rounded-lg px-2 py-1 text-sm font-semibold hover:bg-black/5 dark:hover:bg-white/10" > - {app.config?.current_workspace ?? "Workspace"} + {app.config?.current_workspace ? app.config.workspaces[app.config.current_workspace]?.name ?? "Workspace" : "Workspace"} @@ -236,25 +236,25 @@
- {#each workspaceNames as name} - {@const ws = app.config?.workspaces[name]} + {#each workspaceIds as wsId} + {@const ws = app.config?.workspaces[wsId]}
@@ -620,11 +620,11 @@ {#if confirmRemoveWorkspace} { const name = confirmRemoveWorkspace; confirmRemoveWorkspace = null; if (name) app.removeWorkspace(name); }} + onconfirm={() => { const id = confirmRemoveWorkspace; confirmRemoveWorkspace = null; if (id) app.removeWorkspace(id); }} oncancel={() => (confirmRemoveWorkspace = null)} /> {/if} diff --git a/apps/tauri/src/lib/stores/app.svelte.ts b/apps/tauri/src/lib/stores/app.svelte.ts index c69d472..0bd7158 100644 --- a/apps/tauri/src/lib/stores/app.svelte.ts +++ b/apps/tauri/src/lib/stores/app.svelte.ts @@ -106,13 +106,13 @@ async function addWorkspace(name: string, path: string) { } } -async function switchWorkspace(name: string) { +async function switchWorkspace(id: string) { try { - await invoke("set_current_workspace", { name }); + await invoke("set_current_workspace", { id }); config = await invoke("get_config"); activeListId = null; await loadLists(); - const ws = config?.workspaces[name]; + const ws = config?.workspaces[id]; if (ws) invoke("watch_workspace", { path: ws.path }).catch((e) => console.warn("File watcher failed:", e)); error = null; } catch (e) { @@ -120,9 +120,9 @@ async function switchWorkspace(name: string) { } } -async function renameWorkspace(oldName: string, newName: string) { +async function renameWorkspace(id: string, newName: string) { try { - await invoke("rename_workspace", { oldName, newName }); + await invoke("rename_workspace", { id, newName }); config = await invoke("get_config"); error = null; } catch (e) { @@ -130,9 +130,9 @@ async function renameWorkspace(oldName: string, newName: string) { } } -async function removeWorkspace(name: string) { +async function removeWorkspace(id: string) { try { - await invoke("remove_workspace", { name }); + await invoke("remove_workspace", { id }); config = await invoke("get_config"); if (!hasWorkspace) { screen = "setup"; @@ -307,7 +307,7 @@ async function triggerSync() { error = null; try { const result = await invoke("sync_workspace", { - workspaceName: config.current_workspace, + workspaceId: config.current_workspace, mode: syncMode, }); lastSyncResult = result; @@ -331,7 +331,7 @@ async function setTheme(theme: string | null) { if (!config?.current_workspace) return; try { await invoke("set_workspace_theme", { - workspaceName: config.current_workspace, + workspaceId: config.current_workspace, theme, }); config = await invoke("get_config"); @@ -345,8 +345,10 @@ async function addWebdavWorkspace(name: string, webdavUrl: string, webdavPath: s await invoke("add_webdav_workspace", { name, webdavUrl, webdavPath, username, password }); config = await invoke("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)); + if (config?.current_workspace) { + const ws = config.workspaces[config.current_workspace]; + if (ws) invoke("watch_workspace", { path: ws.path }).catch((e) => console.warn("File watcher failed:", e)); + } screen = "tasks"; error = null; } catch (e) { diff --git a/apps/tauri/src/lib/types.ts b/apps/tauri/src/lib/types.ts index 0bb1cab..52b386c 100644 --- a/apps/tauri/src/lib/types.ts +++ b/apps/tauri/src/lib/types.ts @@ -22,6 +22,7 @@ export interface TaskList { export type WorkspaceMode = "local" | "webdav"; export interface WorkspaceConfig { + name: string; path: string; mode: WorkspaceMode; webdav_url: string | null; diff --git a/crates/onyx-cli/src/commands/init.rs b/crates/onyx-cli/src/commands/init.rs index 807258d..a7e00fc 100644 --- a/crates/onyx-cli/src/commands/init.rs +++ b/crates/onyx-cli/src/commands/init.rs @@ -28,8 +28,8 @@ pub fn execute(path: String, name: String) -> Result<()> { .unwrap_or_else(|_| AppConfig::new()); // Add workspace - config.add_workspace(name.clone(), WorkspaceConfig::new(path_buf.clone())); - config.set_current_workspace(name.clone())?; + let id = config.add_workspace(WorkspaceConfig::new(name.clone(), path_buf.clone())); + config.set_current_workspace(id)?; // Save config config.save_to_file(&config_path) diff --git a/crates/onyx-cli/src/commands/sync.rs b/crates/onyx-cli/src/commands/sync.rs index 2794266..c34d502 100644 --- a/crates/onyx-cli/src/commands/sync.rs +++ b/crates/onyx-cli/src/commands/sync.rs @@ -2,26 +2,30 @@ use anyhow::{Context, Result}; use colored::Colorize; use onyx_core::sync::{SyncMode, sync_workspace, get_sync_status}; use onyx_core::webdav::{WebDavClient, store_credentials, load_credentials}; +use onyx_core::config::AppConfig; use crate::output; use super::{load_config, save_config}; +/// Resolve a workspace name to (id, config). Falls back to current workspace if name is None. +fn resolve_workspace(config: &AppConfig, name: Option<&str>) -> Result<(String, onyx_core::config::WorkspaceConfig)> { + if let Some(name) = name { + let (id, ws) = config.find_by_name(name) + .ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))?; + Ok((id.clone(), ws.clone())) + } else { + let (id, ws) = config.get_current_workspace() + .context("No workspace set. Use 'onyx init' to create one.")?; + Ok((id.clone(), ws.clone())) + } +} + /// Run sync setup: prompt for URL, username, password, test connection, store credentials. pub fn setup(workspace_name: Option) -> Result<()> { let mut config = load_config()?; - - let (name, workspace) = if let Some(name) = workspace_name { - let ws = config.get_workspace(&name) - .ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))? - .clone(); - (name, ws) - } else { - let (n, ws) = config.get_current_workspace() - .context("No workspace set. Use 'onyx init' to create one.")?; - (n.clone(), ws.clone()) - }; + let (id, workspace) = resolve_workspace(&config, workspace_name.as_deref())?; // Prompt for WebDAV URL - output::header(&format!("WebDAV sync setup for workspace \"{}\"", name.green())); + output::header(&format!("WebDAV sync setup for workspace \"{}\"", workspace.name.green())); output::blank(); let url = prompt("WebDAV URL: ")?; @@ -65,9 +69,9 @@ pub fn setup(workspace_name: Option) -> Result<()> { } // Update workspace config with WebDAV URL - let mut ws = workspace; - ws.webdav_url = Some(url); - config.add_workspace(name, ws); + if let Some(ws) = config.workspaces.get_mut(&id) { + ws.webdav_url = Some(url); + } save_config(&config)?; output::success("Sync setup complete. Run 'onyx sync' to sync."); @@ -77,21 +81,11 @@ pub fn setup(workspace_name: Option) -> Result<()> { /// Execute a sync operation. pub fn execute(mode: SyncMode, workspace_name: Option) -> Result<()> { let config = load_config()?; - - let (name, workspace) = if let Some(name) = workspace_name { - let ws = config.get_workspace(&name) - .ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))? - .clone(); - (name, ws) - } else { - let (n, ws) = config.get_current_workspace() - .context("No workspace set. Use 'onyx init' to create one.")?; - (n.clone(), ws.clone()) - }; + let (_id, workspace) = resolve_workspace(&config, workspace_name.as_deref())?; let url = workspace.webdav_url.as_ref() .ok_or_else(|| anyhow::anyhow!( - "No WebDAV URL configured for workspace '{}'. Run 'onyx sync --setup' first.", name + "No WebDAV URL configured for workspace '{}'. Run 'onyx sync --setup' first.", workspace.name ))?; let domain = extract_domain(url); @@ -103,7 +97,7 @@ pub fn execute(mode: SyncMode, workspace_name: Option) -> Result<()> { SyncMode::Push => "Pushing", SyncMode::Pull => "Pulling", }; - output::info(&format!("{} workspace \"{}\"...", mode_str, name.green())); + output::info(&format!("{} workspace \"{}\"...", mode_str, workspace.name.green())); let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?; let result = rt.block_on(sync_workspace( @@ -147,13 +141,12 @@ pub fn status(workspace_name: Option, all: bool) -> Result<()> { if all { // Show status for all workspaces that have sync configured let mut found_any = false; - let mut names: Vec<_> = config.workspaces.keys().cloned().collect(); - names.sort(); - for name in names { - let ws = config.get_workspace(&name).unwrap(); + let mut workspaces: Vec<_> = config.workspaces.values().collect(); + workspaces.sort_by(|a, b| a.name.cmp(&b.name)); + for ws in workspaces { if ws.webdav_url.is_some() { found_any = true; - print_workspace_status(&name, &ws.path, ws.webdav_url.as_deref())?; + print_workspace_status(&ws.name, &ws.path, ws.webdav_url.as_deref())?; output::blank(); } } @@ -163,18 +156,8 @@ pub fn status(workspace_name: Option, all: bool) -> Result<()> { return Ok(()); } - let (name, workspace) = if let Some(name) = workspace_name { - let ws = config.get_workspace(&name) - .ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))? - .clone(); - (name, ws) - } else { - let (n, ws) = config.get_current_workspace() - .context("No workspace set.")?; - (n.clone(), ws.clone()) - }; - - print_workspace_status(&name, &workspace.path, workspace.webdav_url.as_deref())?; + let (_id, workspace) = resolve_workspace(&config, workspace_name.as_deref())?; + print_workspace_status(&workspace.name, &workspace.path, workspace.webdav_url.as_deref())?; Ok(()) } @@ -207,17 +190,13 @@ fn print_workspace_status(name: &str, path: &std::path::Path, webdav_url: Option /// Extract host from a URL for credential storage. fn extract_domain(url: &str) -> String { - // Strip scheme let after_scheme = url.split("://").nth(1).unwrap_or(url); - // Strip path let authority = after_scheme.split('/').next().unwrap_or(after_scheme); - // Strip userinfo (user:pass@host) let host_port = if let Some(at_pos) = authority.rfind('@') { &authority[at_pos + 1..] } else { authority }; - // Strip port host_port.split(':').next().unwrap_or(host_port).to_string() } diff --git a/crates/onyx-cli/src/commands/workspace.rs b/crates/onyx-cli/src/commands/workspace.rs index cdf8b18..5fc4925 100644 --- a/crates/onyx-cli/src/commands/workspace.rs +++ b/crates/onyx-cli/src/commands/workspace.rs @@ -27,18 +27,13 @@ pub fn add(name: String, path: String) -> Result<()> { // Load config let mut config = load_config()?; - // Check if workspace already exists - if config.get_workspace(&name).is_some() { - anyhow::bail!("Workspace '{}' already exists", name); - } - // Add workspace - config.add_workspace(name.clone(), WorkspaceConfig::new(path_buf.clone())); + let id = config.add_workspace(WorkspaceConfig::new(name.clone(), path_buf.clone())); // Save config save_config(&config)?; - output::success(&format!("Added workspace \"{}\" at {}", name, path_buf.display())); + output::success(&format!("Added workspace \"{}\" ({}) at {}", name, &id[..8], path_buf.display())); output::success("Created default list \"My Tasks\""); Ok(()) @@ -55,29 +50,37 @@ pub fn list() -> Result<()> { let current = config.current_workspace.as_deref(); let mut workspaces: Vec<_> = config.workspaces.iter().collect(); - workspaces.sort_by(|a, b| a.0.cmp(b.0)); + workspaces.sort_by(|a, b| a.1.name.cmp(&b.1.name)); - for (name, workspace_config) in workspaces { - let marker = if Some(name.as_str()) == current { + for (id, workspace_config) in workspaces { + let marker = if Some(id.as_str()) == current { " (current)".green() } else { "".normal() }; - output::item(&format!("{}: {}{}", name, workspace_config.path.display(), marker)); + output::item(&format!("{}: {}{}", workspace_config.name, workspace_config.path.display(), marker)); } Ok(()) } +/// Resolve a workspace name to its ID. Errors if not found or ambiguous. +fn resolve_name(config: &onyx_core::config::AppConfig, name: &str) -> Result { + let matches: Vec<_> = config.workspaces.iter() + .filter(|(_, ws)| ws.name == name) + .collect(); + match matches.len() { + 0 => anyhow::bail!("Workspace '{}' not found", name), + 1 => Ok(matches[0].0.clone()), + n => anyhow::bail!("Ambiguous: {} workspaces named '{}'. Use the workspace ID instead.", n, name), + } +} + pub fn switch(name: String) -> Result<()> { let mut config = load_config()?; + let id = resolve_name(&config, &name)?; - // Verify workspace exists - if config.get_workspace(&name).is_none() { - anyhow::bail!("Workspace '{}' not found", name); - } - - config.set_current_workspace(name.clone())?; + config.set_current_workspace(id)?; save_config(&config)?; output::success(&format!("Switched to workspace \"{}\"", name)); @@ -87,11 +90,7 @@ pub fn switch(name: String) -> Result<()> { pub fn remove(name: String) -> Result<()> { let mut config = load_config()?; - - // Verify workspace exists - if config.get_workspace(&name).is_none() { - anyhow::bail!("Workspace '{}' not found", name); - } + let id = resolve_name(&config, &name)?; // Confirm output::warning("This will delete workspace config (files remain on disk)"); @@ -107,7 +106,7 @@ pub fn remove(name: String) -> Result<()> { return Ok(()); } - config.remove_workspace(&name); + config.remove_workspace(&id); save_config(&config)?; output::success(&format!("Removed workspace \"{}\"", name)); @@ -124,14 +123,10 @@ pub fn retarget(name: String, path: String) -> Result<()> { }; let mut config = load_config()?; - - // Verify workspace exists - if config.get_workspace(&name).is_none() { - anyhow::bail!("Workspace '{}' not found", name); - } + let id = resolve_name(&config, &name)?; // Update path - config.add_workspace(name.clone(), WorkspaceConfig::new(path_buf.clone())); + config.workspaces.get_mut(&id).unwrap().path = path_buf.clone(); save_config(&config)?; output::success(&format!("Workspace \"{}\" now points to {}", name, path_buf.display())); @@ -148,9 +143,10 @@ pub fn migrate(name: String, new_path: String) -> Result<()> { }; let mut config = load_config()?; + let id = resolve_name(&config, &name)?; // Get current workspace config - let old_path = config.get_workspace(&name) + let old_path = config.get_workspace(&id) .ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))? .path.clone(); @@ -225,7 +221,7 @@ pub fn migrate(name: String, new_path: String) -> Result<()> { } // Update config - config.add_workspace(name.clone(), WorkspaceConfig::new(new_path_buf.clone())); + config.workspaces.get_mut(&id).unwrap().path = new_path_buf.clone(); save_config(&config)?; output::success(&format!("Migrated {} items to {}", moved.len(), new_path_buf.display())); diff --git a/crates/onyx-core/src/config.rs b/crates/onyx-core/src/config.rs index 7682e72..fd3c1a8 100644 --- a/crates/onyx-core/src/config.rs +++ b/crates/onyx-core/src/config.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::path::PathBuf; use serde::{Deserialize, Serialize}; +use uuid::Uuid; use crate::error::{Error, Result}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -18,6 +19,7 @@ impl Default for WorkspaceMode { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkspaceConfig { + pub name: String, pub path: PathBuf, #[serde(default)] pub mode: WorkspaceMode, @@ -32,11 +34,12 @@ pub struct WorkspaceConfig { } impl WorkspaceConfig { - pub fn new(path: PathBuf) -> Self { - Self { path, mode: WorkspaceMode::Local, webdav_url: None, webdav_path: None, last_sync: None, theme: None } + pub fn new(name: String, path: PathBuf) -> Self { + Self { name, path, mode: WorkspaceMode::Local, webdav_url: None, webdav_path: None, last_sync: None, theme: None } } } +/// Workspaces keyed by UUID string. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct AppConfig { pub workspaces: HashMap, @@ -51,52 +54,51 @@ impl AppConfig { } } - pub fn add_workspace(&mut self, name: String, config: WorkspaceConfig) { - self.workspaces.insert(name, config); + pub fn add_workspace(&mut self, config: WorkspaceConfig) -> String { + let id = Uuid::new_v4().to_string(); + self.workspaces.insert(id.clone(), config); + id } - pub fn remove_workspace(&mut self, name: &str) -> Option { - if self.current_workspace.as_deref() == Some(name) { + pub fn remove_workspace(&mut self, id: &str) -> Option { + if self.current_workspace.as_deref() == Some(id) { self.current_workspace = None; } - self.workspaces.remove(name) + self.workspaces.remove(id) } - pub fn rename_workspace(&mut self, old_name: &str, new_name: String) -> Result<()> { - if !self.workspaces.contains_key(old_name) { - return Err(Error::InvalidData(format!("Workspace '{}' not found", old_name))); - } - if self.workspaces.contains_key(&new_name) { - return Err(Error::InvalidData(format!("Workspace '{}' already exists", new_name))); - } - let ws = self.workspaces.remove(old_name).unwrap(); - if self.current_workspace.as_deref() == Some(old_name) { - self.current_workspace = Some(new_name.clone()); - } - self.workspaces.insert(new_name, ws); + pub fn rename_workspace(&mut self, id: &str, new_name: String) -> Result<()> { + let ws = self.workspaces.get_mut(id) + .ok_or_else(|| Error::InvalidData(format!("Workspace '{}' not found", id)))?; + ws.name = new_name; Ok(()) } - pub fn get_workspace(&self, name: &str) -> Option<&WorkspaceConfig> { - self.workspaces.get(name) + pub fn get_workspace(&self, id: &str) -> Option<&WorkspaceConfig> { + self.workspaces.get(id) } pub fn get_current_workspace(&self) -> Result<(&String, &WorkspaceConfig)> { - let name = self.current_workspace.as_ref() + let id = self.current_workspace.as_ref() .ok_or_else(|| Error::WorkspaceNotFound("No current workspace set".to_string()))?; - let config = self.workspaces.get(name) - .ok_or_else(|| Error::WorkspaceNotFound(name.clone()))?; - Ok((name, config)) + let config = self.workspaces.get(id) + .ok_or_else(|| Error::WorkspaceNotFound(id.clone()))?; + Ok((id, config)) } - pub fn set_current_workspace(&mut self, name: String) -> Result<()> { - if !self.workspaces.contains_key(&name) { - return Err(Error::WorkspaceNotFound(name)); + pub fn set_current_workspace(&mut self, id: String) -> Result<()> { + if !self.workspaces.contains_key(&id) { + return Err(Error::WorkspaceNotFound(id)); } - self.current_workspace = Some(name); + self.current_workspace = Some(id); Ok(()) } + /// Find a workspace by display name. Returns (id, config) of the first match. + pub fn find_by_name(&self, name: &str) -> Option<(&String, &WorkspaceConfig)> { + self.workspaces.iter().find(|(_, ws)| ws.name == name) + } + pub fn load_from_file(path: &PathBuf) -> Result { if !path.exists() { return Ok(Self::new()); @@ -136,11 +138,11 @@ mod tests { } #[test] - fn test_get_current_workspace_name_points_to_removed_workspace() { + fn test_get_current_workspace_id_points_to_removed_workspace() { let mut config = AppConfig::new(); - config.add_workspace("test".to_string(), WorkspaceConfig::new(PathBuf::from("/tmp"))); - config.current_workspace = Some("test".to_string()); - config.workspaces.remove("test"); + let id = config.add_workspace(WorkspaceConfig::new("test".into(), PathBuf::from("/tmp"))); + config.current_workspace = Some(id.clone()); + config.workspaces.remove(&id); let result = config.get_current_workspace(); assert!(result.is_err()); @@ -158,31 +160,31 @@ mod tests { #[test] fn test_set_current_workspace_valid() { let mut config = AppConfig::new(); - config.add_workspace("real".to_string(), WorkspaceConfig::new(PathBuf::from("/tmp"))); - assert!(config.set_current_workspace("real".to_string()).is_ok()); - assert_eq!(config.current_workspace.as_deref(), Some("real")); + let id = config.add_workspace(WorkspaceConfig::new("real".into(), PathBuf::from("/tmp"))); + assert!(config.set_current_workspace(id.clone()).is_ok()); + assert_eq!(config.current_workspace.as_deref(), Some(id.as_str())); } #[test] fn test_remove_current_workspace_clears_current() { let mut config = AppConfig::new(); - config.add_workspace("ws".to_string(), WorkspaceConfig::new(PathBuf::from("/tmp"))); - config.set_current_workspace("ws".to_string()).unwrap(); + let id = config.add_workspace(WorkspaceConfig::new("ws".into(), PathBuf::from("/tmp"))); + config.set_current_workspace(id.clone()).unwrap(); - config.remove_workspace("ws"); + config.remove_workspace(&id); assert!(config.current_workspace.is_none()); - assert!(config.get_workspace("ws").is_none()); + assert!(config.get_workspace(&id).is_none()); } #[test] fn test_remove_noncurrent_workspace_keeps_current() { let mut config = AppConfig::new(); - config.add_workspace("a".to_string(), WorkspaceConfig::new(PathBuf::from("/a"))); - config.add_workspace("b".to_string(), WorkspaceConfig::new(PathBuf::from("/b"))); - config.set_current_workspace("a".to_string()).unwrap(); + let id_a = config.add_workspace(WorkspaceConfig::new("a".into(), PathBuf::from("/a"))); + let id_b = config.add_workspace(WorkspaceConfig::new("b".into(), PathBuf::from("/b"))); + config.set_current_workspace(id_a.clone()).unwrap(); - config.remove_workspace("b"); - assert_eq!(config.current_workspace.as_deref(), Some("a")); + config.remove_workspace(&id_b); + assert_eq!(config.current_workspace.as_deref(), Some(id_a.as_str())); } #[test] @@ -191,16 +193,16 @@ mod tests { let config_path = temp_dir.path().join("config.json"); let mut config = AppConfig::new(); - config.add_workspace("ws1".to_string(), WorkspaceConfig::new(PathBuf::from("/path/one"))); - config.add_workspace("ws2".to_string(), WorkspaceConfig::new(PathBuf::from("/path/two"))); - config.set_current_workspace("ws1".to_string()).unwrap(); + let id1 = config.add_workspace(WorkspaceConfig::new("ws1".into(), PathBuf::from("/path/one"))); + let _id2 = config.add_workspace(WorkspaceConfig::new("ws2".into(), PathBuf::from("/path/two"))); + config.set_current_workspace(id1.clone()).unwrap(); config.save_to_file(&config_path).unwrap(); let loaded = AppConfig::load_from_file(&config_path).unwrap(); - assert_eq!(loaded.current_workspace.as_deref(), Some("ws1")); + assert_eq!(loaded.current_workspace.as_deref(), Some(id1.as_str())); assert_eq!(loaded.workspaces.len(), 2); - assert_eq!(loaded.get_workspace("ws1").unwrap().path, PathBuf::from("/path/one")); - assert_eq!(loaded.get_workspace("ws2").unwrap().path, PathBuf::from("/path/two")); + assert_eq!(loaded.get_workspace(&id1).unwrap().path, PathBuf::from("/path/one")); + assert_eq!(loaded.get_workspace(&id1).unwrap().name, "ws1"); } #[test] @@ -231,13 +233,35 @@ mod tests { } #[test] - fn test_add_workspace_overwrites_existing() { + fn test_duplicate_names_allowed() { let mut config = AppConfig::new(); - config.add_workspace("ws".to_string(), WorkspaceConfig::new(PathBuf::from("/old"))); - config.add_workspace("ws".to_string(), WorkspaceConfig::new(PathBuf::from("/new"))); + let id1 = config.add_workspace(WorkspaceConfig::new("Onyx".into(), PathBuf::from("/a"))); + let id2 = config.add_workspace(WorkspaceConfig::new("Onyx".into(), PathBuf::from("/b"))); - assert_eq!(config.get_workspace("ws").unwrap().path, PathBuf::from("/new")); - assert_eq!(config.workspaces.len(), 1); + assert_ne!(id1, id2); + assert_eq!(config.workspaces.len(), 2); + assert_eq!(config.get_workspace(&id1).unwrap().name, "Onyx"); + assert_eq!(config.get_workspace(&id2).unwrap().name, "Onyx"); + } + + #[test] + fn test_find_by_name() { + let mut config = AppConfig::new(); + let id = config.add_workspace(WorkspaceConfig::new("Tasks".into(), PathBuf::from("/tasks"))); + + let found = config.find_by_name("Tasks"); + assert!(found.is_some()); + assert_eq!(found.unwrap().0, &id); + + assert!(config.find_by_name("Nonexistent").is_none()); + } + + #[test] + fn test_rename_workspace() { + let mut config = AppConfig::new(); + let id = config.add_workspace(WorkspaceConfig::new("Old".into(), PathBuf::from("/tmp"))); + config.rename_workspace(&id, "New".into()).unwrap(); + assert_eq!(config.get_workspace(&id).unwrap().name, "New"); } #[test] @@ -246,38 +270,15 @@ mod tests { let config_path = temp_dir.path().join("config.json"); let mut config = AppConfig::new(); - let mut ws = WorkspaceConfig::new(PathBuf::from("/tasks")); + let mut ws = WorkspaceConfig::new("synced".into(), PathBuf::from("/tasks")); ws.webdav_url = Some("https://dav.example.com/tasks".to_string()); ws.last_sync = Some(chrono::Utc::now()); - config.add_workspace("synced".to_string(), ws); + let id = config.add_workspace(ws); config.save_to_file(&config_path).unwrap(); let loaded = AppConfig::load_from_file(&config_path).unwrap(); - let ws = loaded.get_workspace("synced").unwrap(); + let ws = loaded.get_workspace(&id).unwrap(); assert_eq!(ws.webdav_url.as_deref(), Some("https://dav.example.com/tasks")); assert!(ws.last_sync.is_some()); } - - #[test] - fn test_backwards_compat_loading_old_format() { - let temp_dir = TempDir::new().unwrap(); - let config_path = temp_dir.path().join("config.json"); - - // Write old-format JSON without webdav_url, last_sync, mode, or theme fields - let old_json = r#"{ - "workspaces": { - "personal": { "path": "/home/user/tasks" } - }, - "current_workspace": "personal" - }"#; - std::fs::write(&config_path, old_json).unwrap(); - - let loaded = AppConfig::load_from_file(&config_path).unwrap(); - let ws = loaded.get_workspace("personal").unwrap(); - 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()); - } } From 4c57851e1518eb69befdf2824a422c2bb70213af Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Sun, 5 Apr 2026 15:10:44 -0700 Subject: [PATCH 09/25] Rename workspace and remote folders with confirmation Add WebDAV MOVE support and update workspace rename flow to handle both local and WebDAV-backed workspaces. The Tauri rename_workspace command is made async and now performs filesystem rename for local workspaces and issues a WebDAV MOVE (via a new WebDavClient::move_resource) for remote workspaces, updating stored paths and credentials accordingly. A confirmation dialog is added to SettingsScreen to prompt users before renaming, and minor UI/default tweaks are included (SetupScreen default name). This ensures renames update both local folders and remote WebDAV folders reliably and with user confirmation. --- apps/tauri/src-tauri/src/lib.rs | 79 +++++++++++++++++-- .../src/lib/screens/SettingsScreen.svelte | 19 +++++ apps/tauri/src/lib/screens/SetupScreen.svelte | 2 +- crates/onyx-core/src/webdav.rs | 22 ++++++ 4 files changed, 116 insertions(+), 6 deletions(-) diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index d2269b9..77204d1 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -160,15 +160,84 @@ fn remove_workspace( } #[tauri::command] -fn rename_workspace( +async fn rename_workspace( id: String, new_name: String, state: State<'_, Mutex>, ) -> Result<(), String> { - let mut s = lock_state(&state)?; - s.config.rename_workspace(&id, new_name).map_err(|e| e.to_string())?; - s.repo = None; - s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string()) + // Extract workspace info while holding the lock briefly + let (mode, old_path, webdav_url, webdav_path) = { + let s = lock_state(&state)?; + let ws = s.config.workspaces.get(&id).ok_or("Workspace not found")?; + ( + ws.mode.clone(), + ws.path.clone(), + ws.webdav_url.clone(), + ws.webdav_path.clone(), + ) + }; + + match mode { + WorkspaceMode::Local => { + // Rename the local folder + let parent = old_path.parent().ok_or("Workspace has no parent directory")?; + let new_path = parent.join(&new_name); + if new_path != old_path { + if new_path.exists() { + return Err(format!("A folder named '{}' already exists at that location", new_name)); + } + std::fs::rename(&old_path, &new_path).map_err(|e| format!("Failed to rename folder: {}", e))?; + } + let mut s = lock_state(&state)?; + s.config.rename_workspace(&id, new_name).map_err(|e| e.to_string())?; + if let Some(ws) = s.config.workspaces.get_mut(&id) { + ws.path = new_path; + } + s.repo = None; + s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?; + } + WorkspaceMode::Webdav => { + // Rename the remote folder via WebDAV MOVE + let base_url = webdav_url.as_deref().ok_or("No WebDAV URL configured")?; + let remote_path = webdav_path.as_deref().unwrap_or(""); + + let domain = base_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 client = webdav::WebDavClient::new(base_url, &username, &password) + .map_err(|e| e.to_string())?; + + // Compute new remote path by replacing the last segment + let new_remote_path = if remote_path.is_empty() || remote_path == "/" { + new_name.clone() + } else if let Some(parent) = remote_path.trim_end_matches('/').rsplit_once('/') { + format!("{}/{}", parent.0, new_name) + } else { + new_name.clone() + }; + + if new_remote_path != remote_path { + client.move_resource(remote_path, &new_remote_path).await.map_err(|e| e.to_string())?; + } + + let mut s = lock_state(&state)?; + s.config.rename_workspace(&id, new_name).map_err(|e| e.to_string())?; + if let Some(ws) = s.config.workspaces.get_mut(&id) { + ws.webdav_path = Some(new_remote_path); + } + s.repo = None; + s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?; + } + } + + Ok(()) } // ── Workspace init ─────────────────────────────────────────────────── diff --git a/apps/tauri/src/lib/screens/SettingsScreen.svelte b/apps/tauri/src/lib/screens/SettingsScreen.svelte index e826487..c21ee35 100644 --- a/apps/tauri/src/lib/screens/SettingsScreen.svelte +++ b/apps/tauri/src/lib/screens/SettingsScreen.svelte @@ -1,6 +1,7 @@