+ );
+}
diff --git a/typst-leaf-frontend/src/components/SettingsPanel.tsx b/typst-leaf-frontend/src/components/SettingsPanel.tsx
index cbdf0bf..1bc4476 100644
--- a/typst-leaf-frontend/src/components/SettingsPanel.tsx
+++ b/typst-leaf-frontend/src/components/SettingsPanel.tsx
@@ -26,19 +26,16 @@ function loadIdentity(): Identity {
}
export function SettingsPanel() {
- const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [dirty, setDirty] = useState(false);
useEffect(() => {
- if (open) {
- const id = loadIdentity();
- setName(id.name ?? "");
- setEmail(id.email ?? "");
- setDirty(false);
- }
- }, [open]);
+ const id = loadIdentity();
+ setName(id.name);
+ setEmail(id.email);
+ setDirty(false);
+ }, []);
const save = useCallback(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ name, email }));
@@ -46,72 +43,59 @@ export function SettingsPanel() {
}, [name, email]);
return (
-
-
+
+
+ Git Identity
+
- {open && (
-
-
- Git Identity
-
+
{
+ setName(e.target.value);
+ setDirty(true);
+ }}
+ placeholder="Author name"
+ className="w-full px-2 py-1 text-xs border border-zinc-300 rounded bg-white text-zinc-800 placeholder-zinc-400 outline-none focus:border-emerald-500"
+ />
-
{
- setName(e.target.value);
- setDirty(true);
- }}
- placeholder="Author name"
- className="w-full px-2 py-1 text-xs border border-zinc-300 rounded bg-white text-zinc-800 placeholder-zinc-400 outline-none focus:border-emerald-500"
- />
+
{
+ setEmail(e.target.value);
+ setDirty(true);
+ }}
+ placeholder="Author email"
+ className="w-full px-2 py-1 text-xs border border-zinc-300 rounded bg-white text-zinc-800 placeholder-zinc-400 outline-none focus:border-emerald-500"
+ />
-
{
- setEmail(e.target.value);
- setDirty(true);
- }}
- placeholder="Author email"
- className="w-full px-2 py-1 text-xs border border-zinc-300 rounded bg-white text-zinc-800 placeholder-zinc-400 outline-none focus:border-emerald-500"
- />
+
+
+
+
-
-
-
-
-
-
- Used for Git commits. Stored locally in browser.
-
-
- )}
+
+ Used for Git commits. Stored locally in browser.
+
);
}
diff --git a/typst-leaf-frontend/src/components/StatusBar.tsx b/typst-leaf-frontend/src/components/StatusBar.tsx
new file mode 100644
index 0000000..714eeea
--- /dev/null
+++ b/typst-leaf-frontend/src/components/StatusBar.tsx
@@ -0,0 +1,53 @@
+import { useApp } from "../app-state";
+
+export function StatusBar() {
+ const { state, lsp } = useApp();
+ const s = state.gitStatus;
+
+ const gitSummary = () => {
+ if (!s) return
git · --;
+ return (
+
+ {s.branch}
+ {s.clean ? (
+ · clean
+ ) : (
+ · {s.unstaged.length + s.staged.length} uncommitted
+ )}
+ {s.ahead > 0 && · ↑{s.ahead}}
+ {s.behind > 0 && · ↓{s.behind}}
+
+ );
+ };
+
+ const lspStatus = () => {
+ if (lsp.status === "ready") return
LSP · ready;
+ if (lsp.status === "connecting") return
LSP · connecting;
+ if (lsp.status === "error") return
LSP · error;
+ return
LSP · idle;
+ };
+
+ const previewStatus = () => {
+ if (!state.openFile) return null;
+ if (lsp.previewReady) return
Preview · live;
+ if (lsp.status === "ready" && !lsp.previewReady) return
Preview · starting;
+ return null;
+ };
+
+ return (
+
+ );
+}
diff --git a/typst-leaf-frontend/src/components/ToastHost.tsx b/typst-leaf-frontend/src/components/ToastHost.tsx
new file mode 100644
index 0000000..aa9b861
--- /dev/null
+++ b/typst-leaf-frontend/src/components/ToastHost.tsx
@@ -0,0 +1,46 @@
+import { useEffect } from "react";
+import { useApp } from "../app-state";
+
+export function ToastHost() {
+ const { state, dispatch } = useApp();
+
+ useEffect(() => {
+ if (!state.notice) return;
+ const timer = setTimeout(() => dispatch({ type: "dismissNotice" }), 4000);
+ return () => clearTimeout(timer);
+ }, [state.notice, dispatch]);
+
+ if (!state.notice) return null;
+
+ const colors = {
+ success: "bg-emerald-50 text-emerald-800 border-emerald-200",
+ error: "bg-rose-50 text-rose-800 border-rose-200",
+ info: "bg-stone-50 text-zinc-700 border-zinc-200",
+ };
+ const dots = {
+ success: "bg-emerald-500",
+ error: "bg-rose-500",
+ info: "bg-zinc-400",
+ };
+
+ return (
+
+
+
+ {state.notice.message}
+
+
+
+ );
+}
diff --git a/typst-leaf-frontend/src/components/TopBar.tsx b/typst-leaf-frontend/src/components/TopBar.tsx
new file mode 100644
index 0000000..26bc6c3
--- /dev/null
+++ b/typst-leaf-frontend/src/components/TopBar.tsx
@@ -0,0 +1,105 @@
+import { useApp } from "../app-state";
+
+export function TopBar() {
+ const { state, dispatch, lsp } = useApp();
+
+ const lspChip = () => {
+ switch (lsp.status) {
+ case "ready":
+ return (
+
+
+ LSP
+
+ );
+ case "connecting":
+ return (
+
+
+ LSP
+
+ );
+ case "error":
+ return (
+
+
+ LSP
+
+ );
+ default:
+ return null;
+ }
+ };
+
+ const previewChip = () => {
+ // No preview without an open file (tinymist previews a document).
+ if (!state.openFile) return null;
+ if (lsp.previewReady) {
+ return (
+
+
+ Preview
+
+ );
+ }
+ if (lsp.status === "ready") {
+ return (
+
+
+ Preview · starting
+
+ );
+ }
+ return null;
+ };
+
+ return (
+
+ );
+}
diff --git a/typst-leaf-frontend/src/lsp.ts b/typst-leaf-frontend/src/lsp.ts
index 92a7502..8a5fba9 100644
--- a/typst-leaf-frontend/src/lsp.ts
+++ b/typst-leaf-frontend/src/lsp.ts
@@ -1,4 +1,5 @@
import type { Monaco } from "@monaco-editor/react";
+import { normalizeFsPath } from "./path-utils";
// ============================================================================
// Raw LSP client over WebSocket.
@@ -425,6 +426,15 @@ interface ShowDocumentParams {
selection?: { start: { line: number; character: number }; end: { line: number; character: number } };
}
+/** Validate an LSP position tuple, returning undefined for malformed values. */
+function toPosPair(v: unknown): [number, number] | undefined {
+ if (!Array.isArray(v) || v.length < 2) return undefined;
+ const a = Number(v[0]);
+ const b = Number(v[1]);
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return undefined;
+ return [a, b];
+}
+
export async function startLsp(
monaco: Monaco,
projectName: string,
@@ -485,7 +495,13 @@ export async function startLsp(
const p = params as ShowDocumentParams;
if (p.external) return { success: false };
if (p.uri && onJumpToSource) {
- const filepath = decodeURIComponent(new URL(p.uri).pathname);
+ let filepath: string;
+ try {
+ filepath = normalizeFsPath(p.uri);
+ } catch {
+ console.warn("[lsp] window/showDocument: invalid uri", p.uri);
+ return { success: false };
+ }
const start = p.selection
? [p.selection.start.line, p.selection.start.character] as [number, number]
: undefined;
@@ -502,18 +518,18 @@ export async function startLsp(
client.onNotification("tinymist/preview/scrollSource", (params) => {
const p = params as {
filepath?: string;
- start?: [number, number];
- end?: [number, number];
+ start?: unknown;
+ end?: unknown;
};
if (!onJumpToSource) return;
- if (typeof p.filepath !== "string") {
- console.warn("[lsp] tinymist/preview/scrollSource missing filepath", params);
+ if (typeof p.filepath !== "string" || !p.filepath.trim()) {
+ console.warn("[lsp] tinymist/preview/scrollSource missing or empty filepath", params);
return;
}
- const filepath = p.filepath.includes("://")
- ? decodeURIComponent(new URL(p.filepath).pathname)
- : p.filepath.replaceAll("\\", "/");
- onJumpToSource({ filepath, start: p.start, end: p.end });
+ const filepath = normalizeFsPath(p.filepath);
+ const start = toPosPair(p.start);
+ const end = toPosPair(p.end);
+ onJumpToSource({ filepath, start, end });
});
// LSP handshake
diff --git a/typst-leaf-frontend/src/path-utils.ts b/typst-leaf-frontend/src/path-utils.ts
new file mode 100644
index 0000000..7765789
--- /dev/null
+++ b/typst-leaf-frontend/src/path-utils.ts
@@ -0,0 +1,77 @@
+/**
+ * Shared path utilities for Typst frontend.
+ *
+ * There is a fundamental path duality in this app:
+ * - The **frontend** works with virtual slash-separated paths relative to the
+ * project root (e.g. "sub/main.typ" or "main.typ").
+ * - The **backend** resolves to absolute OS paths under PROJECTS_ROOT.
+ * - The **LSP** speaks file:// URIs with absolute OS paths.
+ *
+ * All three must be converted cleanly. This module centralises those rules so
+ * that app-state.tsx, Editor.tsx, lsp.ts, etc. do not each re-implement them.
+ */
+
+// ---------- Normalization ----------
+
+/**
+ * Take any path-ish input (file:// URI, native backslash path, forward-slash
+ * pathname) and return a normalised forward-slash pathname.
+ *
+ * file:///home/user/projects/hello/sub/main.typ
+ * → /home/user/projects/hello/sub/main.typ
+ *
+ * file:///C:/Users/user/projects/hello/sub/main.typ
+ * → C:/Users/user/projects/hello/sub/main.typ (leading "/" stripped)
+ *
+ * C:\Users\user\projects\hello\sub\main.typ
+ * → C:/Users/user/projects/hello/sub/main.typ
+ */
+export function normalizeFsPath(input: string): string {
+ let result: string;
+ if (input.includes("://")) {
+ try {
+ result = decodeURIComponent(new URL(input).pathname);
+ } catch {
+ result = input.replaceAll("\\", "/");
+ }
+ } else {
+ result = input.replaceAll("\\", "/");
+ }
+ // Strip the leading "/" from Windows drive-letter paths (file:///C:/… → /C:/… → C:/…)
+ if (/^\/[A-Za-z]:\//.test(result) || /^\/[A-Za-z]:$/.test(result)) {
+ result = result.slice(1);
+ }
+ return result;
+}
+
+/**
+ * Encode a virtual relative path so it can be appended to a file:// base URI.
+ * Each path segment is encoded individually so that "/" separators survive.
+ *
+ * "sub/main.typ" → "sub/main.typ"
+ * "my file.typ" → "my%20file.typ"
+ * "sub/nested/two.typ" → "sub/nested/two.typ"
+ */
+export function encodeVirtualPathForFileUri(virtualPath: string): string {
+ return virtualPath.split("/").map(encodeURIComponent).join("/");
+}
+
+// ---------- Relative path extraction ----------
+
+/**
+ * Given a `fileUri` (the project file:// URI from app state) and an absolute
+ * filesystem path string (from tinymist), return the virtual relative path
+ * under the project, or `null` if the path is outside the project root.
+ */
+export function relativePathFromProject(
+ projectUri: string,
+ absolutePath: string,
+): string | null {
+ const filepath = normalizeFsPath(absolutePath);
+ const projectPath = normalizeFsPath(projectUri);
+ const sep = projectPath.endsWith("/") ? "" : "/";
+ if (!filepath.startsWith(projectPath + sep)) {
+ return null;
+ }
+ return filepath.slice(projectPath.length + 1);
+}
diff --git a/typst-leaf-frontend/src/types.ts b/typst-leaf-frontend/src/types.ts
index 9eaf56c..4ad4bd5 100644
--- a/typst-leaf-frontend/src/types.ts
+++ b/typst-leaf-frontend/src/types.ts
@@ -15,6 +15,7 @@ export interface FileEntry {
export interface TreeResponse {
project: string;
files: FileEntry[];
+ folders: { path: string }[];
}
export interface FileResponse {