From 50d859ef8089eacc020319c7b7c7f3e976c0f391 Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Sun, 5 Apr 2026 14:58:31 -0700 Subject: [PATCH] Refactor workspaces to use UUID keys Change workspace identifiers from display names to UUID strings so multiple workspaces can share the same display name. WorkspaceConfig now stores a name field; add_workspace returns a generated UUID. Update all CLI, Tauri commands, frontend stores and screens, WebDAV managed directories, and tests to use/resolve workspace IDs; add helpers find_by_name/resolve_name to map display names to IDs when needed. This removes duplicate-name checks, avoids filesystem conflicts, and preserves display names while using stable unique IDs internally. --- apps/tauri/src-tauri/src/lib.rs | 57 +++--- apps/tauri/src/App.svelte | 2 +- .../src/lib/screens/SettingsScreen.svelte | 17 +- apps/tauri/src/lib/screens/TasksScreen.svelte | 24 +-- apps/tauri/src/lib/stores/app.svelte.ts | 24 +-- apps/tauri/src/lib/types.ts | 1 + crates/onyx-cli/src/commands/init.rs | 4 +- crates/onyx-cli/src/commands/sync.rs | 77 +++----- crates/onyx-cli/src/commands/workspace.rs | 58 +++--- crates/onyx-core/src/config.rs | 165 +++++++++--------- 10 files changed, 197 insertions(+), 232 deletions(-) diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index b8b059b..d2269b9 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -120,15 +120,11 @@ 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); + let ws = WorkspaceConfig::new(name, PathBuf::from(&path)); + let id = s.config.add_workspace(ws); s.config - .set_current_workspace(name) + .set_current_workspace(id) .map_err(|e| e.to_string())?; - // Reset repo so it reopens on next access s.repo = None; s.config .save_to_file(&s.config_path.clone()) @@ -137,12 +133,12 @@ fn add_workspace( #[tauri::command] fn set_current_workspace( - name: String, + id: String, state: State<'_, Mutex>, ) -> Result<(), String> { let mut s = lock_state(&state)?; s.config - .set_current_workspace(name) + .set_current_workspace(id) .map_err(|e| e.to_string())?; s.repo = None; s.config @@ -152,11 +148,11 @@ fn set_current_workspace( #[tauri::command] fn remove_workspace( - name: String, + id: String, state: State<'_, Mutex>, ) -> Result<(), String> { let mut s = lock_state(&state)?; - s.config.remove_workspace(&name); + s.config.remove_workspace(&id); s.repo = None; s.config .save_to_file(&s.config_path.clone()) @@ -165,20 +161,12 @@ fn remove_workspace( #[tauri::command] fn rename_workspace( - old_name: String, + id: 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.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()) } @@ -428,12 +416,12 @@ fn get_group_by_due_date( #[tauri::command] fn set_webdav_config( - workspace_name: String, + workspace_id: String, webdav_url: String, state: State<'_, Mutex>, ) -> Result<(), String> { let mut s = lock_state(&state)?; - if let Some(ws) = s.config.workspaces.get_mut(&workspace_name) { + if let Some(ws) = s.config.workspaces.get_mut(&workspace_id) { ws.webdav_url = Some(webdav_url); } s.config @@ -443,12 +431,12 @@ fn set_webdav_config( #[tauri::command] fn set_workspace_theme( - workspace_name: String, + workspace_id: String, theme: Option, state: State<'_, Mutex>, ) -> Result<(), String> { let mut s = lock_state(&state)?; - if let Some(ws) = s.config.workspaces.get_mut(&workspace_name) { + if let Some(ws) = s.config.workspaces.get_mut(&workspace_id) { ws.theme = theme; } s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string()) @@ -572,20 +560,19 @@ 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); + // Use a UUID-based directory name to avoid filesystem conflicts with duplicate workspace names + let dir_id = uuid::Uuid::new_v4().to_string(); + let managed_dir = s.app_data_dir.join("workspaces").join(&dir_id); 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); + let mut ws = WorkspaceConfig::new(name, 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())?; + let id = s.config.add_workspace(ws); + s.config.set_current_workspace(id).map_err(|e| e.to_string())?; s.repo = None; // Store credentials keyed by hostname @@ -641,14 +628,14 @@ async fn test_webdav_connection( #[tauri::command] async fn sync_workspace( - workspace_name: String, + workspace_id: String, mode: String, state: State<'_, Mutex>, ) -> Result { // 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) + let ws = s.config.workspaces.get(&workspace_id) .ok_or("Workspace not found")?; let base = ws.webdav_url.clone().ok_or("No WebDAV URL configured")?; let full = match &ws.webdav_path { @@ -691,7 +678,7 @@ async fn sync_workspace( { let mut s = lock_state(&state)?; - if let Some(ws) = s.config.workspaces.get_mut(&workspace_name) { + if let Some(ws) = s.config.workspaces.get_mut(&workspace_id) { ws.last_sync = Some(Utc::now()); } s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?; diff --git a/apps/tauri/src/App.svelte b/apps/tauri/src/App.svelte index 9154f58..a808fab 100644 --- a/apps/tauri/src/App.svelte +++ b/apps/tauri/src/App.svelte @@ -34,7 +34,7 @@

Workspace Not Found

- The workspace {app.missingWorkspace} could not be opened. Its folder may have been moved or deleted. + The workspace {app.missingWorkspace && app.config?.workspaces[app.missingWorkspace]?.name || "Unknown"} 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. diff --git a/apps/tauri/src/lib/screens/SettingsScreen.svelte b/apps/tauri/src/lib/screens/SettingsScreen.svelte index 1d0bf38..e826487 100644 --- a/apps/tauri/src/lib/screens/SettingsScreen.svelte +++ b/apps/tauri/src/lib/screens/SettingsScreen.svelte @@ -2,9 +2,9 @@ import { invoke } from "@tauri-apps/api/core"; import { app } from "../stores/app.svelte"; - let { onclose, workspaceName, onrename, ondelete }: { onclose?: () => void; workspaceName: string; onrename?: (newName: string) => void; ondelete?: (name: string) => void } = $props(); + let { onclose, workspaceId, ondelete }: { onclose?: () => void; workspaceId: string; ondelete?: (id: string) => void } = $props(); - let ws = $derived(app.config?.workspaces[workspaceName]); + let ws = $derived(app.config?.workspaces[workspaceId]); let isWebdav = $derived(ws?.mode === "webdav"); let webdavUrl = $state(""); @@ -45,7 +45,7 @@ async function saveWebdav() { if (!webdavUrl.trim()) return; await invoke("set_webdav_config", { - workspaceName, + workspaceId, webdavUrl: webdavUrl.trim(), }); if (webdavUser && webdavPass) { @@ -62,16 +62,15 @@ function startRename() { showKebab = false; renaming = true; - renameValue = workspaceName; + renameValue = ws?.name ?? ""; } async function handleRename() { if (!renaming) return; renaming = false; var trimmed = renameValue.trim(); - if (!trimmed || trimmed === workspaceName) return; - await app.renameWorkspace(workspaceName, trimmed); - onrename?.(trimmed); + if (!trimmed || trimmed === ws?.name) return; + await app.renameWorkspace(workspaceId, trimmed); } function handleWindowClick(e: MouseEvent) { if (showKebab && !(e.target as HTMLElement).closest("[data-settings-kebab]")) showKebab = false; @@ -109,7 +108,7 @@ autofocus /> {:else} -

{workspaceName}

+

{ws?.name}

{/if}
@@ -133,7 +132,7 @@ Rename
@@ -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()); - } }