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.
This commit is contained in:
Tristan Michael 2026-04-05 14:58:31 -07:00
parent a709df609f
commit 50d859ef80
10 changed files with 197 additions and 232 deletions

View file

@ -120,15 +120,11 @@ fn add_workspace(
state: State<'_, Mutex<AppState>>,
) -> 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<AppState>>,
) -> 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<AppState>>,
) -> 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<AppState>>,
) -> 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<AppState>>,
) -> 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<String>,
state: State<'_, Mutex<AppState>>,
) -> 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<AppState>>,
) -> 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<AppState>>,
) -> Result<SyncResult, String> {
// 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())?;

View file

@ -34,7 +34,7 @@
<div class="w-full max-w-sm rounded-2xl bg-card-light p-8 shadow-lg dark:bg-card-dark">
<h1 class="mb-1 text-2xl font-bold">Workspace Not Found</h1>
<p class="mb-2 text-sm text-text-secondary-light dark:text-text-secondary-dark">
The workspace <strong>{app.missingWorkspace}</strong> could not be opened. Its folder may have been moved or deleted.
The workspace <strong>{app.missingWorkspace && app.config?.workspaces[app.missingWorkspace]?.name || "Unknown"}</strong> could not be opened. Its folder may have been moved or deleted.
</p>
<p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark">
It will be removed from your workspace list. You can re-add it if the folder becomes available again.

View file

@ -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}
<p class="text-xl font-bold">{workspaceName}</p>
<p class="text-xl font-bold">{ws?.name}</p>
{/if}
</div>
<div class="relative shrink-0" data-settings-kebab>
@ -133,7 +132,7 @@
Rename
</button>
<button
onclick={() => { showKebab = false; ondelete?.(workspaceName); }}
onclick={() => { showKebab = false; ondelete?.(workspaceId); }}
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-danger hover:bg-black/5 dark:hover:bg-white/10"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">

View file

@ -200,7 +200,7 @@
if (isDesktop) appWindow.startDragging();
}
let workspaceNames = $derived(app.config ? Object.keys(app.config.workspaces).sort((a, b) => a.localeCompare(b)) : []);
let workspaceIds = $derived(app.config ? Object.keys(app.config.workspaces).sort((a, b) => (app.config!.workspaces[a].name).localeCompare(app.config!.workspaces[b].name)) : []);
let translateX = $derived(showDrawer ? '0' : '-80cqi');
</script>
@ -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"
>
<span class="truncate">{app.config?.current_workspace ?? "Workspace"}</span>
<span class="truncate">{app.config?.current_workspace ? app.config.workspaces[app.config.current_workspace]?.name ?? "Workspace" : "Workspace"}</span>
<svg class="h-3.5 w-3.5 shrink-0 transition-transform {showWorkspacePicker ? 'rotate-180' : ''}" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" />
</svg>
@ -236,25 +236,25 @@
<div
class="absolute left-0 top-full z-40 mt-1 w-full rounded-lg border border-border-light bg-surface-light py-1 shadow-lg dark:border-border-dark dark:bg-surface-dark"
>
{#each workspaceNames as name}
{@const ws = app.config?.workspaces[name]}
{#each workspaceIds as wsId}
{@const ws = app.config?.workspaces[wsId]}
<div class="group flex items-center px-1 hover:bg-black/5 dark:hover:bg-white/10">
<button
onclick={() => { if (name !== app.config?.current_workspace) app.switchWorkspace(name); showWorkspacePicker = false; }}
class="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left {name === app.config?.current_workspace ? 'font-bold' : ''}"
onclick={() => { if (wsId !== app.config?.current_workspace) app.switchWorkspace(wsId); showWorkspacePicker = false; }}
class="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left {wsId === app.config?.current_workspace ? 'font-bold' : ''}"
>
{#if name === app.config?.current_workspace}
{#if wsId === app.config?.current_workspace}
<svg class="h-4 w-4 shrink-0 opacity-50" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" />
</svg>
{/if}
<div class="min-w-0 flex-1">
<p class="truncate text-sm">{name}</p>
<p class="truncate text-sm">{ws?.name}</p>
<p class="truncate text-xs opacity-40">{ws?.mode === "webdav" ? ws.webdav_url ?? "WebDAV" : ws?.path?.replace(/\/[^/]+\/?$/, "") ?? ""}</p>
</div>
</button>
<button
onclick={(e) => { e.stopPropagation(); settingsWorkspace = name; showSettings = true; showWorkspacePicker = false; }}
onclick={(e) => { e.stopPropagation(); settingsWorkspace = wsId; showSettings = true; showWorkspacePicker = false; }}
class="shrink-0 rounded p-1 opacity-0 transition-opacity group-hover:opacity-40 hover:!opacity-80"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
@ -597,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)"
>
<SettingsScreen onclose={closeSettings} workspaceName={settingsWorkspace ?? app.config?.current_workspace ?? ""} onrename={(newName) => settingsWorkspace = newName} ondelete={(name) => { closeSettings(); confirmRemoveWorkspace = name; }} />
<SettingsScreen onclose={closeSettings} workspaceId={settingsWorkspace ?? app.config?.current_workspace ?? ""} ondelete={(id) => { closeSettings(); confirmRemoveWorkspace = id; }} />
</div>
</div>
@ -620,11 +620,11 @@
<!-- Remove workspace confirmation -->
{#if confirmRemoveWorkspace}
<ConfirmDialog
message='Remove workspace "{confirmRemoveWorkspace}"?'
message='Remove workspace "{app.config?.workspaces[confirmRemoveWorkspace]?.name ?? confirmRemoveWorkspace}"?'
detail="Files remain on disk."
confirmText="Remove"
danger
onconfirm={() => { 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}

View file

@ -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<AppConfig>("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<AppConfig>("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<AppConfig>("get_config");
if (!hasWorkspace) {
screen = "setup";
@ -307,7 +307,7 @@ async function triggerSync() {
error = null;
try {
const result = await invoke<SyncResult>("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<AppConfig>("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<AppConfig>("get_config");
await loadLists();
const ws = config?.workspaces[name];
if (ws) invoke("watch_workspace", { path: ws.path }).catch((e) => console.warn("File watcher failed:", e));
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) {

View file

@ -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;

View file

@ -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)

View file

@ -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<String>) -> 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<String>) -> 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<String>) -> Result<()> {
/// Execute a sync operation.
pub fn execute(mode: SyncMode, workspace_name: Option<String>) -> 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<String>) -> 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<String>, 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<String>, 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()
}

View file

@ -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<String> {
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()));

View file

@ -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<String, WorkspaceConfig>,
@ -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<WorkspaceConfig> {
if self.current_workspace.as_deref() == Some(name) {
pub fn remove_workspace(&mut self, id: &str) -> Option<WorkspaceConfig> {
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<Self> {
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());
}
}