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
|
### 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,10 +82,106 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue