Rename workspace metadata and add WebDAV folder browsing

Rework WebDAV workspace setup to use .onyx-workspace.json instead of
.metadata.json and to let users pick a remote folder instead of forcing
an Onyx/ subfolder. This updates storage, sync, config types, tests, and
CLI/Tauri commands to store a webdav_path in WorkspaceConfig and to
combine webdav_url + webdav_path for sync.

Changes include:
- Rename .metadata.json → .onyx-workspace.json across storage, sync, and tests so workspace detection and root metadata use the new filename.
- Remove hardcoded automatic "Onyx/" subfolder in sync and use the user-selected remote path directly.
- Add webdav_path field to WorkspaceConfig (Rust and TypeScript types) and thread it through add_webdav_workspace and frontend addWebdavWorkspace.
- Add three Tauri commands (list_remote_folder, inspect_remote_workspace, create_remote_workspace) to support remote folder browsing, workspace preview, and remote workspace creation.
- Rewrite SetupScreen WebDAV flow to Connect → Browse (lazy folder explorer) → Preview or Create, and wire UI state/handlers to the new commands.
- Update CLAUDE.md to document the new on-disk filename and note development phase allowing breaking changes.
This commit is contained in:
Tristan Michael 2026-04-05 14:21:00 -07:00
parent 70af83ccfc
commit 753cb1cad5
8 changed files with 440 additions and 56 deletions

View file

@ -43,7 +43,7 @@ Two-crate workspace (`resolver = "2"`, edition 2021) plus a Tauri app:
### On-disk format ### On-disk format
Workspaces are plain folders. Each task list is a subfolder containing `.listdata.json` (metadata/ordering) and one `.md` file per task. The workspace root has `.metadata.json` for list ordering. Workspaces are plain folders. Each task list is a subfolder containing `.listdata.json` (metadata/ordering) and one `.md` file per task. The workspace root has `.onyx-workspace.json` for list ordering and workspace detection.
### Tauri GUI structure ### Tauri GUI structure
@ -59,10 +59,14 @@ The GUI uses Svelte 5 runes mode (`$state`, `$derived`, `$effect`, `$props()`).
- **Main panel header**: Hamburger + window controls in top bar; list name (large, bold) + kebab below divider (matching task detail layout). Kebab has Rename, Group by due date, Delete completed, Delete list. - **Main panel header**: Hamburger + window controls in top bar; list name (large, bold) + kebab below divider (matching task detail layout). Kebab has Rename, Group by due date, Delete completed, Delete list.
- **New task**: FAB button opens bottom toast sheet (outside sliding container for fixed positioning). - **New task**: FAB button opens bottom toast sheet (outside sliding container for fixed positioning).
### Current state (2026-04-03) ### Development phase
Pre-alpha. No users, no released builds, no data to migrate. Breaking changes to on-disk formats, config structure, or sync conventions are free — do not add migration logic.
### Current state (2026-04-05)
- **Phase 1** (Core + CLI): Complete - **Phase 1** (Core + CLI): Complete
- **Phase 2** (WebDAV sync): Complete — CLI + GUI sync working, auto-creates `Onyx/` subfolder on remote - **Phase 2** (WebDAV sync): In progress — reworking to let users browse and pick a remote folder instead of hardcoding `Onyx/` subfolder
- **Phase 3** (GUI MVP): Complete - **Phase 3** (GUI MVP): Complete
- **Phase 4** (Mobile): Tauri Android cfg-gated, needs `tauri android init` + build - **Phase 4** (Mobile): Tauri Android cfg-gated, needs `tauri android init` + build

View file

