Add Typst Desktop app

This commit is contained in:
2026-07-18 15:00:22 -04:00
commit 8853c614d7
72 changed files with 15548 additions and 0 deletions
+177
View File
@@ -0,0 +1,177 @@
<script lang="ts">
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 { pickFiles } from "$lib/ts/import";
interface Props {
oninsert?: (snippet: string) => void;
onchanged?: () => void;
onclose: () => void;
}
let { oninsert, onchanged, onclose }: Props = $props();
let assets = $state<Asset[]>([]);
let busy = $state(false);
let error = $state("");
let copied = $state<string | null>(null);
async function refresh() {
try {
assets = await api.listAssets();
} catch (caught) {
error = api.errorMessage(caught);
}
}
$effect(() => {
refresh();
});
async function importFiles() {
const sources = await pickFiles("assets");
if (sources.length === 0) return;
busy = true;
error = "";
try {
await api.importAssets(sources);
await refresh();
onchanged?.();
} catch (caught) {
error = api.errorMessage(caught);
} finally {
busy = false;
}
}
async function remove(asset: Asset) {
try {
await api.deleteAsset(asset.name);
await refresh();
onchanged?.();
} catch (caught) {
error = api.errorMessage(caught);
}
}
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]}")`);
}
}
async function copyFamily(family: string) {
await navigator.clipboard.writeText(family);
copied = family;
setTimeout(() => (copied = null), 1200);
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
const iconFor: Record<Asset["kind"], 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>
{#if error}
<p class="text-xs text-[var(--color-danger)]">{error}</p>
{/if}
{#if assets.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.
</p>
</div>
{:else}
<div class="flex flex-col gap-1">
{#each assets as asset (asset.name)}
<div
class="group flex items-center gap-3 rounded-md border border-[var(--color-line)] px-3 py-2"
>
<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>
{:else}
<p class="text-[10px] text-[var(--color-ink-muted)]">
{formatSize(asset.size)}
</p>
{/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)}
aria-label="Delete"
>
<Icon icon="ph:trash" />
</button>
</div>
{/each}
</div>
{/if}
</div>
{#snippet footer()}
<button
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
onclick={onclose}
>
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"
disabled={busy}
onclick={importFiles}
>
<Icon icon="ph:upload-simple" />
{busy ? "Importing..." : "Import files"}
</button>
{/snippet}
</Modal>
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import Modal from "./Modal.svelte";
interface Props {
title: string;
message: string;
confirmLabel?: string;
onconfirm: () => void;
onclose: () => void;
}
let {
title,
message,
confirmLabel = "Delete",
onconfirm,
onclose,
}: Props = $props();
</script>
<Modal {title} icon="ph:warning-circle" {onclose}>
<p class="text-sm text-[var(--color-ink-muted)]">{message}</p>
{#snippet footer()}
<button
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
onclick={onclose}
>
Cancel
</button>
<button
class="rounded-md bg-[var(--color-danger)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
onclick={onconfirm}
>
{confirmLabel}
</button>
{/snippet}
</Modal>
+147
View File
@@ -0,0 +1,147 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import Modal from "./Modal.svelte";
import type { Conflict, Resolution } from "$lib/ts/api";
interface Props {
conflicts: Conflict[];
onresolve: (resolutions: Resolution[]) => void;
onclose: () => void;
}
let { conflicts, onresolve, onclose }: Props = $props();
let index = $state(0);
let choices = $state<string[]>([]);
let mode = $state<("merged" | "local" | "remote")[]>([]);
$effect(() => {
choices = conflicts.map((conflict) =>
conflict.binary ? conflict.remote_text : conflict.merged_text,
);
mode = conflicts.map(() => "merged");
index = 0;
});
const current = $derived(conflicts[index]);
function choose(option: "merged" | "local" | "remote") {
mode[index] = option;
choices[index] =
option === "local"
? current.local_text
: option === "remote"
? current.remote_text
: current.merged_text;
}
const unresolvedMarkers = $derived(
choices[index]?.includes("<<<<<<<") ?? false,
);
const anyUnresolved = $derived(
choices.some((text) => text.includes("<<<<<<<")),
);
function submit() {
onresolve(
conflicts.map((conflict, position) => ({
path: conflict.path,
content: choices[position],
server_hash: conflict.server_hash,
})),
);
}
</script>
<Modal
title="Resolve sync conflicts"
icon="ph:git-merge"
width="max-w-4xl"
{onclose}
>
<div class="flex flex-col gap-4">
<p class="text-xs text-[var(--color-ink-muted)]">
These files changed both on this device and in the cloud. Pick a version or
edit the merged result, then save to upload your resolution.
</p>
<div class="flex flex-wrap gap-1.5">
{#each conflicts as conflict, position}
<button
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs transition
{position === index
? 'border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
: 'border-[var(--color-line)] text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-muted)]'}"
onclick={() => (index = position)}
>
{#if choices[position].includes("<<<<<<<")}
<Icon icon="ph:warning" class="text-[var(--color-danger)]" />
{:else}
<Icon icon="ph:check-circle" class="text-[var(--color-success)]" />
{/if}
{conflict.path}
</button>
{/each}
</div>
{#if current}
{#if current.binary}
<div
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface-muted)] p-4 text-xs"
>
<p class="font-medium">{current.path}</p>
<p class="mt-1 text-[var(--color-ink-muted)]">
This is a binary file and cannot be merged automatically. The cloud
version will be kept.
</p>
</div>
{:else}
<div class="flex items-center gap-1.5">
{#each [["merged", "Merged"], ["local", "This device"], ["remote", "Cloud"]] as [option, label]}
<button
class="rounded-md border px-3 py-1.5 text-xs transition
{mode[index] === option
? 'border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
: 'border-[var(--color-line)] text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-muted)]'}"
onclick={() => choose(option as "merged" | "local" | "remote")}
>
{label}
</button>
{/each}
{#if unresolvedMarkers}
<span
class="ml-2 flex items-center gap-1 text-xs text-[var(--color-danger)]"
>
<Icon icon="ph:warning" />
Conflict markers still present
</span>
{/if}
</div>
<textarea
class="scroll-thin h-80 w-full resize-none rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] p-3 font-mono text-xs leading-relaxed focus:border-[var(--color-accent)] focus:outline-none"
bind:value={choices[index]}
spellcheck="false"
></textarea>
{/if}
{/if}
</div>
{#snippet footer()}
<button
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
onclick={onclose}
>
Cancel
</button>
<button
class="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={anyUnresolved}
onclick={submit}
>
Save and upload
</button>
{/snippet}
</Modal>
+230
View File
@@ -0,0 +1,230 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import {
EditorView,
keymap,
lineNumbers,
highlightActiveLine,
} from "@codemirror/view";
import { EditorState, Compartment } from "@codemirror/state";
import {
defaultKeymap,
history,
historyKeymap,
indentWithTab,
} from "@codemirror/commands";
import {
bracketMatching,
indentOnInput,
Language,
StreamLanguage,
} from "@codemirror/language";
import { toml } from "@codemirror/legacy-modes/mode/toml";
import {
autocompletion,
closeBrackets,
closeBracketsKeymap,
} from "@codemirror/autocomplete";
import { lintGutter, setDiagnostics } from "@codemirror/lint";
import { LSPClient, languageServerExtensions } from "@codemirror/lsp-client";
import { typstCompletions } from "$lib/ts/completions";
import { editorTheme } from "$lib/ts/editor-theme";
import { LspBridge } from "$lib/ts/lsp";
import { app } from "$lib/ts/state.svelte";
import type { Diagnostic } from "$lib/ts/api";
interface Props {
content: string;
filePath: string;
targetPath: string;
enableLsp?: boolean;
diagnostics?: Diagnostic[];
onchange: (value: string) => void;
onsave: () => void;
onlspstatus?: (status: "off" | "starting" | "on" | "unavailable") => void;
onready?: (view: EditorView | null) => void;
}
let {
content,
filePath,
targetPath,
enableLsp = true,
diagnostics = [],
onchange,
onsave,
onlspstatus,
onready,
}: Props = $props();
let host: HTMLDivElement;
let view: EditorView | null = null;
const languageSlot = new Compartment();
const lspSlot = new Compartment();
const themeSlot = new Compartment();
const bridge = new LspBridge();
let client: LSPClient | null = null;
const isToml = $derived(filePath.toLowerCase().endsWith(".toml"));
async function loadTypstLanguage() {
const { typst, TypstParser, typstHighlight } = await import(
"codemirror-lang-typst"
);
const support = typst();
const parser = new (TypstParser as any)(typstHighlight);
return new Language(
support.language.data,
parser,
[parser.updateListener()],
"typst",
);
}
async function connectLsp() {
if (!enableLsp || isToml || !view) return;
onlspstatus?.("starting");
try {
const handle = await bridge.start(targetPath, () => {
onlspstatus?.("off");
});
client = new LSPClient({
rootUri: handle.root_uri,
timeout: 10000,
extensions: languageServerExtensions(),
}).connect(bridge.transport);
const documentUri = `${handle.root_uri}/${filePath}`;
view.dispatch({
effects: lspSlot.reconfigure(client.plugin(documentUri, "typst")),
});
onlspstatus?.("on");
} catch {
onlspstatus?.("unavailable");
}
}
onMount(() => {
view = new EditorView({
parent: host,
state: EditorState.create({
doc: content,
extensions: [
lineNumbers(),
lintGutter(),
history(),
bracketMatching(),
closeBrackets(),
indentOnInput(),
highlightActiveLine(),
languageSlot.of(isToml ? StreamLanguage.define(toml) : []),
lspSlot.of([]),
themeSlot.of(editorTheme(app.theme === "dark")),
...(isToml
? []
: [autocompletion({ override: [typstCompletions] })]),
EditorView.lineWrapping,
keymap.of([
{
key: "Mod-s",
preventDefault: true,
run: () => {
onsave();
return true;
},
},
...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
indentWithTab,
]),
EditorView.updateListener.of((update) => {
if (update.docChanged) onchange(update.state.doc.toString());
}),
],
}),
});
if (!isToml) {
loadTypstLanguage()
.then((language) => {
view?.dispatch({ effects: languageSlot.reconfigure(language) });
})
.catch(() => {});
}
onready?.(view);
connectLsp();
});
onDestroy(() => {
onready?.(null);
bridge.stop();
view?.destroy();
});
function laterDispatch(build: (current: EditorView) => void) {
queueMicrotask(() => {
if (!view) return;
build(view);
});
}
$effect(() => {
const next = content;
if (!view) return;
if (view.state.doc.toString() === next) return;
laterDispatch((current) => {
if (current.state.doc.toString() === next) return;
current.dispatch({
changes: { from: 0, to: current.state.doc.length, insert: next },
});
});
});
$effect(() => {
const isDark = app.theme === "dark";
laterDispatch((current) => {
current.dispatch({ effects: themeSlot.reconfigure(editorTheme(isDark)) });
});
});
$effect(() => {
const entries = diagnostics;
if (!view) return;
laterDispatch((current) => {
const doc = current.state.doc;
const marks = entries
.filter((entry) => entry.line !== null)
.map((entry) => {
const line = doc.line(
Math.min(Math.max(entry.line ?? 1, 1), doc.lines),
);
const from = Math.min(
line.from + Math.max((entry.column ?? 1) - 1, 0),
line.to,
);
return {
from,
to: line.to,
severity: entry.severity.includes("warn")
? ("warning" as const)
: ("error" as const),
message: entry.message,
};
});
current.dispatch(setDiagnostics(current.state, marks));
});
});
</script>
<div class="h-full overflow-hidden bg-[var(--color-surface)]" bind:this={host}></div>
+141
View File
@@ -0,0 +1,141 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import type { EditorView } from "@codemirror/view";
import * as api from "$lib/ts/api";
import {
insertText,
prefixLines,
redoEdit,
setTypstConfig,
undoEdit,
wrapSelection,
} from "$lib/ts/editor-actions";
import { app } from "$lib/ts/state.svelte";
interface Props {
view: EditorView | null;
disabled?: boolean;
onassets: () => void;
onpagesettings: () => void;
}
let { view, disabled = false, onassets, onpagesettings }: Props = $props();
let fonts = $state<string[]>([]);
let selectedFont = $state("");
$effect(() => {
const path = app.target?.path;
api
.listFontFamilies(path)
.then((families) => (fonts = families))
.catch(() => (fonts = []));
});
const stats = $derived(app.compiled?.stats ?? null);
</script>
{#snippet action(
icon: string,
label: string,
run: () => void,
size = "text-base",
)}
<button
class="rounded p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)] disabled:opacity-40 disabled:hover:bg-transparent"
title={label}
aria-label={label}
{disabled}
onclick={run}
>
<Icon {icon} class={size} />
</button>
{/snippet}
{#snippet divider()}
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--color-line)]"></div>
{/snippet}
<div
class="scroll-thin flex shrink-0 items-center gap-0.5 overflow-x-auto border-b border-[var(--color-line)] bg-[var(--color-surface)] px-2 py-1"
>
{@render action("ph:arrow-counter-clockwise", "Undo", () => undoEdit(view))}
{@render action("ph:arrow-clockwise", "Redo", () => redoEdit(view))}
{@render divider()}
{@render action("ph:text-h", "Heading", () => prefixLines(view, "= ", "Heading"))}
{@render action("ph:text-b", "Bold", () => wrapSelection(view, "*", "*", "bold"))}
{@render action("ph:text-italic", "Italic", () =>
wrapSelection(view, "_", "_", "italic"),
)}
{@render action("ph:code", "Raw", () => wrapSelection(view, "`", "`", "code"))}
{@render divider()}
{@render action("ph:sigma", "Inline math", () =>
wrapSelection(view, "$", "$", "x = y"),
)}
{@render action("ph:function", "Block math", () =>
wrapSelection(view, "$ \n ", "\n$", "x = y"),
)}
{@render divider()}
{@render action("ph:list-bullets", "Bullet list", () =>
prefixLines(view, "- ", "List item"),
)}
{@render action("ph:list-numbers", "Numbered list", () =>
prefixLines(view, "+ ", "Numbered item"),
)}
{@render divider()}
{@render action("ph:link", "Link", () =>
insertText(view, '#link("https://")[text]'),
)}
{@render action("ph:table", "Table", () =>
insertText(view, "#table(\n columns: 2,\n [a], [b],\n)"),
)}
{@render action("ph:image-square", "Figure", () =>
insertText(view, '#figure(\n image("file.png"),\n caption: [Caption],\n)'),
)}
{@render action("ph:images", "Images and fonts", onassets)}
{@render divider()}
<select
class="max-w-36 rounded border border-[var(--color-line)] bg-[var(--color-surface)] px-1.5 py-1 text-xs text-[var(--color-ink)] focus:border-[var(--color-accent)] focus:outline-none disabled:opacity-40"
aria-label="Document font"
{disabled}
bind:value={selectedFont}
onchange={(event) => {
const family = event.currentTarget.value;
if (family) setTypstConfig(view, "text", "font", `"${family}"`);
}}
>
<option value="">Font</option>
{#each fonts as family}
<option value={family}>{family}</option>
{/each}
</select>
<button
class="flex items-center gap-1.5 rounded px-2 py-1 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)] disabled:opacity-40"
{disabled}
onclick={onpagesettings}
>
<Icon icon="ph:file-text" />
Page
</button>
<div class="flex-1"></div>
{#if stats}
<span
class="shrink-0 whitespace-nowrap pr-1 text-[10px] text-[var(--color-ink-muted)]"
>
{stats.pages} pages · {stats.words} words · {stats.characters} chars
</span>
{/if}
</div>
+182
View File
@@ -0,0 +1,182 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import type { FileEntry } from "$lib/ts/api";
interface Props {
files: FileEntry[];
activePath: string | null;
entrypoint: string;
onopen: (path: string) => void;
onrename: (path: string) => void;
ondelete: (path: string) => void;
onsetentry: (path: string) => void;
}
let {
files,
activePath,
entrypoint,
onopen,
onrename,
ondelete,
onsetentry,
}: Props = $props();
interface TreeNode {
name: string;
path: string;
file: FileEntry | null;
children: TreeNode[];
}
const tree = $derived(buildTree(files));
function buildTree(entries: FileEntry[]): TreeNode[] {
const root: TreeNode = { name: "", path: "", file: null, children: [] };
for (const entry of entries) {
const segments = entry.path.split("/");
let node = root;
segments.forEach((segment, index) => {
const path = segments.slice(0, index + 1).join("/");
const isLeaf = index === segments.length - 1;
let child = node.children.find((c) => c.name === segment);
if (!child) {
child = {
name: segment,
path,
file: isLeaf ? entry : null,
children: [],
};
node.children.push(child);
}
node = child;
});
}
return sortNodes(root.children);
}
function sortNodes(nodes: TreeNode[]): TreeNode[] {
nodes.sort((a, b) => {
const aIsFolder = a.file === null;
const bIsFolder = b.file === null;
if (aIsFolder !== bIsFolder) return aIsFolder ? -1 : 1;
return a.name.localeCompare(b.name);
});
for (const node of nodes) sortNodes(node.children);
return nodes;
}
function iconFor(node: TreeNode): string {
if (!node.file) return "ph:folder";
if (node.name.endsWith(".typ")) return "ph:file-text";
if (node.name.endsWith(".toml")) return "ph:gear-six";
if (node.file.is_text) return "ph:file";
return "ph:image";
}
let collapsed = $state<Record<string, boolean>>({});
let menuPath = $state<string | null>(null);
</script>
{#snippet branch(nodes: TreeNode[], depth: number)}
{#each nodes as node (node.path)}
<div>
<div
class="group flex items-center gap-1.5 rounded px-2 py-1 text-xs transition
{node.path === activePath
? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
: 'text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)]'}"
style="padding-left: {depth * 12 + 8}px"
>
<button
class="flex min-w-0 flex-1 items-center gap-1.5 text-left"
onclick={() => {
if (node.file) {
onopen(node.path);
} else {
collapsed[node.path] = !collapsed[node.path];
}
}}
>
{#if !node.file}
<Icon
icon={collapsed[node.path] ? "ph:caret-right" : "ph:caret-down"}
class="shrink-0 text-[10px] text-[var(--color-ink-muted)]"
/>
{/if}
<Icon icon={iconFor(node)} class="shrink-0 text-sm" />
<span class="truncate">{node.name}</span>
{#if node.path === entrypoint}
<span
class="shrink-0 rounded bg-[var(--color-accent)] px-1 py-px text-[9px] font-medium text-white"
>
main
</span>
{/if}
</button>
{#if node.file}
<button
class="shrink-0 rounded p-0.5 text-[var(--color-ink-muted)] opacity-0 transition group-hover:opacity-100 hover:bg-[var(--color-surface)]"
onclick={() => (menuPath = menuPath === node.path ? null : node.path)}
aria-label="File actions"
>
<Icon icon="ph:dots-three-vertical" />
</button>
{/if}
</div>
{#if node.file && menuPath === node.path}
<div
class="ml-6 mb-1 flex flex-col rounded border border-[var(--color-line)] bg-[var(--color-surface)] py-1 text-xs shadow-sm"
>
{#if node.name.endsWith(".typ")}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
onsetentry(node.path);
menuPath = null;
}}
>
Set as entrypoint
</button>
{/if}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
onrename(node.path);
menuPath = null;
}}
>
Rename
</button>
<button
class="px-3 py-1.5 text-left text-[var(--color-danger)] hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
ondelete(node.path);
menuPath = null;
}}
>
Delete
</button>
</div>
{/if}
{#if node.children.length > 0 && !collapsed[node.path]}
{@render branch(node.children, depth + 1)}
{/if}
</div>
{/each}
{/snippet}
<div class="scroll-thin flex-1 overflow-y-auto py-1">
{#if files.length === 0}
<p class="px-3 py-4 text-xs text-[var(--color-ink-muted)]">No files yet</p>
{:else}
{@render branch(tree, 0)}
{/if}
</div>
+513
View File
@@ -0,0 +1,513 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import * as api from "$lib/ts/api";
import type { BrowseEntry, EntryKind } from "$lib/ts/api";
import {
app,
breadcrumbs,
browseTo,
openTarget,
} from "$lib/ts/state.svelte";
interface Props {
onnewfolder: () => void;
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;
onclonespace: (spaceId: string, name: string) => void;
ondeletespace: (spaceId: string) => void;
onnewspace: () => void;
onsignin: () => void;
}
let {
onnewfolder,
onnewproject,
onnewdocument,
onupload,
onassets,
onrename,
ondelete,
onlink,
onviewimage,
onclonespace,
ondeletespace,
onnewspace,
onsignin,
}: Props = $props();
let menuFor = $state<string | null>(null);
const trail = $derived(breadcrumbs());
const containers = $derived(
app.entries.filter(
(entry) => entry.kind === "folder" || entry.kind === "project",
),
);
const documents = $derived(
app.entries.filter(
(entry) => entry.kind === "document" || entry.kind === "file",
),
);
let thumbs = $state<Record<string, { kind: string; data: string }>>({});
$effect(() => {
const pending = documents.map((entry) => entry.path);
let cancelled = false;
(async () => {
for (const path of pending) {
if (cancelled) return;
if (thumbs[path]) continue;
try {
const result = await api.thumbnail(path);
if (!cancelled) thumbs[path] = result;
} catch {
continue;
}
}
})();
return () => {
cancelled = true;
};
});
const localSpaceIds = $derived(
new Set(
app.entries
.filter((entry) => entry.space_id)
.map((entry) => entry.space_id as string),
),
);
const iconFor: Record<EntryKind, string> = {
project: "ph:folder-star",
folder: "ph:folder",
document: "ph:file-text",
file: "ph:file",
};
const colorFor: Record<EntryKind, string> = {
project: "text-[var(--color-accent)]",
folder: "text-[var(--color-ink-muted)]",
document: "text-[var(--color-accent)]",
file: "text-[var(--color-ink-muted)]",
};
const imagePaths = $derived(
app.entries
.filter((entry) => api.isImagePath(entry.path))
.map((entry) => entry.path),
);
function activate(entry: BrowseEntry) {
if (entry.kind === "folder") {
browseTo(entry.path);
} else if (entry.kind === "project" || entry.kind === "document") {
openTarget(entry.path);
} else if (api.isImagePath(entry.path)) {
onviewimage(imagePaths, imagePaths.indexOf(entry.path));
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function formatDate(value: string | null): string {
if (!value) return "";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "" : date.toLocaleDateString();
}
</script>
{#snippet actions(entry: BrowseEntry, offset: string)}
<button
class="absolute right-2 {offset} rounded p-1 text-[var(--color-ink-muted)] opacity-0 transition group-hover:opacity-100 hover:bg-[var(--color-surface-sunken)]"
onclick={() => (menuFor = menuFor === entry.path ? null : entry.path)}
aria-label="Actions"
>
<Icon icon="ph:dots-three-vertical" />
</button>
{#if menuFor === entry.path}
<div
class="absolute right-2 top-9 z-10 flex w-40 flex-col rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] py-1 text-xs shadow-lg"
>
{#if entry.kind === "project" || entry.kind === "document"}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
openTarget(entry.path);
menuFor = null;
}}
>
Open in editor
</button>
{:else if api.isImagePath(entry.path)}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
onviewimage(imagePaths, imagePaths.indexOf(entry.path));
menuFor = null;
}}
>
Open image
</button>
{/if}
{#if entry.kind === "project" && !entry.space_id && app.account}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
onlink(entry);
menuFor = null;
}}
>
Upload to cloud
</button>
{/if}
<button
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
onrename(entry);
menuFor = null;
}}
>
Rename
</button>
<button
class="px-3 py-1.5 text-left text-[var(--color-danger)] hover:bg-[var(--color-surface-sunken)]"
onclick={() => {
ondelete(entry);
menuFor = null;
}}
>
Delete
</button>
</div>
{/if}
{/snippet}
<div class="flex h-full flex-col bg-[var(--color-surface-muted)]">
<div
class="flex items-center gap-2 border-b border-[var(--color-line)] bg-[var(--color-surface)] px-4 py-2.5"
>
<div class="flex rounded-lg bg-[var(--color-surface-sunken)] p-0.5 text-xs font-medium">
{#each [["local", "Local", "ph:hard-drives"], ["cloud", "Cloud", "ph:cloud"]] as [value, label, icon]}
<button
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 transition
{app.scope === value
? 'bg-[var(--color-surface)] text-[var(--color-ink)] shadow-sm'
: 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]'}"
onclick={() => (app.scope = value as "local" | "cloud")}
>
<Icon {icon} />
{label}
</button>
{/each}
</div>
<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}
>
<Icon icon="ph:upload-simple" />
Import
</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={onnewfolder}
>
<Icon icon="ph:folder-plus" />
Folder
</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={onnewdocument}
>
<Icon icon="ph:file-plus" />
Document
</button>
<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"
onclick={onnewproject}
>
<Icon icon="ph:plus" />
New project
</button>
{:else if app.account}
<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"
onclick={onnewspace}
>
<Icon icon="ph:plus" />
New space
</button>
{/if}
</div>
{#if app.scope === "local"}
<div
class="flex items-center gap-1 border-b border-[var(--color-line)] bg-[var(--color-surface)] px-4 py-2 text-xs"
>
<button
class="flex items-center gap-1.5 rounded px-2 py-1 transition hover:bg-[var(--color-surface-muted)]
{app.currentDir === '' ? 'font-medium' : 'text-[var(--color-ink-muted)]'}"
onclick={() => browseTo("")}
>
<Icon icon="ph:house" />
Workspace
</button>
{#each trail as crumb, index}
<Icon icon="ph:caret-right" class="text-[10px] text-[var(--color-ink-muted)]" />
<button
class="rounded px-2 py-1 transition hover:bg-[var(--color-surface-muted)]
{index === trail.length - 1 ? 'font-medium' : 'text-[var(--color-ink-muted)]'}"
onclick={() => browseTo(crumb.path)}
>
{crumb.name}
</button>
{/each}
</div>
<div class="scroll-thin flex-1 overflow-y-auto p-4">
{#if app.entries.length === 0}
<div
class="flex h-full flex-col items-center justify-center gap-3 text-[var(--color-ink-muted)]"
>
<Icon icon="ph:folder-open" class="text-5xl" />
<p class="text-sm">This folder is empty.</p>
<div class="flex gap-2">
<button
class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white hover:opacity-90"
onclick={onnewproject}
>
New project
</button>
<button
class="rounded-md border border-[var(--color-line)] px-3 py-1.5 text-xs hover:bg-[var(--color-surface)]"
onclick={onnewdocument}
>
New document
</button>
</div>
</div>
{:else}
<div class="flex flex-col gap-5">
{#if containers.length > 0}
<section class="flex flex-col gap-2">
<h2
class="text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
>
Folders
</h2>
<div class="grid grid-cols-[repeat(auto-fill,minmax(210px,1fr))] gap-2">
{#each containers as entry (entry.path)}
<div
class="group relative flex items-center gap-2.5 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface-sunken)] px-3 py-2.5 transition hover:border-[var(--color-accent)] hover:bg-[var(--color-surface)]"
>
<button
class="flex min-w-0 flex-1 items-center gap-2.5 text-left"
ondblclick={() => activate(entry)}
onclick={() => activate(entry)}
>
<Icon
icon={entry.kind === "project"
? "ph:folder-star-fill"
: "ph:folder-fill"}
class="shrink-0 text-2xl {entry.kind === 'project'
? 'text-[var(--color-accent)]'
: 'text-[var(--color-ink-muted)]'}"
/>
<span class="min-w-0 flex-1">
<span class="flex items-center gap-1.5">
<span
class="truncate text-xs font-medium"
title={entry.name}
>
{entry.name}
</span>
{#if entry.space_id}
<Icon
icon="ph:cloud-check"
class="shrink-0 text-xs text-[var(--color-success)]"
/>
{/if}
</span>
<span
class="block truncate text-[10px] text-[var(--color-ink-muted)]"
>
{entry.kind === "project" ? "Project · " : ""}{entry.child_count}
items
</span>
</span>
</button>
{@render actions(entry, "top-2")}
</div>
{/each}
</div>
</section>
{/if}
{#if documents.length > 0}
<section class="flex flex-col gap-2">
<h2
class="text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
>
Documents
</h2>
<div class="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{#each documents as entry (entry.path)}
<div
class="group relative flex flex-col overflow-hidden rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] transition hover:border-[var(--color-accent)] hover:shadow-md"
>
<button
class="flex flex-col text-left"
ondblclick={() => activate(entry)}
onclick={() => activate(entry)}
>
<span
class="flex h-24 items-center justify-center overflow-hidden border-b border-[var(--color-line)] bg-[var(--color-surface-muted)]"
>
{#if thumbs[entry.path]?.kind === "svg"}
<span
class="flex w-full origin-top scale-100 items-start justify-center bg-white p-1 [&_svg]:h-auto [&_svg]:w-full"
>
{@html thumbs[entry.path].data}
</span>
{:else if thumbs[entry.path]?.kind === "image"}
<img
src={thumbs[entry.path].data}
alt={entry.name}
class="h-full w-full object-contain"
/>
{:else}
<Icon
icon={iconFor[entry.kind]}
class="text-3xl {colorFor[entry.kind]}"
/>
{/if}
</span>
<span class="flex flex-col gap-0.5 px-2.5 py-2">
<span
class="truncate text-xs font-medium"
title={entry.name}
>
{entry.name}
</span>
<span class="text-[10px] text-[var(--color-ink-muted)]">
{formatSize(entry.size)}
{#if entry.modified}
· {formatDate(entry.modified)}
{/if}
</span>
</span>
</button>
{@render actions(entry, "top-2")}
</div>
{/each}
</div>
</section>
{/if}
</div>
{/if}
</div>
{:else}
<div class="scroll-thin flex-1 overflow-y-auto p-4">
{#if !app.account}
<div
class="flex h-full flex-col items-center justify-center gap-3 text-[var(--color-ink-muted)]"
>
<Icon icon="ph:cloud-slash" class="text-5xl" />
<p class="max-w-xs text-center text-sm">
Connect a TypstDrive account to sync your projects across devices.
</p>
<button
class="rounded-md bg-[var(--color-accent)] px-3 py-2 text-xs font-medium text-white hover:opacity-90"
onclick={onsignin}
>
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="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-3">
{#each app.spaces as space (space.id)}
<div
class="group flex flex-col gap-2 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-3 transition hover:border-[var(--color-accent)]"
>
<div class="flex items-center gap-2">
<Icon icon="ph:cloud" class="text-xl text-[var(--color-accent)]" />
{#if localSpaceIds.has(space.id)}
<span title="Downloaded to this device" class="flex">
<Icon
icon="ph:hard-drives"
class="text-sm text-[var(--color-success)]"
/>
</span>
{/if}
</div>
<span class="truncate text-xs font-medium">{space.name}</span>
<span class="text-[10px] text-[var(--color-ink-muted)]">
{space.role} · {formatDate(space.updated_at)}
</span>
<div class="mt-1 flex gap-1">
{#if !localSpaceIds.has(space.id)}
<button
class="flex flex-1 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={() => onclonespace(space.id, space.name)}
>
<Icon icon="ph:download-simple" />
Download
</button>
{/if}
{#if space.role === "owner"}
<button
class="rounded border border-[var(--color-line)] px-2 py-1 text-[10px] text-[var(--color-danger)] hover:bg-[var(--color-surface-muted)]"
onclick={() => ondeletespace(space.id)}
aria-label="Delete space"
>
<Icon icon="ph:trash" />
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
+172
View File
@@ -0,0 +1,172 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { untrack } from "svelte";
import * as api from "$lib/ts/api";
import type { ImageData } from "$lib/ts/api";
interface Props {
paths: string[];
index: number;
onclose: () => void;
}
let { paths, index, onclose }: Props = $props();
let current = $state(untrack(() => index));
let image = $state<ImageData | null>(null);
let error = $state("");
let loading = $state(true);
let zoom = $state(1);
let fit = $state(true);
$effect(() => {
const path = paths[current];
if (!path) return;
loading = true;
error = "";
api
.readImage(path)
.then((result) => {
image = result;
zoom = 1;
fit = true;
})
.catch((caught) => {
image = null;
error = api.errorMessage(caught);
})
.finally(() => (loading = false));
});
function step(delta: number) {
const next = current + delta;
if (next < 0 || next >= paths.length) return;
current = next;
}
function handleKey(event: KeyboardEvent) {
if (event.key === "Escape") onclose();
if (event.key === "ArrowRight") step(1);
if (event.key === "ArrowLeft") step(-1);
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
</script>
<svelte:window on:keydown={handleKey} />
<div
class="fixed inset-0 z-50 flex flex-col bg-black/80"
role="presentation"
onclick={(event) => {
if (event.target === event.currentTarget) onclose();
}}
>
<header
class="flex h-11 shrink-0 items-center gap-3 bg-[var(--color-surface)] px-3 text-xs"
>
<Icon icon="ph:image" class="text-base text-[var(--color-accent)]" />
<span class="font-medium">{image?.name ?? paths[current]?.split("/").pop()}</span>
{#if image}
<span class="text-[var(--color-ink-muted)]">
{#if image.width && image.height}
{image.width} × {image.height} ·
{/if}
{formatSize(image.size)}
</span>
{/if}
<div class="flex-1"></div>
{#if paths.length > 1}
<span class="text-[var(--color-ink-muted)]">
{current + 1} of {paths.length}
</span>
{/if}
<button
class="rounded p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
onclick={() => {
fit = false;
zoom = Math.max(0.1, zoom - 0.25);
}}
aria-label="Zoom out"
>
<Icon icon="ph:minus" />
</button>
<button
class="rounded px-2 py-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
onclick={() => {
fit = !fit;
zoom = 1;
}}
>
{fit ? "Fit" : `${Math.round(zoom * 100)}%`}
</button>
<button
class="rounded p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
onclick={() => {
fit = false;
zoom = Math.min(8, zoom + 0.25);
}}
aria-label="Zoom in"
>
<Icon icon="ph:plus" />
</button>
<button
class="rounded p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-danger)] hover:text-white"
onclick={onclose}
aria-label="Close"
>
<Icon icon="ph:x" />
</button>
</header>
<div class="relative flex min-h-0 flex-1 items-center justify-center">
{#if paths.length > 1}
<button
class="absolute left-3 z-10 rounded-full bg-black/50 p-2 text-white transition hover:bg-black/70 disabled:opacity-30"
disabled={current === 0}
onclick={() => step(-1)}
aria-label="Previous image"
>
<Icon icon="ph:caret-left" class="text-lg" />
</button>
<button
class="absolute right-3 z-10 rounded-full bg-black/50 p-2 text-white transition hover:bg-black/70 disabled:opacity-30"
disabled={current === paths.length - 1}
onclick={() => step(1)}
aria-label="Next image"
>
<Icon icon="ph:caret-right" class="text-lg" />
</button>
{/if}
{#if loading}
<Icon icon="ph:circle-notch" class="animate-spin text-3xl text-white/70" />
{:else if error}
<div class="flex flex-col items-center gap-2 text-white/70">
<Icon icon="ph:warning-circle" class="text-3xl" />
<p class="text-sm">{error}</p>
</div>
{:else if image}
<div class="scroll-thin h-full w-full overflow-auto p-6">
<img
src={image.data}
alt={image.name}
class={fit
? "mx-auto max-h-full max-w-full object-contain"
: "mx-auto max-w-none"}
style={fit ? "" : `width: ${zoom * 100}%`}
/>
</div>
{/if}
</div>
</div>
+89
View File
@@ -0,0 +1,89 @@
<script lang="ts">
import Modal from "./Modal.svelte";
import * as api from "$lib/ts/api";
interface Props {
serverUrl: string;
onsuccess: () => void;
onclose: () => void;
}
let { serverUrl, onsuccess, onclose }: Props = $props();
let url = $state(serverUrl);
let email = $state("");
let password = $state("");
let busy = $state(false);
let error = $state("");
async function submit(event: SubmitEvent) {
event.preventDefault();
if (!email.trim() || !password) {
error = "Enter your email and password";
return;
}
busy = true;
error = "";
try {
await api.cloudLogin(url.trim(), email.trim(), password);
onsuccess();
} catch (caught) {
error = api.errorMessage(caught);
} finally {
busy = false;
}
}
</script>
<Modal title="Connect to TypstDrive" icon="ph:cloud" {onclose}>
<form id="login-form" class="flex flex-col gap-3" onsubmit={submit}>
<label class="flex flex-col gap-1 text-xs">
<span class="font-medium text-[var(--color-ink-muted)]">Server</span>
<input
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={url}
placeholder="https://drive.example.com"
/>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="font-medium text-[var(--color-ink-muted)]">Email</span>
<input
type="email"
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={email}
/>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="font-medium text-[var(--color-ink-muted)]">Password</span>
<input
type="password"
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={password}
/>
</label>
{#if error}
<p class="text-xs text-[var(--color-danger)]">{error}</p>
{/if}
</form>
{#snippet footer()}
<button
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
onclick={onclose}
>
Cancel
</button>
<button
type="submit"
form="login-form"
class="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}
>
{busy ? "Connecting..." : "Connect"}
</button>
{/snippet}
</Modal>
+69
View File
@@ -0,0 +1,69 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import type { Snippet } from "svelte";
interface Props {
title: string;
icon?: string;
width?: string;
onclose: () => void;
children: Snippet;
footer?: Snippet;
}
let {
title,
icon = "ph:squares-four",
width = "max-w-md",
onclose,
children,
footer,
}: Props = $props();
function handleKey(event: KeyboardEvent) {
if (event.key === "Escape") onclose();
}
</script>
<svelte:window on:keydown={handleKey} />
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-6"
role="presentation"
onclick={(event) => {
if (event.target === event.currentTarget) onclose();
}}
>
<div
class="w-full {width} overflow-hidden rounded-xl border border-[var(--color-line)] bg-[var(--color-surface)] shadow-2xl"
role="dialog"
aria-modal="true"
aria-label={title}
>
<header
class="flex items-center gap-2 border-b border-[var(--color-line)] px-5 py-3.5"
>
<Icon {icon} class="text-lg text-[var(--color-accent)]" />
<h2 class="flex-1 text-sm font-semibold">{title}</h2>
<button
class="rounded p-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
onclick={onclose}
aria-label="Close"
>
<Icon icon="ph:x" class="text-base" />
</button>
</header>
<div class="scroll-thin max-h-[70vh] overflow-y-auto px-5 py-4">
{@render children()}
</div>
{#if footer}
<footer
class="flex items-center justify-end gap-2 border-t border-[var(--color-line)] bg-[var(--color-surface-muted)] px-5 py-3"
>
{@render footer()}
</footer>
{/if}
</div>
</div>
+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
import type { EditorView } from "@codemirror/view";
import Modal from "./Modal.svelte";
import { setTypstConfig } from "$lib/ts/editor-actions";
interface Props {
view: EditorView | null;
onclose: () => void;
}
let { view, onclose }: Props = $props();
const papers = [
"a4",
"a3",
"a5",
"us-letter",
"us-legal",
"presentation-16-9",
];
let paper = $state("a4");
let margin = $state("2.5cm");
let columns = $state(1);
let flipped = $state(false);
let numbering = $state("none");
function apply() {
setTypstConfig(view, "page", "paper", `"${paper}"`);
if (margin.trim()) {
setTypstConfig(view, "page", "margin", margin.trim());
}
if (columns > 1) {
setTypstConfig(view, "page", "columns", String(columns));
}
if (flipped) {
setTypstConfig(view, "page", "flipped", "true");
}
if (numbering !== "none") {
setTypstConfig(view, "page", "numbering", `"${numbering}"`);
}
onclose();
}
</script>
<Modal title="Page settings" icon="ph:file-text" {onclose}>
<div class="flex flex-col gap-3">
<label class="flex flex-col gap-1 text-xs">
<span class="font-medium text-[var(--color-ink-muted)]">Paper</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={paper}
>
{#each papers as option}
<option value={option}>{option}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="font-medium text-[var(--color-ink-muted)]">Margin</span>
<input
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={margin}
placeholder="2.5cm"
/>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="font-medium text-[var(--color-ink-muted)]">Columns</span>
<input
type="number"
min="1"
max="6"
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={columns}
/>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="font-medium text-[var(--color-ink-muted)]">Page numbers</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={numbering}
>
<option value="none">None</option>
<option value="1">1, 2, 3</option>
<option value="1 / 1">1 / 10</option>
<option value="i">i, ii, iii</option>
</select>
</label>
<label class="flex items-center gap-2 text-xs">
<input type="checkbox" bind:checked={flipped} />
<span>Landscape</span>
</label>
</div>
{#snippet footer()}
<button
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
onclick={onclose}
>
Cancel
</button>
<button
class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
onclick={apply}
>
Apply
</button>
{/snippet}
</Modal>
+99
View File
@@ -0,0 +1,99 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import type { CompileResult, Diagnostic } from "$lib/ts/api";
interface Props {
compiled: CompileResult | null;
diagnostics: Diagnostic[];
compiling: boolean;
}
let { compiled, diagnostics, compiling }: Props = $props();
let zoom = $state(1);
const errors = $derived(diagnostics.filter((d) => d.severity === "error"));
</script>
<div class="flex h-full flex-col bg-[var(--color-surface-sunken)]">
<div
class="flex items-center gap-2 border-b border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-xs"
>
<Icon icon="ph:file-text" class="text-[var(--color-ink-muted)]" />
<span class="text-[var(--color-ink-muted)]">
{#if compiled}
{compiled.stats.pages} pages, {compiled.stats.words} words
{:else}
Preview
{/if}
</span>
<div class="flex-1"></div>
{#if compiling}
<Icon icon="ph:circle-notch" class="animate-spin text-[var(--color-accent)]" />
{/if}
<button
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
onclick={() => (zoom = Math.max(0.4, zoom - 0.15))}
aria-label="Zoom out"
>
<Icon icon="ph:minus" />
</button>
<span class="w-10 text-center tabular-nums text-[var(--color-ink-muted)]">
{Math.round(zoom * 100)}%
</span>
<button
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
onclick={() => (zoom = Math.min(2.5, zoom + 0.15))}
aria-label="Zoom in"
>
<Icon icon="ph:plus" />
</button>
</div>
{#if errors.length > 0}
<div
class="scroll-thin max-h-40 shrink-0 overflow-y-auto border-b border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2"
>
{#each errors as diagnostic}
<div class="flex items-start gap-2 py-1 text-xs text-[var(--color-danger)]">
<Icon icon="ph:warning-circle" class="mt-0.5 shrink-0" />
<span>
{#if diagnostic.line}
<span class="font-medium">Line {diagnostic.line}:</span>
{/if}
{diagnostic.message}
</span>
</div>
{/each}
</div>
{/if}
<div class="scroll-thin flex-1 overflow-auto p-6">
{#if compiled && compiled.pages.length > 0}
<div
class="mx-auto flex flex-col items-center gap-6"
style="width: {Math.round(zoom * 100)}%; max-width: {zoom > 1 ? 'none' : '820px'};"
>
{#each compiled.pages as page}
<div
class="preview-page w-full overflow-hidden rounded bg-white shadow-lg ring-1 ring-black/5"
>
{@html page}
</div>
{/each}
</div>
{:else}
<div
class="flex h-full flex-col items-center justify-center gap-2 text-[var(--color-ink-muted)]"
>
<Icon icon="ph:file-dashed" class="text-4xl" />
<p class="text-sm">
{errors.length > 0 ? "Fix the errors above to see a preview" : "Nothing to preview yet"}
</p>
</div>
{/if}
</div>
</div>
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts">
import { untrack } from "svelte";
import Modal from "./Modal.svelte";
interface Props {
title: string;
label: string;
icon?: string;
value?: string;
placeholder?: string;
confirmLabel?: string;
danger?: boolean;
suffix?: string;
onsubmit: (value: string) => void;
onclose: () => void;
}
let {
title,
label,
icon = "ph:pencil-simple",
value = "",
placeholder = "",
confirmLabel = "Create",
danger = false,
suffix = "",
onsubmit,
onclose,
}: Props = $props();
let text = $state(
untrack(() =>
suffix && value.endsWith(suffix) ? value.slice(0, -suffix.length) : value,
),
);
function submit(event: SubmitEvent) {
event.preventDefault();
if (!text.trim()) return;
onsubmit(text.trim());
}
</script>
<Modal {title} {icon} {onclose}>
<form id="prompt-form" onsubmit={submit}>
<label class="flex flex-col gap-1 text-xs">
<span class="font-medium text-[var(--color-ink-muted)]">{label}</span>
<div
class="flex items-center rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] focus-within:border-[var(--color-accent)]"
>
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
class="min-w-0 flex-1 bg-transparent px-3 py-2 text-sm focus:outline-none"
bind:value={text}
{placeholder}
/>
{#if suffix}
<span class="pr-3 text-sm text-[var(--color-ink-muted)]">{suffix}</span>
{/if}
</div>
</label>
</form>
{#snippet footer()}
<button
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
onclick={onclose}
>
Cancel
</button>
<button
type="submit"
form="prompt-form"
class="rounded-md px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90
{danger ? 'bg-[var(--color-danger)]' : 'bg-[var(--color-accent)]'}"
>
{confirmLabel}
</button>
{/snippet}
</Modal>
+153
View File
@@ -0,0 +1,153 @@
<script lang="ts">
import Icon from "@iconify/svelte";
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";
interface Props {
onclose: () => void;
onsignin: () => void;
}
let { onclose, onsignin }: Props = $props();
let workspaceRoot = $state(app.settings?.workspace_root ?? "");
let serverUrl = $state(app.settings?.server_url ?? "");
let saving = $state(false);
async function browse() {
const selected = await open({ directory: true, multiple: false });
if (typeof selected === "string") {
workspaceRoot = selected;
}
}
async function save() {
saving = true;
try {
app.settings = await api.updateSettings({
workspaceRoot,
serverUrl,
});
await refreshEntries();
onclose();
} catch (error) {
setError(error);
} finally {
saving = false;
}
}
async function signOut() {
try {
await api.cloudLogout();
app.account = null;
app.spaces = [];
app.settings = await api.getSettings();
} catch (error) {
setError(error);
}
}
</script>
<Modal title="Settings" icon="ph:gear-six" {onclose}>
<div class="flex flex-col gap-5">
<div class="flex flex-col gap-1 text-xs">
<span class="font-medium text-[var(--color-ink-muted)]">Workspace folder</span>
<div class="flex gap-2">
<input
class="flex-1 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={workspaceRoot}
/>
<button
class="flex items-center gap-1 rounded-md border border-[var(--color-line)] px-3 text-xs hover:bg-[var(--color-surface-muted)]"
onclick={browse}
>
<Icon icon="ph:folder-open" />
Browse
</button>
</div>
<span class="text-[var(--color-ink-muted)]">
Projects are stored as plain folders here.
</span>
</div>
<div class="flex flex-col gap-1 text-xs">
<span class="font-medium text-[var(--color-ink-muted)]">TypstDrive server</span>
<input
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={serverUrl}
/>
</div>
<div
class="flex items-center gap-3 rounded-md border border-[var(--color-line)] bg-[var(--color-surface-muted)] p-3"
>
<Icon
icon={app.account ? "ph:user-circle-check" : "ph:user-circle"}
class="text-2xl {app.account ? 'text-[var(--color-success)]' : 'text-[var(--color-ink-muted)]'}"
/>
<div class="flex-1 text-xs">
{#if app.account}
<p class="font-medium">{app.account.username}</p>
<p class="text-[var(--color-ink-muted)]">{app.account.email}</p>
{:else}
<p class="font-medium">Not connected</p>
<p class="text-[var(--color-ink-muted)]">
Sign in to sync projects to the cloud.
</p>
{/if}
</div>
{#if app.account}
<button
class="rounded-md border border-[var(--color-line)] px-3 py-1.5 text-xs hover:bg-[var(--color-surface)]"
onclick={signOut}
>
Sign out
</button>
{:else}
<button
class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white hover:opacity-90"
onclick={onsignin}
>
Sign in
</button>
{/if}
</div>
<div class="flex items-center justify-between text-xs">
<span class="font-medium text-[var(--color-ink-muted)]">Appearance</span>
<div class="flex gap-1">
{#each [["light", "ph:sun"], ["dark", "ph:moon"]] as [value, icon]}
<button
class="flex items-center gap-1.5 rounded-md border px-3 py-1.5 transition
{app.theme === value
? 'border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
: 'border-[var(--color-line)] text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-muted)]'}"
onclick={() => applyTheme(value as "light" | "dark")}
>
<Icon {icon} />
{value === "light" ? "Light" : "Dark"}
</button>
{/each}
</div>
</div>
</div>
{#snippet footer()}
<button
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
onclick={onclose}
>
Cancel
</button>
<button
class="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={saving}
onclick={save}
>
Save
</button>
{/snippet}
</Modal>
+54
View File
@@ -0,0 +1,54 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import { getCurrentWindow } from "@tauri-apps/api/window";
const appWindow = getCurrentWindow();
let maximized = $state(false);
async function sync() {
try {
maximized = await appWindow.isMaximized();
} catch {
maximized = false;
}
}
onMount(() => {
sync();
const pending = appWindow.onResized(sync);
return () => {
pending.then((unlisten) => unlisten());
};
});
</script>
<div class="flex items-center">
<button
class="flex h-11 w-11 items-center justify-center text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
onclick={() => appWindow.minimize()}
aria-label="Minimize"
>
<Icon icon="ph:minus" class="text-sm" />
</button>
<button
class="flex h-11 w-11 items-center justify-center text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
onclick={async () => {
await appWindow.toggleMaximize();
sync();
}}
aria-label={maximized ? "Restore" : "Maximize"}
>
<Icon icon={maximized ? "ph:corners-in" : "ph:square"} class="text-sm" />
</button>
<button
class="flex h-11 w-11 items-center justify-center text-[var(--color-ink-muted)] transition hover:bg-[var(--color-danger)] hover:text-white"
onclick={() => appWindow.close()}
aria-label="Close"
>
<Icon icon="ph:x" class="text-sm" />
</button>
</div>
+268
View File
@@ -0,0 +1,268 @@
import { invoke } from "@tauri-apps/api/core";
export interface Settings {
workspace_root: string;
server_url: string;
device_token: string | null;
account_email: string | null;
account_username: string | null;
}
export interface FileEntry {
path: string;
name: string;
is_text: boolean;
size: number;
}
export interface FilePayload {
path: string;
is_text: boolean;
content: string;
}
export interface Diagnostic {
message: string;
severity: string;
line: number | null;
column: number | null;
}
export interface DocumentStats {
pages: number;
words: number;
characters: number;
}
export interface CompileResult {
pages: string[];
stats: DocumentStats;
diagnostics: Diagnostic[];
}
export interface CompileFailure {
diagnostics: Diagnostic[];
}
export interface Account {
user_id: string;
username: string;
email: string;
}
export interface SpaceSummary {
id: string;
name: string;
entrypoint: string;
role: string;
updated_at: string;
}
export interface Conflict {
path: string;
local_text: string;
remote_text: string;
merged_text: string;
server_hash: string;
auto_merged: boolean;
binary: boolean;
}
export interface SyncReport {
pushed: string[];
pulled: string[];
deleted_local: string[];
deleted_remote: string[];
merged: string[];
conflicts: Conflict[];
}
export interface Resolution {
path: string;
content: string;
server_hash: string;
}
export type EntryKind = "folder" | "project" | "document" | "file";
export interface BrowseEntry {
name: string;
path: string;
kind: EntryKind;
size: number;
modified: string | null;
space_id: string | null;
last_synced_at: string | null;
child_count: number;
}
export interface TargetInfo {
path: string;
entrypoint: string;
standalone: boolean;
is_project: boolean;
space_id: string | null;
files: FileEntry[];
}
export const browseWorkspace = (path: string) =>
invoke<BrowseEntry[]>("browse_workspace", { path });
export const createFolderEntry = (parent: string, name: string) =>
invoke<string>("create_folder_entry", { parent, name });
export const createDocumentEntry = (parent: string, name: string) =>
invoke<string>("create_document_entry", { parent, name });
export const createProjectEntry = (parent: string, name: string) =>
invoke<string>("create_project_entry", { parent, name });
export const renameEntry = (path: string, newName: string) =>
invoke<string>("rename_entry", { path, newName });
export const deleteEntry = (path: string) =>
invoke<void>("delete_entry", { path });
export const uploadEntry = (
parent: string,
name: string,
base64Content: string,
) => invoke<string>("upload_entry", { parent, name, base64Content });
export const targetInfo = (path: string) =>
invoke<TargetInfo>("target_info", { path });
export const readTargetFile = (path: string, file: string) =>
invoke<FilePayload>("read_target_file", { path, file });
export const writeTargetFile = (path: string, file: string, content: string) =>
invoke<void>("write_target_file", { path, file, content });
export const setTargetEntrypoint = (path: string, entrypoint: string) =>
invoke<void>("set_target_entrypoint", { path, entrypoint });
export const compileTarget = (
path: string,
overrides?: Record<string, string>,
) => invoke<CompileResult>("compile_target", { path, overrides });
export const exportTarget = (
path: string,
format: string,
destination: string,
) => invoke<string>("export_target", { path, format, destination });
export interface Asset {
name: string;
kind: "font" | "image" | "file";
size: number;
font_families: string[];
}
export const listAssets = () => invoke<Asset[]>("list_assets");
export interface Thumbnail {
kind: "svg" | "image";
data: string;
}
export const thumbnail = (path: string) =>
invoke<Thumbnail>("thumbnail", { path });
export const clearThumbnails = () => invoke<void>("clear_thumbnails");
export interface ImageData {
name: string;
data: string;
size: number;
width: number | null;
height: number | null;
}
export const readImage = (path: string) =>
invoke<ImageData>("read_image", { path });
export const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "svg", "webp"];
export function isImagePath(path: string): boolean {
const extension = path.split(".").pop()?.toLowerCase() ?? "";
return IMAGE_EXTENSIONS.includes(extension);
}
export const listFontFamilies = (path?: string) =>
invoke<string[]>("list_font_families", { path: path ?? null });
export const importAssets = (sources: string[]) =>
invoke<string[]>("import_assets", { sources });
export const deleteAsset = (name: string) =>
invoke<void>("delete_asset", { name });
export const importIntoTarget = (path: string, sources: string[]) =>
invoke<string[]>("import_into_target", { path, sources });
export const importIntoFolder = (parent: string, sources: string[]) =>
invoke<string[]>("import_into_folder", { parent, sources });
export const getSettings = () => invoke<Settings>("get_settings");
export const updateSettings = (changes: {
workspaceRoot?: string;
serverUrl?: string;
}) => invoke<Settings>("update_settings", changes);
export const cloudLogin = (
serverUrl: string,
email: string,
password: string,
) => invoke<Account>("cloud_login", { serverUrl, email, password });
export const cloudLogout = () => invoke<void>("cloud_logout");
export const cloudAccount = () => invoke<Account | null>("cloud_account");
export const cloudListSpaces = () =>
invoke<SpaceSummary[]>("cloud_list_spaces");
export const cloudCreateSpace = (name: string) =>
invoke<SpaceSummary>("cloud_create_space", { name });
export const cloudDeleteSpace = (spaceId: string) =>
invoke<void>("cloud_delete_space", { spaceId });
export const cloudCloneSpace = (spaceId: string, projectName: string) =>
invoke<SyncReport>("cloud_clone_space", { spaceId, projectName });
export const cloudLinkProject = (project: string, spaceId?: string) =>
invoke<SyncReport>("cloud_link_project", { project, spaceId: spaceId ?? null });
export const cloudUnlinkProject = (project: string) =>
invoke<void>("cloud_unlink_project", { project });
export const cloudPush = (project: string) =>
invoke<SyncReport>("cloud_push", { project });
export const cloudPull = (project: string) =>
invoke<SyncReport>("cloud_pull", { project });
export const cloudSync = (project: string) =>
invoke<SyncReport>("cloud_sync", { project });
export const cloudResolveConflicts = (
project: string,
resolutions: Resolution[],
) => invoke<SyncReport>("cloud_resolve_conflicts", { project, resolutions });
export function errorMessage(error: unknown): string {
if (typeof error === "string") return error;
if (error && typeof error === "object" && "diagnostics" in error) {
const failure = error as CompileFailure;
return failure.diagnostics.map((d) => d.message).join("; ");
}
if (error instanceof Error) return error.message;
return String(error);
}
export function isCompileFailure(error: unknown): error is CompileFailure {
return Boolean(error && typeof error === "object" && "diagnostics" in error);
}
+106
View File
@@ -0,0 +1,106 @@
import {
snippetCompletion,
type CompletionContext,
} from "@codemirror/autocomplete";
const typstOptions = [
snippetCompletion("let ${name} = ${value}", { label: "let", type: "keyword", info: "Variable declaration" }),
snippetCompletion("set ${rule}(${value})", { label: "set", type: "keyword", info: "Set rule" }),
snippetCompletion("show ${selector}: ${rule}", { label: "show", type: "keyword", info: "Show rule" }),
snippetCompletion("import \"${module}\": ${items}", { label: "import", type: "keyword", info: "Import module" }),
snippetCompletion("include \"${file}\"", { label: "include", type: "keyword", info: "Include file" }),
snippetCompletion("if ${condition} {\n\t${}\n}", { label: "if", type: "keyword", info: "If statement" }),
snippetCompletion("else {\n\t${}\n}", { label: "else", type: "keyword", info: "Else statement" }),
snippetCompletion("for ${item} in ${collection} {\n\t${}\n}", { label: "for", type: "keyword", info: "For loop" }),
snippetCompletion("while ${condition} {\n\t${}\n}", { label: "while", type: "keyword", info: "While loop" }),
snippetCompletion("break", { label: "break", type: "keyword", info: "Break loop" }),
snippetCompletion("continue", { label: "continue", type: "keyword", info: "Continue loop" }),
snippetCompletion("return ${value}", { label: "return", type: "keyword", info: "Return value" }),
snippetCompletion("context", { label: "context", type: "keyword", info: "Context expression" }),
snippetCompletion("align(${alignment})[${content}]", { label: "align", type: "function", info: "Align content" }),
snippetCompletion("page(${content})", { label: "page", type: "function", info: "Page configuration" }),
snippetCompletion("pagebreak()", { label: "pagebreak", type: "function", info: "Break page" }),
snippetCompletion("colbreak()", { label: "colbreak", type: "function", info: "Break column" }),
snippetCompletion("place(${alignment})[${content}]", { label: "place", type: "function", info: "Place content" }),
snippetCompletion("columns(${2})[${content}]", { label: "columns", type: "function", info: "Multiple columns" }),
snippetCompletion("pad(${10pt})[${content}]", { label: "pad", type: "function", info: "Pad content" }),
snippetCompletion("stack(dir: ${ttb}, spacing: ${10pt}, ${items})", { label: "stack", type: "function", info: "Stack items" }),
snippetCompletion("grid(columns: ${2}, gutter: ${10pt}, ${items})", { label: "grid", type: "function", info: "Grid layout" }),
snippetCompletion("table(columns: ${2}, ${items})", { label: "table", type: "function", info: "Table layout" }),
snippetCompletion("rect(width: ${100%}, height: ${100%})[${content}]", { label: "rect", type: "function", info: "Draw rectangle" }),
snippetCompletion("square(size: ${10pt})[${content}]", { label: "square", type: "function", info: "Draw square" }),
snippetCompletion("circle(radius: ${10pt})[${content}]", { label: "circle", type: "function", info: "Draw circle" }),
snippetCompletion("ellipse(width: ${20pt}, height: ${10pt})[${content}]", { label: "ellipse", type: "function", info: "Draw ellipse" }),
snippetCompletion("line(length: ${100%})", { label: "line", type: "function", info: "Draw line" }),
snippetCompletion("polygon(${vertices})", { label: "polygon", type: "function", info: "Draw polygon" }),
snippetCompletion("path(${vertices})", { label: "path", type: "function", info: "Draw path" }),
snippetCompletion("image(\"${path}\", width: ${100%})", { label: "image", type: "function", info: "Insert image" }),
snippetCompletion("box[${content}]", { label: "box", type: "function", info: "Box inline content" }),
snippetCompletion("block[${content}]", { label: "block", type: "function", info: "Block content" }),
snippetCompletion("figure(${content}, caption: [${caption}])", { label: "figure", type: "function", info: "Figure with caption" }),
snippetCompletion("text(size: ${11pt}, font: \"${Arial}\")[${content}]", { label: "text", type: "function", info: "Text styling" }),
snippetCompletion("heading(level: ${1})[${title}]", { label: "heading", type: "function", info: "Heading" }),
snippetCompletion("par[${content}]", { label: "par", type: "function", info: "Paragraph" }),
snippetCompletion("list([${item}])", { label: "list", type: "function", info: "Bullet list" }),
snippetCompletion("enum([${item}])", { label: "enum", type: "function", info: "Numbered list" }),
snippetCompletion("terms([${term}], [${description}])", { label: "terms", type: "function", info: "Terms list" }),
snippetCompletion("strong[${content}]", { label: "strong", type: "function", info: "Bold text" }),
snippetCompletion("emph[${content}]", { label: "emph", type: "function", info: "Italic text" }),
snippetCompletion("underline[${content}]", { label: "underline", type: "function", info: "Underline text" }),
snippetCompletion("strike[${content}]", { label: "strike", type: "function", info: "Strikethrough text" }),
snippetCompletion("overline[${content}]", { label: "overline", type: "function", info: "Overline text" }),
snippetCompletion("sub[${content}]", { label: "sub", type: "function", info: "Subscript text" }),
snippetCompletion("super[${content}]", { label: "super", type: "function", info: "Superscript text" }),
snippetCompletion("raw(\"${code}\", block: ${true})", { label: "raw", type: "function", info: "Raw code block" }),
snippetCompletion("link(\"${url}\")[${text}]", { label: "link", type: "function", info: "Hyperlink" }),
snippetCompletion("ref(<${label}>)", { label: "ref", type: "function", info: "Reference" }),
snippetCompletion("cite(<${label}>)", { label: "cite", type: "function", info: "Citation" }),
snippetCompletion("bibliography(\"${file.bib}\")", { label: "bibliography", type: "function", info: "Bibliography" }),
snippetCompletion("outline(title: [${Contents}])", { label: "outline", type: "function", info: "Table of contents" }),
snippetCompletion("rgb(\"${#000000}\")", { label: "rgb", type: "function", info: "RGB Color" }),
snippetCompletion("cmyk(${0%}, ${0%}, ${0%}, ${100%})", { label: "cmyk", type: "function", info: "CMYK Color" }),
snippetCompletion("luma(${0%})", { label: "luma", type: "function", info: "Luma (Grayscale) Color" }),
snippetCompletion("color", { label: "color", type: "variable" }),
snippetCompletion("gradient", { label: "gradient", type: "variable" }),
snippetCompletion("pattern(size: (${10pt}, ${10pt}))[${content}]", { label: "pattern", type: "function", info: "Fill pattern" }),
snippetCompletion("type(${value})", { label: "type", type: "function", info: "Get type of value" }),
snippetCompletion("repr(${value})", { label: "repr", type: "function", info: "String representation" }),
snippetCompletion("str(${value})", { label: "str", type: "function", info: "Convert to string" }),
snippetCompletion("int(${value})", { label: "int", type: "function", info: "Convert to integer" }),
snippetCompletion("float(${value})", { label: "float", type: "function", info: "Convert to float" }),
snippetCompletion("datetime(year: ${2024}, month: ${1}, day: ${1})", { label: "datetime", type: "function", info: "Date and time" }),
snippetCompletion("math", { label: "math", type: "variable", info: "Math module" }),
snippetCompletion("calc", { label: "calc", type: "variable", info: "Calc module" }),
snippetCompletion("sys", { label: "sys", type: "variable", info: "System module" }),
snippetCompletion("frac(${num}, ${denom})", { label: "frac", type: "function", info: "Fraction (Math)" }),
snippetCompletion("binom(${n}, ${k})", { label: "binom", type: "function", info: "Binomial (Math)" }),
snippetCompletion("mat(${1}, ${2}; ${3}, ${4})", { label: "mat", type: "function", info: "Matrix (Math)" }),
snippetCompletion("vec(${1}, ${2})", { label: "vec", type: "function", info: "Vector (Math)" }),
snippetCompletion("cases(${a}, ${b})", { label: "cases", type: "function", info: "Cases (Math)" }),
snippetCompletion("sqrt(${x})", { label: "sqrt", type: "function", info: "Square root (Math)" }),
snippetCompletion("root(${3}, ${x})", { label: "root", type: "function", info: "N-th root (Math)" }),
snippetCompletion("abs(${x})", { label: "abs", type: "function", info: "Absolute value (Math)" }),
snippetCompletion("norm(${x})", { label: "norm", type: "function", info: "Norm (Math)" }),
snippetCompletion("floor(${x})", { label: "floor", type: "function", info: "Floor (Math)" }),
snippetCompletion("ceil(${x})", { label: "ceil", type: "function", info: "Ceiling (Math)" }),
snippetCompletion("round(${x})", { label: "round", type: "function", info: "Round (Math)" }),
snippetCompletion("cancel(${x})", { label: "cancel", type: "function", info: "Cancel/strike (Math)" }),
snippetCompletion("attach(${base}, t: ${top}, b: ${bottom})", { label: "attach", type: "function", info: "Attach scripts (Math)" }),
snippetCompletion("scripts(${expr})", { label: "scripts", type: "function", info: "Scripts (Math)" }),
snippetCompletion("limits(${expr})", { label: "limits", type: "function", info: "Limits (Math)" }),
snippetCompletion("op(\"${name}\")", { label: "op", type: "function", info: "Operator (Math)" }),
snippetCompletion("lr(${expr})", { label: "lr", type: "function", info: "Left/Right scales (Math)" }),
snippetCompletion("mid(${|})", { label: "mid", type: "function", info: "Mid delimiter (Math)" })
];
export function typstCompletions(context: CompletionContext) {
const word = context.matchBefore(/[\w#]*/);
if (!word || (word.from === word.to && !context.explicit)) return null;
return {
from: word.text.startsWith("#") ? word.from + 1 : word.from,
options: typstOptions,
validFor: /^[\w]*$/,
};
}
+112
View File
@@ -0,0 +1,112 @@
import type { EditorView } from "@codemirror/view";
import { undo, redo } from "@codemirror/commands";
export function insertText(view: EditorView | null, text: string) {
if (!view) return;
const { from, to } = view.state.selection.main;
view.dispatch({
changes: { from, to, insert: text },
selection: { anchor: from + text.length },
});
view.focus();
}
export function wrapSelection(
view: EditorView | null,
prefix: string,
suffix: string,
placeholder = "",
) {
if (!view) return;
const selection = view.state.selection.main;
const selected = view.state.doc.sliceString(selection.from, selection.to);
const body = selected || placeholder;
view.dispatch({
changes: { from: selection.from, to: selection.to, insert: prefix + body + suffix },
selection: {
anchor: selection.from + prefix.length,
head: selection.from + prefix.length + body.length,
},
});
view.focus();
}
export function prefixLines(
view: EditorView | null,
prefix: string,
placeholder = "",
) {
if (!view) return;
const selection = view.state.selection.main;
const startLine = view.state.doc.lineAt(selection.from);
const endLine = view.state.doc.lineAt(selection.to);
if (selection.empty && startLine.text.trim() === "") {
insertText(view, prefix + placeholder);
return;
}
const changes = [];
for (let number = startLine.number; number <= endLine.number; number += 1) {
const line = view.state.doc.line(number);
if (line.text.startsWith(prefix)) continue;
changes.push({ from: line.from, insert: prefix });
}
view.dispatch({ changes });
view.focus();
}
export function undoEdit(view: EditorView | null) {
if (!view) return;
undo(view);
view.focus();
}
export function redoEdit(view: EditorView | null) {
if (!view) return;
redo(view);
view.focus();
}
export function setTypstConfig(
view: EditorView | null,
setting: string,
property: string,
value: string,
) {
if (!view) return;
const content = view.state.doc.toString();
const rule = new RegExp(`^#set\\s+${setting}\\s*\\(([^)]*)\\)`, "m");
const match = content.match(rule);
if (!match || match.index === undefined) {
view.dispatch({
changes: { from: 0, insert: `#set ${setting}(${property}: ${value})\n` },
});
view.focus();
return;
}
const existing = match[1];
const property_rule = new RegExp(
`${property}\\s*:\\s*(?:\\([^)]*\\)|"[^"]*"|[^,)]+)`,
);
const next = property_rule.test(existing)
? existing.replace(property_rule, `${property}: ${value}`)
: existing.trim()
? `${existing}, ${property}: ${value}`
: `${property}: ${value}`;
view.dispatch({
changes: {
from: match.index,
to: match.index + match[0].length,
insert: `#set ${setting}(${next})`,
},
});
view.focus();
}
+210
View File
@@ -0,0 +1,210 @@
import { EditorView } from "@codemirror/view";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags as t } from "@lezer/highlight";
interface ThemeColors {
background: string;
surface: string;
text: string;
selection: string;
activeLine: string;
cursor: string;
border: string;
keyword: string;
string: string;
number: string;
comment: string;
variable: string;
function: string;
heading: string;
}
const palette: Record<"light" | "dark", ThemeColors> = {
light: {
background: "#ffffff",
surface: "#f6f7f9",
text: "#14161a",
selection: "#dbe6fe",
activeLine: "#f6f7f9",
cursor: "#3b6cf6",
border: "#dfe2e7",
keyword: "#7c3aed",
string: "#0f766e",
number: "#b45309",
comment: "#6b7280",
variable: "#14161a",
function: "#2563eb",
heading: "#1d4ed8",
},
dark: {
background: "#16181d",
surface: "#1d2026",
text: "#eef0f4",
selection: "#2f3a52",
activeLine: "#1d2026",
cursor: "#6b93ff",
border: "#2f343d",
keyword: "#c4a7f7",
string: "#8ddba4",
number: "#f0b37e",
comment: "#7b8496",
variable: "#eef0f4",
function: "#7aa2ff",
heading: "#8fb3ff",
},
};
export function editorTheme(isDark: boolean) {
const colors = palette[isDark ? "dark" : "light"];
const theme = EditorView.theme(
{
"&": {
color: colors.text,
backgroundColor: colors.background,
height: "100%",
fontSize: "13px",
},
".cm-content": {
caretColor: colors.cursor,
padding: "12px 0",
},
".cm-cursor, .cm-dropCursor": { borderLeftColor: colors.cursor },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
{ backgroundColor: colors.selection },
".cm-activeLine": { backgroundColor: colors.activeLine },
".cm-gutters": {
backgroundColor: colors.background,
color: colors.comment,
border: "none",
},
".cm-activeLineGutter": { backgroundColor: colors.activeLine },
"&.cm-focused .cm-matchingBracket": {
backgroundColor: colors.selection,
outline: `1px solid ${colors.border}`,
},
".cm-tooltip": {
backgroundColor: colors.surface,
color: colors.text,
border: `1px solid ${colors.border}`,
borderRadius: "6px",
maxWidth: "500px",
},
".cm-tooltip-hover": { maxHeight: "300px", overflow: "auto" },
".cm-tooltip .cm-tooltip-arrow:before": {
borderTopColor: colors.border,
borderBottomColor: colors.border,
},
".cm-tooltip .cm-tooltip-arrow:after": {
borderTopColor: colors.surface,
borderBottomColor: colors.surface,
},
".cm-tooltip.cm-tooltip-autocomplete": {
backgroundColor: colors.surface,
border: `1px solid ${colors.border}`,
padding: "4px",
},
".cm-tooltip.cm-tooltip-autocomplete > ul": {
fontFamily: "inherit",
maxHeight: "16em",
},
".cm-tooltip.cm-tooltip-autocomplete > ul > li": {
color: colors.text,
padding: "3px 8px",
borderRadius: "4px",
display: "flex",
alignItems: "center",
gap: "6px",
},
".cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]": {
backgroundColor: colors.cursor,
color: "#ffffff",
},
".cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected] .cm-completionDetail":
{ color: "#ffffff" },
".cm-completionLabel": { color: "inherit" },
".cm-completionMatchedText": {
textDecoration: "none",
fontWeight: "600",
color: "inherit",
},
".cm-completionDetail": {
color: colors.comment,
fontStyle: "normal",
marginLeft: "auto",
fontSize: "0.85em",
},
".cm-completionIcon": {
color: colors.comment,
opacity: "1",
width: "1.1em",
},
".cm-completionInfo": {
backgroundColor: colors.surface,
color: colors.text,
border: `1px solid ${colors.border}`,
borderRadius: "6px",
padding: "6px 8px",
},
".cm-panels": { backgroundColor: colors.surface, color: colors.text },
".cm-searchMatch": { backgroundColor: "#72a1ff59" },
".cm-selectionMatch": { backgroundColor: "#aafe661a" },
},
{ dark: isDark },
);
const highlightStyle = HighlightStyle.define([
{ tag: t.keyword, color: colors.keyword },
{
tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName],
color: colors.variable,
},
{ tag: [t.function(t.variableName), t.labelName], color: colors.function },
{
tag: [t.color, t.constant(t.name), t.standard(t.name)],
color: colors.function,
},
{ tag: [t.definition(t.name), t.separator], color: colors.variable },
{
tag: [
t.typeName,
t.className,
t.number,
t.changed,
t.annotation,
t.modifier,
t.self,
t.namespace,
],
color: colors.number,
},
{
tag: [
t.operator,
t.operatorKeyword,
t.url,
t.escape,
t.regexp,
t.special(t.string),
],
color: colors.keyword,
},
{ tag: [t.meta, t.comment], color: colors.comment, fontStyle: "italic" },
{ tag: t.strong, fontWeight: "bold" },
{ tag: t.emphasis, fontStyle: "italic" },
{ tag: t.strikethrough, textDecoration: "line-through" },
{ tag: t.link, color: colors.function, textDecoration: "underline" },
{ tag: t.heading, fontWeight: "bold", color: colors.heading },
{ tag: [t.atom, t.bool, t.special(t.variableName)], color: colors.number },
{
tag: [t.processingInstruction, t.string, t.inserted],
color: colors.string,
},
{ tag: t.invalid, color: "#ff5c57" },
]);
return [theme, syntaxHighlighting(highlightStyle, { fallback: true })];
}
+35
View File
@@ -0,0 +1,35 @@
import { open } from "@tauri-apps/plugin-dialog";
export const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "svg", "webp"];
export const FONT_EXTENSIONS = ["ttf", "otf", "ttc", "otc"];
export const DATA_EXTENSIONS = ["bib", "csl", "json", "yaml", "yml", "csv", "toml"];
export type PickKind = "all" | "assets" | "images" | "fonts";
export async function pickFiles(kind: PickKind = "all"): Promise<string[]> {
const filters =
kind === "images"
? [{ name: "Images", extensions: IMAGE_EXTENSIONS }]
: kind === "fonts"
? [{ name: "Fonts", extensions: FONT_EXTENSIONS }]
: kind === "assets"
? [
{
name: "Images and fonts",
extensions: [...IMAGE_EXTENSIONS, ...FONT_EXTENSIONS],
},
{ name: "Images", extensions: IMAGE_EXTENSIONS },
{ name: "Fonts", extensions: FONT_EXTENSIONS },
]
: [
{
name: "Typst files",
extensions: ["typ", ...DATA_EXTENSIONS, ...IMAGE_EXTENSIONS, ...FONT_EXTENSIONS],
},
];
const selected = await open({ multiple: true, filters });
if (!selected) return [];
return Array.isArray(selected) ? selected : [selected];
}
+74
View File
@@ -0,0 +1,74 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
export interface LspHandle {
root_uri: string;
document_uri: string;
}
type Handler = (value: string) => void;
export class LspBridge {
private handlers: Handler[] = [];
private unlistenMessage: UnlistenFn | null = null;
private unlistenClosed: UnlistenFn | null = null;
handle: LspHandle | null = null;
readonly transport = {
send: (message: string) => {
invoke("lsp_send", { message }).catch(() => {});
},
subscribe: (handler: Handler) => {
this.handlers.push(handler);
},
unsubscribe: (handler: Handler) => {
this.handlers = this.handlers.filter((existing) => existing !== handler);
},
};
async start(path: string, onClosed?: () => void): Promise<LspHandle> {
await this.stop();
this.unlistenMessage = await listen<string>("lsp://message", (event) => {
const message = this.filterDiagnostics(event.payload);
for (const handler of this.handlers) handler(message);
});
this.unlistenClosed = await listen("lsp://closed", () => {
onClosed?.();
});
this.handle = await invoke<LspHandle>("lsp_start", { path });
return this.handle;
}
private filterDiagnostics(message: string): string {
if (!message.includes("publishDiagnostics")) return message;
try {
const parsed = JSON.parse(message);
if (parsed.method === "textDocument/publishDiagnostics") {
parsed.params.diagnostics = parsed.params.diagnostics.filter(
(diagnostic: { message: string }) =>
!diagnostic.message.toLowerCase().includes("unknown font family"),
);
return JSON.stringify(parsed);
}
} catch {
return message;
}
return message;
}
async stop() {
this.unlistenMessage?.();
this.unlistenClosed?.();
this.unlistenMessage = null;
this.unlistenClosed = null;
this.handlers = [];
this.handle = null;
await invoke("lsp_stop").catch(() => {});
}
}
export const lspAvailable = () => invoke<boolean>("lsp_running");
+348
View File
@@ -0,0 +1,348 @@
import * as api from "./api";
import type {
Account,
BrowseEntry,
CompileResult,
Conflict,
Diagnostic,
Settings,
SpaceSummary,
TargetInfo,
} from "./api";
export type Scope = "local" | "cloud";
export type View = "files" | "editor";
export type LspStatus = "off" | "starting" | "on" | "unavailable";
interface AppState {
view: View;
scope: Scope;
settings: Settings | null;
account: Account | null;
currentDir: string;
entries: BrowseEntry[];
spaces: SpaceSummary[];
target: TargetInfo | null;
activePath: string | null;
editorContent: string;
dirty: boolean;
compiled: CompileResult | null;
diagnostics: Diagnostic[];
compiling: boolean;
lspStatus: LspStatus;
syncing: boolean;
conflicts: Conflict[];
status: string;
error: string;
theme: "light" | "dark";
}
export const app = $state<AppState>({
view: "files",
scope: "local",
settings: null,
account: null,
currentDir: "",
entries: [],
spaces: [],
target: null,
activePath: null,
editorContent: "",
dirty: false,
compiled: null,
diagnostics: [],
compiling: false,
lspStatus: "off",
syncing: false,
conflicts: [],
status: "",
error: "",
theme: "light",
});
export function setError(error: unknown) {
app.error = api.errorMessage(error);
app.status = "";
}
export function setStatus(message: string) {
app.status = message;
app.error = "";
}
export function clearMessages() {
app.status = "";
app.error = "";
}
export function applyTheme(theme: "light" | "dark") {
app.theme = theme;
document.documentElement.dataset.theme = theme;
localStorage.setItem("typst-desktop-theme", theme);
}
export function breadcrumbs(): { name: string; path: string }[] {
if (!app.currentDir) return [];
const segments = app.currentDir.split("/");
return segments.map((name, index) => ({
name,
path: segments.slice(0, index + 1).join("/"),
}));
}
export async function bootstrap() {
const stored = localStorage.getItem("typst-desktop-theme");
applyTheme(stored === "dark" ? "dark" : "light");
try {
app.settings = await api.getSettings();
await browseTo("");
await refreshAccount();
} catch (error) {
setError(error);
}
}
export async function browseTo(path: string) {
try {
app.entries = await api.browseWorkspace(path);
app.currentDir = path;
clearMessages();
} catch (error) {
setError(error);
}
}
export async function refreshEntries() {
await browseTo(app.currentDir);
}
export async function refreshAccount() {
try {
app.account = await api.cloudAccount();
if (app.account) {
await refreshSpaces();
} else {
app.spaces = [];
}
} catch {
app.account = null;
}
}
export async function refreshSpaces() {
try {
app.spaces = await api.cloudListSpaces();
} catch (error) {
setError(error);
}
}
export async function openTarget(path: string) {
try {
const target = await api.targetInfo(path);
app.target = target;
app.view = "editor";
app.activePath = null;
app.editorContent = "";
app.dirty = false;
app.compiled = null;
app.diagnostics = [];
app.lspStatus = "off";
clearMessages();
const preferred =
target.files.find((file) => file.path === target.entrypoint) ??
target.files.find((file) => file.path.endsWith(".typ")) ??
target.files[0];
if (preferred) await openFile(preferred.path);
} catch (error) {
setError(error);
}
}
export async function closeTarget() {
cancelScheduledCompile();
if (app.dirty) await saveActiveFile();
app.view = "files";
app.target = null;
app.activePath = null;
app.editorContent = "";
app.compiled = null;
app.diagnostics = [];
app.lspStatus = "off";
await refreshEntries();
}
export async function refreshTarget() {
if (!app.target) return;
try {
app.target = await api.targetInfo(app.target.path);
} catch (error) {
setError(error);
}
}
export async function openFile(file: string) {
if (!app.target) return;
cancelScheduledCompile();
if (app.dirty && app.activePath) await saveActiveFile();
try {
const payload = await api.readTargetFile(app.target.path, file);
app.activePath = file;
app.editorContent = payload.is_text ? payload.content : "";
app.dirty = false;
if (payload.is_text) await compile();
} catch (error) {
setError(error);
}
}
export async function saveActiveFile() {
if (!app.target || !app.activePath) return;
try {
await api.writeTargetFile(
app.target.path,
app.activePath,
app.editorContent,
);
app.dirty = false;
} catch (error) {
setError(error);
}
}
function liveOverrides(): Record<string, string> | undefined {
if (!app.dirty || !app.activePath) return undefined;
return { [app.activePath]: app.editorContent };
}
let compileRunning = false;
let compileQueued = false;
export async function compile() {
if (!app.target) return;
if (compileRunning) {
compileQueued = true;
return;
}
compileRunning = true;
app.compiling = true;
try {
const result = await api.compileTarget(app.target.path, liveOverrides());
app.compiled = result;
app.diagnostics = result.diagnostics;
} catch (error) {
if (api.isCompileFailure(error)) {
app.diagnostics = error.diagnostics;
} else {
setError(error);
}
} finally {
compileRunning = false;
app.compiling = false;
if (compileQueued) {
compileQueued = false;
await compile();
}
}
}
const COMPILE_DEBOUNCE_MS = 400;
let compileTimer: ReturnType<typeof setTimeout> | null = null;
export function scheduleCompile() {
if (compileTimer) clearTimeout(compileTimer);
compileTimer = setTimeout(() => {
compileTimer = null;
compile();
}, COMPILE_DEBOUNCE_MS);
}
export function cancelScheduledCompile() {
if (compileTimer) {
clearTimeout(compileTimer);
compileTimer = null;
}
}
export async function saveAndCompile() {
cancelScheduledCompile();
await saveActiveFile();
await compile();
}
export async function runSync(
action: "sync" | "push" | "pull",
project = app.target?.path,
) {
if (!project) return;
app.syncing = true;
clearMessages();
try {
const report =
action === "push"
? await api.cloudPush(project)
: action === "pull"
? await api.cloudPull(project)
: await api.cloudSync(project);
app.conflicts = report.conflicts;
if (report.conflicts.length > 0) {
setError(`${report.conflicts.length} file(s) need conflict resolution`);
} else {
setStatus(summarize(report));
}
await refreshTarget();
if (app.activePath) {
const payload = await api.readTargetFile(project, app.activePath);
if (payload.is_text) {
app.editorContent = payload.content;
app.dirty = false;
}
}
await compile();
} catch (error) {
setError(error);
} finally {
app.syncing = false;
}
}
function summarize(report: {
pushed: string[];
pulled: string[];
merged: string[];
deleted_local: string[];
deleted_remote: string[];
}): string {
const parts: string[] = [];
if (report.pushed.length) parts.push(`${report.pushed.length} uploaded`);
if (report.pulled.length) parts.push(`${report.pulled.length} downloaded`);
if (report.merged.length) parts.push(`${report.merged.length} merged`);
if (report.deleted_local.length)
parts.push(`${report.deleted_local.length} removed locally`);
if (report.deleted_remote.length)
parts.push(`${report.deleted_remote.length} removed in cloud`);
return parts.length
? `Sync complete: ${parts.join(", ")}`
: "Already up to date";
}