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:
parent
70af83ccfc
commit
753cb1cad5
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -43,7 +43,7 @@ Two-crate workspace (`resolver = "2"`, edition 2021) plus a Tauri app:
|
|||
|
||||
### 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
|
||||
|
||||
|
|
@ -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.
|
||||
- **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 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 4** (Mobile): Tauri Android cfg-gated, needs `tauri android init` + build
|
||||
|
||||
|
|
|
|||
|
|
@ -451,10 +451,118 @@ fn set_workspace_theme(
|
|||
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]
|
||||
fn add_webdav_workspace(
|
||||
name: String,
|
||||
webdav_url: String,
|
||||
webdav_path: String,
|
||||
username: String,
|
||||
password: String,
|
||||
state: State<'_, Mutex<AppState>>,
|
||||
|
|
@ -467,6 +575,7 @@ fn add_webdav_workspace(
|
|||
let mut ws = WorkspaceConfig::new(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())?;
|
||||
|
|
@ -529,12 +638,17 @@ async fn sync_workspace(
|
|||
mode: String,
|
||||
state: State<'_, Mutex<AppState>>,
|
||||
) -> 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 s = lock_state(&state)?;
|
||||
let ws = s.config.workspaces.get(&workspace_name)
|
||||
.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
|
||||
|
|
@ -686,6 +800,9 @@ pub fn run() {
|
|||
set_webdav_config,
|
||||
set_workspace_theme,
|
||||
add_webdav_workspace,
|
||||
list_remote_folder,
|
||||
inspect_remote_workspace,
|
||||
create_remote_workspace,
|
||||
store_credentials,
|
||||
load_credentials,
|
||||
test_webdav_connection,
|
||||
|
|
|
|||
|
|
@ -13,14 +13,38 @@
|
|||
const isWindows = currentPlatform === "windows";
|
||||
const isMobile = currentPlatform === "android" || currentPlatform === "ios";
|
||||
|
||||
// ── Shared state ──────────────────────────────────────────────────
|
||||
let mode = $state<"local" | "webdav" | null>(isMobile ? "webdav" : null);
|
||||
let name = $state("");
|
||||
let path = $state("");
|
||||
|
||||
// ── WebDAV state ──────────────────────────────────────────────────
|
||||
let webdavUrl = $state("");
|
||||
let webdavUser = $state("");
|
||||
let webdavPass = $state("");
|
||||
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() {
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
if (selected) path = selected as string;
|
||||
|
|
@ -42,6 +66,8 @@
|
|||
await app.addWorkspace(wsName, folder);
|
||||
}
|
||||
|
||||
// ── WebDAV handlers ───────────────────────────────────────────────
|
||||
|
||||
async function testConnection() {
|
||||
testStatus = "testing";
|
||||
try {
|
||||
|
|
@ -56,11 +82,107 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function handleCreateWebdav() {
|
||||
if (!name.trim() || !webdavUrl.trim()) return;
|
||||
await app.addWebdavWorkspace(name.trim(), webdavUrl.trim(), webdavUser, webdavPass);
|
||||
async function connectAndBrowse() {
|
||||
testStatus = "testing";
|
||||
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) {
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest("button, input")) return;
|
||||
|
|
@ -75,6 +197,22 @@
|
|||
webdavUser = "";
|
||||
webdavPass = "";
|
||||
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>
|
||||
|
||||
|
|
@ -214,27 +352,17 @@
|
|||
</button>
|
||||
{/if}
|
||||
|
||||
{:else}
|
||||
<!-- Step 2b: WebDAV workspace -->
|
||||
{:else if webdavStep === "connect"}
|
||||
<!-- Step 2b: WebDAV connect -->
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="url"
|
||||
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"
|
||||
/>
|
||||
|
||||
|
|
@ -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"
|
||||
/>
|
||||
|
||||
<div class="mb-4 flex gap-2">
|
||||
<button
|
||||
onclick={testConnection}
|
||||
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>
|
||||
{#if testStatus === "fail"}
|
||||
<p class="mb-3 text-xs text-danger">Connection failed. Check your URL and credentials.</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={handleCreateWebdav}
|
||||
disabled={!name.trim() || !webdavUrl.trim()}
|
||||
onclick={connectAndBrowse}
|
||||
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"
|
||||
>
|
||||
Create Workspace
|
||||
{testStatus === "testing" ? "Connecting..." : "Connect"}
|
||||
</button>
|
||||
|
||||
{#if !isMobile}
|
||||
|
|
@ -278,6 +400,148 @@
|
|||
Back
|
||||
</button>
|
||||
{/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}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -329,9 +329,9 @@ async function setTheme(theme: string | null) {
|
|||
}
|
||||
}
|
||||
|
||||
async function addWebdavWorkspace(name: string, webdavUrl: string, username: string, password: string) {
|
||||
async function addWebdavWorkspace(name: string, webdavUrl: string, webdavPath: string, username: string, password: string) {
|
||||
try {
|
||||
await invoke("add_webdav_workspace", { name, webdavUrl, username, password });
|
||||
await invoke("add_webdav_workspace", { name, webdavUrl, webdavPath, username, password });
|
||||
config = await invoke<AppConfig>("get_config");
|
||||
await loadLists();
|
||||
const ws = config?.workspaces[name];
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export interface WorkspaceConfig {
|
|||
path: string;
|
||||
mode: WorkspaceMode;
|
||||
webdav_url: string | null;
|
||||
webdav_path: string | null;
|
||||
last_sync: string | null;
|
||||
theme: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ pub struct WorkspaceConfig {
|
|||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub webdav_url: Option<String>,
|
||||
#[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>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub theme: Option<String>,
|
||||
|
|
@ -31,7 +33,7 @@ pub struct WorkspaceConfig {
|
|||
|
||||
impl WorkspaceConfig {
|
||||
pub fn new(path: PathBuf) -> Self {
|
||||
Self { path, mode: WorkspaceMode::Local, webdav_url: None, last_sync: None, theme: None }
|
||||
Self { path, mode: WorkspaceMode::Local, webdav_url: None, webdav_path: None, last_sync: None, theme: None }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use uuid::Uuid;
|
|||
use crate::error::{Error, Result};
|
||||
use crate::models::{Task, TaskList, TaskStatus};
|
||||
|
||||
/// Metadata stored in root .metadata.json
|
||||
/// Metadata stored in root .onyx-workspace.json
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RootMetadata {
|
||||
pub version: u32,
|
||||
|
|
@ -124,7 +124,7 @@ impl FileSystemStorage {
|
|||
}
|
||||
|
||||
fn metadata_path(&self) -> PathBuf {
|
||||
self.root_path.join(".metadata.json")
|
||||
self.root_path.join(".onyx-workspace.json")
|
||||
}
|
||||
|
||||
fn list_dir_path(&self, list_id: Uuid) -> Result<PathBuf> {
|
||||
|
|
@ -658,7 +658,7 @@ mod tests {
|
|||
fn test_init_creates_metadata() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let _storage = init_storage(&temp_dir);
|
||||
assert!(temp_dir.path().join(".metadata.json").exists());
|
||||
assert!(temp_dir.path().join(".onyx-workspace.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -683,7 +683,7 @@ mod tests {
|
|||
let storage = init_storage(&temp_dir);
|
||||
|
||||
// Delete the metadata file to simulate missing
|
||||
fs::remove_file(temp_dir.path().join(".metadata.json")).unwrap();
|
||||
fs::remove_file(temp_dir.path().join(".onyx-workspace.json")).unwrap();
|
||||
|
||||
let meta = storage.read_root_metadata().unwrap();
|
||||
assert_eq!(meta.version, 1);
|
||||
|
|
|
|||
|
|
@ -371,8 +371,8 @@ pub fn compute_checksum(data: &[u8]) -> String {
|
|||
fn is_syncable(path: &str) -> bool {
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
let filename = parts.last().copied().unwrap_or(path);
|
||||
// .metadata.json only at workspace root (depth 1)
|
||||
if filename == ".metadata.json" {
|
||||
// .onyx-workspace.json only at workspace root (depth 1)
|
||||
if filename == ".onyx-workspace.json" {
|
||||
return parts.len() == 1;
|
||||
}
|
||||
// .listdata.json only inside a list directory (depth 2)
|
||||
|
|
@ -529,9 +529,7 @@ async fn sync_workspace_inner(
|
|||
mode: SyncMode,
|
||||
on_progress: Option<ProgressCallback>,
|
||||
) -> Result<SyncResult> {
|
||||
// Sync into an "Onyx" subfolder so we don't scan the user's entire cloud storage
|
||||
let sync_url = format!("{}/Onyx", webdav_url.trim_end_matches('/'));
|
||||
let client = WebDavClient::new(&sync_url, username, password)?;
|
||||
let client = WebDavClient::new(webdav_url, username, password)?;
|
||||
let mut sync_state = SyncState::load(workspace_path);
|
||||
let queue = OfflineQueue::load(workspace_path);
|
||||
let mut result = SyncResult::default();
|
||||
|
|
@ -542,8 +540,6 @@ async fn sync_workspace_inner(
|
|||
}
|
||||
};
|
||||
|
||||
// Ensure remote Onyx folder exists (creates it on first sync)
|
||||
client.create_dir("").await.ok();
|
||||
client.test_connection().await?;
|
||||
|
||||
// Scan local files
|
||||
|
|
@ -1117,9 +1113,9 @@ mod tests {
|
|||
// .listdata.json inside a list dir (depth 2)
|
||||
assert!(is_syncable("My Tasks/.listdata.json"));
|
||||
assert!(!is_syncable(".listdata.json")); // root-level not valid
|
||||
// .metadata.json only at root (depth 1)
|
||||
assert!(is_syncable(".metadata.json"));
|
||||
assert!(!is_syncable("My Tasks/.metadata.json")); // nested not valid
|
||||
// .onyx-workspace.json only at root (depth 1)
|
||||
assert!(is_syncable(".onyx-workspace.json"));
|
||||
assert!(!is_syncable("My Tasks/.onyx-workspace.json")); // nested not valid
|
||||
// Non-syncable
|
||||
assert!(!is_syncable(".syncstate.json"));
|
||||
assert!(!is_syncable("random.txt"));
|
||||
|
|
@ -1133,7 +1129,7 @@ mod tests {
|
|||
let root = temp_dir.path();
|
||||
|
||||
// Create a workspace-like structure
|
||||
std::fs::write(root.join(".metadata.json"), "{}").unwrap();
|
||||
std::fs::write(root.join(".onyx-workspace.json"), "{}").unwrap();
|
||||
std::fs::create_dir_all(root.join("My Tasks")).unwrap();
|
||||
std::fs::write(root.join("My Tasks").join(".listdata.json"), "{}").unwrap();
|
||||
std::fs::write(root.join("My Tasks").join("task1.md"), "# Task 1").unwrap();
|
||||
|
|
@ -1144,8 +1140,8 @@ mod tests {
|
|||
std::fs::write(root.join(".syncstate.json"), "{}").unwrap();
|
||||
|
||||
let files = scan_local_files(root).unwrap();
|
||||
assert_eq!(files.len(), 4); // .metadata.json, .listdata.json, task1.md, task2.md
|
||||
assert!(files.iter().any(|f| f.path == ".metadata.json"));
|
||||
assert_eq!(files.len(), 4); // .onyx-workspace.json, .listdata.json, task1.md, task2.md
|
||||
assert!(files.iter().any(|f| f.path == ".onyx-workspace.json"));
|
||||
assert!(files.iter().any(|f| f.path == "My Tasks/.listdata.json"));
|
||||
assert!(files.iter().any(|f| f.path == "My Tasks/task1.md"));
|
||||
assert!(files.iter().any(|f| f.path == "My Tasks/task2.md"));
|
||||
|
|
@ -1159,12 +1155,12 @@ mod tests {
|
|||
fn test_get_sync_status_no_state() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let root = temp_dir.path();
|
||||
std::fs::write(root.join(".metadata.json"), "{}").unwrap();
|
||||
std::fs::write(root.join(".onyx-workspace.json"), "{}").unwrap();
|
||||
|
||||
let status = get_sync_status(root).unwrap();
|
||||
assert!(status.last_sync.is_none());
|
||||
assert_eq!(status.tracked_files, 0);
|
||||
assert_eq!(status.pending_changes, 1); // .metadata.json is new
|
||||
assert_eq!(status.pending_changes, 1); // .onyx-workspace.json is new
|
||||
assert_eq!(status.queued_operations, 0);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue