diff --git a/src-tauri/src/assets.rs b/src-tauri/src/assets.rs index ec04db6..cb4764c 100644 --- a/src-tauri/src/assets.rs +++ b/src-tauri/src/assets.rs @@ -34,7 +34,7 @@ pub fn assets_dir(app: &AppHandle, store: &Store) -> Result { Ok(dir) } -fn families_in(data: &[u8]) -> Vec { +pub fn families_in(data: &[u8]) -> Vec { let mut families = BTreeSet::new(); for font in typst::text::Font::iter(typst::foundations::Bytes::new(data.to_vec())) { families.insert(font.info().family.clone()); diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 72541b0..08b51d0 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -1,4 +1,5 @@ use rusqlite::{params, Connection, OptionalExtension}; +use serde::Serialize; use std::collections::HashMap; use std::sync::Mutex; use tauri::{AppHandle, Manager}; @@ -9,7 +10,15 @@ pub struct Store { connection: Mutex, } -const SCHEMA: [&str; 4] = [ +#[derive(Serialize, Clone)] +pub struct DocumentLink { + pub document_id: String, + pub base_hash: String, + pub role: String, + pub base_content: String, +} + +const SCHEMA: [&str; 5] = [ "CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL @@ -27,6 +36,13 @@ const SCHEMA: [&str; 4] = [ content BLOB, PRIMARY KEY (project_path, file_path) )", + "CREATE TABLE IF NOT EXISTS document_links ( + path TEXT PRIMARY KEY, + document_id TEXT NOT NULL, + base_hash TEXT NOT NULL, + role TEXT NOT NULL, + base_content TEXT + )", "CREATE TABLE IF NOT EXISTS thumbnails ( path TEXT PRIMARY KEY, kind TEXT NOT NULL, @@ -257,6 +273,56 @@ impl Store { }) } + pub fn save_document_link( + &self, + path: &str, + document_id: &str, + base_hash: &str, + role: &str, + base_content: &str, + ) -> Result<(), String> { + self.with(|connection| { + connection.execute( + "INSERT INTO document_links (path, document_id, base_hash, role, base_content) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(path) DO UPDATE SET + document_id = excluded.document_id, + base_hash = excluded.base_hash, + role = excluded.role, + base_content = excluded.base_content", + params![path, document_id, base_hash, role, base_content], + )?; + Ok(()) + }) + } + + pub fn document_link(&self, path: &str) -> Result, String> { + self.with(|connection| { + connection + .query_row( + "SELECT document_id, base_hash, role, base_content + FROM document_links WHERE path = ?1", + params![path], + |row| { + Ok(DocumentLink { + document_id: row.get(0)?, + base_hash: row.get(1)?, + role: row.get(2)?, + base_content: row.get::<_, Option>(3)?.unwrap_or_default(), + }) + }, + ) + .optional() + }) + } + + pub fn forget_document_link(&self, path: &str) -> Result<(), String> { + self.with(|connection| { + connection.execute("DELETE FROM document_links WHERE path = ?1", params![path])?; + Ok(()) + }) + } + pub fn thumbnail(&self, path: &str, modified: i64) -> Result, String> { self.with(|connection| { connection diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7018681..363fb00 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -57,6 +57,26 @@ fn load_project( Ok((dir, store.meta(project)?)) } +#[derive(Serialize)] +pub struct AppInfo { + pub version: String, + pub typst_version: String, + pub authors: String, + pub license: String, + pub tauri_version: String, +} + +#[tauri::command] +fn app_info() -> AppInfo { + AppInfo { + version: env!("CARGO_PKG_VERSION").to_string(), + typst_version: "0.14.2".to_string(), + authors: "SirBlobby".to_string(), + license: "Apache-2.0".to_string(), + tauri_version: tauri::VERSION.to_string(), + } +} + #[tauri::command] fn get_settings(app: AppHandle, store: State<'_, Store>) -> Result { load_settings(&app, &store) @@ -68,6 +88,8 @@ fn update_settings( store: State<'_, Store>, workspace_root: Option, server_url: Option, + autosave_seconds: Option, + sync_minutes: Option, ) -> Result { let mut settings = load_settings(&app, &store)?; if let Some(root) = workspace_root { @@ -79,6 +101,12 @@ fn update_settings( if let Some(url) = server_url { settings.server_url = url.trim_end_matches('/').to_string(); } + if let Some(seconds) = autosave_seconds { + settings.autosave_seconds = seconds; + } + if let Some(minutes) = sync_minutes { + settings.sync_minutes = minutes; + } save_settings(&store, &settings)?; Ok(settings) } @@ -331,6 +359,7 @@ fn compile_target( app: AppHandle, store: State<'_, Store>, path: String, + entrypoint: Option, overrides: Option>, ) -> Result { let target = resolve_target(&app, &store, &path).map_err(failure)?; @@ -340,7 +369,12 @@ fn compile_target( files.insert(file, content.into_bytes()); } - compiler::compile_to_svg(target.entrypoint, files) + let entrypoint = match entrypoint { + Some(file) if files.contains_key(&file) => file, + _ => target.entrypoint, + }; + + compiler::compile_to_svg(entrypoint, files) .map_err(|diagnostics| CompileFailure { diagnostics }) } @@ -397,6 +431,87 @@ fn list_assets(app: AppHandle, store: State<'_, Store>) -> Result, St assets::list_assets(&app, &store) } +#[derive(Serialize)] +pub struct Resource { + pub name: String, + pub reference: String, + pub path: String, + pub scope: String, + pub kind: String, + pub size: u64, + pub font_families: Vec, +} + +fn resource_kind(name: &str) -> String { + if assets::is_font(name) { + "font" + } else if assets::is_image(name) { + "image" + } else { + "file" + } + .to_string() +} + +#[tauri::command] +fn list_resources( + app: AppHandle, + store: State<'_, Store>, + path: String, +) -> Result, String> { + let mut resources = Vec::new(); + + for asset in assets::list_assets(&app, &store)? { + resources.push(Resource { + reference: asset.name.clone(), + path: format!("{}/{}", assets::ASSETS_DIR, asset.name), + scope: "shared".to_string(), + kind: asset.kind, + size: asset.size, + font_families: asset.font_families, + name: asset.name, + }); + } + + let target = resolve_target(&app, &store, &path)?; + let prefix = if target.standalone { + path.rsplit_once('/').map(|(dir, _)| dir).unwrap_or("") + } else { + path.as_str() + }; + + for file in list_files(&target.root)? { + if file.path.to_lowercase().ends_with(".typ") { + continue; + } + + let full = target.root.join(&file.path); + let font_families = if assets::is_font(&file.name) { + std::fs::read(&full) + .map(|data| assets::families_in(&data)) + .unwrap_or_default() + } else { + Vec::new() + }; + + resources.push(Resource { + name: file.name, + reference: file.path.clone(), + path: if prefix.is_empty() { + file.path.clone() + } else { + format!("{}/{}", prefix, file.path) + }, + scope: "project".to_string(), + kind: resource_kind(&file.path), + size: file.size, + font_families, + }); + } + + Ok(resources) +} + #[tauri::command] fn list_font_families(app: AppHandle, store: State<'_, Store>, path: Option) -> Result, String> { let files = match path { @@ -524,6 +639,115 @@ fn cloud_list_spaces(app: AppHandle, store: State<'_, Store>) -> Result, +) -> Result, String> { + let (server_url, token) = cloud_credentials(&app, &store)?; + sync::list_folders(&server_url, &token) +} + +#[tauri::command] +fn cloud_list_documents( + app: AppHandle, + store: State<'_, Store>, + folder_id: Option, +) -> Result, String> { + let (server_url, token) = cloud_credentials(&app, &store)?; + sync::list_documents(&server_url, &token, folder_id.as_deref()) +} + +#[tauri::command] +fn cloud_list_shared( + app: AppHandle, + store: State<'_, Store>, +) -> Result { + let (server_url, token) = cloud_credentials(&app, &store)?; + sync::list_shared(&server_url, &token) +} + +/// Downloads a cloud document into the workspace and remembers where it came +/// from so it can be synced back. +#[tauri::command] +fn cloud_download_document( + app: AppHandle, + store: State<'_, Store>, + document_id: String, + parent: String, +) -> Result { + let (server_url, token) = cloud_credentials(&app, &store)?; + let document = sync::pull_document(&server_url, &token, &document_id)?; + + let mut name = document.title.replace('/', "-").trim().to_string(); + if name.is_empty() { + name = "document".to_string(); + } + if !name.to_lowercase().ends_with(".typ") { + name.push_str(".typ"); + } + + let path = join_path(&parent, &name); + let full = workspace_path(&app, &store, &path)?; + if let Some(dir) = full.parent() { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + std::fs::write(&full, &document.content).map_err(|e| e.to_string())?; + + store.save_document_link( + &path, + &document_id, + &document.hash, + &document.role, + &document.content, + )?; + + Ok(path) +} + +#[tauri::command] +fn cloud_sync_document( + app: AppHandle, + store: State<'_, Store>, + path: String, +) -> Result { + let (server_url, token) = cloud_credentials(&app, &store)?; + sync::sync_document(&server_url, &token, &app, &store, &path) +} + +#[tauri::command] +fn cloud_resolve_document( + app: AppHandle, + store: State<'_, Store>, + path: String, + content: String, + server_hash: String, +) -> Result<(), String> { + let (server_url, token) = cloud_credentials(&app, &store)?; + sync::resolve_document_conflict( + &server_url, + &token, + &app, + &store, + &path, + &content, + &server_hash, + ) +} + +#[tauri::command] +fn cloud_unlink_document(store: State<'_, Store>, path: String) -> Result<(), String> { + store.forget_document_link(&path) +} + +#[tauri::command] +fn cloud_document_link( + store: State<'_, Store>, + path: String, +) -> Result, String> { + store.document_link(&path) +} + #[tauri::command] fn cloud_create_space(app: AppHandle, store: State<'_, Store>, name: String) -> Result { let (server_url, token) = cloud_credentials(&app, &store)?; @@ -667,6 +891,7 @@ pub fn run() { } }) .invoke_handler(tauri::generate_handler![ + app_info, get_settings, update_settings, browse_workspace, @@ -686,6 +911,7 @@ pub fn run() { read_image, clear_thumbnails, list_assets, + list_resources, list_font_families, import_assets, delete_asset, @@ -699,6 +925,14 @@ pub fn run() { cloud_logout, cloud_account, cloud_list_spaces, + cloud_list_folders, + cloud_list_documents, + cloud_list_shared, + cloud_download_document, + cloud_sync_document, + cloud_resolve_document, + cloud_document_link, + cloud_unlink_document, cloud_create_space, cloud_delete_space, cloud_clone_space, diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs index 13afeea..055e1b2 100644 --- a/src-tauri/src/sync.rs +++ b/src-tauri/src/sync.rs @@ -566,3 +566,285 @@ pub fn resolve_conflict( .insert(path.to_string(), server_hash.to_string()); store.save_meta(project, meta) } + +#[derive(Deserialize, Serialize, Clone)] +pub struct CloudFolder { + pub id: String, + pub name: String, + pub parent_id: Option, +} + +#[derive(Deserialize, Serialize, Clone)] +pub struct CloudDocument { + pub id: String, + pub title: String, + pub folder_id: Option, + pub role: String, + pub updated_at: String, +} + +#[derive(Deserialize, Serialize)] +pub struct SharedItems { + pub documents: Vec, + pub spaces: Vec, +} + +pub fn list_folders(server_url: &str, token: &str) -> Result, String> { + agent() + .get(&endpoint(server_url, "/folders")) + .set("Authorization", &format!("Bearer {}", token)) + .call() + .map_err(describe)? + .into_json::>() + .map_err(|e| e.to_string()) +} + +pub fn list_documents( + server_url: &str, + token: &str, + folder_id: Option<&str>, +) -> Result, String> { + let mut request = agent() + .get(&endpoint(server_url, "/documents")) + .set("Authorization", &format!("Bearer {}", token)); + + if let Some(folder) = folder_id { + request = request.query("folder_id", folder); + } + + request + .call() + .map_err(describe)? + .into_json::>() + .map_err(|e| e.to_string()) +} + +pub fn list_shared(server_url: &str, token: &str) -> Result { + agent() + .get(&endpoint(server_url, "/shared")) + .set("Authorization", &format!("Bearer {}", token)) + .call() + .map_err(describe)? + .into_json::() + .map_err(|e| e.to_string()) +} + +#[derive(Deserialize, Serialize)] +pub struct DocumentContent { + pub id: String, + pub title: String, + pub role: String, + pub hash: String, + pub content: String, +} + +pub fn pull_document( + server_url: &str, + token: &str, + document_id: &str, +) -> Result { + agent() + .get(&endpoint(server_url, &format!("/documents/{}", document_id))) + .set("Authorization", &format!("Bearer {}", token)) + .call() + .map_err(describe)? + .into_json::() + .map_err(|e| e.to_string()) +} + +/// Syncs one downloaded cloud document. The merge base is the copy stored when +/// the document was last exchanged with the server. +pub fn sync_document( + server_url: &str, + token: &str, + app: &tauri::AppHandle, + store: &Store, + path: &str, +) -> Result { + let link = store + .document_link(path)? + .ok_or("This document is not linked to the cloud")?; + + let full = crate::workspace::workspace_path(app, store, path)?; + let local = std::fs::read_to_string(&full).map_err(|e| e.to_string())?; + let remote = pull_document(server_url, token, &link.document_id)?; + + let mut report = SyncReport::default(); + + let local_hash = content_hash(local.as_bytes()); + let editable = remote.role == "owner" || remote.role == "editor"; + + if local_hash == remote.hash { + store.save_document_link(path, &link.document_id, &remote.hash, &remote.role, &local)?; + return Ok(report); + } + + if local_hash == link.base_hash { + std::fs::write(&full, &remote.content).map_err(|e| e.to_string())?; + store.save_document_link( + path, + &link.document_id, + &remote.hash, + &remote.role, + &remote.content, + )?; + report.pulled.push(path.to_string()); + return Ok(report); + } + + if remote.hash == link.base_hash { + if !editable { + return Err("You only have view access to this document".to_string()); + } + match push_document( + server_url, + token, + &link.document_id, + &local, + Some(&link.base_hash), + )? { + PushResult::Applied => { + store.save_document_link( + path, + &link.document_id, + &local_hash, + &remote.role, + &local, + )?; + report.pushed.push(path.to_string()); + } + PushResult::Conflict { + server_hash, + server_text, + } => { + report.conflicts.push(Conflict { + path: path.to_string(), + local_text: local, + remote_text: server_text.clone(), + merged_text: server_text, + server_hash, + auto_merged: false, + binary: false, + }); + } + } + return Ok(report); + } + + match diffy::merge(&link.base_content, &local, &remote.content) { + Ok(merged) => { + std::fs::write(&full, &merged).map_err(|e| e.to_string())?; + + if editable { + match push_document( + server_url, + token, + &link.document_id, + &merged, + Some(&remote.hash), + )? { + PushResult::Applied => { + store.save_document_link( + path, + &link.document_id, + &content_hash(merged.as_bytes()), + &remote.role, + &merged, + )?; + } + PushResult::Conflict { + server_hash, + server_text, + } => { + report.conflicts.push(Conflict { + path: path.to_string(), + local_text: merged.clone(), + remote_text: server_text.clone(), + merged_text: server_text, + server_hash, + auto_merged: false, + binary: false, + }); + return Ok(report); + } + } + } + + report.merged.push(path.to_string()); + } + Err(conflicted) => { + report.conflicts.push(Conflict { + path: path.to_string(), + local_text: local, + remote_text: remote.content, + merged_text: conflicted, + server_hash: remote.hash, + auto_merged: false, + binary: false, + }); + } + } + + Ok(report) +} + +/// Applies a resolved cloud document and uploads it. +pub fn resolve_document_conflict( + server_url: &str, + token: &str, + app: &tauri::AppHandle, + store: &Store, + path: &str, + content: &str, + server_hash: &str, +) -> Result<(), String> { + let link = store + .document_link(path)? + .ok_or("This document is not linked to the cloud")?; + + let full = crate::workspace::workspace_path(app, store, path)?; + std::fs::write(&full, content).map_err(|e| e.to_string())?; + + match push_document(server_url, token, &link.document_id, content, Some(server_hash))? { + PushResult::Applied => store.save_document_link( + path, + &link.document_id, + &content_hash(content.as_bytes()), + &link.role, + content, + ), + PushResult::Conflict { .. } => { + Err("The document changed again in the cloud. Sync and merge once more.".to_string()) + } + } +} + +pub fn push_document( + server_url: &str, + token: &str, + document_id: &str, + content: &str, + base_hash: Option<&str>, +) -> Result { + let response = agent() + .put(&endpoint(server_url, &format!("/documents/{}", document_id))) + .set("Authorization", &format!("Bearer {}", token)) + .send_json(ureq::json!({ + "content": content, + "base_hash": base_hash, + })); + + match response { + Ok(_) => Ok(PushResult::Applied), + Err(ureq::Error::Status(409, body)) => { + let conflict = body + .into_json::() + .map_err(|e| format!("Malformed conflict response: {}", e))?; + Ok(PushResult::Conflict { + server_hash: conflict.server_hash, + server_text: conflict.server_content, + }) + } + Err(other) => Err(describe(other)), + } +} diff --git a/src-tauri/src/workspace.rs b/src-tauri/src/workspace.rs index 258d0bb..ef5091a 100644 --- a/src-tauri/src/workspace.rs +++ b/src-tauri/src/workspace.rs @@ -35,6 +35,10 @@ pub struct Settings { pub account_email: Option, #[serde(default)] pub account_username: Option, + #[serde(default)] + pub autosave_seconds: u32, + #[serde(default)] + pub sync_minutes: u32, } impl Settings { @@ -49,6 +53,8 @@ impl Settings { device_token: None, account_email: None, account_username: None, + autosave_seconds: 5, + sync_minutes: 0, } } } diff --git a/src/lib/components/AssetsModal.svelte b/src/lib/components/AssetsModal.svelte index 0e7c04f..9d22d7a 100644 --- a/src/lib/components/AssetsModal.svelte +++ b/src/lib/components/AssetsModal.svelte @@ -2,8 +2,9 @@ import Icon from "@iconify/svelte"; import Modal from "./Modal.svelte"; import * as api from "$lib/ts/api"; - import type { Asset } from "$lib/ts/api"; + import type { Resource } from "$lib/ts/api"; import { pickFiles } from "$lib/ts/import"; + import { app } from "$lib/ts/state.svelte"; interface Props { oninsert?: (snippet: string) => void; @@ -13,14 +14,33 @@ let { oninsert, onchanged, onclose }: Props = $props(); - let assets = $state([]); + let resources = $state([]); + let previews = $state>({}); + let query = $state(""); + let scope = $state<"all" | "project" | "shared">("all"); let busy = $state(false); let error = $state(""); let copied = $state(null); + const filtered = $derived( + resources.filter((resource) => { + if (scope !== "all" && resource.scope !== scope) return false; + const needle = query.trim().toLowerCase(); + if (!needle) return true; + return ( + resource.name.toLowerCase().includes(needle) || + resource.reference.toLowerCase().includes(needle) || + resource.font_families.some((family) => + family.toLowerCase().includes(needle), + ) + ); + }), + ); + async function refresh() { + if (!app.target) return; try { - assets = await api.listAssets(); + resources = await api.listResources(app.target.path); } catch (caught) { error = api.errorMessage(caught); } @@ -30,14 +50,42 @@ refresh(); }); - async function importFiles() { + $effect(() => { + const images = filtered.filter((resource) => resource.kind === "image"); + let cancelled = false; + + (async () => { + for (const resource of images) { + if (cancelled) return; + if (previews[resource.path]) continue; + try { + const result = await api.thumbnail(resource.path); + if (!cancelled && result.kind === "image") { + previews[resource.path] = result.data; + } + } catch { + continue; + } + } + })(); + + return () => { + cancelled = true; + }; + }); + + async function importInto(destination: "project" | "shared") { const sources = await pickFiles("assets"); if (sources.length === 0) return; busy = true; error = ""; try { - await api.importAssets(sources); + if (destination === "shared") { + await api.importAssets(sources); + } else if (app.target) { + await api.importIntoTarget(app.target.path, sources); + } await refresh(); onchanged?.(); } catch (caught) { @@ -47,9 +95,14 @@ } } - async function remove(asset: Asset) { + async function remove(resource: Resource) { try { - await api.deleteAsset(asset.name); + if (resource.scope === "shared") { + await api.deleteAsset(resource.name); + } else { + await api.deleteEntry(resource.path); + } + delete previews[resource.path]; await refresh(); onchanged?.(); } catch (caught) { @@ -57,17 +110,19 @@ } } - function insert(asset: Asset) { - if (asset.kind === "image") { - oninsert?.(`#image("${asset.name}")`); - } else if (asset.font_families.length > 0) { - oninsert?.(`#set text(font: "${asset.font_families[0]}")`); + function insert(resource: Resource) { + if (resource.kind === "image") { + oninsert?.(`#image("${resource.reference}")`); + } else if (resource.font_families.length > 0) { + oninsert?.(`#set text(font: "${resource.font_families[0]}")`); + } else { + oninsert?.(`"${resource.reference}"`); } } - async function copyFamily(family: string) { - await navigator.clipboard.writeText(family); - copied = family; + async function copyReference(value: string) { + await navigator.clipboard.writeText(value); + copied = value; setTimeout(() => (copied = null), 1200); } @@ -77,80 +132,121 @@ return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } - const iconFor: Record = { + const iconFor: Record = { image: "ph:image", font: "ph:text-aa", file: "ph:file", }; - -
-

- Files imported here are available to every project. Reference an image by - its file name, and a font by its family name. -

+ +
+
+
+ + + {#if query} + + {/if} +
+ +
+ {#each [["all", "All"], ["project", "This project"], ["shared", "Shared"]] as [value, label]} + + {/each} +
+
{#if error}

{error}

{/if} - {#if assets.length === 0} + {#if filtered.length === 0}

- No images or fonts imported yet. + {query ? "Nothing matches that search." : "No images or fonts yet."}

{:else} -
- {#each assets as asset (asset.name)} +
+ {#each filtered as resource (resource.path)}
- - -
-

{asset.name}

- {#if asset.font_families.length > 0} -
- {#each asset.font_families as family} - - {/each} -
+ + +
+ + {resource.name} + + + {#if resource.font_families.length > 0} + + {:else} + + {resource.scope === "shared" ? "Shared" : "Project"} · {formatSize( + resource.size, + )} + {/if}
- {#if oninsert && (asset.kind === "image" || asset.font_families.length > 0)} - - {/if} -
{/each} @@ -166,12 +262,20 @@ Close + {/snippet} diff --git a/src/lib/components/FileViewer.svelte b/src/lib/components/FileViewer.svelte index 5cb8480..f898e3c 100644 --- a/src/lib/components/FileViewer.svelte +++ b/src/lib/components/FileViewer.svelte @@ -6,7 +6,9 @@ app, breadcrumbs, browseTo, + openCloudFolder, openTarget, + refreshCloud, } from "$lib/ts/state.svelte"; interface Props { @@ -14,11 +16,11 @@ onnewproject: () => void; onnewdocument: () => void; onupload: () => void; - onassets: () => void; onrename: (entry: BrowseEntry) => void; ondelete: (entry: BrowseEntry) => void; onlink: (entry: BrowseEntry) => void; onviewimage: (paths: string[], index: number) => void; + ondownloaddocument: (documentId: string, title: string) => void; onclonespace: (spaceId: string, name: string) => void; ondeletespace: (spaceId: string) => void; onnewspace: () => void; @@ -30,11 +32,11 @@ onnewproject, onnewdocument, onupload, - onassets, onrename, ondelete, onlink, onviewimage, + ondownloaddocument, onclonespace, ondeletespace, onnewspace, @@ -45,6 +47,12 @@ const trail = $derived(breadcrumbs()); + $effect(() => { + if (app.scope === "cloud" && app.account) { + refreshCloud(); + } + }); + const containers = $derived( app.entries.filter( (entry) => entry.kind === "folder" || entry.kind === "project", @@ -221,13 +229,6 @@
{#if app.scope === "local"} -
- {:else if app.spaces.length === 0} -
- -

No cloud spaces yet.

-
{:else} +
+ + + + {#if app.cloudLoading} + + {/if} +
+ + {#if app.cloudFolders.length > 0} +
+ {#each app.cloudFolders as folder (folder.id)} + + {/each} +
+ {/if} + + {#if app.cloudDocuments.length > 0} +

+ Documents +

+
+ {#each app.cloudDocuments as document (document.id)} +
+ + + {document.title} + + + {document.role} · {formatDate(document.updated_at)} + + +
+ {/each} +
+ {/if} + + {#if app.spaces.length === 0 && app.cloudDocuments.length === 0 && app.cloudFolders.length === 0} +
+ +

Nothing here yet.

+
+ {/if} + + {#if app.spaces.length > 0} +

+ Spaces +

+ {/if}
{#each app.spaces as space (space.id)}
+ import Icon from "@iconify/svelte"; + import { openUrl } from "@tauri-apps/plugin-opener"; + import Modal from "./Modal.svelte"; + import * as api from "$lib/ts/api"; + import type { AppInfo } from "$lib/ts/api"; + + interface Props { + onclose: () => void; + } + + let { onclose }: Props = $props(); + + let info = $state(null); + + $effect(() => { + api + .appInfo() + .then((result) => (info = result)) + .catch(() => (info = null)); + }); + + const links = [ + { + label: "Typst Documentation", + url: "https://typst.app/docs/", + icon: "ph:book-open", + }, + { + label: "Typst Universe", + url: "https://typst.app/universe/", + icon: "ph:planet", + }, + ]; + + + +
+
+ +
+

Typst Desktop

+

+ A local editor for Typst documents +

+
+
+ + {#if info} +
+
+
Version
+
{info.version}
+
+
+
Typst
+
{info.typst_version}
+
+
+
Tauri
+
{info.tauri_version}
+
+
+
Author
+
{info.authors}
+
+
+
License
+
{info.license}
+
+
+ {/if} + +
+ {#each links as link} + + {/each} +
+ +

+ Typst Desktop is a community application and is not affiliated with, + endorsed by, or supported by the official Typst project. The links above + open the official Typst website in your browser. +

+
+ + {#snippet footer()} + + {/snippet} +
diff --git a/src/lib/components/SettingsModal.svelte b/src/lib/components/SettingsModal.svelte index 4601b62..c4c96fb 100644 --- a/src/lib/components/SettingsModal.svelte +++ b/src/lib/components/SettingsModal.svelte @@ -3,7 +3,14 @@ import { open } from "@tauri-apps/plugin-dialog"; import Modal from "./Modal.svelte"; import * as api from "$lib/ts/api"; - import { app, applyTheme, refreshEntries, setError } from "$lib/ts/state.svelte"; + import { untrack } from "svelte"; + import { + app, + applyTheme, + refreshEntries, + restartAutoSync, + setError, + } from "$lib/ts/state.svelte"; interface Props { onclose: () => void; @@ -12,10 +19,26 @@ let { onclose, onsignin }: Props = $props(); - let workspaceRoot = $state(app.settings?.workspace_root ?? ""); - let serverUrl = $state(app.settings?.server_url ?? ""); + let workspaceRoot = $state(untrack(() => app.settings?.workspace_root ?? "")); + let serverUrl = $state(untrack(() => app.settings?.server_url ?? "")); + let autosaveSeconds = $state(untrack(() => app.settings?.autosave_seconds ?? 0)); + let syncMinutes = $state(untrack(() => app.settings?.sync_minutes ?? 0)); let saving = $state(false); + const autosaveOptions = [ + { value: 0, label: "Off" }, + { value: 5, label: "5 seconds" }, + { value: 10, label: "10 seconds" }, + { value: 15, label: "15 seconds" }, + ]; + + const syncOptions = [ + { value: 0, label: "Off" }, + { value: 1, label: "1 minute" }, + { value: 2, label: "2 minutes" }, + { value: 5, label: "5 minutes" }, + ]; + async function browse() { const selected = await open({ directory: true, multiple: false }); if (typeof selected === "string") { @@ -29,7 +52,10 @@ app.settings = await api.updateSettings({ workspaceRoot, serverUrl, + autosaveSeconds, + syncMinutes, }); + restartAutoSync(); await refreshEntries(); onclose(); } catch (error) { @@ -81,6 +107,37 @@ />
+
+ Autosave + + + Saves the file being edited after you stop typing. + +
+ +
+ Automatic sync + + + Pulls and pushes cloud-linked projects on a timer. Conflicts pause + syncing until they are resolved. + +
+
diff --git a/src/lib/ts/api.ts b/src/lib/ts/api.ts index 7549484..dc02f16 100644 --- a/src/lib/ts/api.ts +++ b/src/lib/ts/api.ts @@ -6,6 +6,8 @@ export interface Settings { device_token: string | null; account_email: string | null; account_username: string | null; + autosave_seconds: number; + sync_minutes: number; } export interface FileEntry { @@ -143,8 +145,9 @@ export const setTargetEntrypoint = (path: string, entrypoint: string) => export const compileTarget = ( path: string, + entrypoint?: string, overrides?: Record, -) => invoke("compile_target", { path, overrides }); +) => invoke("compile_target", { path, entrypoint, overrides }); export const exportTarget = ( path: string, @@ -161,6 +164,19 @@ export interface Asset { export const listAssets = () => invoke("list_assets"); +export interface Resource { + name: string; + reference: string; + path: string; + scope: "shared" | "project"; + kind: "font" | "image" | "file"; + size: number; + font_families: string[]; +} + +export const listResources = (path: string) => + invoke("list_resources", { path }); + export interface Thumbnail { kind: "svg" | "image"; data: string; @@ -204,11 +220,23 @@ export const importIntoTarget = (path: string, sources: string[]) => export const importIntoFolder = (parent: string, sources: string[]) => invoke("import_into_folder", { parent, sources }); +export interface AppInfo { + version: string; + typst_version: string; + authors: string; + license: string; + tauri_version: string; +} + +export const appInfo = () => invoke("app_info"); + export const getSettings = () => invoke("get_settings"); export const updateSettings = (changes: { workspaceRoot?: string; serverUrl?: string; + autosaveSeconds?: number; + syncMinutes?: number; }) => invoke("update_settings", changes); export const cloudLogin = ( @@ -224,6 +252,60 @@ export const cloudAccount = () => invoke("cloud_account"); export const cloudListSpaces = () => invoke("cloud_list_spaces"); +export interface CloudFolder { + id: string; + name: string; + parent_id: string | null; +} + +export interface CloudDocument { + id: string; + title: string; + folder_id: string | null; + role: string; + updated_at: string; +} + +export interface SharedItems { + documents: CloudDocument[]; + spaces: SpaceSummary[]; +} + +export interface DocumentLink { + document_id: string; + base_hash: string; + role: string; + base_content: string; +} + +export const cloudListFolders = () => + invoke("cloud_list_folders"); + +export const cloudListDocuments = (folderId?: string | null) => + invoke("cloud_list_documents", { + folderId: folderId ?? null, + }); + +export const cloudListShared = () => invoke("cloud_list_shared"); + +export const cloudDownloadDocument = (documentId: string, parent: string) => + invoke("cloud_download_document", { documentId, parent }); + +export const cloudSyncDocument = (path: string) => + invoke("cloud_sync_document", { path }); + +export const cloudResolveDocument = ( + path: string, + content: string, + serverHash: string, +) => invoke("cloud_resolve_document", { path, content, serverHash }); + +export const cloudDocumentLink = (path: string) => + invoke("cloud_document_link", { path }); + +export const cloudUnlinkDocument = (path: string) => + invoke("cloud_unlink_document", { path }); + export const cloudCreateSpace = (name: string) => invoke("cloud_create_space", { name }); diff --git a/src/lib/ts/state.svelte.ts b/src/lib/ts/state.svelte.ts index 87fb131..c70f8e4 100644 --- a/src/lib/ts/state.svelte.ts +++ b/src/lib/ts/state.svelte.ts @@ -2,9 +2,12 @@ import * as api from "./api"; import type { Account, BrowseEntry, + CloudDocument, + CloudFolder, CompileResult, Conflict, Diagnostic, + DocumentLink, Settings, SpaceSummary, TargetInfo, @@ -23,6 +26,11 @@ interface AppState { currentDir: string; entries: BrowseEntry[]; spaces: SpaceSummary[]; + cloudFolder: string | null | "shared"; + cloudFolders: CloudFolder[]; + cloudDocuments: CloudDocument[]; + cloudLoading: boolean; + documentLink: DocumentLink | null; target: TargetInfo | null; activePath: string | null; @@ -49,6 +57,11 @@ export const app = $state({ currentDir: "", entries: [], spaces: [], + cloudFolder: null, + cloudFolders: [], + cloudDocuments: [], + cloudLoading: false, + documentLink: null, target: null, activePath: null, @@ -102,6 +115,7 @@ export async function bootstrap() { try { app.settings = await api.getSettings(); + restartAutoSync(); await browseTo(""); await refreshAccount(); } catch (error) { @@ -144,6 +158,53 @@ export async function refreshSpaces() { } } +export async function refreshCloud() { + if (!app.account) return; + + app.cloudLoading = true; + try { + if (app.cloudFolder === "shared") { + const shared = await api.cloudListShared(); + app.cloudDocuments = shared.documents; + app.spaces = shared.spaces; + app.cloudFolders = []; + } else { + const [folders, documents, spaces] = await Promise.all([ + api.cloudListFolders(), + api.cloudListDocuments(app.cloudFolder), + api.cloudListSpaces(), + ]); + app.cloudFolders = folders.filter( + (folder) => (folder.parent_id ?? null) === app.cloudFolder, + ); + app.cloudDocuments = documents; + app.spaces = spaces; + } + } catch (error) { + setError(error); + } finally { + app.cloudLoading = false; + } +} + +export async function openCloudFolder(id: string | null | "shared") { + app.cloudFolder = id; + await refreshCloud(); +} + +export async function downloadDocument(documentId: string, title: string) { + try { + const path = await api.cloudDownloadDocument(documentId, ""); + app.scope = "local"; + await browseTo(""); + setStatus(`Downloaded '${title}' to this device`); + return path; + } catch (error) { + setError(error); + return null; + } +} + export async function openTarget(path: string) { try { const target = await api.targetInfo(path); @@ -157,6 +218,10 @@ export async function openTarget(path: string) { app.lspStatus = "off"; clearMessages(); + app.documentLink = target.standalone + ? await api.cloudDocumentLink(path).catch(() => null) + : null; + const preferred = target.files.find((file) => file.path === target.entrypoint) ?? target.files.find((file) => file.path.endsWith(".typ")) ?? @@ -170,6 +235,7 @@ export async function openTarget(path: string) { export async function closeTarget() { cancelScheduledCompile(); + cancelAutosave(); if (app.dirty) await saveActiveFile(); app.view = "files"; app.target = null; @@ -194,6 +260,7 @@ export async function openFile(file: string) { if (!app.target) return; cancelScheduledCompile(); + cancelAutosave(); if (app.dirty && app.activePath) await saveActiveFile(); @@ -242,7 +309,15 @@ export async function compile() { app.compiling = true; try { - const result = await api.compileTarget(app.target.path, liveOverrides()); + const previewFile = + app.activePath && app.activePath.toLowerCase().endsWith(".typ") + ? app.activePath + : undefined; + const result = await api.compileTarget( + app.target.path, + previewFile, + liveOverrides(), + ); app.compiled = result; app.diagnostics = result.diagnostics; } catch (error) { @@ -280,8 +355,57 @@ export function cancelScheduledCompile() { } } +let autosaveTimer: ReturnType | null = null; + +export function scheduleAutosave() { + const seconds = app.settings?.autosave_seconds ?? 0; + if (autosaveTimer) clearTimeout(autosaveTimer); + if (seconds <= 0) return; + + autosaveTimer = setTimeout(() => { + autosaveTimer = null; + if (app.dirty) saveActiveFile(); + }, seconds * 1000); +} + +export function cancelAutosave() { + if (autosaveTimer) { + clearTimeout(autosaveTimer); + autosaveTimer = null; + } +} + +let syncTimer: ReturnType | null = null; + +export function restartAutoSync() { + if (syncTimer) { + clearInterval(syncTimer); + syncTimer = null; + } + + const minutes = app.settings?.sync_minutes ?? 0; + if (minutes <= 0) return; + + syncTimer = setInterval(() => { + autoSync(); + }, minutes * 60 * 1000); +} + +async function autoSync() { + if (!app.account || app.syncing) return; + if (app.conflicts.length > 0) return; + + const linked = app.target?.space_id || app.documentLink; + const project = linked ? app.target?.path : null; + if (!project) return; + + if (app.dirty) await saveActiveFile(); + await runSync("sync", project, true); +} + export async function saveAndCompile() { cancelScheduledCompile(); + cancelAutosave(); await saveActiveFile(); await compile(); } @@ -289,15 +413,17 @@ export async function saveAndCompile() { export async function runSync( action: "sync" | "push" | "pull", project = app.target?.path, + quiet = false, ) { if (!project) return; app.syncing = true; - clearMessages(); + if (!quiet) clearMessages(); try { - const report = - action === "push" + const report = app.documentLink + ? await api.cloudSyncDocument(project) + : action === "push" ? await api.cloudPush(project) : action === "pull" ? await api.cloudPull(project) @@ -307,7 +433,7 @@ export async function runSync( if (report.conflicts.length > 0) { setError(`${report.conflicts.length} file(s) need conflict resolution`); - } else { + } else if (!quiet) { setStatus(summarize(report)); } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 6ba2856..214faa9 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -16,6 +16,7 @@ import AssetsModal from "$lib/components/AssetsModal.svelte"; import WindowControls from "$lib/components/WindowControls.svelte"; import ImageViewer from "$lib/components/ImageViewer.svelte"; + import InfoModal from "$lib/components/InfoModal.svelte"; import EditorToolbar from "$lib/components/EditorToolbar.svelte"; import PageSettingsModal from "$lib/components/PageSettingsModal.svelte"; @@ -31,6 +32,7 @@ clearMessages, closeTarget, compile, + downloadDocument, openFile, openTarget, refreshAccount, @@ -39,6 +41,7 @@ refreshTarget, runSync, saveAndCompile, + scheduleAutosave, scheduleCompile, setError, setStatus, @@ -61,6 +64,7 @@ | { kind: "delete-file"; path: string } | { kind: "login" } | { kind: "settings" } + | { kind: "info" } | { kind: "assets" } | { kind: "page-settings" } | { kind: "conflicts" }; @@ -231,6 +235,20 @@ } } + const resolveDocumentConflicts = (resolutions: api.Resolution[]) => + guard(async () => { + for (const resolution of resolutions) { + await api.cloudResolveDocument( + app.target!.path, + resolution.content, + resolution.server_hash, + ); + } + app.conflicts = []; + if (app.activePath) await openFile(app.activePath); + setStatus("Conflicts resolved and uploaded"); + }); + const resolveConflicts = (resolutions: api.Resolution[]) => guard(async () => { const report = await api.cloudResolveConflicts( @@ -365,14 +383,6 @@ {lspLabel[app.lspStatus]} - -
- {#if app.target?.space_id} + {#if app.target?.space_id || app.documentLink} + +
@@ -461,11 +479,12 @@ onnewfolder={() => (dialog = { kind: "new-folder" })} onnewdocument={() => (dialog = { kind: "new-document" })} onupload={importFiles} - onassets={() => (dialog = { kind: "assets" })} onrename={(entry) => (dialog = { kind: "rename-entry", entry })} ondelete={(entry) => (dialog = { kind: "delete-entry", entry })} onlink={(entry) => (dialog = { kind: "link-entry", entry })} onviewimage={(paths, index) => (imageViewer = { paths, index })} + ondownloaddocument={(documentId, title) => + downloadDocument(documentId, title)} onnewspace={() => (dialog = { kind: "new-space" })} onclonespace={(id, name) => (dialog = { kind: "clone-space", id, name })} ondeletespace={(id) => (dialog = { kind: "delete-space", id })} @@ -553,6 +572,7 @@ app.editorContent = value; app.dirty = true; scheduleCompile(); + scheduleAutosave(); }} onsave={saveAndCompile} onlspstatus={(status) => (app.lspStatus = status)} @@ -746,6 +766,8 @@ /> {:else if dialog.kind === "settings"} (dialog = { kind: "login" })} /> +{:else if dialog.kind === "info"} + {:else if dialog.kind === "assets"} {/if}