Add cloud browsing, autosave, and editor improvements

This commit is contained in:
2026-07-18 16:56:15 -04:00
parent bcdfd4c917
commit ea011f2d3e
12 changed files with 1281 additions and 103 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ pub fn assets_dir(app: &AppHandle, store: &Store) -> Result<PathBuf, String> {
Ok(dir)
}
fn families_in(data: &[u8]) -> Vec<String> {
pub fn families_in(data: &[u8]) -> Vec<String> {
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());
+67 -1
View File
@@ -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<Connection>,
}
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<Option<DocumentLink>, 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<String>>(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<Option<(String, String)>, String> {
self.with(|connection| {
connection
+235 -1
View File
@@ -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<Settings, String> {
load_settings(&app, &store)
@@ -68,6 +88,8 @@ fn update_settings(
store: State<'_, Store>,
workspace_root: Option<String>,
server_url: Option<String>,
autosave_seconds: Option<u32>,
sync_minutes: Option<u32>,
) -> Result<Settings, String> {
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<String>,
overrides: Option<std::collections::HashMap<String, String>>,
) -> Result<CompileResult, CompileFailure> {
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<Vec<Asset>, 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<String>,
}
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<Vec<Resource>, 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<String>) -> Result<Vec<String>, String> {
let files = match path {
@@ -524,6 +639,115 @@ fn cloud_list_spaces(app: AppHandle, store: State<'_, Store>) -> Result<Vec<Spac
sync::list_spaces(&server_url, &token)
}
#[tauri::command]
fn cloud_list_folders(
app: AppHandle,
store: State<'_, Store>,
) -> Result<Vec<sync::CloudFolder>, 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<String>,
) -> Result<Vec<sync::CloudDocument>, 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<sync::SharedItems, String> {
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<String, String> {
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<SyncReport, String> {
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<Option<db::DocumentLink>, String> {
store.document_link(&path)
}
#[tauri::command]
fn cloud_create_space(app: AppHandle, store: State<'_, Store>, name: String) -> Result<SpaceSummary, String> {
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,
+282
View File
@@ -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<String>,
}
#[derive(Deserialize, Serialize, Clone)]
pub struct CloudDocument {
pub id: String,
pub title: String,
pub folder_id: Option<String>,
pub role: String,
pub updated_at: String,
}
#[derive(Deserialize, Serialize)]
pub struct SharedItems {
pub documents: Vec<CloudDocument>,
pub spaces: Vec<SpaceSummary>,
}
pub fn list_folders(server_url: &str, token: &str) -> Result<Vec<CloudFolder>, String> {
agent()
.get(&endpoint(server_url, "/folders"))
.set("Authorization", &format!("Bearer {}", token))
.call()
.map_err(describe)?
.into_json::<Vec<CloudFolder>>()
.map_err(|e| e.to_string())
}
pub fn list_documents(
server_url: &str,
token: &str,
folder_id: Option<&str>,
) -> Result<Vec<CloudDocument>, 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::<Vec<CloudDocument>>()
.map_err(|e| e.to_string())
}
pub fn list_shared(server_url: &str, token: &str) -> Result<SharedItems, String> {
agent()
.get(&endpoint(server_url, "/shared"))
.set("Authorization", &format!("Bearer {}", token))
.call()
.map_err(describe)?
.into_json::<SharedItems>()
.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<DocumentContent, String> {
agent()
.get(&endpoint(server_url, &format!("/documents/{}", document_id)))
.set("Authorization", &format!("Bearer {}", token))
.call()
.map_err(describe)?
.into_json::<DocumentContent>()
.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<SyncReport, 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)?;
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<PushResult, String> {
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::<ConflictBody>()
.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)),
}
}
+6
View File
@@ -35,6 +35,10 @@ pub struct Settings {
pub account_email: Option<String>,
#[serde(default)]
pub account_username: Option<String>,
#[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,
}
}
}
+168 -64
View File
@@ -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<Asset[]>([]);
let resources = $state<Resource[]>([]);
let previews = $state<Record<string, string>>({});
let query = $state("");
let scope = $state<"all" | "project" | "shared">("all");
let busy = $state(false);
let error = $state("");
let copied = $state<string | null>(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<Asset["kind"], string> = {
const iconFor: Record<string, string> = {
image: "ph:image",
font: "ph:text-aa",
file: "ph:file",
};
</script>
<Modal title="Images and fonts" icon="ph:images" width="max-w-2xl" {onclose}>
<div class="flex flex-col gap-4">
<p class="text-xs text-[var(--color-ink-muted)]">
Files imported here are available to every project. Reference an image by
its file name, and a font by its family name.
</p>
<Modal title="Assets" icon="ph:images" width="max-w-3xl" {onclose}>
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<div
class="flex flex-1 items-center gap-2 rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 focus-within:border-[var(--color-accent)]"
>
<Icon icon="ph:magnifying-glass" class="text-[var(--color-ink-muted)]" />
<input
class="min-w-0 flex-1 bg-transparent text-sm focus:outline-none"
placeholder="Search images, fonts, and files"
bind:value={query}
/>
{#if query}
<button
class="text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]"
onclick={() => (query = "")}
aria-label="Clear search"
>
<Icon icon="ph:x" />
</button>
{/if}
</div>
<div class="flex rounded-md bg-[var(--color-surface-sunken)] p-0.5 text-xs">
{#each [["all", "All"], ["project", "This project"], ["shared", "Shared"]] as [value, label]}
<button
class="rounded px-2.5 py-1.5 transition
{scope === value
? 'bg-[var(--color-surface)] font-medium shadow-sm'
: 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]'}"
onclick={() => (scope = value as "all" | "project" | "shared")}
>
{label}
</button>
{/each}
</div>
</div>
{#if error}
<p class="text-xs text-[var(--color-danger)]">{error}</p>
{/if}
{#if assets.length === 0}
{#if filtered.length === 0}
<div
class="flex flex-col items-center gap-3 rounded-lg border border-dashed border-[var(--color-line)] px-4 py-10 text-center"
>
<Icon icon="ph:image-square" class="text-4xl text-[var(--color-ink-muted)]" />
<p class="text-xs text-[var(--color-ink-muted)]">
No images or fonts imported yet.
{query ? "Nothing matches that search." : "No images or fonts yet."}
</p>
</div>
{:else}
<div class="flex flex-col gap-1">
{#each assets as asset (asset.name)}
<div
class="scroll-thin grid max-h-96 grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-2 overflow-y-auto"
>
{#each filtered as resource (resource.path)}
<div
class="group flex items-center gap-3 rounded-md border border-[var(--color-line)] px-3 py-2"
class="group relative flex flex-col overflow-hidden rounded-lg border border-[var(--color-line)] transition hover:border-[var(--color-accent)]"
>
<Icon
icon={iconFor[asset.kind]}
class="text-lg text-[var(--color-accent)]"
/>
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-medium">{asset.name}</p>
{#if asset.font_families.length > 0}
<div class="mt-0.5 flex flex-wrap gap-1">
{#each asset.font_families as family}
<button
class="rounded bg-[var(--color-surface-sunken)] px-1.5 py-px text-[10px] text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]"
onclick={() => copyFamily(family)}
title="Copy family name"
>
{copied === family ? "Copied" : family}
</button>
{/each}
</div>
<button
class="flex h-20 items-center justify-center overflow-hidden bg-[var(--color-surface-muted)]"
onclick={() => insert(resource)}
title="Insert into document"
>
{#if previews[resource.path]}
<img
src={previews[resource.path]}
alt={resource.name}
class="h-full w-full object-contain"
/>
{:else}
<p class="text-[10px] text-[var(--color-ink-muted)]">
{formatSize(asset.size)}
</p>
<Icon
icon={iconFor[resource.kind] ?? "ph:file"}
class="text-2xl text-[var(--color-accent)]"
/>
{/if}
</button>
<div class="flex flex-col gap-0.5 px-2 py-1.5">
<span class="truncate text-[11px] font-medium" title={resource.reference}>
{resource.name}
</span>
{#if resource.font_families.length > 0}
<button
class="truncate text-left text-[10px] text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]"
onclick={() => copyReference(resource.font_families[0])}
title="Copy family name"
>
{copied === resource.font_families[0]
? "Copied"
: resource.font_families[0]}
</button>
{:else}
<span class="text-[10px] text-[var(--color-ink-muted)]">
{resource.scope === "shared" ? "Shared" : "Project"} · {formatSize(
resource.size,
)}
</span>
{/if}
</div>
{#if oninsert && (asset.kind === "image" || asset.font_families.length > 0)}
<button
class="rounded border border-[var(--color-line)] px-2 py-1 text-[10px] opacity-0 transition group-hover:opacity-100 hover:bg-[var(--color-surface-muted)]"
onclick={() => insert(asset)}
>
Insert
</button>
{/if}
<button
class="rounded p-1 text-[var(--color-ink-muted)] opacity-0 transition group-hover:opacity-100 hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-danger)]"
onclick={() => remove(asset)}
class="absolute right-1 top-1 rounded bg-[var(--color-surface)]/90 p-1 text-[var(--color-ink-muted)] opacity-0 transition group-hover:opacity-100 hover:text-[var(--color-danger)]"
onclick={() => remove(resource)}
aria-label="Delete"
>
<Icon icon="ph:trash" />
<Icon icon="ph:trash" class="text-xs" />
</button>
</div>
{/each}
@@ -166,12 +262,20 @@
Close
</button>
<button
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50"
class="flex items-center gap-1.5 rounded-md border border-[var(--color-line)] px-3 py-1.5 text-xs transition hover:bg-[var(--color-surface-muted)] disabled:opacity-50"
disabled={busy}
onclick={importFiles}
onclick={() => importInto("shared")}
>
<Icon icon="ph:upload-simple" />
{busy ? "Importing..." : "Import files"}
Add to shared
</button>
<button
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50"
disabled={busy}
onclick={() => importInto("project")}
>
<Icon icon="ph:upload-simple" />
{busy ? "Importing..." : "Add to project"}
</button>
{/snippet}
</Modal>
+108 -16
View File
@@ -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 @@
<div class="flex-1"></div>
{#if app.scope === "local"}
<button
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
onclick={onassets}
>
<Icon icon="ph:images" />
Assets
</button>
<button
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
onclick={onupload}
@@ -454,14 +455,105 @@
Sign in
</button>
</div>
{:else if app.spaces.length === 0}
<div
class="flex h-full flex-col items-center justify-center gap-3 text-[var(--color-ink-muted)]"
>
<Icon icon="ph:cloud" class="text-5xl" />
<p class="text-sm">No cloud spaces yet.</p>
</div>
{:else}
<div class="mb-3 flex items-center gap-1 text-xs">
<button
class="flex items-center gap-1.5 rounded px-2 py-1 transition hover:bg-[var(--color-surface)]
{app.cloudFolder === null ? 'font-medium' : 'text-[var(--color-ink-muted)]'}"
onclick={() => openCloudFolder(null)}
>
<Icon icon="ph:cloud" />
My Drive
</button>
<button
class="flex items-center gap-1.5 rounded px-2 py-1 transition hover:bg-[var(--color-surface)]
{app.cloudFolder === 'shared'
? 'font-medium'
: 'text-[var(--color-ink-muted)]'}"
onclick={() => openCloudFolder("shared")}
>
<Icon icon="ph:users-three" />
Shared with me
</button>
{#if app.cloudLoading}
<Icon
icon="ph:circle-notch"
class="animate-spin text-[var(--color-accent)]"
/>
{/if}
</div>
{#if app.cloudFolders.length > 0}
<div
class="mb-4 grid grid-cols-[repeat(auto-fill,minmax(210px,1fr))] gap-2"
>
{#each app.cloudFolders as folder (folder.id)}
<button
class="flex items-center gap-2.5 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface-sunken)] px-3 py-2.5 text-left transition hover:border-[var(--color-accent)] hover:bg-[var(--color-surface)]"
onclick={() => openCloudFolder(folder.id)}
>
<Icon
icon="ph:folder-fill"
class="shrink-0 text-2xl text-[var(--color-ink-muted)]"
/>
<span class="truncate text-xs font-medium">{folder.name}</span>
</button>
{/each}
</div>
{/if}
{#if app.cloudDocuments.length > 0}
<h2
class="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
>
Documents
</h2>
<div
class="mb-4 grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3"
>
{#each app.cloudDocuments as document (document.id)}
<div
class="flex flex-col gap-2 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-3 transition hover:border-[var(--color-accent)]"
>
<Icon
icon="ph:file-text"
class="text-xl text-[var(--color-accent)]"
/>
<span class="truncate text-xs font-medium" title={document.title}>
{document.title}
</span>
<span class="text-[10px] text-[var(--color-ink-muted)]">
{document.role} · {formatDate(document.updated_at)}
</span>
<button
class="mt-1 flex items-center justify-center gap-1 rounded border border-[var(--color-line)] px-2 py-1 text-[10px] hover:bg-[var(--color-surface-muted)]"
onclick={() => ondownloaddocument(document.id, document.title)}
>
<Icon icon="ph:download-simple" />
Download
</button>
</div>
{/each}
</div>
{/if}
{#if app.spaces.length === 0 && app.cloudDocuments.length === 0 && app.cloudFolders.length === 0}
<div
class="flex flex-col items-center justify-center gap-3 py-16 text-[var(--color-ink-muted)]"
>
<Icon icon="ph:cloud" class="text-5xl" />
<p class="text-sm">Nothing here yet.</p>
</div>
{/if}
{#if app.spaces.length > 0}
<h2
class="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
>
Spaces
</h2>
{/if}
<div class="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-3">
{#each app.spaces as space (space.id)}
<div
+107
View File
@@ -0,0 +1,107 @@
<script lang="ts">
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<AppInfo | null>(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",
},
];
</script>
<Modal title="About Typst Desktop" icon="ph:info" {onclose}>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<Icon icon="ph:file-code" class="text-3xl text-[var(--color-accent)]" />
<div>
<p class="text-sm font-semibold">Typst Desktop</p>
<p class="text-xs text-[var(--color-ink-muted)]">
A local editor for Typst documents
</p>
</div>
</div>
{#if info}
<dl class="flex flex-col gap-1 text-xs">
<div class="flex justify-between border-b border-[var(--color-line)] py-1.5">
<dt class="text-[var(--color-ink-muted)]">Version</dt>
<dd class="font-medium">{info.version}</dd>
</div>
<div class="flex justify-between border-b border-[var(--color-line)] py-1.5">
<dt class="text-[var(--color-ink-muted)]">Typst</dt>
<dd class="font-medium">{info.typst_version}</dd>
</div>
<div class="flex justify-between border-b border-[var(--color-line)] py-1.5">
<dt class="text-[var(--color-ink-muted)]">Tauri</dt>
<dd class="font-medium">{info.tauri_version}</dd>
</div>
<div class="flex justify-between border-b border-[var(--color-line)] py-1.5">
<dt class="text-[var(--color-ink-muted)]">Author</dt>
<dd class="font-medium">{info.authors}</dd>
</div>
<div class="flex justify-between py-1.5">
<dt class="text-[var(--color-ink-muted)]">License</dt>
<dd class="font-medium">{info.license}</dd>
</div>
</dl>
{/if}
<div class="flex flex-col gap-1.5">
{#each links as link}
<button
class="flex items-center gap-2 rounded-md border border-[var(--color-line)] px-3 py-2 text-xs transition hover:border-[var(--color-accent)] hover:bg-[var(--color-surface-muted)]"
onclick={() => openUrl(link.url)}
>
<Icon icon={link.icon} class="text-base text-[var(--color-accent)]" />
<span class="flex-1 text-left">{link.label}</span>
<Icon
icon="ph:arrow-square-out"
class="text-[var(--color-ink-muted)]"
/>
</button>
{/each}
</div>
<p
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface-muted)] p-3 text-[11px] leading-relaxed text-[var(--color-ink-muted)]"
>
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.
</p>
</div>
{#snippet footer()}
<button
class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
onclick={onclose}
>
Close
</button>
{/snippet}
</Modal>
+60 -3
View File
@@ -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 @@
/>
</div>
<div class="flex flex-col gap-1 text-xs">
<span class="font-medium text-[var(--color-ink-muted)]">Autosave</span>
<select
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none"
bind:value={autosaveSeconds}
>
{#each autosaveOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
<span class="text-[var(--color-ink-muted)]">
Saves the file being edited after you stop typing.
</span>
</div>
<div class="flex flex-col gap-1 text-xs">
<span class="font-medium text-[var(--color-ink-muted)]">Automatic sync</span>
<select
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none"
bind:value={syncMinutes}
>
{#each syncOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
<span class="text-[var(--color-ink-muted)]">
Pulls and pushes cloud-linked projects on a timer. Conflicts pause
syncing until they are resolved.
</span>
</div>
<div
class="flex items-center gap-3 rounded-md border border-[var(--color-line)] bg-[var(--color-surface-muted)] p-3"
>
+83 -1
View File
@@ -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<string, string>,
) => invoke<CompileResult>("compile_target", { path, overrides });
) => invoke<CompileResult>("compile_target", { path, entrypoint, overrides });
export const exportTarget = (
path: string,
@@ -161,6 +164,19 @@ export interface Asset {
export const listAssets = () => invoke<Asset[]>("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<Resource[]>("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<string[]>("import_into_folder", { parent, sources });
export interface AppInfo {
version: string;
typst_version: string;
authors: string;
license: string;
tauri_version: string;
}
export const appInfo = () => invoke<AppInfo>("app_info");
export const getSettings = () => invoke<Settings>("get_settings");
export const updateSettings = (changes: {
workspaceRoot?: string;
serverUrl?: string;
autosaveSeconds?: number;
syncMinutes?: number;
}) => invoke<Settings>("update_settings", changes);
export const cloudLogin = (
@@ -224,6 +252,60 @@ export const cloudAccount = () => invoke<Account | null>("cloud_account");
export const cloudListSpaces = () =>
invoke<SpaceSummary[]>("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<CloudFolder[]>("cloud_list_folders");
export const cloudListDocuments = (folderId?: string | null) =>
invoke<CloudDocument[]>("cloud_list_documents", {
folderId: folderId ?? null,
});
export const cloudListShared = () => invoke<SharedItems>("cloud_list_shared");
export const cloudDownloadDocument = (documentId: string, parent: string) =>
invoke<string>("cloud_download_document", { documentId, parent });
export const cloudSyncDocument = (path: string) =>
invoke<SyncReport>("cloud_sync_document", { path });
export const cloudResolveDocument = (
path: string,
content: string,
serverHash: string,
) => invoke<void>("cloud_resolve_document", { path, content, serverHash });
export const cloudDocumentLink = (path: string) =>
invoke<DocumentLink | null>("cloud_document_link", { path });
export const cloudUnlinkDocument = (path: string) =>
invoke<void>("cloud_unlink_document", { path });
export const cloudCreateSpace = (name: string) =>
invoke<SpaceSummary>("cloud_create_space", { name });
+131 -5
View File
@@ -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<AppState>({
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<typeof setTimeout> | 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<typeof setInterval> | 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));
}
+33 -11
View File
@@ -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]}
</span>
<button
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
onclick={() => (dialog = { kind: "assets" })}
>
<Icon icon="ph:images" />
Assets
</button>
<button
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
onclick={saveAndCompile}
@@ -402,7 +412,7 @@
</div>
</div>
{#if app.target?.space_id}
{#if app.target?.space_id || app.documentLink}
<button
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-2.5 py-1.5 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50"
disabled={app.syncing}
@@ -425,6 +435,14 @@
<Icon icon="ph:gear-six" />
</button>
<button
class="rounded-md p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
onclick={() => (dialog = { kind: "info" })}
aria-label="About"
>
<Icon icon="ph:info" />
</button>
<div class="ml-1 h-5 w-px bg-[var(--color-line)]"></div>
<WindowControls />
@@ -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"}
<SettingsModal onclose={close} onsignin={() => (dialog = { kind: "login" })} />
{:else if dialog.kind === "info"}
<InfoModal onclose={close} />
{:else if dialog.kind === "assets"}
<AssetsModal
oninsert={app.view === "editor" && editorView
@@ -762,7 +784,7 @@
{:else if dialog.kind === "conflicts"}
<ConflictModal
conflicts={app.conflicts}
onresolve={resolveConflicts}
onresolve={app.documentLink ? resolveDocumentConflicts : resolveConflicts}
onclose={close}
/>
{/if}