From 4b685311a0c4d12ef3a23181ac8e44a8d054785c Mon Sep 17 00:00:00 2001 From: SirBlobby Date: Mon, 20 Jul 2026 17:48:20 -0400 Subject: [PATCH] Documents by default, cloud project rename, multi-select drag, new settings --- bun.lock | 35 ++-- package.json | 3 +- src-tauri/Cargo.lock | 200 ++++++++++++++++++++-- src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 3 +- src-tauri/src/db.rs | 26 +-- src-tauri/src/lib.rs | 94 +++++++---- src-tauri/src/sync.rs | 119 +++++++++----- src-tauri/src/workspace.rs | 16 +- src-tauri/tauri.conf.json | 1 + src/app.css | 24 +++ src/lib/components/FileTree.svelte | 208 +++++++++++++++++------ src/lib/components/FileViewer.svelte | 77 +++++---- src/lib/components/SettingsModal.svelte | 152 ++++++++++++++++- src/lib/ts/api.ts | 42 +++-- src/lib/ts/state.svelte.ts | 210 +++++++++++++++++++++--- src/routes/+page.svelte | 119 +++++++++----- 17 files changed, 1038 insertions(+), 292 deletions(-) diff --git a/bun.lock b/bun.lock index 8b70148..97ef6a3 100644 --- a/bun.lock +++ b/bun.lock @@ -5,31 +5,32 @@ "": { "name": "typst-desktop", "dependencies": { - "@codemirror/autocomplete": "^6.20.2", - "@codemirror/commands": "^6.10.3", - "@codemirror/language": "^6.10.8", - "@codemirror/legacy-modes": "^6.5.1", - "@codemirror/lint": "^6.9.6", - "@codemirror/lsp-client": "^6.2.4", - "@codemirror/state": "^6.6.0", - "@codemirror/view": "^6.43.0", - "@iconify/svelte": "^5.2.1", - "@lezer/highlight": "^1.2.1", + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/commands": "^6.10.4", + "@codemirror/language": "^6.12.4", + "@codemirror/legacy-modes": "^6.5.3", + "@codemirror/lint": "^6.9.7", + "@codemirror/lsp-client": "^6.2.5", + "@codemirror/state": "^6.7.1", + "@codemirror/view": "^6.43.6", + "@crabnebula/tauri-plugin-drag": "^2", + "@iconify/svelte": "^5.2.2", + "@lezer/highlight": "^1.2.3", "@tauri-apps/api": "^2.11.1", - "@tauri-apps/plugin-dialog": "^2.4.1", + "@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-opener": "^2.5.4", "codemirror": "^6.0.2", "codemirror-lang-typst": "^0.4.0", }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.70.0", + "@sveltejs/kit": "^2.70.1", "@sveltejs/vite-plugin-svelte": "^5.1.1", - "@tailwindcss/vite": "^4.3.0", + "@tailwindcss/vite": "^4.3.3", "@tauri-apps/cli": "^2.11.4", - "svelte": "^5.56.6", + "svelte": "^5.56.7", "svelte-check": "^4.7.3", - "tailwindcss": "^4.3.0", + "tailwindcss": "^4.3.3", "typescript": "~5.6.3", "vite": "^6.4.3", "vite-plugin-top-level-await": "^1.6.0", @@ -56,6 +57,8 @@ "@codemirror/view": ["@codemirror/view@6.43.6", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA=="], + "@crabnebula/tauri-plugin-drag": ["@crabnebula/tauri-plugin-drag@2.1.0", "", { "dependencies": { "@tauri-apps/api": "^2.0.0" } }, "sha512-LnUXAZwQt1cdMoGDLJ6ogW9wFCYServCZXlGadS7CA+CZ9eXS7L+Q7QyQW6g/zGw9YI2MwKFqf1aSNBGyWw+OA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -400,7 +403,7 @@ "style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="], - "svelte": ["svelte@5.56.6", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-p4HDLDogGHKRKCrgckQHNs5PEfXkju6JI5jTywueaKJI5hAdjPohEhRtQ0M1SWC/+TA73SPln+r7srr+7e4nZA=="], + "svelte": ["svelte@5.56.7", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-5qERUZX80oQj6XrDMUmD2Uhd/cIpCPDWWKBK3ZHmyRUC9apPyamWM8xMo31mbWsIQxwG2hVoSnOJ/EcnhVkkzQ=="], "svelte-check": ["svelte-check@4.7.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.0", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-DHdTCGX62R0fCxBEaT+USdASAnoaRBaaNczkRJl0K7o3WyoCeVUbVxo6fKqpOll/B+WMWCsiFK0eFrJSNBKZIg=="], diff --git a/package.json b/package.json index d22d63a..ed25ea5 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@codemirror/view": "^6.43.6", "@iconify/svelte": "^5.2.2", "@lezer/highlight": "^1.2.3", + "@crabnebula/tauri-plugin-drag": "^2.1.0", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-opener": "^2.5.4", @@ -36,7 +37,7 @@ "tailwindcss": "^4.3.3", "@sveltejs/kit": "^2.70.1", "@sveltejs/vite-plugin-svelte": "^5.1.1", - "svelte": "^5.56.6", + "svelte": "^5.56.7", "svelte-check": "^4.7.3", "typescript": "~5.6.3", "vite": "^6.4.3", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 848b568..f805edf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -722,6 +722,19 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + [[package]] name = "core-graphics" version = "0.25.0" @@ -1074,6 +1087,28 @@ dependencies = [ "serde", ] +[[package]] +name = "drag" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e90b4a25ace5ce0561534b073943594cbcd21af936e64d09aec444568411f8c" +dependencies = [ + "core-graphics 0.24.0", + "dunce", + "gdk", + "gdkx11", + "gtk", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "raw-window-handle", + "serde", + "thiserror 2.0.18", + "windows 0.52.0", + "windows-core 0.58.0", +] + [[package]] name = "dtoa" version = "1.0.11" @@ -3044,9 +3079,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.1", "block2", + "libc", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", "objc2-foundation", + "objc2-quartz-core", ] [[package]] @@ -3066,6 +3109,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ + "bitflags 2.13.1", "objc2", "objc2-foundation", ] @@ -3126,6 +3170,19 @@ dependencies = [ "objc2-core-graphics", ] +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -4783,7 +4840,7 @@ dependencies = [ "bitflags 2.13.1", "block2", "core-foundation", - "core-graphics", + "core-graphics 0.25.0", "crossbeam-channel", "dbus", "dispatch2", @@ -4808,7 +4865,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -4890,7 +4947,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -4989,6 +5046,21 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-drag" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "729ca0ce4b1169869d3405216d3c09a524f41ea5e2eec89f917cd6623f8a70ca" +dependencies = [ + "base64 0.22.1", + "drag", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-fs" version = "2.5.1" @@ -5031,7 +5103,7 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.18", "url", - "windows", + "windows 0.61.3", "zbus", ] @@ -5057,7 +5129,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -5082,7 +5154,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -5616,6 +5688,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-drag", "tauri-plugin-opener", "typst", "typst-assets", @@ -6533,10 +6606,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", ] [[package]] @@ -6557,7 +6630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -6613,6 +6686,18 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-implement 0.52.0", + "windows-interface 0.52.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -6635,14 +6720,36 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -6654,8 +6761,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -6672,6 +6779,28 @@ dependencies = [ "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12168c33176773b86799be25e2a2ba07c7aab9968b37541f1094dbd7a60c8946" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -6683,6 +6812,28 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-interface" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d8dc32e0095a7eeccebd0e3f09e9509365ecb3fc6ac4d6f5f14a3f6392942d1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -6716,6 +6867,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -6734,6 +6894,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -7104,7 +7274,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 214202e..aa47b77 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,6 +22,7 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = [] } tauri-plugin-opener = "2" tauri-plugin-dialog = "2" +tauri-plugin-drag = "2.1.1" serde = { version = "1", features = ["derive"] } serde_json = "1" rusqlite = { version = "0.32", features = ["bundled"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 95aa293..462baca 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -17,6 +17,7 @@ "opener:allow-reveal-item-in-dir", "dialog:default", "dialog:allow-open", - "dialog:allow-save" + "dialog:allow-save", + "drag:default" ] } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 35971b7..f58af3c 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -27,7 +27,7 @@ const SCHEMA: [&str; 5] = [ "CREATE TABLE IF NOT EXISTS projects ( path TEXT PRIMARY KEY, entrypoint TEXT NOT NULL DEFAULT 'main.typ', - space_id TEXT, + cloud_project_id TEXT, last_synced_at TEXT )", "CREATE TABLE IF NOT EXISTS base_files ( @@ -53,8 +53,10 @@ const SCHEMA: [&str; 5] = [ )", ]; -const MIGRATIONS: [&str; 1] = - ["ALTER TABLE document_links ADD COLUMN synced_at TEXT"]; +const MIGRATIONS: [&str; 2] = [ + "ALTER TABLE document_links ADD COLUMN synced_at TEXT", + "ALTER TABLE projects RENAME COLUMN space_id TO cloud_project_id", +]; impl Store { pub fn open(app: &AppHandle) -> Result { @@ -130,14 +132,14 @@ impl Store { let row: Option<(String, Option, Option)> = self.with(|connection| { connection .query_row( - "SELECT entrypoint, space_id, last_synced_at FROM projects WHERE path = ?1", + "SELECT entrypoint, cloud_project_id, last_synced_at FROM projects WHERE path = ?1", params![project], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .optional() })?; - let Some((entrypoint, space_id, last_synced_at)) = row else { + let Some((entrypoint, cloud_project_id, last_synced_at)) = row else { return Ok(ProjectMeta::default()); }; @@ -158,7 +160,7 @@ impl Store { Ok(ProjectMeta { entrypoint, - space_id, + cloud_project_id, last_synced_at, base_hashes, }) @@ -180,16 +182,16 @@ impl Store { pub fn save_meta(&self, project: &str, meta: &ProjectMeta) -> Result<(), String> { self.with(|connection| { connection.execute( - "INSERT INTO projects (path, entrypoint, space_id, last_synced_at) + "INSERT INTO projects (path, entrypoint, cloud_project_id, last_synced_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(path) DO UPDATE SET entrypoint = excluded.entrypoint, - space_id = excluded.space_id, + cloud_project_id = excluded.cloud_project_id, last_synced_at = excluded.last_synced_at", params![ project, meta.entrypoint, - meta.space_id, + meta.cloud_project_id, meta.last_synced_at ], )?; @@ -335,11 +337,11 @@ impl Store { }) } - pub fn all_space_links(&self) -> Result)>, String> { + pub fn all_cloud_project_links(&self) -> Result)>, String> { self.with(|connection| { let mut statement = connection.prepare( - "SELECT path, space_id, last_synced_at FROM projects - WHERE space_id IS NOT NULL", + "SELECT path, cloud_project_id, last_synced_at FROM projects + WHERE cloud_project_id IS NOT NULL", )?; let rows = statement.query_map([], |row| { Ok(( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5d52f50..a1b39d2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,7 +16,7 @@ use assets::Asset; use db::Store; use compiler::{CompileResult, Diagnostic}; use lsp::{LspHandle, LspState}; -use sync::{Account, SpaceSummary, SyncReport}; +use sync::{Account, ProjectSummary, SyncReport}; use workspace::{ browse, is_project_dir, is_text_file, list_files, load_settings, project_file_path, read_target_files, resolve_target, save_settings, workspace_path, BrowseEntry, @@ -351,7 +351,7 @@ pub struct TargetInfo { pub entrypoint: String, pub standalone: bool, pub is_project: bool, - pub space_id: Option, + pub cloud_project_id: Option, pub files: Vec, } @@ -368,7 +368,7 @@ fn target_info(app: AppHandle, store: State<'_, Store>, path: String) -> Result< entrypoint: target.entrypoint.clone(), standalone: true, is_project: false, - space_id: None, + cloud_project_id: None, files: vec![FileEntry { path: target.entrypoint.clone(), name: target.entrypoint, @@ -385,7 +385,7 @@ fn target_info(app: AppHandle, store: State<'_, Store>, path: String) -> Result< entrypoint: target.entrypoint, standalone: false, is_project: is_project_dir(&target.root), - space_id: meta.space_id, + cloud_project_id: meta.cloud_project_id, files: list_files(&target.root)?, }) } @@ -725,9 +725,9 @@ fn cloud_account(app: AppHandle, store: State<'_, Store>) -> Result) -> Result, String> { +fn cloud_list_projects(app: AppHandle, store: State<'_, Store>) -> Result, String> { let (server_url, token) = cloud_credentials(&app, &store)?; - sync::list_spaces(&server_url, &token) + sync::list_cloud_projects(&server_url, &token) } #[tauri::command] @@ -864,6 +864,30 @@ fn cloud_unlink_document(store: State<'_, Store>, path: String) -> Result<(), St store.forget_document_link(&path) } +#[tauri::command] +fn cloud_create_document( + app: AppHandle, + store: State<'_, Store>, + path: String, + title: String, +) -> Result { + let (server_url, token) = cloud_credentials(&app, &store)?; + let full = workspace_path(&app, &store, &path)?; + let content = std::fs::read_to_string(&full).map_err(|e| e.to_string())?; + + let document = sync::create_document(&server_url, &token, title.trim(), &content)?; + + store.save_document_link( + &path, + &document.id, + &document.hash, + &document.role, + &document.content, + )?; + + Ok(document.id) +} + #[derive(Serialize)] pub struct LinkedDocument { pub path: String, @@ -899,21 +923,21 @@ fn cloud_linked_documents( } #[derive(Serialize)] -pub struct LinkedSpace { +pub struct LinkedProject { pub path: String, - pub space_id: String, + pub cloud_project_id: String, pub synced_at: Option, pub sync_state: Option, } #[tauri::command] -fn cloud_linked_spaces( +fn cloud_linked_projects( app: AppHandle, store: State<'_, Store>, -) -> Result, String> { +) -> Result, String> { let mut linked = Vec::new(); - for (path, space_id, synced_at) in store.all_space_links()? { + for (path, cloud_project_id, synced_at) in store.all_cloud_project_links()? { let Ok(full) = workspace_path(&app, &store, &path) else { continue; }; @@ -921,10 +945,10 @@ fn cloud_linked_spaces( continue; } - linked.push(LinkedSpace { + linked.push(LinkedProject { sync_state: workspace::project_sync_state(&full, synced_at.as_deref()), path, - space_id, + cloud_project_id, synced_at, }); } @@ -941,22 +965,22 @@ fn cloud_document_link( } #[tauri::command] -fn cloud_create_space(app: AppHandle, store: State<'_, Store>, name: String) -> Result { +fn cloud_create_project(app: AppHandle, store: State<'_, Store>, name: String) -> Result { let (server_url, token) = cloud_credentials(&app, &store)?; - sync::create_space(&server_url, &token, name.trim()) + sync::create_cloud_project(&server_url, &token, name.trim()) } #[tauri::command] -fn cloud_delete_space(app: AppHandle, store: State<'_, Store>, space_id: String) -> Result<(), String> { +fn cloud_delete_project(app: AppHandle, store: State<'_, Store>, cloud_project_id: String) -> Result<(), String> { let (server_url, token) = cloud_credentials(&app, &store)?; - sync::delete_space(&server_url, &token, &space_id) + sync::delete_cloud_project(&server_url, &token, &cloud_project_id) } #[tauri::command] -fn cloud_clone_space( +fn cloud_clone_project( app: AppHandle, store: State<'_, Store>, - space_id: String, + cloud_project_id: String, project_name: String, ) -> Result { let (server_url, token) = cloud_credentials(&app, &store)?; @@ -965,7 +989,15 @@ fn cloud_clone_space( if dir.exists() { return Err(format!("A project named '{}' already exists", project_name)); } - sync::clone_space(&server_url, &token, &app, &store, &project, &dir, &space_id) + sync::clone_cloud_project( + &server_url, + &token, + &app, + &store, + &project, + &dir, + &cloud_project_id, + ) } #[tauri::command] @@ -973,17 +1005,17 @@ fn cloud_link_project( app: AppHandle, store: State<'_, Store>, project: String, - space_id: Option, + cloud_project_id: Option, ) -> Result { let (server_url, token) = cloud_credentials(&app, &store)?; let (dir, mut meta) = load_project(&app, &store, &project)?; - let space_id = match space_id { + let cloud_project_id = match cloud_project_id { Some(id) if !id.trim().is_empty() => id, - _ => sync::create_space(&server_url, &token, &project)?.id, + _ => sync::create_cloud_project(&server_url, &token, &project)?.id, }; - meta.space_id = Some(space_id); + meta.cloud_project_id = Some(cloud_project_id); meta.base_hashes.clear(); store.save_meta(&project, &meta)?; @@ -993,7 +1025,7 @@ fn cloud_link_project( #[tauri::command] fn cloud_unlink_project(app: AppHandle, store: State<'_, Store>, project: String) -> Result<(), String> { let (dir, mut meta) = load_project(&app, &store, &project)?; - meta.space_id = None; + meta.cloud_project_id = None; meta.base_hashes.clear(); meta.last_synced_at = None; let _ = dir; @@ -1069,6 +1101,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_drag::init()) .setup(|app| { let store = Store::open(&app.handle())?; app.manage(store); @@ -1119,7 +1152,7 @@ pub fn run() { cloud_login, cloud_logout, cloud_account, - cloud_list_spaces, + cloud_list_projects, cloud_list_folders, cloud_list_documents, cloud_list_shared, @@ -1130,11 +1163,12 @@ pub fn run() { cloud_resolve_document, cloud_document_link, cloud_linked_documents, - cloud_linked_spaces, + cloud_linked_projects, cloud_unlink_document, - cloud_create_space, - cloud_delete_space, - cloud_clone_space, + cloud_create_document, + cloud_create_project, + cloud_delete_project, + cloud_clone_project, cloud_link_project, cloud_unlink_project, cloud_push, diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs index 4d2ad78..5308556 100644 --- a/src-tauri/src/sync.rs +++ b/src-tauri/src/sync.rs @@ -87,7 +87,7 @@ pub fn me(server_url: &str, token: &str) -> Result { } #[derive(Deserialize, Serialize, Clone)] -pub struct SpaceSummary { +pub struct ProjectSummary { pub id: String, pub name: String, pub entrypoint: String, @@ -95,29 +95,37 @@ pub struct SpaceSummary { pub updated_at: String, } -pub fn list_spaces(server_url: &str, token: &str) -> Result, String> { +pub fn list_cloud_projects(server_url: &str, token: &str) -> Result, String> { agent() - .get(&endpoint(server_url, "/spaces")) + .get(&endpoint(server_url, "/projects")) .set("Authorization", &format!("Bearer {}", token)) .call() .map_err(describe)? - .into_json::>() + .into_json::>() .map_err(|e| e.to_string()) } -pub fn create_space(server_url: &str, token: &str, name: &str) -> Result { +pub fn create_cloud_project( + server_url: &str, + token: &str, + name: &str, +) -> Result { agent() - .post(&endpoint(server_url, "/spaces")) + .post(&endpoint(server_url, "/projects")) .set("Authorization", &format!("Bearer {}", token)) .send_json(ureq::json!({ "name": name })) .map_err(describe)? - .into_json::() + .into_json::() .map_err(|e| e.to_string()) } -pub fn delete_space(server_url: &str, token: &str, space_id: &str) -> Result<(), String> { +pub fn delete_cloud_project( + server_url: &str, + token: &str, + cloud_project_id: &str, +) -> Result<(), String> { agent() - .delete(&endpoint(server_url, &format!("/spaces/{}", space_id))) + .delete(&endpoint(server_url, &format!("/projects/{}", cloud_project_id))) .set("Authorization", &format!("Bearer {}", token)) .call() .map_err(describe)?; @@ -131,7 +139,9 @@ pub struct ManifestEntry { } #[derive(Deserialize)] -pub struct SpaceManifest { +pub struct ProjectManifest { + pub project_id: String, + pub name: String, pub entrypoint: String, pub files: Vec, } @@ -139,17 +149,17 @@ pub struct SpaceManifest { pub fn get_manifest( server_url: &str, token: &str, - space_id: &str, -) -> Result { + cloud_project_id: &str, +) -> Result { agent() .get(&endpoint( server_url, - &format!("/spaces/{}/manifest", space_id), + &format!("/projects/{}/manifest", cloud_project_id), )) .set("Authorization", &format!("Bearer {}", token)) .call() .map_err(describe)? - .into_json::() + .into_json::() .map_err(|e| e.to_string()) } @@ -176,11 +186,14 @@ impl FileContent { pub fn pull_file( server_url: &str, token: &str, - space_id: &str, + cloud_project_id: &str, path: &str, ) -> Result { agent() - .get(&endpoint(server_url, &format!("/spaces/{}/file", space_id))) + .get(&endpoint( + server_url, + &format!("/projects/{}/file", cloud_project_id), + )) .query("path", path) .set("Authorization", &format!("Bearer {}", token)) .call() @@ -204,7 +217,7 @@ pub enum PushResult { pub fn push_file( server_url: &str, token: &str, - space_id: &str, + cloud_project_id: &str, path: &str, bytes: &[u8], base_hash: Option<&str>, @@ -216,7 +229,10 @@ pub fn push_file( }; let response = agent() - .put(&endpoint(server_url, &format!("/spaces/{}/file", space_id))) + .put(&endpoint( + server_url, + &format!("/projects/{}/file", cloud_project_id), + )) .set("Authorization", &format!("Bearer {}", token)) .send_json(ureq::json!({ "path": path, @@ -248,11 +264,14 @@ pub fn push_file( pub fn delete_remote_file( server_url: &str, token: &str, - space_id: &str, + cloud_project_id: &str, path: &str, ) -> Result<(), String> { let response = agent() - .delete(&endpoint(server_url, &format!("/spaces/{}/file", space_id))) + .delete(&endpoint( + server_url, + &format!("/projects/{}/file", cloud_project_id), + )) .query("path", path) .set("Authorization", &format!("Bearer {}", token)) .call(); @@ -306,12 +325,12 @@ pub fn pull_project( project_dir: &Path, meta: &mut ProjectMeta, ) -> Result { - let space_id = meta - .space_id + let cloud_project_id = meta + .cloud_project_id .clone() - .ok_or("Project is not linked to a cloud space")?; + .ok_or("Project is not linked to a cloud project")?; - let manifest = get_manifest(server_url, token, &space_id)?; + let manifest = get_manifest(server_url, token, &cloud_project_id)?; let mut report = SyncReport::default(); let local_files: HashSet = collect_files(project_dir)?.into_iter().collect(); @@ -327,7 +346,7 @@ pub fn pull_project( if base.is_some() { continue; } - let remote = pull_file(server_url, token, &space_id, &entry.path)?; + let remote = pull_file(server_url, token, &cloud_project_id, &entry.path)?; write_local(project_dir, &entry.path, &remote.bytes()?)?; meta.base_hashes.insert(entry.path.clone(), remote.hash); report.pulled.push(entry.path.clone()); @@ -346,7 +365,7 @@ pub fn pull_project( continue; } - let remote = pull_file(server_url, token, &space_id, &entry.path)?; + let remote = pull_file(server_url, token, &cloud_project_id, &entry.path)?; let remote_bytes = remote.bytes()?; if base.as_deref() == Some(local_hash.as_str()) { @@ -428,10 +447,10 @@ pub fn push_project( project_dir: &Path, meta: &mut ProjectMeta, ) -> Result { - let space_id = meta - .space_id + let cloud_project_id = meta + .cloud_project_id .clone() - .ok_or("Project is not linked to a cloud space")?; + .ok_or("Project is not linked to a cloud project")?; let mut report = SyncReport::default(); let local_files = collect_files(project_dir)?; @@ -446,7 +465,14 @@ pub fn push_project( continue; } - match push_file(server_url, token, &space_id, path, &bytes, base.as_deref())? { + match push_file( + server_url, + token, + &cloud_project_id, + path, + &bytes, + base.as_deref(), + )? { PushResult::Applied => { meta.base_hashes.insert(path.clone(), hash); report.pushed.push(path.clone()); @@ -481,7 +507,7 @@ pub fn push_project( .collect(); for path in removed { - delete_remote_file(server_url, token, &space_id, &path)?; + delete_remote_file(server_url, token, &cloud_project_id, &path)?; meta.base_hashes.remove(&path); report.deleted_remote.push(path); } @@ -522,20 +548,20 @@ pub fn report_progress( ); } -pub fn clone_space( +pub fn clone_cloud_project( server_url: &str, token: &str, app: &tauri::AppHandle, store: &Store, project: &str, project_dir: &Path, - space_id: &str, + cloud_project_id: &str, ) -> Result { std::fs::create_dir_all(project_dir).map_err(|e| e.to_string())?; - let manifest = get_manifest(server_url, token, space_id)?; + let manifest = get_manifest(server_url, token, cloud_project_id)?; let mut meta = store.meta(project)?; - meta.space_id = Some(space_id.to_string()); + meta.cloud_project_id = Some(cloud_project_id.to_string()); meta.entrypoint = manifest.entrypoint.clone(); let mut report = SyncReport::default(); @@ -544,7 +570,7 @@ pub fn clone_space( for (index, entry) in manifest.files.iter().enumerate() { report_progress(app, project, index, total, false); - let remote = pull_file(server_url, token, space_id, &entry.path)?; + let remote = pull_file(server_url, token, cloud_project_id, &entry.path)?; write_local(project_dir, &entry.path, &remote.bytes()?)?; meta.base_hashes.insert(entry.path.clone(), remote.hash); report.pulled.push(entry.path.clone()); @@ -621,7 +647,7 @@ pub struct CloudDocument { #[derive(Deserialize, Serialize)] pub struct SharedItems { pub documents: Vec, - pub spaces: Vec, + pub projects: Vec, } pub fn list_folders(server_url: &str, token: &str) -> Result, String> { @@ -687,6 +713,25 @@ pub fn pull_document( .map_err(|e| e.to_string()) } +pub fn create_document( + server_url: &str, + token: &str, + title: &str, + content: &str, +) -> Result { + agent() + .post(&endpoint(server_url, "/documents")) + .set("Authorization", &format!("Bearer {}", token)) + .send_json(ureq::json!({ + "title": title, + "content": content, + "folder_id": null, + })) + .map_err(describe)? + .into_json::() + .map_err(|e| e.to_string()) +} + pub fn sync_document( server_url: &str, token: &str, diff --git a/src-tauri/src/workspace.rs b/src-tauri/src/workspace.rs index fa5918e..1759c9d 100644 --- a/src-tauri/src/workspace.rs +++ b/src-tauri/src/workspace.rs @@ -115,7 +115,7 @@ pub fn workspace_root(app: &AppHandle, store: &Store) -> Result #[derive(Serialize, Deserialize, Clone)] pub struct ProjectMeta { pub entrypoint: String, - pub space_id: Option, + pub cloud_project_id: Option, pub last_synced_at: Option, pub base_hashes: HashMap, } @@ -124,7 +124,7 @@ impl Default for ProjectMeta { fn default() -> Self { ProjectMeta { entrypoint: "main.typ".to_string(), - space_id: None, + cloud_project_id: None, last_synced_at: None, base_hashes: HashMap::new(), } @@ -154,7 +154,7 @@ pub struct BrowseEntry { pub kind: String, pub size: u64, pub modified: Option, - pub space_id: Option, + pub cloud_project_id: Option, pub last_synced_at: Option, pub child_count: usize, pub cloud_linked: bool, @@ -259,9 +259,9 @@ pub fn browse(app: &AppHandle, store: &Store, relative: &str) -> Result Result Result import Icon from "@iconify/svelte"; - import type { FileEntry } from "$lib/ts/api"; + import { resolveResource } from "@tauri-apps/api/path"; + import { startDrag } from "@crabnebula/tauri-plugin-drag"; + import { absolutePath, type FileEntry } from "$lib/ts/api"; interface Props { files: FileEntry[]; activePath: string | null; entrypoint: string; - selected: string | null; + targetPath: string; + selected: Set; dropTarget: string | null; onopen: (path: string) => void; - onselect: (path: string | null, isDir: boolean) => void; + onselect: (paths: string[], primary: string | null, isDir: boolean) => void; onrename: (path: string) => void; - ondelete: (path: string) => void; + ondelete: (paths: string[]) => void; onduplicate: (path: string) => void; onreveal: (path: string) => void; onsetentry: (path: string) => void; @@ -25,6 +28,7 @@ files, activePath, entrypoint, + targetPath, selected, dropTarget, onopen, @@ -111,9 +115,59 @@ null, ); let dragging = $state(null); + let dragPaths = $state([]); + let anchorPath = $state(null); + let nativeDragSent = false; + let iconPathPromise: Promise | null = null; + + function flattenVisible(nodes: TreeNode[]): TreeNode[] { + const result: TreeNode[] = []; + for (const node of nodes) { + result.push(node); + if (node.isDir && node.children.length > 0 && !collapsed[node.path]) { + result.push(...flattenVisible(node.children)); + } + } + return result; + } + + function selectRange(from: string, to: TreeNode) { + const flat = flattenVisible(tree); + const fromIndex = flat.findIndex((node) => node.path === from); + const toIndex = flat.findIndex((node) => node.path === to.path); + + if (fromIndex === -1 || toIndex === -1) { + onselect([to.path], to.path, to.isDir); + return; + } + + const [start, end] = + fromIndex < toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex]; + const paths = flat.slice(start, end + 1).map((node) => node.path); + onselect(paths, to.path, to.isDir); + } + + function handleRowClick(event: MouseEvent, node: TreeNode) { + if (event.shiftKey && anchorPath) { + selectRange(anchorPath, node); + return; + } + + if (event.ctrlKey || event.metaKey) { + const next = new Set(selected); + if (next.has(node.path)) { + next.delete(node.path); + } else { + next.add(node.path); + } + anchorPath = node.path; + onselect([...next], node.path, node.isDir); + return; + } + + anchorPath = node.path; + onselect([node.path], node.path, node.isDir); - function activate(node: TreeNode) { - onselect(node.path, node.isDir); if (node.isDir) { collapsed[node.path] = !collapsed[node.path]; } else { @@ -123,7 +177,10 @@ function openMenu(event: MouseEvent, node: TreeNode) { event.preventDefault(); - onselect(node.path, node.isDir); + if (!selected.has(node.path)) { + anchorPath = node.path; + onselect([node.path], node.path, node.isDir); + } menu = { path: node.path, isDir: node.isDir, x: event.clientX, y: event.clientY }; } @@ -134,7 +191,9 @@ } if (event.key === "Delete") { event.preventDefault(); - ondelete(node.path); + const paths = + selected.has(node.path) && selected.size > 1 ? [...selected] : [node.path]; + ondelete(paths); } } @@ -157,6 +216,41 @@ const index = path.lastIndexOf("/"); return index === -1 ? "" : path.slice(0, index); } + + function joinTargetPath(path: string): string { + return targetPath ? `${targetPath}/${path}` : path; + } + + function resolveAbsolutePaths(paths: string[]): Promise { + return Promise.all(paths.map((path) => absolutePath(joinTargetPath(path)))); + } + + function dragIcon(): Promise { + if (!iconPathPromise) iconPathPromise = resolveResource("icons/32x32.png"); + return iconPathPromise; + } + + async function exportViaOsDrag(paths: string[]) { + if (nativeDragSent || paths.length === 0) return; + nativeDragSent = true; + try { + const [absolutePaths, icon] = await Promise.all([ + resolveAbsolutePaths(paths), + dragIcon(), + ]); + await startDrag({ item: absolutePaths, icon }); + } catch (error) { + console.error("Failed to start native drag", error); + } + } + + function handleWindowDragLeave(event: DragEvent) { + if (!dragging || nativeDragSent) return; + // relatedTarget is null only when the pointer leaves the whole window, + // as opposed to moving between elements inside it. + if (event.relatedTarget !== null) return; + exportViaOsDrag(dragPaths); + } { if (!(event.target as HTMLElement).closest("[data-tree-row]")) menu = null; }} + on:dragleave={handleWindowDragLeave} /> {#snippet branch(nodes: TreeNode[], depth: number)} @@ -175,26 +270,35 @@ data-tree-dir={node.isDir ? "true" : "false"} role="treeitem" tabindex="0" - aria-selected={node.path === selected} + aria-selected={selected.has(node.path)} draggable="true" class="group flex items-center gap-1.5 rounded px-2 py-1 text-xs transition {node.path === activePath ? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]' - : node.path === selected + : selected.has(node.path) ? 'bg-[var(--color-surface-sunken)]' : 'text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)]'} {dropTarget === node.path ? 'ring-1 ring-inset ring-[var(--color-accent)]' : ''}" style="padding-left: {depth * 12 + 8}px" - onclick={() => activate(node)} + onclick={(event) => handleRowClick(event, node)} oncontextmenu={(event) => openMenu(event, node)} onkeydown={(event) => handleKey(event, node)} ondragstart={(event) => { dragging = node.path; + dragPaths = + selected.has(node.path) && selected.size > 1 + ? [...selected] + : [node.path]; + nativeDragSent = false; event.dataTransfer?.setData("text/plain", node.path); }} - ondragend={() => (dragging = null)} + ondragend={() => { + dragging = null; + dragPaths = []; + nativeDragSent = false; + }} ondragover={(event) => { if (!dragging || !node.isDir) return; event.preventDefault(); @@ -254,7 +358,7 @@ >
- {:else if target.path.endsWith(".typ")} + {:else if single && target.path.endsWith(".typ")} - - - -
+ {#if single} + + + + +
+ {/if} + {/if} diff --git a/src/lib/components/FileViewer.svelte b/src/lib/components/FileViewer.svelte index ef3edfd..760f588 100644 --- a/src/lib/components/FileViewer.svelte +++ b/src/lib/components/FileViewer.svelte @@ -8,7 +8,7 @@ browseTo, cloudBreadcrumbs, linkedDocument, - linkedSpace, + linkedProject, openCloudFolder, openTarget, refreshCloud, @@ -22,13 +22,14 @@ onrename: (entry: BrowseEntry) => void; ondelete: (entry: BrowseEntry) => void; onlink: (entry: BrowseEntry) => void; + onsavetocloud: (entry: BrowseEntry) => void; onviewimage: (paths: string[], index: number) => void; ondownloaddocument: (documentId: string, title: string) => void; onremovedownload: (path: string) => void; ondownloadfile: (fileId: string, name: string) => void; - onclonespace: (spaceId: string, name: string) => void; - ondeletespace: (spaceId: string) => void; - onnewspace: () => void; + oncloneproject: (cloudProjectId: string, name: string) => void; + ondeleteproject: (cloudProjectId: string) => void; + onnewcloudproject: () => void; onsignin: () => void; } @@ -40,13 +41,14 @@ onrename, ondelete, onlink, + onsavetocloud, onviewimage, ondownloaddocument, onremovedownload, ondownloadfile, - onclonespace, - ondeletespace, - onnewspace, + oncloneproject, + ondeleteproject, + onnewcloudproject, onsignin, }: Props = $props(); @@ -82,7 +84,7 @@ const pending = [ ...app.linkedDocuments.map((linked) => linked.path), - ...app.linkedSpaces.map((linked) => linked.path), + ...app.linkedProjects.map((linked) => linked.path), ]; let cancelled = false; @@ -353,7 +355,7 @@ Open image {/if} - {#if entry.kind === "project" && !entry.space_id && app.account} + {#if entry.kind === "project" && !entry.cloud_project_id && app.account} {/if} + {#if entry.kind === "document" && !entry.cloud_linked && app.account} + + {/if} {:else if app.account} {/if} @@ -490,16 +503,16 @@
- +
{:else} @@ -798,7 +811,7 @@ {/if} - {#if app.spaces.length === 0 && app.cloudDocuments.length === 0 && app.cloudFolders.length === 0 && app.cloudFiles.length === 0} + {#if app.cloudProjects.length === 0 && app.cloudDocuments.length === 0 && app.cloudFolders.length === 0 && app.cloudFiles.length === 0}
@@ -807,26 +820,26 @@
{/if} - {#if app.spaces.length > 0} + {#if app.cloudProjects.length > 0}

- Spaces + Cloud projects

{/if}
- {#each app.spaces as space (space.id)} - {@const linked = linkedSpace(space.id)} + {#each app.cloudProjects as project (project.id)} + {@const linked = linkedProject(project.id)} {@render cloudCard( "ph:folder-star", - space.name, + project.name, linked - ? `Project · ${space.role}` - : `${space.role} · ${formatDate(space.updated_at)}`, + ? `Project · ${project.role}` + : `${project.role} · ${formatDate(project.updated_at)}`, linked, linked ? () => openTarget(linked.path) : null, - () => onclonespace(space.id, space.name), - space.role === "owner" ? () => ondeletespace(space.id) : null, + () => oncloneproject(project.id, project.name), + project.role === "owner" ? () => ondeleteproject(project.id) : null, )} {/each}
diff --git a/src/lib/components/SettingsModal.svelte b/src/lib/components/SettingsModal.svelte index adb52e6..7f9db44 100644 --- a/src/lib/components/SettingsModal.svelte +++ b/src/lib/components/SettingsModal.svelte @@ -9,9 +9,15 @@ import { app, applyTheme, + applyAccent, + applyTextScale, + applyReduceMotion, + applyContrast, refreshEntries, restartAutoSync, setError, + type ThemePreference, + type TextScale, } from "$lib/ts/state.svelte"; interface Props { @@ -21,15 +27,37 @@ let { onclose, onsignin }: Props = $props(); - type Section = "files" | "appearance" | "account" | "about"; + type Section = + | "files" + | "appearance" + | "accessibility" + | "account" + | "about"; const sections: { id: Section; label: string; icon: string }[] = [ { id: "files", label: "Files", icon: "ph:folder" }, { id: "appearance", label: "Appearance", icon: "ph:palette" }, + { id: "accessibility", label: "Accessibility", icon: "ph:wheelchair" }, { id: "account", label: "Account", icon: "ph:user-circle" }, { id: "about", label: "About", icon: "ph:info" }, ]; + const accentPresets = [ + "#3b6cf6", + "#7c5cfc", + "#22b573", + "#f2994a", + "#ec4899", + "#14b8a6", + ]; + + const textScaleOptions: { value: TextScale; label: string }[] = [ + { value: "small", label: "Small" }, + { value: "default", label: "Default" }, + { value: "large", label: "Large" }, + { value: "xlarge", label: "Extra large" }, + ]; + let section = $state
("files"); let workspaceRoot = $state(untrack(() => app.settings?.workspace_root ?? "")); @@ -105,7 +133,7 @@ try { await api.cloudLogout(); app.account = null; - app.spaces = []; + app.cloudProjects = []; app.settings = await api.getSettings(); } catch (error) { setError(error); @@ -172,13 +200,13 @@
Theme
- {#each [["light", "Light", "ph:sun"], ["dark", "Dark", "ph:moon"]] as [value, label, icon]} + {#each [["light", "Light", "ph:sun"], ["dark", "Dark", "ph:moon"], ["system", "System", "ph:desktop"]] as [value, label, icon]}
- Applies immediately to the editor and preview. + System follows your OS setting and updates live. + +
+ +
+ + Accent color + +
+ + {#each accentPresets as preset} + + {/each} + + applyAccent((event.target as HTMLInputElement).value)} + /> +
+ + Overrides the accent color used across the app. + +
+ + {:else if section === "accessibility"} +
+
+ + UI text scale + +
+ {#each textScaleOptions as option} + + {/each} +
+ + Scales text and controls throughout the app. + +
+ +
+ + + Shortens transitions and animations across the app. + +
+ +
+ + + Increases contrast for borders, muted text, and focus outlines.
@@ -305,13 +437,17 @@ {#snippet footer()} + {@const draftless = + section === "about" || + section === "appearance" || + section === "accessibility"} - {#if section !== "about" && section !== "appearance"} + {#if !draftless}