@ -451,10 +451,118 @@ fn set_workspace_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())
} }
/// A remote folder entry returned to the frontend.
#[derive(Debug, Serialize, Deserialize)]
struct RemoteFolderEntry {
name: String,
is_workspace: bool,
}
/// Summary of a list inside a remote workspace.
#[derive(Debug, Serialize, Deserialize)]
struct RemoteListInfo {
name: String,
task_count: usize,
}
#[tauri::command]
async fn list_remote_folder(
url: String,
username: String,
password: String,
path: String,
) -> Result<Vec<RemoteFolderEntry>, String> {
let client = onyx_core::webdav::WebDavClient::new(&url, &username, &password)
.map_err(|e| e.to_string())?;
let entries = client.list_files(&path).await.map_err(|e| e.to_string())?;
let mut folders = Vec::new();
for entry in entries {
if !entry.is_dir { continue; }
// Check if this folder contains .onyx-workspace.json
let sub_path = if path.is_empty() {
entry.path.clone()
} else {
format!("{}/{}", path.trim_end_matches('/'), entry.path)
};
let sub_files = client.list_files(&sub_path).await.unwrap_or_default();
let is_workspace = sub_files.iter().any(|f| !f.is_dir && f.path == ".onyx-workspace.json");
folders.push(RemoteFolderEntry {
name: entry.path,
is_workspace,
});
}
Ok(folders)
}
#[tauri::command]
async fn inspect_remote_workspace(
url: String,
username: String,
password: String,
path: String,
) -> Result<Vec<RemoteListInfo>, String> {
let client = onyx_core::webdav::WebDavClient::new(&url, &username, &password)
.map_err(|e| e.to_string())?;
let entries = client.list_files(&path).await.map_err(|e| e.to_string())?;
let mut lists = Vec::new();
for entry in entries {
if !entry.is_dir { continue; }
let list_path = if path.is_empty() {
entry.path.clone()
} else {
format!("{}/{}", path.trim_end_matches('/'), entry.path)
};
let files = client.list_files(&list_path).await.unwrap_or_default();
let has_listdata = files.iter().any(|f| !f.is_dir && f.path == ".listdata.json");
if has_listdata {
let task_count = files.iter().filter(|f| !f.is_dir && f.path.ends_with(".md")).count();
lists.push(RemoteListInfo {
name: entry.path,
task_count,
});
}
}
Ok(lists)
}
#[tauri::command]
async fn create_remote_workspace(
url: String,
username: String,
password: String,
path: String,
) -> Result<(), String> {
let client = onyx_core::webdav::WebDavClient::new(&url, &username, &password)
.map_err(|e| e.to_string())?;
if !path.is_empty() {
client.ensure_dir(&path).await.map_err(|e| e.to_string())?;
}
// Upload an empty .onyx-workspace.json
let metadata = serde_json::json!({
"version": 1,
"list_order": [],
"last_opened_list": null,
});
let file_path = if path.is_empty() {
".onyx-workspace.json".to_string()
} else {
format!("{}/{}", path.trim_end_matches('/'), ".onyx-workspace.json")
};
client.put_file(&file_path, serde_json::to_string_pretty(&metadata).unwrap().into_bytes())
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command] #[tauri::command]
fn add_webdav_workspace( fn add_webdav_workspace(
name: String, name: String,
webdav_url: String, webdav_url: String,
webdav_path: String,
username: String, username: String,
password: String, password: String,
state: State<'_, Mutex<AppState>>, state: State<'_, Mutex<AppState>>,
@ -467,6 +575,7 @@ fn add_webdav_workspace(
let mut ws = WorkspaceConfig::new(managed_dir); let mut ws = WorkspaceConfig::new(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);
s.config.add_workspace(name.clone(), ws); s.config.add_workspace(name.clone(), ws);
s.config.set_current_workspace(name).map_err(|e| e.to_string())?; s.config.set_current_workspace(name).map_err(|e| e.to_string())?;
@ -529,12 +638,17 @@ async fn sync_workspace(
mode: String, mode: String,
state: State<'_, Mutex<AppState>>, state: State<'_, Mutex<AppState>>,
) -> Result<SyncResult, String> { ) -> Result<SyncResult, String> {
// Step 1: read config // 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_name)
.ok_or("Workspace not found")?; .ok_or("Workspace not found")?;
(ws.path.clone(), ws.webdav_url.clone().ok_or("No WebDAV URL configured")?) let base = ws.webdav_url.clone().ok_or("No WebDAV URL configured")?;
let full = match &ws.webdav_path {
Some(p) if !p.is_empty() => format!("{}/{}", base.trim_end_matches('/'), p.trim_matches('/')),
_ => base,
};
(ws.path.clone(), full)
}; };
// Step 2: load credentials // Step 2: load credentials
@ -686,6 +800,9 @@ pub fn run() {
set_webdav_config, set_webdav_config,
set_workspace_theme, set_workspace_theme,
add_webdav_workspace, add_webdav_workspace,
list_remote_folder,
inspect_remote_workspace,
create_remote_workspace,
store_credentials, store_credentials,
load_credentials, load_credentials,
test_webdav_connection, test_webdav_connection,

View file

@ -13,14 +13,38 @@
const isWindows = currentPlatform === "windows"; const isWindows = currentPlatform === "windows";
const isMobile = currentPlatform === "android" || currentPlatform === "ios"; const isMobile = currentPlatform === "android" || currentPlatform === "ios";
// ── Shared state ──────────────────────────────────────────────────
let mode = $state<"local" | "webdav" | null>(isMobile ? "webdav" : null); let mode = $state<"local" | "webdav" | null>(isMobile ? "webdav" : null);
let name = $state(""); let name = $state("");
let path = $state(""); let path = $state("");
// ── WebDAV state ──────────────────────────────────────────────────
let webdavUrl = $state(""); let webdavUrl = $state("");
let webdavUser = $state(""); let webdavUser = $state("");
let webdavPass = $state(""); let webdavPass = $state("");
let testStatus = $state<"idle" | "testing" | "ok" | "fail">("idle"); let testStatus = $state<"idle" | "testing" | "ok" | "fail">("idle");
// WebDAV step: "connect" → "browse" → "preview" | "create"
let webdavStep = $state<"connect" | "browse" | "preview" | "create">("connect");
let browsePath = $state<string[]>([]); // stack of folder names for navigation
let browseLoading = $state(false);
let browseEntries = $state<{ name: string; is_workspace: boolean }[]>([]);
let browseError = $state<string | null>(null);
// Workspace preview state
let previewName = $state("");
let previewLists = $state<{ name: string; task_count: number }[]>([]);
let previewLoading = $state(false);
// Create workspace state
let createName = $state("");
let creating = $state(false);
// ── Derived ───────────────────────────────────────────────────────
let currentBrowsePath = $derived(browsePath.join("/"));
// ── Local workspace handlers ──────────────────────────────────────
async function pickFolder() { async function pickFolder() {
const selected = await open({ directory: true, multiple: false }); const selected = await open({ directory: true, multiple: false });
if (selected) path = selected as string; if (selected) path = selected as string;
@ -42,6 +66,8 @@
await app.addWorkspace(wsName, folder); await app.addWorkspace(wsName, folder);
} }
// ── WebDAV handlers ───────────────────────────────────────────────
async function testConnection() { async function testConnection() {
testStatus = "testing"; testStatus = "testing";
try { try {
@ -56,11 +82,107 @@
} }
} }
async function handleCreateWebdav() { async function connectAndBrowse() {
if (!name.trim() || !webdavUrl.trim()) return; testStatus = "testing";
await app.addWebdavWorkspace(name.trim(), webdavUrl.trim(), webdavUser, webdavPass); try {
await invoke("test_webdav_connection", {
url: webdavUrl,
username: webdavUser,
password: webdavPass,
});
testStatus = "ok";
webdavStep = "browse";
browsePath = [];
await loadFolder();
} catch {
testStatus = "fail";
}
} }
async function loadFolder() {
browseLoading = true;
browseError = null;
try {
browseEntries = await invoke("list_remote_folder", {
url: webdavUrl,
username: webdavUser,
password: webdavPass,
path: currentBrowsePath,
});
} catch (e) {
browseError = String(e);
browseEntries = [];
} finally {
browseLoading = false;
}
}
async function navigateInto(folder: { name: string; is_workspace: boolean }) {
if (folder.is_workspace) {
previewName = folder.name;
previewLoading = true;
webdavStep = "preview";
try {
const wsPath = currentBrowsePath
? `${currentBrowsePath}/${folder.name}`
: folder.name;
previewLists = await invoke("inspect_remote_workspace", {
url: webdavUrl,
username: webdavUser,
password: webdavPass,
path: wsPath,
});
} catch (e) {
browseError = String(e);
webdavStep = "browse";
} finally {
previewLoading = false;
}
} else {
browsePath = [...browsePath, folder.name];
await loadFolder();
}
}
function navigateUp() {
browsePath = browsePath.slice(0, -1);
loadFolder();
}
async function openExistingWorkspace() {
const wsPath = currentBrowsePath
? `${currentBrowsePath}/${previewName}`
: previewName;
await app.addWebdavWorkspace(previewName, webdavUrl.trim(), wsPath, webdavUser, webdavPass);
}
function startCreate() {
createName = "";
webdavStep = "create";
}
async function handleCreateWebdav() {
if (!createName.trim()) return;
creating = true;
try {
const wsPath = currentBrowsePath
? `${currentBrowsePath}/${createName.trim()}`
: createName.trim();
await invoke("create_remote_workspace", {
url: webdavUrl,
username: webdavUser,
password: webdavPass,
path: wsPath,
});
await app.addWebdavWorkspace(createName.trim(), webdavUrl.trim(), wsPath, webdavUser, webdavPass);
} catch (e) {
browseError = String(e);
creating = false;
}
}
// ── Window dragging ───────────────────────────────────────────────
function handleDrag(e: MouseEvent) { function handleDrag(e: MouseEvent) {
if (e.button !== 0) return; if (e.button !== 0) return;
if ((e.target as HTMLElement).closest("button, input")) return; if ((e.target as HTMLElement).closest("button, input")) return;
@ -75,6 +197,22 @@
webdavUser = ""; webdavUser = "";
webdavPass = ""; webdavPass = "";
testStatus = "idle"; testStatus = "idle";
webdavStep = "connect";
browsePath = [];
browseEntries = [];
browseError = null;
}
function webdavBack() {
if (webdavStep === "preview" || webdavStep === "create") {
webdavStep = "browse";
} else if (webdavStep === "browse") {
webdavStep = "connect";
browsePath = [];
browseEntries = [];
} else {
goBack();
}
} }
</script> </script>
@ -214,27 +352,17 @@
</button> </button>
{/if} {/if}
{:else} {:else if webdavStep === "connect"}
<!-- Step 2b: WebDAV workspace --> <!-- Step 2b: WebDAV connect -->
<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">
Connect to a WebDAV server for cloud-synced tasks. Connect to a WebDAV server.
</p> </p>
<label class="mb-1 block text-sm font-medium">
Workspace name
<input
type="text"
bind:value={name}
placeholder="My Tasks"
class="mt-1 mb-4 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm font-normal outline-none focus:border-primary dark:border-border-dark"
/>
</label>
<label class="mb-1 block text-xs font-medium opacity-60">Server URL</label> <label class="mb-1 block text-xs font-medium opacity-60">Server URL</label>
<input <input
type="url" type="url"
bind:value={webdavUrl} bind:value={webdavUrl}
placeholder="https://dav.example.com/tasks/" placeholder="https://dav.example.com/remote.php/dav/files/user/"
class="mb-3 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark" class="mb-3 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
/> />
@ -252,22 +380,16 @@
class="mb-4 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark" class="mb-4 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
/> />
<div class="mb-4 flex gap-2"> {#if testStatus === "fail"}
<button <p class="mb-3 text-xs text-danger">Connection failed. Check your URL and credentials.</p>
onclick={testConnection} {/if}
disabled={!webdavUrl.trim()}
class="rounded-lg border border-border-light px-4 py-2 text-sm font-medium hover:bg-black/5 disabled:opacity-40 dark:border-border-dark dark:hover:bg-white/10"
>
{testStatus === "testing" ? "Testing..." : testStatus === "ok" ? "Connected" : testStatus === "fail" ? "Failed -- Retry" : "Test Connection"}
</button>
</div>
<button <button
onclick={handleCreateWebdav} onclick={connectAndBrowse}
disabled={!name.trim() || !webdavUrl.trim()} disabled={!webdavUrl.trim() || testStatus === "testing"}
class="w-full rounded-lg bg-primary py-2.5 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40" class="w-full rounded-lg bg-primary py-2.5 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
> >
Create Workspace {testStatus === "testing" ? "Connecting..." : "Connect"}
</button> </button>
{#if !isMobile} {#if !isMobile}
@ -278,6 +400,148 @@
Back Back
</button> </button>
{/if} {/if}
{:else if webdavStep === "browse"}
<!-- Step 3: Folder explorer -->
<p class="mb-4 text-sm text-text-secondary-light dark:text-text-secondary-dark">
Pick a folder or create a new workspace.
</p>
<!-- Breadcrumb / back navigation -->
<div class="mb-3 flex items-center gap-1 text-xs text-text-secondary-light dark:text-text-secondary-dark">
{#if browsePath.length > 0}
<button onclick={navigateUp} class="flex items-center gap-0.5 hover:opacity-80">
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z" />
</svg>
</button>
{/if}
<span class="truncate font-mono">/{currentBrowsePath}</span>
</div>
<!-- Folder list -->
<div class="mb-4 max-h-48 overflow-y-auto rounded-lg border border-border-light dark:border-border-dark">
{#if browseLoading}
<div class="flex items-center justify-center py-6 text-xs opacity-50">Loading...</div>
{:else if browseError}
<div class="px-3 py-4 text-xs text-danger">{browseError}</div>
{:else if browseEntries.length === 0}
<div class="px-3 py-4 text-xs opacity-50">No folders found.</div>
{:else}
{#each browseEntries as entry}
<button
onclick={() => navigateInto(entry)}
class="flex w-full items-center gap-2 border-b border-border-light px-3 py-2.5 text-left text-sm last:border-b-0 hover:bg-black/5 dark:border-border-dark dark:hover:bg-white/10"
>
{#if entry.is_workspace}
<!-- Workspace icon -->
<svg class="h-4 w-4 shrink-0 text-primary" viewBox="0 0 20 20" fill="currentColor">
<path d="M10.362 1.093a.75.75 0 00-.724 0L2.523 5.018 10 9.143l7.477-4.125-7.115-3.925zM18 6.443l-7.25 4v8.25l6.862-3.786A.75.75 0 0018 14.25V6.443zM9.25 18.693v-8.25l-7.25-4v7.807a.75.75 0 00.388.657l6.862 3.786z" />
</svg>
{:else}
<!-- Folder icon -->
<svg class="h-4 w-4 shrink-0 opacity-40" viewBox="0 0 20 20" fill="currentColor">
<path d="M3.75 3A1.75 1.75 0 002 4.75v3.26a3.235 3.235 0 011.75-.51h12.5c.644 0 1.245.188 1.75.51V6.75A1.75 1.75 0 0016.25 5h-4.836a.25.25 0 01-.177-.073L9.823 3.513A1.75 1.75 0 008.586 3H3.75zM3.75 9A1.75 1.75 0 002 10.75v4.5c0 .966.784 1.75 1.75 1.75h12.5A1.75 1.75 0 0018 15.25v-4.5A1.75 1.75 0 0016.25 9H3.75z" />
</svg>
{/if}
<span class="truncate">{entry.name}</span>
{#if entry.is_workspace}
<span class="ml-auto shrink-0 rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">workspace</span>
{:else}
<svg class="ml-auto h-3.5 w-3.5 shrink-0 opacity-30" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" />
</svg>
{/if}
</button>
{/each}
{/if}
</div>
<button
onclick={startCreate}
class="w-full rounded-lg bg-primary py-2.5 text-sm font-medium text-white hover:bg-primary-hover"
>
Create Workspace
</button>
<button
onclick={webdavBack}
class="mt-3 w-full rounded-lg py-2 text-sm opacity-50 hover:opacity-80"
>
Back
</button>
{:else if webdavStep === "preview"}
<!-- Step 4a: Workspace preview -->
<div class="mb-4 flex items-center gap-2">
<button onclick={() => (webdavStep = "browse")} class="rounded-lg p-1 opacity-50 hover:opacity-80">
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z" />
</svg>
</button>
<h2 class="text-lg font-semibold">{previewName}</h2>
</div>
{#if previewLoading}
<div class="flex items-center justify-center py-8 text-xs opacity-50">Loading workspace...</div>
{:else if previewLists.length === 0}
<p class="mb-6 py-4 text-center text-xs opacity-50">No lists in this workspace yet.</p>
{:else}
<div class="mb-6 max-h-48 overflow-y-auto rounded-lg border border-border-light dark:border-border-dark">
{#each previewLists as list}
<div class="flex items-center justify-between border-b border-border-light px-3 py-2.5 text-sm last:border-b-0 dark:border-border-dark">
<span class="truncate">{list.name}</span>
<span class="shrink-0 rounded-full bg-black/5 px-2 py-0.5 text-xs tabular-nums dark:bg-white/10">
{list.task_count} {list.task_count === 1 ? "task" : "tasks"}
</span>
</div>
{/each}
</div>
{/if}
<button
onclick={openExistingWorkspace}
class="w-full rounded-lg bg-primary py-2.5 text-sm font-medium text-white hover:bg-primary-hover"
>
Open Workspace
</button>
{:else if webdavStep === "create"}
<!-- Step 4b: Create workspace -->
<div class="mb-4 flex items-center gap-2">
<button onclick={() => (webdavStep = "browse")} class="rounded-lg p-1 opacity-50 hover:opacity-80">
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z" />
</svg>
</button>
<h2 class="text-lg font-semibold">New Workspace</h2>
</div>
<p class="mb-1 text-xs text-text-secondary-light dark:text-text-secondary-dark">
Creating in: <span class="font-mono">/{currentBrowsePath}</span>
</p>
<label class="mb-1 block text-sm font-medium">
Workspace name
<input
type="text"
bind:value={createName}
placeholder="My Tasks"
class="mt-1 mb-4 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm font-normal outline-none focus:border-primary dark:border-border-dark"
/>
</label>
{#if browseError}
<p class="mb-3 text-xs text-danger">{browseError}</p>
{/if}
<button
onclick={handleCreateWebdav}
disabled={!createName.trim() || creating}
class="w-full rounded-lg bg-primary py-2.5 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
>
{creating ? "Creating..." : "Create Workspace"}
</button>
{/if} {/if}
</div> </div>
</div> </div>

View file

@ -329,9 +329,9 @@ async function setTheme(theme: string | null) {
} }
} }
async function addWebdavWorkspace(name: string, webdavUrl: string, username: string, password: string) { async function addWebdavWorkspace(name: string, webdavUrl: string, webdavPath: string, username: string, password: string) {
try { try {
await invoke("add_webdav_workspace", { name, webdavUrl, 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]; const ws = config?.workspaces[name];

View file

@ -25,6 +25,7 @@ export interface WorkspaceConfig {
path: string; path: string;
mode: WorkspaceMode; mode: WorkspaceMode;
webdav_url: string | null; webdav_url: string | null;
webdav_path: string | null;
last_sync: string | null; last_sync: string | null;
theme: string | null; theme: string | null;
} }

View file

@ -24,6 +24,8 @@ pub struct WorkspaceConfig {
#[serde(skip_serializing_if = "Option::is_none", default)] #[serde(skip_serializing_if = "Option::is_none", default)]
pub webdav_url: Option<String>, pub webdav_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)] #[serde(skip_serializing_if = "Option::is_none", default)]
pub webdav_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub last_sync: Option<chrono::DateTime<chrono::Utc>>, pub last_sync: Option<chrono::DateTime<chrono::Utc>>,
#[serde(skip_serializing_if = "Option::is_none", default)] #[serde(skip_serializing_if = "Option::is_none", default)]
pub theme: Option<String>, pub theme: Option<String>,
@ -31,7 +33,7 @@ pub struct WorkspaceConfig {
impl WorkspaceConfig { impl WorkspaceConfig {
pub fn new(path: PathBuf) -> Self { pub fn new(path: PathBuf) -> Self {
Self { path, mode: WorkspaceMode::Local, webdav_url: None, last_sync: None, theme: None } Self { path, mode: WorkspaceMode::Local, webdav_url: None, webdav_path: None, last_sync: None, theme: None }
} }
} }

View file

@ -7,7 +7,7 @@ use uuid::Uuid;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::models::{Task, TaskList, TaskStatus}; use crate::models::{Task, TaskList, TaskStatus};
/// Metadata stored in root .metadata.json /// Metadata stored in root .onyx-workspace.json
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RootMetadata { pub struct RootMetadata {
pub version: u32, pub version: u32,
@ -124,7 +124,7 @@ impl FileSystemStorage {
} }
fn metadata_path(&self) -> PathBuf { fn metadata_path(&self) -> PathBuf {
self.root_path.join(".metadata.json") self.root_path.join(".onyx-workspace.json")
} }
fn list_dir_path(&self, list_id: Uuid) -> Result<PathBuf> { fn list_dir_path(&self, list_id: Uuid) -> Result<PathBuf> {
@ -658,7 +658,7 @@ mod tests {
fn test_init_creates_metadata() { fn test_init_creates_metadata() {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let _storage = init_storage(&temp_dir); let _storage = init_storage(&temp_dir);
assert!(temp_dir.path().join(".metadata.json").exists()); assert!(temp_dir.path().join(".onyx-workspace.json").exists());
} }
#[test] #[test]
@ -683,7 +683,7 @@ mod tests {
let storage = init_storage(&temp_dir); let storage = init_storage(&temp_dir);
// Delete the metadata file to simulate missing // Delete the metadata file to simulate missing
fs::remove_file(temp_dir.path().join(".metadata.json")).unwrap(); fs::remove_file(temp_dir.path().join(".onyx-workspace.json")).unwrap();
let meta = storage.read_root_metadata().unwrap(); let meta = storage.read_root_metadata().unwrap();
assert_eq!(meta.version, 1); assert_eq!(meta.version, 1);

View file

@ -371,8 +371,8 @@ pub fn compute_checksum(data: &[u8]) -> String {
fn is_syncable(path: &str) -> bool { fn is_syncable(path: &str) -> bool {
let parts: Vec<&str> = path.split('/').collect(); let parts: Vec<&str> = path.split('/').collect();
let filename = parts.last().copied().unwrap_or(path); let filename = parts.last().copied().unwrap_or(path);
// .metadata.json only at workspace root (depth 1) // .onyx-workspace.json only at workspace root (depth 1)
if filename == ".metadata.json" { if filename == ".onyx-workspace.json" {
return parts.len() == 1; return parts.len() == 1;
} }
// .listdata.json only inside a list directory (depth 2) // .listdata.json only inside a list directory (depth 2)
@ -529,9 +529,7 @@ async fn sync_workspace_inner(
mode: SyncMode, mode: SyncMode,
on_progress: Option<ProgressCallback>, on_progress: Option<ProgressCallback>,
) -> Result<SyncResult> { ) -> Result<SyncResult> {
// Sync into an "Onyx" subfolder so we don't scan the user's entire cloud storage let client = WebDavClient::new(webdav_url, username, password)?;
let sync_url = format!("{}/Onyx", webdav_url.trim_end_matches('/'));
let client = WebDavClient::new(&sync_url, username, password)?;
let mut sync_state = SyncState::load(workspace_path); let mut sync_state = SyncState::load(workspace_path);
let queue = OfflineQueue::load(workspace_path); let queue = OfflineQueue::load(workspace_path);
let mut result = SyncResult::default(); let mut result = SyncResult::default();
@ -542,8 +540,6 @@ async fn sync_workspace_inner(
} }
}; };
// Ensure remote Onyx folder exists (creates it on first sync)
client.create_dir("").await.ok();
client.test_connection().await?; client.test_connection().await?;
// Scan local files // Scan local files
@ -1117,9 +1113,9 @@ mod tests {
// .listdata.json inside a list dir (depth 2) // .listdata.json inside a list dir (depth 2)
assert!(is_syncable("My Tasks/.listdata.json")); assert!(is_syncable("My Tasks/.listdata.json"));
assert!(!is_syncable(".listdata.json")); // root-level not valid assert!(!is_syncable(".listdata.json")); // root-level not valid
// .metadata.json only at root (depth 1) // .onyx-workspace.json only at root (depth 1)
assert!(is_syncable(".metadata.json")); assert!(is_syncable(".onyx-workspace.json"));
assert!(!is_syncable("My Tasks/.metadata.json")); // nested not valid assert!(!is_syncable("My Tasks/.onyx-workspace.json")); // nested not valid
// Non-syncable // Non-syncable
assert!(!is_syncable(".syncstate.json")); assert!(!is_syncable(".syncstate.json"));
assert!(!is_syncable("random.txt")); assert!(!is_syncable("random.txt"));
@ -1133,7 +1129,7 @@ mod tests {
let root = temp_dir.path(); let root = temp_dir.path();
// Create a workspace-like structure // Create a workspace-like structure
std::fs::write(root.join(".metadata.json"), "{}").unwrap(); std::fs::write(root.join(".onyx-workspace.json"), "{}").unwrap();
std::fs::create_dir_all(root.join("My Tasks")).unwrap(); std::fs::create_dir_all(root.join("My Tasks")).unwrap();
std::fs::write(root.join("My Tasks").join(".listdata.json"), "{}").unwrap(); std::fs::write(root.join("My Tasks").join(".listdata.json"), "{}").unwrap();
std::fs::write(root.join("My Tasks").join("task1.md"), "# Task 1").unwrap(); std::fs::write(root.join("My Tasks").join("task1.md"), "# Task 1").unwrap();
@ -1144,8 +1140,8 @@ mod tests {
std::fs::write(root.join(".syncstate.json"), "{}").unwrap(); std::fs::write(root.join(".syncstate.json"), "{}").unwrap();
let files = scan_local_files(root).unwrap(); let files = scan_local_files(root).unwrap();
assert_eq!(files.len(), 4); // .metadata.json, .listdata.json, task1.md, task2.md assert_eq!(files.len(), 4); // .onyx-workspace.json, .listdata.json, task1.md, task2.md
assert!(files.iter().any(|f| f.path == ".metadata.json")); assert!(files.iter().any(|f| f.path == ".onyx-workspace.json"));
assert!(files.iter().any(|f| f.path == "My Tasks/.listdata.json")); assert!(files.iter().any(|f| f.path == "My Tasks/.listdata.json"));
assert!(files.iter().any(|f| f.path == "My Tasks/task1.md")); assert!(files.iter().any(|f| f.path == "My Tasks/task1.md"));
assert!(files.iter().any(|f| f.path == "My Tasks/task2.md")); assert!(files.iter().any(|f| f.path == "My Tasks/task2.md"));
@ -1159,12 +1155,12 @@ mod tests {
fn test_get_sync_status_no_state() { fn test_get_sync_status_no_state() {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path(); let root = temp_dir.path();
std::fs::write(root.join(".metadata.json"), "{}").unwrap(); std::fs::write(root.join(".onyx-workspace.json"), "{}").unwrap();
let status = get_sync_status(root).unwrap(); let status = get_sync_status(root).unwrap();
assert!(status.last_sync.is_none()); assert!(status.last_sync.is_none());
assert_eq!(status.tracked_files, 0); assert_eq!(status.tracked_files, 0);
assert_eq!(status.pending_changes, 1); // .metadata.json is new assert_eq!(status.pending_changes, 1); // .onyx-workspace.json is new
assert_eq!(status.queued_operations, 0); assert_eq!(status.queued_operations, 0);
} }