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:
parent
a709df609f
commit
50d859ef80
|
|
@ -120,15 +120,11 @@ fn add_workspace(
|
||||||
state: State<'_, Mutex<AppState>>,
|
state: State<'_, Mutex<AppState>>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut s = lock_state(&state)?;
|
let mut s = lock_state(&state)?;
|
||||||
if s.config.workspaces.contains_key(&name) {
|
let ws = WorkspaceConfig::new(name, PathBuf::from(&path));
|
||||||
return Err(format!("A workspace named '{}' already exists", name));
|
let id = s.config.add_workspace(ws);
|
||||||
}
|
|
||||||
let ws = WorkspaceConfig::new(PathBuf::from(&path));
|
|
||||||
s.config.add_workspace(name.clone(), ws);
|
|
||||||
s.config
|
s.config
|
||||||
.set_current_workspace(name)
|
.set_current_workspace(id)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
// Reset repo so it reopens on next access
|
|
||||||
s.repo = None;
|
s.repo = None;
|
||||||
s.config
|
s.config
|
||||||
.save_to_file(&s.config_path.clone())
|
.save_to_file(&s.config_path.clone())
|
||||||
|
|
@ -137,12 +133,12 @@ fn add_workspace(
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn set_current_workspace(
|
fn set_current_workspace(
|
||||||
name: String,
|
id: String,
|
||||||
state: State<'_, Mutex<AppState>>,
|
state: State<'_, Mutex<AppState>>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut s = lock_state(&state)?;
|
let mut s = lock_state(&state)?;
|
||||||
s.config
|
s.config
|
||||||
.set_current_workspace(name)
|
.set_current_workspace(id)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
s.repo = None;
|
s.repo = None;
|
||||||
s.config
|
s.config
|
||||||
|
|
@ -152,11 +148,11 @@ fn set_current_workspace(
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn remove_workspace(
|
fn remove_workspace(
|
||||||
name: String,
|
id: String,
|
||||||
state: State<'_, Mutex<AppState>>,
|
state: State<'_, Mutex<AppState>>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut s = lock_state(&state)?;
|
let mut s = lock_state(&state)?;
|
||||||
s.config.remove_workspace(&name);
|
s.config.remove_workspace(&id);
|
||||||
s.repo = None;
|
s.repo = None;
|
||||||
s.config
|
s.config
|
||||||
.save_to_file(&s.config_path.clone())
|
.save_to_file(&s.config_path.clone())
|
||||||
|
|
@ -165,20 +161,12 @@ fn remove_workspace(
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn rename_workspace(
|
fn rename_workspace(
|
||||||
old_name: String,
|
id: String,
|
||||||
new_name: String,
|
new_name: String,
|
||||||
state: State<'_, Mutex<AppState>>,
|
state: State<'_, Mutex<AppState>>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut s = lock_state(&state)?;
|
let mut s = lock_state(&state)?;
|
||||||
let ws = s.config.get_workspace(&old_name)
|
s.config.rename_workspace(&id, new_name).map_err(|e| e.to_string())?;
|
||||||
.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.repo = None;
|
||||||
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())
|
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]
|
#[tauri::command]
|
||||||
fn set_webdav_config(
|
fn set_webdav_config(
|
||||||
workspace_name: String,
|
workspace_id: String,
|
||||||
webdav_url: String,
|
webdav_url: String,
|
||||||
state: State<'_, Mutex<AppState>>,
|
state: State<'_, Mutex<AppState>>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut s = lock_state(&state)?;
|
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);
|
ws.webdav_url = Some(webdav_url);
|
||||||
}
|
}
|
||||||
s.config
|
s.config
|
||||||
|
|
@ -443,12 +431,12 @@ fn set_webdav_config(
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn set_workspace_theme(
|
fn set_workspace_theme(
|
||||||
workspace_name: String,
|
workspace_id: String,
|
||||||
theme: Option<String>,
|
theme: Option<String>,
|
||||||
state: State<'_, Mutex<AppState>>,
|
state: State<'_, Mutex<AppState>>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut s = lock_state(&state)?;
|
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;
|
ws.theme = theme;
|
||||||
}
|
}
|
||||||
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())
|
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>>,
|
state: State<'_, Mutex<AppState>>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut s = lock_state(&state)?;
|
let mut s = lock_state(&state)?;
|
||||||
if s.config.workspaces.contains_key(&name) {
|
// Use a UUID-based directory name to avoid filesystem conflicts with duplicate workspace names
|
||||||
return Err(format!("A workspace named '{}' already exists", name));
|
let dir_id = uuid::Uuid::new_v4().to_string();
|
||||||
}
|
let managed_dir = s.app_data_dir.join("workspaces").join(&dir_id);
|
||||||
let managed_dir = s.app_data_dir.join("workspaces").join(&name);
|
|
||||||
std::fs::create_dir_all(&managed_dir).map_err(|e| e.to_string())?;
|
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())?;
|
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.mode = WorkspaceMode::Webdav;
|
||||||
ws.webdav_url = Some(webdav_url.clone());
|
ws.webdav_url = Some(webdav_url.clone());
|
||||||
ws.webdav_path = Some(webdav_path);
|
ws.webdav_path = Some(webdav_path);
|
||||||
|
|
||||||
s.config.add_workspace(name.clone(), ws);
|
let id = s.config.add_workspace(ws);
|
||||||
s.config.set_current_workspace(name).map_err(|e| e.to_string())?;
|
s.config.set_current_workspace(id).map_err(|e| e.to_string())?;
|
||||||
s.repo = None;
|
s.repo = None;
|
||||||
|
|
||||||
// Store credentials keyed by hostname
|
// Store credentials keyed by hostname
|
||||||
|
|
@ -641,14 +628,14 @@ async fn test_webdav_connection(
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn sync_workspace(
|
async fn sync_workspace(
|
||||||
workspace_name: String,
|
workspace_id: String,
|
||||||
mode: String,
|
mode: String,
|
||||||
state: State<'_, Mutex<AppState>>,
|
state: State<'_, Mutex<AppState>>,
|
||||||
) -> Result<SyncResult, String> {
|
) -> Result<SyncResult, String> {
|
||||||
// Step 1: read config — combine base URL with the user-chosen remote path
|
// Step 1: read config — combine base URL with the user-chosen remote path
|
||||||
let (workspace_path, webdav_url) = {
|
let (workspace_path, webdav_url) = {
|
||||||
let s = lock_state(&state)?;
|
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")?;
|
.ok_or("Workspace not found")?;
|
||||||
let base = ws.webdav_url.clone().ok_or("No WebDAV URL configured")?;
|
let base = ws.webdav_url.clone().ok_or("No WebDAV URL configured")?;
|
||||||
let full = match &ws.webdav_path {
|
let full = match &ws.webdav_path {
|
||||||
|
|
@ -691,7 +678,7 @@ async fn sync_workspace(
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut s = lock_state(&state)?;
|
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());
|
ws.last_sync = Some(Utc::now());
|
||||||
}
|
}
|
||||||
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?;
|
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?;
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
<div class="w-full max-w-sm rounded-2xl bg-card-light p-8 shadow-lg dark:bg-card-dark">
|
<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>
|
<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">
|
<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>
|
||||||
<p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark">
|
<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.
|
It will be removed from your workspace list. You can re-add it if the folder becomes available again.
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { app } from "../stores/app.svelte";
|
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 isWebdav = $derived(ws?.mode === "webdav");
|
||||||
|
|
||||||
let webdavUrl = $state("");
|
let webdavUrl = $state("");
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
async function saveWebdav() {
|
async function saveWebdav() {
|
||||||
if (!webdavUrl.trim()) return;
|
if (!webdavUrl.trim()) return;
|
||||||
await invoke("set_webdav_config", {
|
await invoke("set_webdav_config", {
|
||||||
workspaceName,
|
workspaceId,
|
||||||
webdavUrl: webdavUrl.trim(),
|
webdavUrl: webdavUrl.trim(),
|
||||||
});
|
});
|
||||||
if (webdavUser && webdavPass) {
|
if (webdavUser && webdavPass) {
|
||||||
|
|
@ -62,16 +62,15 @@
|
||||||
function startRename() {
|
function startRename() {
|
||||||
showKebab = false;
|
showKebab = false;
|
||||||
renaming = true;
|
renaming = true;
|
||||||
renameValue = workspaceName;
|
renameValue = ws?.name ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRename() {
|
async function handleRename() {
|
||||||
if (!renaming) return;
|
if (!renaming) return;
|
||||||
renaming = false;
|
renaming = false;
|
||||||
var trimmed = renameValue.trim();
|
var trimmed = renameValue.trim();
|
||||||
if (!trimmed || trimmed === workspaceName) return;
|
if (!trimmed || trimmed === ws?.name) return;
|
||||||
await app.renameWorkspace(workspaceName, trimmed);
|
await app.renameWorkspace(workspaceId, trimmed);
|
||||||
onrename?.(trimmed);
|
|
||||||
}
|
}
|
||||||
function handleWindowClick(e: MouseEvent) {
|
function handleWindowClick(e: MouseEvent) {
|
||||||
if (showKebab && !(e.target as HTMLElement).closest("[data-settings-kebab]")) showKebab = false;
|
if (showKebab && !(e.target as HTMLElement).closest("[data-settings-kebab]")) showKebab = false;
|
||||||
|
|
@ -109,7 +108,7 @@
|
||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-xl font-bold">{workspaceName}</p>
|
<p class="text-xl font-bold">{ws?.name}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="relative shrink-0" data-settings-kebab>
|
<div class="relative shrink-0" data-settings-kebab>
|
||||||
|
|
@ -133,7 +132,7 @@
|
||||||
Rename
|
Rename
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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">
|
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,7 @@
|
||||||
if (isDesktop) appWindow.startDragging();
|
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');
|
let translateX = $derived(showDrawer ? '0' : '-80cqi');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -226,7 +226,7 @@
|
||||||
onclick={() => (showWorkspacePicker = !showWorkspacePicker)}
|
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"
|
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">
|
<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" />
|
<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>
|
</svg>
|
||||||
|
|
@ -236,25 +236,25 @@
|
||||||
<div
|
<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"
|
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}
|
{#each workspaceIds as wsId}
|
||||||
{@const ws = app.config?.workspaces[name]}
|
{@const ws = app.config?.workspaces[wsId]}
|
||||||
<div class="group flex items-center px-1 hover:bg-black/5 dark:hover:bg-white/10">
|
<div class="group flex items-center px-1 hover:bg-black/5 dark:hover:bg-white/10">
|
||||||
<button
|
<button
|
||||||
onclick={() => { if (name !== app.config?.current_workspace) app.switchWorkspace(name); showWorkspacePicker = false; }}
|
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 {name === app.config?.current_workspace ? 'font-bold' : ''}"
|
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">
|
<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" />
|
<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>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="min-w-0 flex-1">
|
<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>
|
<p class="truncate text-xs opacity-40">{ws?.mode === "webdav" ? ws.webdav_url ?? "WebDAV" : ws?.path?.replace(/\/[^/]+\/?$/, "") ?? ""}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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">
|
<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'}"
|
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)"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -620,11 +620,11 @@
|
||||||
<!-- Remove workspace confirmation -->
|
<!-- Remove workspace confirmation -->
|
||||||
{#if confirmRemoveWorkspace}
|
{#if confirmRemoveWorkspace}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
message='Remove workspace "{confirmRemoveWorkspace}"?'
|
message='Remove workspace "{app.config?.workspaces[confirmRemoveWorkspace]?.name ?? confirmRemoveWorkspace}"?'
|
||||||
detail="Files remain on disk."
|
detail="Files remain on disk."
|
||||||
confirmText="Remove"
|
confirmText="Remove"
|
||||||
danger
|
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)}
|
oncancel={() => (confirmRemoveWorkspace = null)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -106,13 +106,13 @@ async function addWorkspace(name: string, path: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function switchWorkspace(name: string) {
|
async function switchWorkspace(id: string) {
|
||||||
try {
|
try {
|
||||||
await invoke("set_current_workspace", { name });
|
await invoke("set_current_workspace", { id });
|
||||||
config = await invoke<AppConfig>("get_config");
|
config = await invoke<AppConfig>("get_config");
|
||||||
activeListId = null;
|
activeListId = null;
|
||||||
await loadLists();
|
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));
|
if (ws) invoke("watch_workspace", { path: ws.path }).catch((e) => console.warn("File watcher failed:", e));
|
||||||
error = null;
|
error = null;
|
||||||
} catch (e) {
|
} 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 {
|
try {
|
||||||
await invoke("rename_workspace", { oldName, newName });
|
await invoke("rename_workspace", { id, newName });
|
||||||
config = await invoke<AppConfig>("get_config");
|
config = await invoke<AppConfig>("get_config");
|
||||||
error = null;
|
error = null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -130,9 +130,9 @@ async function renameWorkspace(oldName: string, newName: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeWorkspace(name: string) {
|
async function removeWorkspace(id: string) {
|
||||||
try {
|
try {
|
||||||
await invoke("remove_workspace", { name });
|
await invoke("remove_workspace", { id });
|
||||||
config = await invoke<AppConfig>("get_config");
|
config = await invoke<AppConfig>("get_config");
|
||||||
if (!hasWorkspace) {
|
if (!hasWorkspace) {
|
||||||
screen = "setup";
|
screen = "setup";
|
||||||
|
|
@ -307,7 +307,7 @@ async function triggerSync() {
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
const result = await invoke<SyncResult>("sync_workspace", {
|
const result = await invoke<SyncResult>("sync_workspace", {
|
||||||
workspaceName: config.current_workspace,
|
workspaceId: config.current_workspace,
|
||||||
mode: syncMode,
|
mode: syncMode,
|
||||||
});
|
});
|
||||||
lastSyncResult = result;
|
lastSyncResult = result;
|
||||||
|
|
@ -331,7 +331,7 @@ async function setTheme(theme: string | null) {
|
||||||
if (!config?.current_workspace) return;
|
if (!config?.current_workspace) return;
|
||||||
try {
|
try {
|
||||||
await invoke("set_workspace_theme", {
|
await invoke("set_workspace_theme", {
|
||||||
workspaceName: config.current_workspace,
|
workspaceId: config.current_workspace,
|
||||||
theme,
|
theme,
|
||||||
});
|
});
|
||||||
config = await invoke<AppConfig>("get_config");
|
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 });
|
await invoke("add_webdav_workspace", { name, webdavUrl, webdavPath, username, password });
|
||||||
config = await invoke<AppConfig>("get_config");
|
config = await invoke<AppConfig>("get_config");
|
||||||
await loadLists();
|
await loadLists();
|
||||||
const ws = config?.workspaces[name];
|
if (config?.current_workspace) {
|
||||||
if (ws) invoke("watch_workspace", { path: ws.path }).catch((e) => console.warn("File watcher failed:", e));
|
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";
|
screen = "tasks";
|
||||||
error = null;
|
error = null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export interface TaskList {
|
||||||
export type WorkspaceMode = "local" | "webdav";
|
export type WorkspaceMode = "local" | "webdav";
|
||||||
|
|
||||||
export interface WorkspaceConfig {
|
export interface WorkspaceConfig {
|
||||||
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
mode: WorkspaceMode;
|
mode: WorkspaceMode;
|
||||||
webdav_url: string | null;
|
webdav_url: string | null;
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,8 @@ pub fn execute(path: String, name: String) -> Result<()> {
|
||||||
.unwrap_or_else(|_| AppConfig::new());
|
.unwrap_or_else(|_| AppConfig::new());
|
||||||
|
|
||||||
// Add workspace
|
// Add workspace
|
||||||
config.add_workspace(name.clone(), WorkspaceConfig::new(path_buf.clone()));
|
let id = config.add_workspace(WorkspaceConfig::new(name.clone(), path_buf.clone()));
|
||||||
config.set_current_workspace(name.clone())?;
|
config.set_current_workspace(id)?;
|
||||||
|
|
||||||
// Save config
|
// Save config
|
||||||
config.save_to_file(&config_path)
|
config.save_to_file(&config_path)
|
||||||
|
|
|
||||||
|
|
@ -2,26 +2,30 @@ use anyhow::{Context, Result};
|
||||||
use colored::Colorize;
|
use colored::Colorize;
|
||||||
use onyx_core::sync::{SyncMode, sync_workspace, get_sync_status};
|
use onyx_core::sync::{SyncMode, sync_workspace, get_sync_status};
|
||||||
use onyx_core::webdav::{WebDavClient, store_credentials, load_credentials};
|
use onyx_core::webdav::{WebDavClient, store_credentials, load_credentials};
|
||||||
|
use onyx_core::config::AppConfig;
|
||||||
use crate::output;
|
use crate::output;
|
||||||
use super::{load_config, save_config};
|
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.
|
/// Run sync setup: prompt for URL, username, password, test connection, store credentials.
|
||||||
pub fn setup(workspace_name: Option<String>) -> Result<()> {
|
pub fn setup(workspace_name: Option<String>) -> Result<()> {
|
||||||
let mut config = load_config()?;
|
let mut config = load_config()?;
|
||||||
|
let (id, workspace) = resolve_workspace(&config, workspace_name.as_deref())?;
|
||||||
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())
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prompt for WebDAV URL
|
// 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();
|
output::blank();
|
||||||
|
|
||||||
let url = prompt("WebDAV URL: ")?;
|
let url = prompt("WebDAV URL: ")?;
|
||||||
|
|
@ -65,9 +69,9 @@ pub fn setup(workspace_name: Option<String>) -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update workspace config with WebDAV URL
|
// Update workspace config with WebDAV URL
|
||||||
let mut ws = workspace;
|
if let Some(ws) = config.workspaces.get_mut(&id) {
|
||||||
ws.webdav_url = Some(url);
|
ws.webdav_url = Some(url);
|
||||||
config.add_workspace(name, ws);
|
}
|
||||||
save_config(&config)?;
|
save_config(&config)?;
|
||||||
|
|
||||||
output::success("Sync setup complete. Run 'onyx sync' to sync.");
|
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.
|
/// Execute a sync operation.
|
||||||
pub fn execute(mode: SyncMode, workspace_name: Option<String>) -> Result<()> {
|
pub fn execute(mode: SyncMode, workspace_name: Option<String>) -> Result<()> {
|
||||||
let config = load_config()?;
|
let config = load_config()?;
|
||||||
|
let (_id, workspace) = resolve_workspace(&config, workspace_name.as_deref())?;
|
||||||
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 url = workspace.webdav_url.as_ref()
|
let url = workspace.webdav_url.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!(
|
.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);
|
let domain = extract_domain(url);
|
||||||
|
|
@ -103,7 +97,7 @@ pub fn execute(mode: SyncMode, workspace_name: Option<String>) -> Result<()> {
|
||||||
SyncMode::Push => "Pushing",
|
SyncMode::Push => "Pushing",
|
||||||
SyncMode::Pull => "Pulling",
|
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 rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?;
|
||||||
let result = rt.block_on(sync_workspace(
|
let result = rt.block_on(sync_workspace(
|
||||||
|
|
@ -147,13 +141,12 @@ pub fn status(workspace_name: Option<String>, all: bool) -> Result<()> {
|
||||||
if all {
|
if all {
|
||||||
// Show status for all workspaces that have sync configured
|
// Show status for all workspaces that have sync configured
|
||||||
let mut found_any = false;
|
let mut found_any = false;
|
||||||
let mut names: Vec<_> = config.workspaces.keys().cloned().collect();
|
let mut workspaces: Vec<_> = config.workspaces.values().collect();
|
||||||
names.sort();
|
workspaces.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
for name in names {
|
for ws in workspaces {
|
||||||
let ws = config.get_workspace(&name).unwrap();
|
|
||||||
if ws.webdav_url.is_some() {
|
if ws.webdav_url.is_some() {
|
||||||
found_any = true;
|
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();
|
output::blank();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -163,18 +156,8 @@ pub fn status(workspace_name: Option<String>, all: bool) -> Result<()> {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let (name, workspace) = if let Some(name) = workspace_name {
|
let (_id, workspace) = resolve_workspace(&config, workspace_name.as_deref())?;
|
||||||
let ws = config.get_workspace(&name)
|
print_workspace_status(&workspace.name, &workspace.path, workspace.webdav_url.as_deref())?;
|
||||||
.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())?;
|
|
||||||
Ok(())
|
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.
|
/// Extract host from a URL for credential storage.
|
||||||
fn extract_domain(url: &str) -> String {
|
fn extract_domain(url: &str) -> String {
|
||||||
// Strip scheme
|
|
||||||
let after_scheme = url.split("://").nth(1).unwrap_or(url);
|
let after_scheme = url.split("://").nth(1).unwrap_or(url);
|
||||||
// Strip path
|
|
||||||
let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
|
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('@') {
|
let host_port = if let Some(at_pos) = authority.rfind('@') {
|
||||||
&authority[at_pos + 1..]
|
&authority[at_pos + 1..]
|
||||||
} else {
|
} else {
|
||||||
authority
|
authority
|
||||||
};
|
};
|
||||||
// Strip port
|
|
||||||
host_port.split(':').next().unwrap_or(host_port).to_string()
|
host_port.split(':').next().unwrap_or(host_port).to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,18 +27,13 @@ pub fn add(name: String, path: String) -> Result<()> {
|
||||||
// Load config
|
// Load config
|
||||||
let mut config = 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
|
// 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
|
||||||
save_config(&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\"");
|
output::success("Created default list \"My Tasks\"");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -55,29 +50,37 @@ pub fn list() -> Result<()> {
|
||||||
let current = config.current_workspace.as_deref();
|
let current = config.current_workspace.as_deref();
|
||||||
|
|
||||||
let mut workspaces: Vec<_> = config.workspaces.iter().collect();
|
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 {
|
for (id, workspace_config) in workspaces {
|
||||||
let marker = if Some(name.as_str()) == current {
|
let marker = if Some(id.as_str()) == current {
|
||||||
" (current)".green()
|
" (current)".green()
|
||||||
} else {
|
} else {
|
||||||
"".normal()
|
"".normal()
|
||||||
};
|
};
|
||||||
output::item(&format!("{}: {}{}", name, workspace_config.path.display(), marker));
|
output::item(&format!("{}: {}{}", workspace_config.name, workspace_config.path.display(), marker));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
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<()> {
|
pub fn switch(name: String) -> Result<()> {
|
||||||
let mut config = load_config()?;
|
let mut config = load_config()?;
|
||||||
|
let id = resolve_name(&config, &name)?;
|
||||||
|
|
||||||
// Verify workspace exists
|
config.set_current_workspace(id)?;
|
||||||
if config.get_workspace(&name).is_none() {
|
|
||||||
anyhow::bail!("Workspace '{}' not found", name);
|
|
||||||
}
|
|
||||||
|
|
||||||
config.set_current_workspace(name.clone())?;
|
|
||||||
save_config(&config)?;
|
save_config(&config)?;
|
||||||
|
|
||||||
output::success(&format!("Switched to workspace \"{}\"", name));
|
output::success(&format!("Switched to workspace \"{}\"", name));
|
||||||
|
|
@ -87,11 +90,7 @@ pub fn switch(name: String) -> Result<()> {
|
||||||
|
|
||||||
pub fn remove(name: String) -> Result<()> {
|
pub fn remove(name: String) -> Result<()> {
|
||||||
let mut config = load_config()?;
|
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm
|
// Confirm
|
||||||
output::warning("This will delete workspace config (files remain on disk)");
|
output::warning("This will delete workspace config (files remain on disk)");
|
||||||
|
|
@ -107,7 +106,7 @@ pub fn remove(name: String) -> Result<()> {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
config.remove_workspace(&name);
|
config.remove_workspace(&id);
|
||||||
save_config(&config)?;
|
save_config(&config)?;
|
||||||
|
|
||||||
output::success(&format!("Removed workspace \"{}\"", name));
|
output::success(&format!("Removed workspace \"{}\"", name));
|
||||||
|
|
@ -124,14 +123,10 @@ pub fn retarget(name: String, path: String) -> Result<()> {
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut config = load_config()?;
|
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update path
|
// 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)?;
|
save_config(&config)?;
|
||||||
|
|
||||||
output::success(&format!("Workspace \"{}\" now points to {}", name, path_buf.display()));
|
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 mut config = load_config()?;
|
||||||
|
let id = resolve_name(&config, &name)?;
|
||||||
|
|
||||||
// Get current workspace config
|
// 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))?
|
.ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))?
|
||||||
.path.clone();
|
.path.clone();
|
||||||
|
|
||||||
|
|
@ -225,7 +221,7 @@ pub fn migrate(name: String, new_path: String) -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update config
|
// 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)?;
|
save_config(&config)?;
|
||||||
|
|
||||||
output::success(&format!("Migrated {} items to {}", moved.len(), new_path_buf.display()));
|
output::success(&format!("Migrated {} items to {}", moved.len(), new_path_buf.display()));
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
|
@ -18,6 +19,7 @@ impl Default for WorkspaceMode {
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct WorkspaceConfig {
|
pub struct WorkspaceConfig {
|
||||||
|
pub name: String,
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mode: WorkspaceMode,
|
pub mode: WorkspaceMode,
|
||||||
|
|
@ -32,11 +34,12 @@ pub struct WorkspaceConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkspaceConfig {
|
impl WorkspaceConfig {
|
||||||
pub fn new(path: PathBuf) -> Self {
|
pub fn new(name: String, path: PathBuf) -> Self {
|
||||||
Self { path, mode: WorkspaceMode::Local, webdav_url: None, webdav_path: None, last_sync: None, theme: None }
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
pub workspaces: HashMap<String, WorkspaceConfig>,
|
pub workspaces: HashMap<String, WorkspaceConfig>,
|
||||||
|
|
@ -51,52 +54,51 @@ impl AppConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_workspace(&mut self, name: String, config: WorkspaceConfig) {
|
pub fn add_workspace(&mut self, config: WorkspaceConfig) -> String {
|
||||||
self.workspaces.insert(name, config);
|
let id = Uuid::new_v4().to_string();
|
||||||
|
self.workspaces.insert(id.clone(), config);
|
||||||
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_workspace(&mut self, name: &str) -> Option<WorkspaceConfig> {
|
pub fn remove_workspace(&mut self, id: &str) -> Option<WorkspaceConfig> {
|
||||||
if self.current_workspace.as_deref() == Some(name) {
|
if self.current_workspace.as_deref() == Some(id) {
|
||||||
self.current_workspace = None;
|
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<()> {
|
pub fn rename_workspace(&mut self, id: &str, new_name: String) -> Result<()> {
|
||||||
if !self.workspaces.contains_key(old_name) {
|
let ws = self.workspaces.get_mut(id)
|
||||||
return Err(Error::InvalidData(format!("Workspace '{}' not found", old_name)));
|
.ok_or_else(|| Error::InvalidData(format!("Workspace '{}' not found", id)))?;
|
||||||
}
|
ws.name = new_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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_workspace(&self, name: &str) -> Option<&WorkspaceConfig> {
|
pub fn get_workspace(&self, id: &str) -> Option<&WorkspaceConfig> {
|
||||||
self.workspaces.get(name)
|
self.workspaces.get(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_current_workspace(&self) -> Result<(&String, &WorkspaceConfig)> {
|
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()))?;
|
.ok_or_else(|| Error::WorkspaceNotFound("No current workspace set".to_string()))?;
|
||||||
let config = self.workspaces.get(name)
|
let config = self.workspaces.get(id)
|
||||||
.ok_or_else(|| Error::WorkspaceNotFound(name.clone()))?;
|
.ok_or_else(|| Error::WorkspaceNotFound(id.clone()))?;
|
||||||
Ok((name, config))
|
Ok((id, config))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_current_workspace(&mut self, name: String) -> Result<()> {
|
pub fn set_current_workspace(&mut self, id: String) -> Result<()> {
|
||||||
if !self.workspaces.contains_key(&name) {
|
if !self.workspaces.contains_key(&id) {
|
||||||
return Err(Error::WorkspaceNotFound(name));
|
return Err(Error::WorkspaceNotFound(id));
|
||||||
}
|
}
|
||||||
self.current_workspace = Some(name);
|
self.current_workspace = Some(id);
|
||||||
Ok(())
|
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> {
|
pub fn load_from_file(path: &PathBuf) -> Result<Self> {
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(Self::new());
|
return Ok(Self::new());
|
||||||
|
|
@ -136,11 +138,11 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let mut config = AppConfig::new();
|
||||||
config.add_workspace("test".to_string(), WorkspaceConfig::new(PathBuf::from("/tmp")));
|
let id = config.add_workspace(WorkspaceConfig::new("test".into(), PathBuf::from("/tmp")));
|
||||||
config.current_workspace = Some("test".to_string());
|
config.current_workspace = Some(id.clone());
|
||||||
config.workspaces.remove("test");
|
config.workspaces.remove(&id);
|
||||||
|
|
||||||
let result = config.get_current_workspace();
|
let result = config.get_current_workspace();
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
|
|
@ -158,31 +160,31 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_set_current_workspace_valid() {
|
fn test_set_current_workspace_valid() {
|
||||||
let mut config = AppConfig::new();
|
let mut config = AppConfig::new();
|
||||||
config.add_workspace("real".to_string(), WorkspaceConfig::new(PathBuf::from("/tmp")));
|
let id = config.add_workspace(WorkspaceConfig::new("real".into(), PathBuf::from("/tmp")));
|
||||||
assert!(config.set_current_workspace("real".to_string()).is_ok());
|
assert!(config.set_current_workspace(id.clone()).is_ok());
|
||||||
assert_eq!(config.current_workspace.as_deref(), Some("real"));
|
assert_eq!(config.current_workspace.as_deref(), Some(id.as_str()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_remove_current_workspace_clears_current() {
|
fn test_remove_current_workspace_clears_current() {
|
||||||
let mut config = AppConfig::new();
|
let mut config = AppConfig::new();
|
||||||
config.add_workspace("ws".to_string(), WorkspaceConfig::new(PathBuf::from("/tmp")));
|
let id = config.add_workspace(WorkspaceConfig::new("ws".into(), PathBuf::from("/tmp")));
|
||||||
config.set_current_workspace("ws".to_string()).unwrap();
|
config.set_current_workspace(id.clone()).unwrap();
|
||||||
|
|
||||||
config.remove_workspace("ws");
|
config.remove_workspace(&id);
|
||||||
assert!(config.current_workspace.is_none());
|
assert!(config.current_workspace.is_none());
|
||||||
assert!(config.get_workspace("ws").is_none());
|
assert!(config.get_workspace(&id).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_remove_noncurrent_workspace_keeps_current() {
|
fn test_remove_noncurrent_workspace_keeps_current() {
|
||||||
let mut config = AppConfig::new();
|
let mut config = AppConfig::new();
|
||||||
config.add_workspace("a".to_string(), WorkspaceConfig::new(PathBuf::from("/a")));
|
let id_a = config.add_workspace(WorkspaceConfig::new("a".into(), PathBuf::from("/a")));
|
||||||
config.add_workspace("b".to_string(), WorkspaceConfig::new(PathBuf::from("/b")));
|
let id_b = config.add_workspace(WorkspaceConfig::new("b".into(), PathBuf::from("/b")));
|
||||||
config.set_current_workspace("a".to_string()).unwrap();
|
config.set_current_workspace(id_a.clone()).unwrap();
|
||||||
|
|
||||||
config.remove_workspace("b");
|
config.remove_workspace(&id_b);
|
||||||
assert_eq!(config.current_workspace.as_deref(), Some("a"));
|
assert_eq!(config.current_workspace.as_deref(), Some(id_a.as_str()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -191,16 +193,16 @@ mod tests {
|
||||||
let config_path = temp_dir.path().join("config.json");
|
let config_path = temp_dir.path().join("config.json");
|
||||||
|
|
||||||
let mut config = AppConfig::new();
|
let mut config = AppConfig::new();
|
||||||
config.add_workspace("ws1".to_string(), WorkspaceConfig::new(PathBuf::from("/path/one")));
|
let id1 = config.add_workspace(WorkspaceConfig::new("ws1".into(), PathBuf::from("/path/one")));
|
||||||
config.add_workspace("ws2".to_string(), WorkspaceConfig::new(PathBuf::from("/path/two")));
|
let _id2 = config.add_workspace(WorkspaceConfig::new("ws2".into(), PathBuf::from("/path/two")));
|
||||||
config.set_current_workspace("ws1".to_string()).unwrap();
|
config.set_current_workspace(id1.clone()).unwrap();
|
||||||
config.save_to_file(&config_path).unwrap();
|
config.save_to_file(&config_path).unwrap();
|
||||||
|
|
||||||
let loaded = AppConfig::load_from_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.workspaces.len(), 2);
|
||||||
assert_eq!(loaded.get_workspace("ws1").unwrap().path, PathBuf::from("/path/one"));
|
assert_eq!(loaded.get_workspace(&id1).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().name, "ws1");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -231,13 +233,35 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_add_workspace_overwrites_existing() {
|
fn test_duplicate_names_allowed() {
|
||||||
let mut config = AppConfig::new();
|
let mut config = AppConfig::new();
|
||||||
config.add_workspace("ws".to_string(), WorkspaceConfig::new(PathBuf::from("/old")));
|
let id1 = config.add_workspace(WorkspaceConfig::new("Onyx".into(), PathBuf::from("/a")));
|
||||||
config.add_workspace("ws".to_string(), WorkspaceConfig::new(PathBuf::from("/new")));
|
let id2 = config.add_workspace(WorkspaceConfig::new("Onyx".into(), PathBuf::from("/b")));
|
||||||
|
|
||||||
assert_eq!(config.get_workspace("ws").unwrap().path, PathBuf::from("/new"));
|
assert_ne!(id1, id2);
|
||||||
assert_eq!(config.workspaces.len(), 1);
|
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]
|
#[test]
|
||||||
|
|
@ -246,38 +270,15 @@ mod tests {
|
||||||
let config_path = temp_dir.path().join("config.json");
|
let config_path = temp_dir.path().join("config.json");
|
||||||
|
|
||||||
let mut config = AppConfig::new();
|
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.webdav_url = Some("https://dav.example.com/tasks".to_string());
|
||||||
ws.last_sync = Some(chrono::Utc::now());
|
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();
|
config.save_to_file(&config_path).unwrap();
|
||||||
|
|
||||||
let loaded = AppConfig::load_from_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_eq!(ws.webdav_url.as_deref(), Some("https://dav.example.com/tasks"));
|
||||||
assert!(ws.last_sync.is_some());
|
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue