Files
typst-desktop/src/routes/+page.svelte
T

791 lines
25 KiB
Svelte

<script lang="ts">
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import { save } from "@tauri-apps/plugin-dialog";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import FileViewer from "$lib/components/FileViewer.svelte";
import FileTree from "$lib/components/FileTree.svelte";
import Editor from "$lib/components/Editor.svelte";
import Preview from "$lib/components/Preview.svelte";
import PromptModal from "$lib/components/PromptModal.svelte";
import ConfirmModal from "$lib/components/ConfirmModal.svelte";
import ConflictModal from "$lib/components/ConflictModal.svelte";
import LoginModal from "$lib/components/LoginModal.svelte";
import SettingsModal from "$lib/components/SettingsModal.svelte";
import AssetsModal from "$lib/components/AssetsModal.svelte";
import WindowControls from "$lib/components/WindowControls.svelte";
import ImageViewer from "$lib/components/ImageViewer.svelte";
import InfoModal from "$lib/components/InfoModal.svelte";
import EditorToolbar from "$lib/components/EditorToolbar.svelte";
import PageSettingsModal from "$lib/components/PageSettingsModal.svelte";
import type { EditorView } from "@codemirror/view";
import { insertText } from "$lib/ts/editor-actions";
import * as api from "$lib/ts/api";
import type { BrowseEntry } from "$lib/ts/api";
import { pickFiles } from "$lib/ts/import";
import {
app,
browseTo,
clearMessages,
closeTarget,
compile,
downloadDocument,
openFile,
openTarget,
refreshAccount,
refreshEntries,
refreshSpaces,
refreshTarget,
runSync,
saveAndCompile,
scheduleAutosave,
scheduleCompile,
setError,
setStatus,
} from "$lib/ts/state.svelte";
type Dialog =
| { kind: "none" }
| { kind: "new-project" }
| { kind: "new-folder" }
| { kind: "new-document" }
| { kind: "rename-entry"; entry: BrowseEntry }
| { kind: "delete-entry"; entry: BrowseEntry }
| { kind: "link-entry"; entry: BrowseEntry }
| { kind: "new-space" }
| { kind: "delete-space"; id: string }
| { kind: "clone-space"; id: string; name: string }
| { kind: "new-file" }
| { kind: "new-subfolder" }
| { kind: "rename-file"; path: string }
| { kind: "delete-file"; path: string }
| { kind: "login" }
| { kind: "settings" }
| { kind: "info" }
| { kind: "assets" }
| { kind: "page-settings" }
| { kind: "conflicts" };
let dialog = $state<Dialog>({ kind: "none" });
let editorView = $state<EditorView | null>(null);
let imageViewer = $state<{ paths: string[]; index: number } | null>(null);
const activeFile = $derived(
app.target?.files.find((file) => file.path === app.activePath) ?? null,
);
function close() {
dialog = { kind: "none" };
}
async function guard(action: () => Promise<void>) {
try {
await action();
close();
} catch (error) {
setError(error);
}
}
const createProject = (name: string) =>
guard(async () => {
const path = await api.createProjectEntry(app.currentDir, name);
await refreshEntries();
await openTarget(path);
});
const createFolder = (name: string) =>
guard(async () => {
await api.createFolderEntry(app.currentDir, name);
await refreshEntries();
});
const createDocument = (name: string) =>
guard(async () => {
const path = await api.createDocumentEntry(app.currentDir, name);
await refreshEntries();
await openTarget(path);
});
const renameEntry = (entry: BrowseEntry, name: string) =>
guard(async () => {
await api.renameEntry(entry.path, name);
await refreshEntries();
});
const deleteEntry = (entry: BrowseEntry) =>
guard(async () => {
await api.deleteEntry(entry.path);
await refreshEntries();
setStatus(`Deleted '${entry.name}'`);
});
const linkEntry = (entry: BrowseEntry) =>
guard(async () => {
const report = await api.cloudLinkProject(entry.path);
await refreshEntries();
await refreshSpaces();
setStatus(`Uploaded ${report.pushed.length} files to a new cloud space`);
});
const createSpace = (name: string) =>
guard(async () => {
await api.cloudCreateSpace(name);
await refreshSpaces();
});
const deleteSpace = (id: string) =>
guard(async () => {
await api.cloudDeleteSpace(id);
await refreshSpaces();
});
const cloneSpace = (id: string, name: string) =>
guard(async () => {
await api.cloudCloneSpace(id, name);
app.scope = "local";
await browseTo("");
setStatus(`Downloaded '${name}' to this device`);
});
async function importFiles() {
const sources = await pickFiles("all");
if (sources.length === 0) return;
try {
if (app.view === "editor" && app.target) {
const imported = await api.importIntoTarget(app.target.path, sources);
await refreshTarget();
await compile();
setStatus(`Imported ${imported.length} file(s)`);
} else {
const imported = await api.importIntoFolder(app.currentDir, sources);
await refreshEntries();
setStatus(`Imported ${imported.length} file(s)`);
}
} catch (error) {
setError(error);
}
}
const createFileInTarget = (name: string) =>
guard(async () => {
const path = name.includes(".") ? name : `${name}.typ`;
await api.writeTargetFile(app.target!.path, path, "");
await refreshTarget();
await openFile(path);
});
const createFolderInTarget = (path: string) =>
guard(async () => {
await api.createFolderEntry(app.target!.path, path);
await refreshTarget();
});
const renameFile = (path: string, next: string) =>
guard(async () => {
const payload = await api.readTargetFile(app.target!.path, path);
await api.writeTargetFile(app.target!.path, next, payload.content);
await api.deleteEntry(`${app.target!.path}/${path}`);
await refreshTarget();
if (app.activePath === path) await openFile(next);
});
const deleteFile = (path: string) =>
guard(async () => {
await api.deleteEntry(`${app.target!.path}/${path}`);
if (app.activePath === path) {
app.activePath = null;
app.editorContent = "";
}
await refreshTarget();
await compile();
});
async function setEntrypoint(path: string) {
if (!app.target) return;
try {
await api.setTargetEntrypoint(app.target.path, path);
await refreshTarget();
await compile();
setStatus(`'${path}' is now the entrypoint`);
} catch (error) {
setError(error);
}
}
async function exportAs(format: string) {
if (!app.target) return;
const name = app.target.path.split("/").pop() ?? "document";
const destination = await save({
defaultPath: `${name.replace(/\.typ$/i, "")}.${format}`,
filters: [{ name: format.toUpperCase(), extensions: [format] }],
});
if (!destination) return;
try {
await api.exportTarget(app.target.path, format, destination);
setStatus(`Exported to ${destination}`);
} catch (error) {
setError(error);
}
}
const resolveDocumentConflicts = (resolutions: api.Resolution[]) =>
guard(async () => {
for (const resolution of resolutions) {
await api.cloudResolveDocument(
app.target!.path,
resolution.content,
resolution.server_hash,
);
}
app.conflicts = [];
if (app.activePath) await openFile(app.activePath);
setStatus("Conflicts resolved and uploaded");
});
const resolveConflicts = (resolutions: api.Resolution[]) =>
guard(async () => {
const report = await api.cloudResolveConflicts(
app.target!.path,
resolutions,
);
app.conflicts = report.conflicts;
await refreshTarget();
if (app.activePath) await openFile(app.activePath);
setStatus("Conflicts resolved and uploaded");
});
function handleKeydown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key === "s") {
event.preventDefault();
if (app.view === "editor") saveAndCompile();
}
}
let dropActive = $state(false);
function dropDestination():
| { kind: "folder"; path: string }
| { kind: "target"; path: string }
| null {
if (app.view === "editor" && app.target) {
return { kind: "target", path: app.target.path };
}
if (app.view === "files" && app.scope === "local") {
return { kind: "folder", path: app.currentDir };
}
return null;
}
async function dropPaths(paths: string[]) {
const destination = dropDestination();
if (!destination || paths.length === 0) return;
try {
const imported =
destination.kind === "target"
? await api.importIntoTarget(destination.path, paths)
: await api.importIntoFolder(destination.path, paths);
if (destination.kind === "target") {
await refreshTarget();
await compile();
} else {
await refreshEntries();
}
setStatus(`Imported ${imported.length} item(s)`);
} catch (error) {
setError(error);
}
}
onMount(() => {
const pending = getCurrentWebview().onDragDropEvent((event) => {
if (event.payload.type === "over") {
dropActive = dropDestination() !== null;
} else if (event.payload.type === "drop") {
dropActive = false;
dropPaths(event.payload.paths);
} else {
dropActive = false;
}
});
return () => {
pending.then((unlisten) => unlisten());
};
});
const lspLabel: Record<string, string> = {
off: "LSP off",
starting: "LSP starting",
on: "LSP ready",
unavailable: "LSP unavailable",
};
</script>
<svelte:window on:keydown={handleKeydown} />
<div class="flex h-screen flex-col">
<header
data-tauri-drag-region
class="flex h-11 shrink-0 select-none items-center gap-2 border-b border-[var(--color-line)] bg-[var(--color-surface)] pl-3"
>
{#if app.view === "editor"}
<button
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
onclick={closeTarget}
>
<Icon icon="ph:arrow-left" />
Files
</button>
<span data-tauri-drag-region class="text-sm font-medium">
{app.target?.path.split("/").pop()}
</span>
{#if app.target?.standalone}
<span
class="rounded bg-[var(--color-surface-sunken)] px-1.5 py-0.5 text-[10px] text-[var(--color-ink-muted)]"
>
single file
</span>
{/if}
{#if app.dirty}
<span class="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)]"></span>
{/if}
{:else}
<Icon icon="ph:file-code" class="text-lg text-[var(--color-accent)]" />
<span data-tauri-drag-region class="text-sm font-semibold">
Typst Desktop
</span>
{/if}
<div data-tauri-drag-region class="h-full flex-1"></div>
{#if app.view === "editor"}
<span
class="flex items-center gap-1 text-[10px] text-[var(--color-ink-muted)]"
title="Typst language server (tinymist)"
>
<span
class="h-1.5 w-1.5 rounded-full
{app.lspStatus === 'on'
? 'bg-[var(--color-success)]'
: app.lspStatus === 'starting'
? 'bg-[var(--color-accent)]'
: 'bg-[var(--color-ink-muted)]'}"
></span>
{lspLabel[app.lspStatus]}
</span>
<button
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
onclick={saveAndCompile}
>
<Icon icon="ph:floppy-disk" />
Save
</button>
<div class="group relative">
<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)]"
>
<Icon icon="ph:export" />
Export
</button>
<div
class="invisible absolute right-0 top-full z-20 flex w-32 flex-col rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] py-1 text-xs shadow-lg group-hover:visible"
>
{#each ["pdf", "png", "html"] as format}
<button
class="px-3 py-1.5 text-left uppercase hover:bg-[var(--color-surface-sunken)]"
onclick={() => exportAs(format)}
>
{format}
</button>
{/each}
</div>
</div>
{#if app.target?.space_id || app.documentLink}
<button
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-2.5 py-1.5 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50"
disabled={app.syncing}
onclick={() => runSync("sync")}
>
<Icon
icon={app.syncing ? "ph:circle-notch" : "ph:arrows-clockwise"}
class={app.syncing ? "animate-spin" : ""}
/>
Sync
</button>
{/if}
{/if}
<button
class="rounded-md p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
onclick={() => (dialog = { kind: "settings" })}
aria-label="Settings"
>
<Icon icon="ph:gear-six" />
</button>
<button
class="rounded-md p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
onclick={() => (dialog = { kind: "info" })}
aria-label="About"
>
<Icon icon="ph:info" />
</button>
<div class="ml-1 h-5 w-px bg-[var(--color-line)]"></div>
<WindowControls />
</header>
{#if app.status || app.error}
<div
class="flex shrink-0 items-center gap-2 border-b px-3 py-1.5 text-xs
{app.error
? 'border-[var(--color-danger)]/30 bg-[var(--color-danger)]/10 text-[var(--color-danger)]'
: 'border-[var(--color-success)]/30 bg-[var(--color-success)]/10 text-[var(--color-success)]'}"
>
<Icon icon={app.error ? "ph:warning-circle" : "ph:check-circle"} />
<span class="flex-1">{app.error || app.status}</span>
{#if app.conflicts.length > 0}
<button
class="rounded border border-current px-2 py-0.5 font-medium"
onclick={() => (dialog = { kind: "conflicts" })}
>
Resolve
</button>
{/if}
<button onclick={clearMessages} aria-label="Dismiss">
<Icon icon="ph:x" />
</button>
</div>
{/if}
<div class="flex min-h-0 flex-1">
{#if app.view === "files"}
<div class="flex-1">
<FileViewer
onnewproject={() => (dialog = { kind: "new-project" })}
onnewfolder={() => (dialog = { kind: "new-folder" })}
onnewdocument={() => (dialog = { kind: "new-document" })}
onupload={importFiles}
onrename={(entry) => (dialog = { kind: "rename-entry", entry })}
ondelete={(entry) => (dialog = { kind: "delete-entry", entry })}
onlink={(entry) => (dialog = { kind: "link-entry", entry })}
onviewimage={(paths, index) => (imageViewer = { paths, index })}
ondownloaddocument={(documentId, title) =>
downloadDocument(documentId, title)}
onnewspace={() => (dialog = { kind: "new-space" })}
onclonespace={(id, name) => (dialog = { kind: "clone-space", id, name })}
ondeletespace={(id) => (dialog = { kind: "delete-space", id })}
onsignin={() => (dialog = { kind: "login" })}
/>
</div>
{:else}
{#if !app.target?.standalone}
<div
class="flex w-56 shrink-0 flex-col border-r border-[var(--color-line)] bg-[var(--color-surface)]"
>
<div
class="flex items-center justify-between border-b border-[var(--color-line)] px-3 py-1.5"
>
<span
class="text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
>
Files
</span>
<div class="flex gap-0.5">
<button
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
onclick={() => (dialog = { kind: "new-file" })}
aria-label="New file"
>
<Icon icon="ph:file-plus" />
</button>
<button
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
onclick={() => (dialog = { kind: "new-subfolder" })}
aria-label="New folder"
>
<Icon icon="ph:folder-plus" />
</button>
<button
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
onclick={importFiles}
aria-label="Import files from this computer"
>
<Icon icon="ph:upload-simple" />
</button>
</div>
</div>
<FileTree
files={app.target?.files ?? []}
activePath={app.activePath}
entrypoint={app.target?.entrypoint ?? "main.typ"}
onopen={openFile}
onrename={(path) => (dialog = { kind: "rename-file", path })}
ondelete={(path) => (dialog = { kind: "delete-file", path })}
onsetentry={setEntrypoint}
/>
</div>
{/if}
<div class="flex min-w-0 flex-1">
<div class="flex min-w-0 flex-1 flex-col border-r border-[var(--color-line)]">
{#if app.activePath && activeFile?.is_text}
<EditorToolbar
view={editorView}
disabled={app.activePath.toLowerCase().endsWith(".toml")}
onassets={() => (dialog = { kind: "assets" })}
onpagesettings={() => (dialog = { kind: "page-settings" })}
/>
<div
class="flex shrink-0 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>{app.activePath}</span>
{#if app.dirty}
<span class="text-[var(--color-ink-muted)]">edited</span>
{/if}
</div>
{#key app.target?.path + ":" + app.activePath}
<div class="min-h-0 flex-1">
<Editor
content={app.editorContent}
filePath={app.activePath}
targetPath={app.target?.path ?? ""}
diagnostics={app.diagnostics}
onchange={(value) => {
app.editorContent = value;
app.dirty = true;
scheduleCompile();
scheduleAutosave();
}}
onsave={saveAndCompile}
onlspstatus={(status) => (app.lspStatus = status)}
onready={(view) => (editorView = view)}
/>
</div>
{/key}
{:else}
<div
class="flex flex-1 flex-col items-center justify-center gap-2 bg-[var(--color-surface)] text-[var(--color-ink-muted)]"
>
<Icon icon="ph:file-dashed" class="text-4xl" />
<p class="text-sm">
{app.activePath
? "This file cannot be edited as text"
: "Select a file to edit"}
</p>
</div>
{/if}
</div>
<div class="min-w-0 flex-1">
<Preview
compiled={app.compiled}
diagnostics={app.diagnostics}
compiling={app.compiling}
/>
</div>
</div>
{/if}
</div>
</div>
{#if imageViewer}
<ImageViewer
paths={imageViewer.paths}
index={imageViewer.index}
onclose={() => (imageViewer = null)}
/>
{/if}
{#if dropActive}
<div
class="pointer-events-none fixed inset-0 z-40 flex items-center justify-center bg-[var(--color-accent)]/10 p-8"
>
<div
class="flex flex-col items-center gap-3 rounded-xl border-2 border-dashed border-[var(--color-accent)] bg-[var(--color-surface)]/95 px-10 py-8 shadow-xl"
>
<Icon icon="ph:tray-arrow-down" class="text-4xl text-[var(--color-accent)]" />
<p class="text-sm font-medium">
{app.view === "editor"
? `Drop to add files to ${app.target?.path.split("/").pop()}`
: app.currentDir
? `Drop to add files to ${app.currentDir.split("/").pop()}`
: "Drop to add files to your workspace"}
</p>
<p class="text-xs text-[var(--color-ink-muted)]">
Images, fonts, and Typst files are copied in.
</p>
</div>
</div>
{/if}
{#if dialog.kind === "new-project"}
<PromptModal
title="New project"
label="Project name"
icon="ph:folder-plus"
placeholder="My thesis"
onsubmit={createProject}
onclose={close}
/>
{:else if dialog.kind === "new-folder"}
<PromptModal
title="New folder"
label="Folder name"
icon="ph:folder-plus"
onsubmit={createFolder}
onclose={close}
/>
{:else if dialog.kind === "new-document"}
<PromptModal
title="New document"
label="Document name"
icon="ph:file-plus"
placeholder="notes"
suffix=".typ"
onsubmit={createDocument}
onclose={close}
/>
{:else if dialog.kind === "rename-entry"}
{@const target = dialog}
<PromptModal
title="Rename"
label="New name"
value={target.entry.name}
confirmLabel="Rename"
onsubmit={(name) => renameEntry(target.entry, name)}
onclose={close}
/>
{:else if dialog.kind === "delete-entry"}
{@const target = dialog}
<ConfirmModal
title="Delete"
message="'{target.entry.name}' will be permanently removed from this device."
onconfirm={() => deleteEntry(target.entry)}
onclose={close}
/>
{:else if dialog.kind === "link-entry"}
{@const target = dialog}
<ConfirmModal
title="Upload to cloud"
message="A new cloud space will be created for '{target.entry.name}' and its files uploaded."
confirmLabel="Upload"
onconfirm={() => linkEntry(target.entry)}
onclose={close}
/>
{:else if dialog.kind === "new-space"}
<PromptModal
title="New cloud space"
label="Space name"
icon="ph:cloud-plus"
onsubmit={createSpace}
onclose={close}
/>
{:else if dialog.kind === "delete-space"}
{@const target = dialog}
<ConfirmModal
title="Delete cloud space"
message="This permanently deletes the space and its files from TypstDrive. Local copies are kept."
onconfirm={() => deleteSpace(target.id)}
onclose={close}
/>
{:else if dialog.kind === "clone-space"}
{@const target = dialog}
<PromptModal
title="Download space"
label="Save as project"
icon="ph:download-simple"
value={target.name}
confirmLabel="Download"
onsubmit={(name) => cloneSpace(target.id, name)}
onclose={close}
/>
{:else if dialog.kind === "new-file"}
<PromptModal
title="New file"
label="File name"
icon="ph:file-plus"
placeholder="chapter-1"
onsubmit={createFileInTarget}
onclose={close}
/>
{:else if dialog.kind === "new-subfolder"}
<PromptModal
title="New folder"
label="Folder name"
icon="ph:folder-plus"
placeholder="figures"
onsubmit={createFolderInTarget}
onclose={close}
/>
{:else if dialog.kind === "rename-file"}
{@const target = dialog}
<PromptModal
title="Rename file"
label="New path"
value={target.path}
confirmLabel="Rename"
onsubmit={(next) => renameFile(target.path, next)}
onclose={close}
/>
{:else if dialog.kind === "delete-file"}
{@const target = dialog}
<ConfirmModal
title="Delete file"
message="'{target.path}' will be permanently deleted."
onconfirm={() => deleteFile(target.path)}
onclose={close}
/>
{:else if dialog.kind === "login"}
<LoginModal
serverUrl={app.settings?.server_url ?? ""}
onsuccess={async () => {
close();
await refreshAccount();
app.scope = "cloud";
setStatus("Connected to TypstDrive");
}}
onclose={close}
/>
{:else if dialog.kind === "settings"}
<SettingsModal onclose={close} onsignin={() => (dialog = { kind: "login" })} />
{:else if dialog.kind === "info"}
<InfoModal onclose={close} />
{:else if dialog.kind === "assets"}
<AssetsModal
oninsert={app.view === "editor" && editorView
? (snippet) => {
insertText(editorView, snippet);
close();
}
: undefined}
onchanged={compile}
onclose={close}
/>
{:else if dialog.kind === "page-settings"}
<PageSettingsModal view={editorView} onclose={close} />
{:else if dialog.kind === "conflicts"}
<ConflictModal
conflicts={app.conflicts}
onresolve={app.documentLink ? resolveDocumentConflicts : resolveConflicts}
onclose={close}
/>
{/if}