Add Typst Desktop app
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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]*$/,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 })];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user