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
+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));
}