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