feat: barebones MVP — editor, live LSP, and preview
Backend (typst-leaf-backend) - REST API for multi-project file management under PROJECTS_ROOT (list/create projects, get file tree, read/write files; zod-validated sandbox prevents path traversal). - LSP thin proxy: spawns a single tinymist lsp process, pipes one active WebSocket straight to its stdio via vscode-ws-jsonrpc. New connections trigger a takeover (old session disposed, fresh tinymist spawned). - Live preview pipeline: the frontend sends tinymist.startDefaultPreview over the LSP wire, reads the returned data-plane port, and relays it to the backend. The backend proxies /preview (WS, with retry and an Origin header for typst-preview) and /preview-app (HTTP, injecting a script that rewrites the webview's WS path to /preview and forces light mode). - Graceful SIGTERM/SIGINT shutdown. - Auto-seeds a hello project on first boot. - Removed execa dependency (no longer needed). Frontend (typst-leaf-frontend) - Monaco editor with typst syntax highlighting (Monarch tokenizer) and live LSP diagnostics (markers), completion, hover, definitions, and formatting — all driven by a raw JSON-RPC WebSocket client (no monaco-languageclient or vscode-api dependency). - Project picker and file tree sidebar. - Live preview pane: iframes the typst-preview webview through the backend's /preview-app proxy, so the pipedepeline works remotely. - Monaco bundled locally (no CDN) with a Vite worker setup. - Vite dev proxy so relative /api, /lsp, /preview, and /preVIEW-app URLs reach the backend on port 4000. - Light-mode UI (editor 'vs' theme, white chrome). - Prevent esbuild from tree-shaking monaco.editor services (optimizeDeps.esbuildOptions.treeShaking: false). Infrastructure - Added vscode-ws-jsonrpc to backend dependencies. - Removed monaco-languageclient and vscode-ws-jsonrpc from frontend dependencies (replaced by the raw JSON-RPC client). - Added monaco-editor as a direct frontend dependency. - Updated README (correct frontend port 1420, tinymist install command, environment variables, architecture). - Added projects/ to .gitignore. - Renamed Tauri productName and page title to typst-leaf.
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + React + Typescript</title>
|
||||
<title>typst-leaf</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -14,11 +14,10 @@
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"monaco-languageclient": "^10.7.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"monaco-vim": "^0.4.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"vscode-ws-jsonrpc": "^3.5.0",
|
||||
"zod": "^3.24.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "typst-leaf-frontend",
|
||||
"productName": "typst-leaf",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.nav.typst-leaf-frontend",
|
||||
"build": {
|
||||
|
||||
@@ -1,5 +1,48 @@
|
||||
function App() {
|
||||
return <div className="h-screen w-screen bg-zinc-900 text-zinc-100" />;
|
||||
import { AppProvider, useApp } from "./app-state";
|
||||
import { EditorView } from "./components/Editor";
|
||||
import { FileTree } from "./components/FileTree";
|
||||
import { Preview } from "./components/Preview";
|
||||
import { ProjectPicker } from "./components/ProjectPicker";
|
||||
|
||||
function Shell() {
|
||||
const { state, lsp } = useApp();
|
||||
return (
|
||||
<div className="h-screen w-screen bg-white text-zinc-800 flex">
|
||||
<aside className="w-60 border-r border-zinc-200 flex flex-col bg-zinc-50">
|
||||
<div className="px-3 py-2 border-b border-zinc-200 text-sm font-semibold flex items-center gap-2">
|
||||
<span>typst-leaf</span>
|
||||
<span className="ml-auto text-xs font-normal text-zinc-400">
|
||||
{lsp.status === "ready"
|
||||
? "●"
|
||||
: lsp.status === "connecting"
|
||||
? "◌"
|
||||
: lsp.status === "error"
|
||||
? "✕"
|
||||
: "○"}
|
||||
</span>
|
||||
</div>
|
||||
<ProjectPicker />
|
||||
<div className="flex-1 overflow-auto">
|
||||
<FileTree />
|
||||
</div>
|
||||
{state.error && (
|
||||
<div className="p-2 text-xs text-red-500 border-t border-zinc-200">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
<main className="flex-1 flex min-w-0">
|
||||
<EditorView />
|
||||
<Preview />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default function App() {
|
||||
return (
|
||||
<AppProvider>
|
||||
<Shell />
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type {
|
||||
FileResponse,
|
||||
Project,
|
||||
TreeResponse,
|
||||
} from "./types";
|
||||
|
||||
const API = "/api";
|
||||
|
||||
async function jsonOrThrow<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`${res.status} ${res.statusText}: ${text}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listProjects: () =>
|
||||
fetch(`${API}/projects`).then((r) =>
|
||||
jsonOrThrow<{ projects: string[] }>(r).then((d) => d.projects),
|
||||
),
|
||||
createProject: (name: string) =>
|
||||
fetch(`${API}/projects/${encodeURIComponent(name)}`, { method: "POST" }).then(
|
||||
(r) => jsonOrThrow<Project>(r),
|
||||
),
|
||||
getProject: (name: string) =>
|
||||
fetch(`${API}/projects/${encodeURIComponent(name)}`).then((r) =>
|
||||
jsonOrThrow<Project>(r),
|
||||
),
|
||||
getTree: (name: string) =>
|
||||
fetch(`${API}/projects/${encodeURIComponent(name)}/tree`).then((r) =>
|
||||
jsonOrThrow<TreeResponse>(r),
|
||||
),
|
||||
getFile: (project: string, path: string) =>
|
||||
fetch(
|
||||
`${API}/projects/${encodeURIComponent(project)}/file?path=${encodeURIComponent(path)}`,
|
||||
).then((r) => jsonOrThrow<FileResponse>(r)),
|
||||
putFile: (project: string, path: string, content: string) =>
|
||||
fetch(
|
||||
`${API}/projects/${encodeURIComponent(project)}/file?path=${encodeURIComponent(path)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
},
|
||||
).then((r) => jsonOrThrow<FileResponse>(r)),
|
||||
sinkPreviewPort: (port: number) =>
|
||||
fetch(`${API}/preview/sink`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ port }),
|
||||
}).then((r) => jsonOrThrow<{ ok: boolean; port: number }>(r)),
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { api } from "./api";
|
||||
import { loadMonaco } from "./monaco-setup";
|
||||
import { startLsp, startPreview, type RawLspClient } from "./lsp";
|
||||
import type { Monaco } from "@monaco-editor/react";
|
||||
import type { FileEntry } from "./types";
|
||||
|
||||
interface State {
|
||||
projects: string[];
|
||||
currentProject: string | null;
|
||||
currentUri: string | null;
|
||||
tree: FileEntry[];
|
||||
openFile: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { type: "setProjects"; projects: string[] }
|
||||
| { type: "selectProject"; project: string; uri: string }
|
||||
| { type: "setTree"; tree: FileEntry[] }
|
||||
| { type: "openFile"; path: string | null }
|
||||
| { type: "loading"; loading: boolean }
|
||||
| { type: "error"; error: string | null };
|
||||
|
||||
const initial: State = {
|
||||
projects: [],
|
||||
currentProject: null,
|
||||
currentUri: null,
|
||||
tree: [],
|
||||
openFile: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "setProjects":
|
||||
return { ...state, projects: action.projects };
|
||||
case "selectProject":
|
||||
return {
|
||||
...state,
|
||||
currentProject: action.project,
|
||||
currentUri: action.uri,
|
||||
tree: [],
|
||||
openFile: null,
|
||||
};
|
||||
case "setTree":
|
||||
return { ...state, tree: action.tree };
|
||||
case "openFile":
|
||||
return { ...state, openFile: action.path };
|
||||
case "loading":
|
||||
return { ...state, loading: action.loading };
|
||||
case "error":
|
||||
return { ...state, error: action.error };
|
||||
}
|
||||
}
|
||||
|
||||
interface LspState {
|
||||
monaco: Monaco | null;
|
||||
client: RawLspClient | null;
|
||||
status: "idle" | "connecting" | "ready" | "error";
|
||||
previewReady: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const AppContext = createContext<{
|
||||
state: State;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
lsp: LspState;
|
||||
} | null>(null);
|
||||
|
||||
export function useApp() {
|
||||
const ctx = useContext(AppContext);
|
||||
if (!ctx) throw new Error("useApp outside provider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function AppProvider({ children }: { children: ReactNode }) {
|
||||
const [state, dispatch] = useReducer(reducer, initial);
|
||||
const [lsp, setLsp] = useState<LspState>({
|
||||
monaco: null,
|
||||
client: null,
|
||||
status: "idle",
|
||||
previewReady: false,
|
||||
error: null,
|
||||
});
|
||||
const sessionRef = useRef<{ dispose: () => void } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadMonaco()
|
||||
.then((monaco) => setLsp((s) => ({ ...s, monaco })))
|
||||
.catch((e) =>
|
||||
setLsp((s) => ({ ...s, status: "error", error: String(e) })),
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.listProjects()
|
||||
.then((projects) => dispatch({ type: "setProjects", projects }))
|
||||
.catch((e) => dispatch({ type: "error", error: String(e) }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.currentProject || !lsp.monaco) return;
|
||||
const sessionRefLocal = sessionRef;
|
||||
let cancelled = false;
|
||||
const project = state.currentProject;
|
||||
const uri = state.currentUri!;
|
||||
setLsp((s) => ({ ...s, status: "connecting", previewReady: false }));
|
||||
startLsp(lsp.monaco, project, uri, async (client) => {
|
||||
// First file opened: ask tinymist for a live preview and relay the
|
||||
// (random) data-plane port to the backend so /preview-app can proxy.
|
||||
try {
|
||||
const port = await startPreview(client);
|
||||
await api.sinkPreviewPort(port);
|
||||
setLsp((s) => ({ ...s, previewReady: true }));
|
||||
} catch (e) {
|
||||
console.error("preview start failed:", String(e));
|
||||
}
|
||||
})
|
||||
.then((session) => {
|
||||
if (cancelled) {
|
||||
session.dispose();
|
||||
return;
|
||||
}
|
||||
sessionRefLocal.current = session;
|
||||
setLsp({
|
||||
monaco: lsp.monaco,
|
||||
client: session.client,
|
||||
status: "ready",
|
||||
previewReady: false,
|
||||
error: null,
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("[lsp] startLsp failed:", e);
|
||||
if (!cancelled) {
|
||||
setLsp({
|
||||
monaco: lsp.monaco,
|
||||
client: null,
|
||||
status: "error",
|
||||
previewReady: false,
|
||||
error: e instanceof Error ? `${e.name}: ${e.message}` : String(e),
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
sessionRefLocal.current?.dispose();
|
||||
sessionRefLocal.current = null;
|
||||
};
|
||||
}, [state.currentProject, state.currentUri, lsp.monaco]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.currentProject) return;
|
||||
api
|
||||
.getTree(state.currentProject)
|
||||
.then((t) => dispatch({ type: "setTree", tree: t.files }))
|
||||
.catch((e) => dispatch({ type: "error", error: String(e) }));
|
||||
}, [state.currentProject]);
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={{ state, dispatch, lsp }}>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import Editor, { type Monaco, type OnMount } from "@monaco-editor/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { useApp } from "../app-state";
|
||||
|
||||
type ITextModel = Monaco["editor"]["ITextModel"];
|
||||
type IStandaloneCodeEditor = Monaco["editor"]["IStandaloneCodeEditor"];
|
||||
|
||||
export function EditorView() {
|
||||
const { state, lsp } = useApp();
|
||||
const monaco = lsp.monaco;
|
||||
const [model, setModel] = useState<ITextModel | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const editorRef = useRef<IStandaloneCodeEditor | null>(null);
|
||||
const project = state.currentProject;
|
||||
const uri = state.currentUri;
|
||||
const file = state.openFile;
|
||||
const savingRef = useRef(false);
|
||||
|
||||
// Create/replace the Monaco model for the open file. Monaco comes from the
|
||||
// app-level loader (no per-component polling); the effect re-runs when the
|
||||
// file, project, or monaco instance changes.
|
||||
useEffect(() => {
|
||||
if (!monaco || !project || !file || !uri) {
|
||||
setModel((m: ITextModel | null) => {
|
||||
m?.dispose();
|
||||
return null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
api
|
||||
.getFile(project, file)
|
||||
.then((f) => {
|
||||
if (cancelled) return;
|
||||
const base = uri.endsWith("/") ? uri : uri + "/";
|
||||
const docUri = base + file;
|
||||
// Create the model OUTSIDE the setState updater: React StrictMode
|
||||
// double-invokes updaters with the same `prev`, and createModel is a
|
||||
// side effect (the second call would crash on "model already exists").
|
||||
// Disposing any stale model with this URI also keeps remounts idempotent.
|
||||
const uriObj = monaco.Uri.parse(docUri);
|
||||
monaco.editor.getModel(uriObj)?.dispose();
|
||||
const next = monaco.editor.createModel(f.content, "typst", uriObj);
|
||||
setModel((prev: ITextModel | null) => {
|
||||
prev?.dispose();
|
||||
return next;
|
||||
});
|
||||
// Creating the model triggers the LSP `didOpen` (via bindModels in
|
||||
// app-state), which in turn starts the live preview. No disk write
|
||||
// needed — the preview is driven by the LSP document state.
|
||||
})
|
||||
.catch((e) => !cancelled && setError(String(e)));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [monaco, project, file, uri]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && model) {
|
||||
editorRef.current.setModel(model);
|
||||
}
|
||||
}, [model]);
|
||||
|
||||
async function save() {
|
||||
if (!project || !file || !model || savingRef.current) return;
|
||||
savingRef.current = true;
|
||||
try {
|
||||
await api.putFile(project, file, model.getValue());
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
const handleMount: OnMount = (editor, m) => {
|
||||
editorRef.current = editor;
|
||||
if (model) editor.setModel(model);
|
||||
editor.addCommand(m.KeyMod.CtrlCmd | m.KeyCode.KeyS, () => {
|
||||
void save();
|
||||
});
|
||||
};
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-zinc-400 text-sm">
|
||||
Select a project to begin.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!file) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-zinc-400 text-sm">
|
||||
Open a file from the sidebar.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-red-400 text-sm p-4">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-zinc-200 text-xs text-zinc-500 font-mono">
|
||||
<span>{file}</span>
|
||||
<span className="ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void save()}
|
||||
className="bg-white hover:bg-zinc-100 text-zinc-700 border border-zinc-300 px-2 py-0.5 rounded"
|
||||
>
|
||||
Save (Ctrl+S)
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<Editor
|
||||
defaultLanguage="typst"
|
||||
theme="vs"
|
||||
onMount={handleMount}
|
||||
options={{
|
||||
fontSize: 14,
|
||||
minimap: { enabled: false },
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useApp } from "../app-state";
|
||||
|
||||
export function FileTree() {
|
||||
const { state, dispatch } = useApp();
|
||||
|
||||
if (!state.currentProject) {
|
||||
return (
|
||||
<div className="p-2 text-xs text-zinc-400">select a project</div>
|
||||
);
|
||||
}
|
||||
if (state.tree.length === 0) {
|
||||
return <div className="p-2 text-xs text-zinc-400">no .typ files</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 p-2">
|
||||
<div className="text-xs uppercase tracking-wide text-zinc-400 mb-1">
|
||||
Files
|
||||
</div>
|
||||
{state.tree.map((f) => (
|
||||
<button
|
||||
key={f.path}
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: "openFile", path: f.path })}
|
||||
className={`text-left text-sm px-2 py-1 rounded font-mono ${
|
||||
f.path === state.openFile
|
||||
? "bg-emerald-100 text-emerald-800 font-medium"
|
||||
: "hover:bg-zinc-100 text-zinc-600"
|
||||
}`}
|
||||
>
|
||||
{f.path}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useApp } from "../app-state";
|
||||
|
||||
export function Preview() {
|
||||
const { state, lsp } = useApp();
|
||||
|
||||
let status = "idle";
|
||||
if (!state.currentProject) status = "no project";
|
||||
else if (lsp.status !== "ready") status = `LSP ${lsp.status}`;
|
||||
else if (!lsp.previewReady) status = "starting preview…";
|
||||
else status = "live";
|
||||
|
||||
return (
|
||||
<div className="w-1/2 border-l border-zinc-200 flex flex-col bg-white">
|
||||
<div className="px-3 py-1.5 border-b border-zinc-200 text-xs text-zinc-500 font-mono flex items-center gap-2">
|
||||
<span>Preview</span>
|
||||
<span className="ml-auto text-zinc-400">{status}</span>
|
||||
</div>
|
||||
<div className="flex-1 bg-zinc-50 relative">
|
||||
{state.currentProject && lsp.status === "ready" && lsp.previewReady ? (
|
||||
<iframe
|
||||
key={state.currentProject}
|
||||
src="/preview-app"
|
||||
title="Typst preview"
|
||||
className="absolute inset-0 w-full h-full bg-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-zinc-400 text-sm p-6 gap-2">
|
||||
<span>{status}</span>
|
||||
{lsp.status === "error" && lsp.error && (
|
||||
<pre className="mt-2 max-w-full overflow-auto text-xs text-red-500 bg-red-50 p-3 rounded whitespace-pre-wrap break-all">
|
||||
{lsp.error}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { useApp } from "../app-state";
|
||||
|
||||
export function ProjectPicker() {
|
||||
const { state, dispatch } = useApp();
|
||||
const [name, setName] = useState("");
|
||||
|
||||
async function select(p: string) {
|
||||
if (p === state.currentProject) return;
|
||||
const info = await api.getProject(p);
|
||||
dispatch({ type: "selectProject", project: info.project, uri: info.uri });
|
||||
}
|
||||
|
||||
async function create() {
|
||||
const n = name.trim();
|
||||
if (!n) return;
|
||||
await api.createProject(n);
|
||||
setName("");
|
||||
const projects = await api.listProjects();
|
||||
dispatch({ type: "setProjects", projects });
|
||||
const info = await api.getProject(n);
|
||||
dispatch({ type: "selectProject", project: info.project, uri: info.uri });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-2 border-b border-zinc-200">
|
||||
<div className="text-xs uppercase tracking-wide text-zinc-400">
|
||||
Projects
|
||||
</div>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{state.projects.map((p) => (
|
||||
<li key={p}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(p)}
|
||||
className={`w-full text-left text-sm px-2 py-1 rounded ${
|
||||
p === state.currentProject
|
||||
? "bg-emerald-100 text-emerald-800 font-medium"
|
||||
: "hover:bg-zinc-100 text-zinc-600"
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{state.projects.length === 0 && (
|
||||
<li className="text-xs text-zinc-400 px-2">no projects yet</li>
|
||||
)}
|
||||
</ul>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") create();
|
||||
}}
|
||||
placeholder="new-project"
|
||||
className="flex-1 bg-white text-zinc-800 text-sm px-2 py-1 rounded border border-zinc-300 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 placeholder:text-zinc-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={create}
|
||||
className="text-sm bg-emerald-600 hover:bg-emerald-500 text-white px-2 py-1 rounded"
|
||||
>
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
import type { Monaco } from "@monaco-editor/react";
|
||||
|
||||
// ============================================================================
|
||||
// Raw LSP client over WebSocket.
|
||||
//
|
||||
// We deliberately avoid monaco-languageclient / vscode-languageclient here.
|
||||
// monaco-languageclient v10's low-level client requires bootstrapping the
|
||||
// entire @codingame/monaco-vscode-api service stack (the wrapper does this),
|
||||
// which is heavy and fragile. Driving LSP directly is ~300 lines, has no
|
||||
// magic, and the model<->LSP binding + Monaco provider registration are done
|
||||
// explicitly. The wire protocol is just JSON-RPC messages, one per WebSocket
|
||||
// text frame (raw JSON, no Content-Length framing — that's only for stdio).
|
||||
// ============================================================================
|
||||
|
||||
interface LspSession {
|
||||
client: RawLspClient;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
type NotifHandler = (params: unknown) => void;
|
||||
type ReqHandler = (params: unknown) => Promise<unknown> | unknown;
|
||||
|
||||
export class RawLspClient {
|
||||
private ws: WebSocket;
|
||||
private nextId = 1;
|
||||
private pending = new Map<
|
||||
number,
|
||||
{ resolve: (v: unknown) => void; reject: (e: Error) => void }
|
||||
>();
|
||||
private notifHandlers = new Map<string, NotifHandler[]>();
|
||||
private reqHandlers = new Map<string, ReqHandler>();
|
||||
|
||||
constructor(ws: WebSocket) {
|
||||
this.ws = ws;
|
||||
ws.addEventListener("message", (e) => this.onMessage(e));
|
||||
}
|
||||
|
||||
private onMessage(e: MessageEvent): void {
|
||||
let msg: Record<string, unknown>;
|
||||
try {
|
||||
msg = JSON.parse(
|
||||
typeof e.data === "string" ? e.data : e.data.toString(),
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const hasMethod = typeof msg.method === "string";
|
||||
const id = msg.id;
|
||||
const hasId = id !== undefined && id !== null;
|
||||
|
||||
if (hasMethod && hasId) {
|
||||
// request from server -> handle and reply
|
||||
const handler = this.reqHandlers.get(msg.method as string);
|
||||
Promise.resolve(handler ? handler(msg.params) : undefined).then(
|
||||
(result) =>
|
||||
this.send({ jsonrpc: "2.0", id, result: result ?? null }),
|
||||
(err) =>
|
||||
this.send({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else if (hasMethod) {
|
||||
// notification from server
|
||||
const handlers = this.notifHandlers.get(msg.method as string);
|
||||
if (handlers) for (const h of handlers) h(msg.params);
|
||||
} else if (hasId) {
|
||||
// response to a request we sent
|
||||
const p = this.pending.get(id as number);
|
||||
if (p) {
|
||||
this.pending.delete(id as number);
|
||||
if (msg.error) {
|
||||
p.reject(
|
||||
new Error(
|
||||
(msg.error as { message: string }).message ?? "LSP error",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
p.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private send(msg: unknown): void {
|
||||
if (this.ws.readyState === this.ws.OPEN) {
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
notify(method: string, params?: unknown): void {
|
||||
this.send({ jsonrpc: "2.0", method, params });
|
||||
}
|
||||
|
||||
sendRequest(method: string, params?: unknown): Promise<unknown> {
|
||||
const id = this.nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject });
|
||||
this.send({ jsonrpc: "2.0", id, method, params });
|
||||
});
|
||||
}
|
||||
|
||||
onNotification(method: string, handler: NotifHandler): void {
|
||||
const arr = this.notifHandlers.get(method) ?? [];
|
||||
arr.push(handler);
|
||||
this.notifHandlers.set(method, arr);
|
||||
}
|
||||
|
||||
onRequest(method: string, handler: ReqHandler): void {
|
||||
this.reqHandlers.set(method, handler);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
for (const [, p] of this.pending) p.reject(new Error("disposed"));
|
||||
this.pending.clear();
|
||||
this.notifHandlers.clear();
|
||||
this.reqHandlers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- LSP <-> Monaco type helpers ----------
|
||||
|
||||
interface LspPosition {
|
||||
line: number;
|
||||
character: number;
|
||||
}
|
||||
interface LspRange {
|
||||
start: LspPosition;
|
||||
end: LspPosition;
|
||||
}
|
||||
interface LspDiagnostic {
|
||||
message: string;
|
||||
severity?: number;
|
||||
source?: string;
|
||||
range: LspRange;
|
||||
}
|
||||
|
||||
function toMonacoRange(
|
||||
monaco: Monaco,
|
||||
r: LspRange | undefined,
|
||||
): Monaco["ranges"]["IRange"] {
|
||||
if (!r) return new monaco.Range(1, 1, 1, 1);
|
||||
return new monaco.Range(
|
||||
r.start.line + 1,
|
||||
r.start.character + 1,
|
||||
r.end.line + 1,
|
||||
r.end.character + 1,
|
||||
);
|
||||
}
|
||||
|
||||
function toMarker(d: LspDiagnostic): Monaco["editor"]["IMarkerData"] {
|
||||
// LSP severity: 1=Error 2=Warning 3=Info 4=Hint
|
||||
// MarkerSeverity: Error=8 Warning=4 Info=2 Hint=1
|
||||
const sev =
|
||||
d.severity === 1 ? 8 : d.severity === 2 ? 4 : d.severity === 3 ? 2 : 1;
|
||||
return {
|
||||
message: d.message,
|
||||
severity: sev as Monaco["MarkerSeverity"],
|
||||
source: d.source ?? "typst",
|
||||
startLineNumber: d.range.start.line + 1,
|
||||
startColumn: d.range.start.character + 1,
|
||||
endLineNumber: d.range.end.line + 1,
|
||||
endColumn: d.range.end.character + 1,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- Model <-> LSP binding ----------
|
||||
|
||||
interface ModelBinding {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
function bindModels(
|
||||
client: RawLspClient,
|
||||
monaco: Monaco,
|
||||
onFirstOpen?: () => void,
|
||||
): ModelBinding {
|
||||
const versions = new Map<string, number>();
|
||||
const opened = new Set<string>();
|
||||
const disposers: Array<() => void> = [];
|
||||
const perModel = new Map<string, () => void>();
|
||||
let firstOpenFired = false;
|
||||
|
||||
const openModel = (model: Monaco["editor"]["ITextModel"]) => {
|
||||
const u = model.uri.toString();
|
||||
// The @monaco-editor/react <Editor> creates default models with
|
||||
// inmemory:// URIs. We only sync models that have file:// URIs
|
||||
// (the ones we explicitly create), otherwise tinymist panics trying
|
||||
// to resolve the URI.
|
||||
if (model.uri.scheme !== "file") return;
|
||||
if (opened.has(u) || model.getLanguageId() !== "typst") return;
|
||||
opened.add(u);
|
||||
versions.set(u, 1);
|
||||
client.notify("textDocument/didOpen", {
|
||||
textDocument: {
|
||||
uri: u,
|
||||
languageId: "typst",
|
||||
version: 1,
|
||||
text: model.getValue(),
|
||||
},
|
||||
});
|
||||
const onChange = model.onDidChangeContent(() => {
|
||||
const v = (versions.get(u) ?? 1) + 1;
|
||||
versions.set(u, v);
|
||||
client.notify("textDocument/didChange", {
|
||||
textDocument: { uri: u, version: v },
|
||||
contentChanges: [{ text: model.getValue() }],
|
||||
});
|
||||
});
|
||||
perModel.set(u, () => onChange.dispose());
|
||||
if (!firstOpenFired) {
|
||||
firstOpenFired = true;
|
||||
onFirstOpen?.();
|
||||
}
|
||||
};
|
||||
|
||||
const closeModel = (uri: string) => {
|
||||
if (!opened.has(uri)) return;
|
||||
opened.delete(uri);
|
||||
versions.delete(uri);
|
||||
perModel.get(uri)?.();
|
||||
perModel.delete(uri);
|
||||
client.notify("textDocument/didClose", { textDocument: { uri } });
|
||||
};
|
||||
|
||||
for (const m of monaco.editor.getModels()) openModel(m);
|
||||
disposers.push(
|
||||
monaco.editor.onDidCreateModel((m: Monaco["editor"]["ITextModel"]) =>
|
||||
openModel(m),
|
||||
),
|
||||
);
|
||||
disposers.push(
|
||||
monaco.editor.onWillDisposeModel((m: Monaco["editor"]["ITextModel"]) => {
|
||||
if (m.uri.scheme === "file") closeModel(m.uri.toString());
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
dispose() {
|
||||
for (const d of disposers) d();
|
||||
for (const [, d] of perModel) d();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- Monaco provider registration (LSP-backed) ----------
|
||||
|
||||
function registerProviders(
|
||||
client: RawLspClient,
|
||||
monaco: Monaco,
|
||||
capabilities: Record<string, { [k: string]: unknown } | undefined>,
|
||||
): Monaco["IDisposable"][] {
|
||||
const disposables: Monaco["IDisposable"][] = [];
|
||||
const doc = (model: Monaco["editor"]["ITextModel"]) => ({
|
||||
textDocument: { uri: model.uri.toString() },
|
||||
});
|
||||
const pos = (p: Monaco["Position"]) => ({
|
||||
line: p.lineNumber - 1,
|
||||
character: p.column - 1,
|
||||
});
|
||||
type ITextModel = Monaco["editor"]["ITextModel"];
|
||||
type IPosition = Monaco["Position"];
|
||||
|
||||
if (capabilities.completionProvider) {
|
||||
const triggers =
|
||||
(capabilities.completionProvider.triggerCharacters as string[] | undefined) ??
|
||||
[];
|
||||
disposables.push(
|
||||
monaco.languages.registerCompletionItemProvider("typst", {
|
||||
triggerCharacters: triggers,
|
||||
provideCompletionItems: async (model: ITextModel, position: IPosition) => {
|
||||
const r = await client.sendRequest("textDocument/completion", {
|
||||
...doc(model),
|
||||
position: pos(position),
|
||||
});
|
||||
const items = Array.isArray(r)
|
||||
? r
|
||||
: ((r as { items?: unknown[] })?.items ?? []);
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = new monaco.Range(
|
||||
position.lineNumber,
|
||||
word.startColumn,
|
||||
position.lineNumber,
|
||||
word.endColumn,
|
||||
);
|
||||
return {
|
||||
suggestions: (items as Record<string, unknown>[]).map((it) => ({
|
||||
label: it.label as string,
|
||||
kind: (it.kind as number) ?? 1,
|
||||
detail: it.detail as string,
|
||||
documentation: (
|
||||
typeof it.documentation === "string"
|
||||
? it.documentation
|
||||
: (it.documentation as { value?: string })?.value
|
||||
) as string,
|
||||
insertText: (it.insertText as string) ?? (it.label as string),
|
||||
insertTextRules:
|
||||
it.insertTextFormat === 2
|
||||
? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet
|
||||
: undefined,
|
||||
range,
|
||||
})),
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (capabilities.hoverProvider) {
|
||||
disposables.push(
|
||||
monaco.languages.registerHoverProvider("typst", {
|
||||
provideHover: async (model: ITextModel, position: IPosition) => {
|
||||
const r = (await client.sendRequest("textDocument/hover", {
|
||||
...doc(model),
|
||||
position: pos(position),
|
||||
})) as { contents?: unknown[]; range?: LspRange } | null;
|
||||
if (!r) return undefined;
|
||||
const contents = (r.contents ?? []).map((c) =>
|
||||
typeof c === "string"
|
||||
? c
|
||||
: (c as { value?: string })?.value ?? "",
|
||||
);
|
||||
return {
|
||||
range: toMonacoRange(monaco, r.range),
|
||||
contents: contents.filter(Boolean),
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const defProv = async (
|
||||
model: Monaco["editor"]["ITextModel"],
|
||||
position: Monaco["Position"],
|
||||
) => {
|
||||
const r = await client.sendRequest("textDocument/definition", {
|
||||
...doc(model),
|
||||
position: pos(position),
|
||||
});
|
||||
const locs = (Array.isArray(r) ? r : r ? [r] : []) as {
|
||||
uri: string;
|
||||
range: LspRange;
|
||||
}[];
|
||||
return locs.map((l) => ({
|
||||
uri: monaco.Uri.parse(l.uri),
|
||||
range: toMonacoRange(monaco, l.range),
|
||||
}));
|
||||
};
|
||||
|
||||
if (capabilities.definitionProvider) {
|
||||
disposables.push(
|
||||
monaco.languages.registerDefinitionProvider("typst", {
|
||||
provideDefinition: defProv,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (capabilities.referencesProvider) {
|
||||
disposables.push(
|
||||
monaco.languages.registerReferenceProvider("typst", {
|
||||
provideReferences: defProv,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (capabilities.documentSymbolProvider) {
|
||||
disposables.push(
|
||||
monaco.languages.registerDocumentSymbolProvider("typst", {
|
||||
provideDocumentSymbols: async (model: ITextModel) => {
|
||||
const r = await client.sendRequest(
|
||||
"textDocument/documentSymbol",
|
||||
doc(model),
|
||||
);
|
||||
return ((r as Record<string, unknown>[]) ?? []).map((s) => ({
|
||||
name: s.name as string,
|
||||
detail: (s.detail as string) ?? "",
|
||||
kind: (s.kind as number) ?? 1,
|
||||
range: toMonacoRange(monaco, s.range as LspRange),
|
||||
selectionRange: toMonacoRange(monaco, s.selectionRange as LspRange),
|
||||
}));
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (capabilities.documentFormattingProvider) {
|
||||
disposables.push(
|
||||
monaco.languages.registerDocumentFormattingEditProvider("typst", {
|
||||
provideDocumentFormattingEdits: async (model: ITextModel) => {
|
||||
const r = await client.sendRequest("textDocument/formatting", {
|
||||
...doc(model),
|
||||
options: { tabSize: 2, insertSpaces: true },
|
||||
});
|
||||
return ((r as { range: LspRange; newText: string }[]) ?? []).map(
|
||||
(e) => ({
|
||||
range: toMonacoRange(monaco, e.range),
|
||||
text: e.newText,
|
||||
}),
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return disposables;
|
||||
}
|
||||
|
||||
// ---------- Session bootstrap ----------
|
||||
|
||||
export async function startLsp(
|
||||
monaco: Monaco,
|
||||
projectName: string,
|
||||
projectUri: string,
|
||||
onFirstOpen?: (client: RawLspClient) => void,
|
||||
): Promise<LspSession> {
|
||||
const wsUrl = `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/lsp`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onOpen = () => {
|
||||
ws.removeEventListener("open", onOpen);
|
||||
ws.removeEventListener("error", onError);
|
||||
resolve();
|
||||
};
|
||||
const onError = (e: Event) => {
|
||||
ws.removeEventListener("open", onOpen);
|
||||
ws.removeEventListener("error", onError);
|
||||
reject(
|
||||
new Error(
|
||||
`WebSocket to ${wsUrl} failed: ${(e as ErrorEvent).message ?? "unknown"}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
ws.addEventListener("open", onOpen);
|
||||
ws.addEventListener("error", onError);
|
||||
});
|
||||
|
||||
const client = new RawLspClient(ws);
|
||||
|
||||
// Diagnostics → Monaco markers (using the low‑level `setModelMarkers`
|
||||
// because esbuild's tree‑shaking strips `createDiagnosticCollection`
|
||||
// from standaloneLanguages when bundling monaco‑editor for the browser).
|
||||
const diagOwner = "typst-leaf";
|
||||
client.onNotification("textDocument/publishDiagnostics", (params) => {
|
||||
const p = params as { uri: string; diagnostics: LspDiagnostic[] };
|
||||
const model = monaco.editor.getModel(monaco.Uri.parse(p.uri));
|
||||
if (model) {
|
||||
monaco.editor.setModelMarkers(
|
||||
model,
|
||||
diagOwner,
|
||||
p.diagnostics.map(toMarker),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Server -> client requests tinymist issues during init
|
||||
client.onRequest("workspace/configuration", (params) => {
|
||||
const items = (params as { items?: unknown[] }).items ?? [];
|
||||
return items.map(() => ({}));
|
||||
});
|
||||
client.onRequest("workspace/workspaceFolders", () => [
|
||||
{ uri: projectUri, name: projectName },
|
||||
]);
|
||||
client.onRequest("client/registerCapability", () => undefined);
|
||||
client.onRequest("window/workDoneProgress/create", () => undefined);
|
||||
|
||||
// LSP handshake
|
||||
// Force typst-preview into light mode by passing `dark` theme as
|
||||
// `--invert-colors=never` during initialization. The preview
|
||||
// `x-preview` input carries a `theme` (string) and a `version` (int);
|
||||
// tinymist passes them through to the Typst document as `sys.inputs`
|
||||
// so templates can read them. We set a light theme here so the
|
||||
// rendering stays light from the very first compile.
|
||||
const initResult = (await client.sendRequest("initialize", {
|
||||
processId: null,
|
||||
rootUri: projectUri,
|
||||
capabilities: {
|
||||
workspace: { configuration: true, workspaceFolders: true },
|
||||
textDocument: {
|
||||
synchronization: { openClose: true, change: 1 },
|
||||
completion: {
|
||||
completionItem: {
|
||||
snippet: true,
|
||||
documentationFormat: ["markdown", "plaintext"],
|
||||
},
|
||||
},
|
||||
hover: { contentFormat: ["markdown", "plaintext"] },
|
||||
signatureHelp: {},
|
||||
definition: {},
|
||||
references: {},
|
||||
documentSymbol: {},
|
||||
formatting: {},
|
||||
publishDiagnostics: { relatedInformation: true },
|
||||
},
|
||||
},
|
||||
trace: "off",
|
||||
workspaceFolders: [{ uri: projectUri, name: projectName }],
|
||||
initializationOptions: {
|
||||
"tinymist.preview.browsing.args": [
|
||||
"--data-plane-host=127.0.0.1:0",
|
||||
"--invert-colors=never",
|
||||
],
|
||||
"tinymist.preview.invert_colors": "never",
|
||||
},
|
||||
})) as { capabilities?: Record<string, { [k: string]: unknown } | undefined> };
|
||||
client.notify("initialized", {});
|
||||
|
||||
const capabilities = initResult.capabilities ?? {};
|
||||
const providers = registerProviders(client, monaco, capabilities);
|
||||
const binding = bindModels(client, monaco, () => onFirstOpen?.(client));
|
||||
|
||||
return {
|
||||
client,
|
||||
dispose: () => {
|
||||
try {
|
||||
binding.dispose();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
for (const p of providers) {
|
||||
try {
|
||||
p.dispose();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
try {
|
||||
client.dispose();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Ask tinymist to start a live (LSP-driven) preview and return the random
|
||||
// data-plane port it assigned. The caller relays this port to the backend
|
||||
// (POST /api/preview/sink) so the backend can proxy the preview webview.
|
||||
export async function startPreview(
|
||||
client: RawLspClient,
|
||||
): Promise<number> {
|
||||
const result = (await client.sendRequest("workspace/executeCommand", {
|
||||
command: "tinymist.startDefaultPreview",
|
||||
arguments: [{}],
|
||||
})) as { dataPlanePort?: number } | null;
|
||||
const port = result?.dataPlanePort;
|
||||
if (typeof port !== "number") {
|
||||
throw new Error("tinymist did not return a preview port");
|
||||
}
|
||||
return port;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { loader, type Monaco } from "@monaco-editor/react";
|
||||
import * as monaco from "monaco-editor";
|
||||
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
|
||||
|
||||
let initialized = false;
|
||||
let monacoPromise: Promise<Monaco> | null = null;
|
||||
|
||||
// A Monarch tokenizer for Typst covering the common surface (headings,
|
||||
// script keywords, math, comments, emphasis, strings, raw blocks). This is
|
||||
// syntax-only; the LSP additionally provides diagnostics and (optionally)
|
||||
// semantic tokens.
|
||||
const typstLanguageDefinition: monaco.languages.IMonarchLanguage = {
|
||||
defaultToken: "",
|
||||
tokenPostfix: ".typst",
|
||||
brackets: [
|
||||
{ open: "(", close: ")", token: "delimiter.parenthesis" },
|
||||
{ open: "[", close: "]", token: "delimiter.square" },
|
||||
{ open: "{", close: "}", token: "delimiter.curly" },
|
||||
],
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/^=+\s.*$/, "keyword.heading"],
|
||||
[/\*[^*]+\*/, "string.emphasis"],
|
||||
[/_[^_]+_/, "string.emphasis"],
|
||||
[/\/\/.*$/, "comment"],
|
||||
[/\/\*/, "comment", "@comment"],
|
||||
[/#\w+/, "keyword"], // #set, #let, #show, #if, ...
|
||||
[/\$/, { token: "string", next: "@math" }],
|
||||
[/"/, "string", "@string"],
|
||||
[/[{}()\[\]]/, "@brackets"],
|
||||
],
|
||||
comment: [
|
||||
[/\*\//, "comment", "@pop"],
|
||||
[/[^*]+/, "comment"],
|
||||
[/\*/, "comment"],
|
||||
],
|
||||
math: [
|
||||
[/\$/, { token: "string", next: "@pop" }],
|
||||
[/\\[a-zA-Z]+/, "keyword"],
|
||||
[/[a-zA-Z]+/, "variable"],
|
||||
[/[^\$]/, "string"],
|
||||
],
|
||||
string: [
|
||||
[/[^"]/, "string"],
|
||||
[/"/, "string", "@pop"],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function initEnvironment(): void {
|
||||
if (initialized) return;
|
||||
// Vite bundles the editor worker via `?worker`. Monaco only needs the base
|
||||
// editor worker for plaintext/typst (no language-specific workers required).
|
||||
self.MonacoEnvironment = {
|
||||
getWorker: () => new editorWorker(),
|
||||
};
|
||||
loader.config({ monaco });
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
export function loadMonaco(): Promise<Monaco> {
|
||||
if (!monacoPromise) {
|
||||
initEnvironment();
|
||||
// loader.init() resolves to the same `monaco` instance we configured above.
|
||||
monacoPromise = loader.init().then((m) => {
|
||||
m.languages.register({ id: "typst" });
|
||||
m.languages.setMonarchTokensProvider("typst", typstLanguageDefinition);
|
||||
return m;
|
||||
});
|
||||
}
|
||||
return monacoPromise;
|
||||
}
|
||||
|
||||
export function getMonaco(): Monaco {
|
||||
return monaco as unknown as Monaco;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface Project {
|
||||
project: string;
|
||||
uri: string;
|
||||
}
|
||||
|
||||
export interface ProjectSummary {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface FileEntry {
|
||||
path: string;
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
export interface TreeResponse {
|
||||
project: string;
|
||||
files: FileEntry[];
|
||||
}
|
||||
|
||||
export interface FileResponse {
|
||||
path: string;
|
||||
content: string;
|
||||
mtime: number;
|
||||
}
|
||||
@@ -8,6 +8,15 @@ const host = process.env.TAURI_DEV_HOST;
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [tailwindcss(), react()],
|
||||
// Force esbuild to tree-shake as little as possible from monaco-editor.
|
||||
// By default, Vite's pre-bundling applies tree-shaking which discards
|
||||
// `monaco.languages.createDiagnosticCollection` and other standalone-editor
|
||||
// services that we need at runtime.
|
||||
optimizeDeps: {
|
||||
esbuildOptions: {
|
||||
treeShaking: false,
|
||||
},
|
||||
},
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
@@ -29,5 +38,19 @@ export default defineConfig(async () => ({
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
// Dev proxy: the SPA talks to the backend via relative URLs so that the
|
||||
// same code works in dev (Vite :1420 → backend :4000) and in self-hosted
|
||||
// prod (behind a reverse proxy). The preview webview and its WS are also
|
||||
// proxied so the iframe is same-origin with the app.
|
||||
proxy: {
|
||||
"/api": "http://localhost:4000",
|
||||
"/preview-app": {
|
||||
target: "http://localhost:4000",
|
||||
// the webview is HTML; rewrite so the path is preserved for upstream
|
||||
ws: false,
|
||||
},
|
||||
"/lsp": { target: "ws://localhost:4000", ws: true },
|
||||
"/preview": { target: "ws://localhost:4000", ws: true },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user