feat: Git workflow, Vim mode, and preview→editor SyncTeX

Add three core VISION features on top of the barebones MVP:

Git workflow (backend + frontend):
- Backend: simple-git wrapper with per-project repo isolation, init
  (idempotent .gitignore reconciliation, handles empty repos), status,
  log, commit (per-commit identity via -c flags), remote, push, pull
- gitFor() now refuses to run without the project's own .git, preventing
  accidental parent-repo detection when projects/ lives inside the
  typst-leaf worktree
- Frontend: GitPanel (branch, ahead/behind, inline commit, push/pull,
  remote prompt, collapsible log), SettingsPanel (git identity in
  localStorage), Ctrl+Shift+S focuses commit input
- Status/log refresh driven by gitVersion bumps (no polling) with
  cancellation guards across project switches
- Auto-init on status failure for pre-existing projects

Vim mode:
- Off-by-default toggle persisted to localStorage
- Dynamic monaco-vim import, status bar div, StrictMode-safe lifecycle
  via editorReady reset on unmount

SyncTeX (preview → editor):
- Handle tinymist/preview/scrollSource notification and
  window/showDocument request
- Cross-platform path normalization (Windows drive letters, native
  backslash paths, URL-decoded paths)
- Editor jump effect with Monaco selection/reveal/focus, clears on
  completion

Robustness fixes across review cycles:
- Cancellation guards on git status/log effects to prevent stale writes
- bumpGitVersion after auto-init so log viewer updates
- jsonOrThrow extracts structured backend errors, handles empty bodies
- SettingsPanel/GitPanel validate localStorage identity shape
- GitPanel clears log state on project switch
- File path URI encoding preserves path separators (segment-wise encode)
- tsconfig lib bumped to ES2021 for String.replaceAll

AGENTS.md: removed stale execa/monaco-languageclient references

PLAN.md: implementation plan for this chunk
This commit is contained in:
2026-07-02 00:35:32 +05:30
parent 061f95ddee
commit bfb4d8bef8
16 changed files with 1123 additions and 45 deletions
+2 -2
View File
@@ -26,8 +26,8 @@ typst-leaf-backend/ Node.js/TS — stateless orchestration: spawns tinymist,
typst-leaf-frontend/ React (Vite) + Monaco — the IDE SPA; also contains src-tauri/ for the desktop build
```
* **Backend** (`typst-leaf-backend/`): ESM (`"type": "module"`), strict TS with `noUncheckedIndexedAccess`. Entry: `src/index.ts`. Dev via `tsx watch`; build via `tsc``dist/`. Uses `express` + `ws` + `simple-git` + `execa` + `zod`.
* **Frontend** (`typst-leaf-frontend/`): React 19 + Vite. Monaco Editor wired to LSP via `monaco-languageclient` + `vscode-ws-jsonrpc`. Styling is **Tailwind v4** via the `@tailwindcss/vite` plugin (import with `@import "tailwindcss"`; there is no `tailwind.config.js`).
* **Backend** (`typst-leaf-backend/`): ESM (`"type": "module"`), strict TS with `noUncheckedIndexedAccess`. Entry: `src/index.ts`. Dev via `tsx watch`; build via `tsc``dist/`. Uses `express` + `ws` + `simple-git` + `zod`.
* **Frontend** (`typst-leaf-frontend/`): React 19 + Vite. Monaco Editor wired to LSP via a raw JSON-RPC WebSocket client (`src/lsp.ts`). Styling is **Tailwind v4** via the `@tailwindcss/vite` plugin (import with `@import "tailwindcss"`; there is no `tailwind.config.js`).
* **Tauri v2** lives at `typst-leaf-frontend/src-tauri/`. It shells out to `pnpm dev`/`pnpm build` and serves `../dist`.
* **Data plane:** `ws://localhost:4000/lsp` (LSP), `ws://localhost:4000/preview` (SVG stream).
+80
View File
@@ -0,0 +1,80 @@
# Next Chunk: Git + Vim + SyncTeX (Core VISION)
## Part A — Vim Mode (frontend-only)
**Goal:** Off-by-default vim toggle, persisted to localStorage.
- `Editor.tsx`:
- Always render `<div ref={vimStatusRef} />` (empty when vim is off, monaco-vim populates it when on).
- On `handleMount`: if `localStorage.getItem("typst-leaf.vim") === "true"`, `initVimMode(editor, vimStatusRef.current)`.
- Add "Vim" toggle button in the editor toolbar (emerald when active).
- On toggle: persist to localStorage; init or dispose accordingly.
- Store `initVimMode`'s returned controller in a ref for disposal on unmount/toggle-off.
## Part B — Git
### Backend
- `git.ts`: factory `gitFor(project: string)``simpleGit(projectDir(project))`.
- `routes/git.ts` (mounted at `/api/projects/:project/git`):
- `POST /init``git init`, set default identity from env, initial commit, `.gitignore`.
- `GET /status` — branch, ahead/behind, staged/unstaged, clean.
- `GET /log?limit=20` — commit history.
- `POST /commit` — body `{ message, all?, identity? }`. Uses `-c user.name=... -c user.email=...` per-commit flags (not repo config).
- `PUT /remote` — body `{ url }``git remote add` or `set-url`.
- `POST /push` / `POST /pull` — to origin, return `{ ok, error? }`.
- `routes/projects.ts`: `POST /:project` calls `git.init` after seeding `main.typ`.
- `index.ts`: mount gitRouter.
### Frontend
- `api.ts`: gitInit, gitStatus, gitLog, gitCommit, gitPush, gitPull, setRemote.
- `types.ts`: FileStatus, CommitInfo, GitStatus.
- `GitPanel.tsx` (sidebar, below FileTree):
- Branch name, ahead/behind badges, unstaged file count.
- Inline commit: message input + Commit button (Enter to submit).
- Push/Pull buttons; prompts for remote URL if none configured.
- Refreshes on project open + after saves + after commits.
- `SettingsPanel.tsx` (gear icon in sidebar header toggles panel):
- Git Author Name, Git Author Email inputs.
- Persisted to `localStorage["typst-leaf.git-identity"]`.
- Sent with every commit as `identity`.
- `Editor.tsx`: `CtrlCmd+Shift+KeyS` → dispatch `commitFocus` via app state.
- `app-state.tsx`: `gitStatus`, `gitVersion` (incremented on save to trigger refresh), `commitFocus` (incremented on shortcut).
## Part C — SyncTeX (Preview → Editor)
### LSP Client (`lsp.ts`)
Two LSP message paths from tinymist, handle both:
1. **`tinymist/preview/scrollSource` notification**:
- Params: `{ filepath: string, start?: [number, number], end?: [number, number] }`
- Convert: `filepath` → relPath, `start/end` are 0-indexed [row, col]
- Call `onJumpToSource({ filepath, start, end })`
2. **`window/showDocument` request**:
- Params: `{ uri: string, selection?: { start: {line, character}, end: {line, character} } }`
- Convert: `uri` (file://) → relPath, selection is 0-indexed LspRange
- Call `onJumpToSource(...)`, respond `{ success: true }`
### App State (`app-state.tsx`)
- Add `jumpTarget: { file: string; start?: [number, number]; end?: [number, number] } | null` to state.
- In `startLsp`, pass `onJumpToSource` callback:
1. Convert absolute `filepath` → relPath (strip `new URL(projectUri).pathname` prefix)
2. **Ignore if filepath is not under the project** (e.g. imported package file)
3. If `relPath !== state.openFile`, dispatch `openFile`
4. Dispatch `setJumpTarget({ file: relPath, start, end })`
### Editor (`Editor.tsx`)
- Effect deps: `[jumpTarget, model]`
- When `jumpTarget` is truthy AND `model` is the correct file:
- Convert 0-indexed [row, col] → Monaco 1-indexed (line = row + 1, column = col + 1)
- `editor.setSelection(range)`, `editor.revealRangeInCenter(range)`, focus editor
- Dispatch `clearJumpTarget`
## Part D — Doc Cleanup
- `AGENTS.md`: Remove stale `execa` (backend has simple-git, not execa). Update Architecture & Boundaries to reflect that frontend uses raw LSP, not `monaco-languageclient`. Fix stale `vscode-ws-jsonrpc` references (frontend removed it; backend still uses it).
+67
View File
@@ -0,0 +1,67 @@
import fs from "node:fs/promises";
import path from "node:path";
import simpleGit from "simple-git";
import { projectDir } from "./paths.js";
export async function gitFor(project: string) {
const dir = projectDir(project);
const hasGit = await fs.stat(path.join(dir, ".git")).catch(() => null);
if (!hasGit) {
throw new Error("project is not a git repository");
}
return simpleGit(dir);
}
export async function gitInit(
project: string,
identity?: { name?: string; email?: string },
): Promise<void> {
const dir = projectDir(project);
const g = simpleGit(dir);
const alreadyRepo = !!(
await fs.stat(path.join(dir, ".git")).catch(() => null)
);
if (!alreadyRepo) {
await g.init();
}
const name =
identity?.name ||
process.env.GIT_AUTHOR_NAME ||
process.env.GIT_COMMITTER_NAME ||
"typst-leaf";
const email =
identity?.email ||
process.env.GIT_AUTHOR_EMAIL ||
process.env.GIT_COMMITTER_EMAIL ||
"typst-leaf@local";
const flags = ["-c", `user.name=${name}`, "-c", `user.email=${email}`];
const gitignore = path.join(dir, ".gitignore");
let content = "*.pdf\n";
try {
const existing = await fs.readFile(gitignore, "utf8");
if (!existing.includes("*.pdf")) {
content = existing.trimEnd() + "\n*.pdf\n";
} else {
content = existing;
}
} catch {
// file doesn't exist — use default content
}
await fs.writeFile(gitignore, content, "utf8");
if (alreadyRepo) {
// Existing repos with commits are done; repos that were `git init`-ed
// but never committed fall through to create an initial commit so
// that git status/log don't fail.
const log = await g.log({ maxCount: 1 }).catch(() => null);
if (log?.latest) return;
}
await g.add(".");
await g.raw([...flags, "commit", "-m", "initial commit"]);
}
+2
View File
@@ -7,6 +7,7 @@ import { attachPreview } from "./preview-proxy.js";
import { previewWebviewRouter } from "./preview-webview.js";
import { seedIfMissing } from "./seed.js";
import { filesRouter } from "./routes/files.js";
import { gitRouter } from "./routes/git.js";
import { previewRouter } from "./routes/preview.js";
import { projectsRouter } from "./routes/projects.js";
@@ -28,6 +29,7 @@ app.get("/api/health", (_req, res) => {
app.use("/api/projects", projectsRouter);
app.use("/api/projects", filesRouter);
app.use("/api/projects", gitRouter);
app.use("/api/preview", previewRouter);
app.use("/preview-app", previewWebviewRouter);
+213
View File
@@ -0,0 +1,213 @@
import { Router, type Router as ExpressRouter } from "express";
import { z } from "zod";
import { projectNameSchema } from "../paths.js";
import { gitFor, gitInit } from "../git.js";
export const gitRouter: ExpressRouter = Router();
const projectParam = z.object({ project: projectNameSchema });
const commitBody = z.object({
message: z.string().min(1, "commit message required"),
all: z.boolean().optional().default(true),
identity: z
.object({ name: z.string().optional(), email: z.string().optional() })
.optional(),
});
const remoteBody = z.object({ url: z.string().min(1) });
const identityBody = z
.object({
name: z.string().optional(),
email: z.string().optional(),
})
.optional();
gitRouter.post("/:project/git/init", async (req, res) => {
const p = projectParam.safeParse(req.params);
if (!p.success) {
res.status(400).json({ error: p.error.issues });
return;
}
try {
const identity = identityBody.parse(req.body);
await gitInit(p.data.project, identity);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ ok: false, error: (err as Error).message });
}
});
gitRouter.get("/:project/git/status", async (req, res) => {
const p = projectParam.safeParse(req.params);
if (!p.success) {
res.status(400).json({ error: p.error.issues });
return;
}
try {
const g = await gitFor(p.data.project);
const status = await g.status();
const branches = await g.branch();
const log = await g.log({ maxCount: 1 });
const remote = await g.getRemotes(true);
res.json({
branch: branches.current,
clean: status.isClean(),
staged: status.staged.map((p) => {
const entry = status.files.find((f) => f.path === p);
const s = entry?.index ? entry.index : "A";
return { path: p, status: s };
}),
unstaged: status.files
.filter((f) => f.working_dir !== " ")
.map((f) => ({ path: f.path, status: f.working_dir })),
ahead: status.ahead,
behind: status.behind,
hasRemote: !!remote.find((r) => r.name === "origin"),
lastCommit: log.latest?.message ?? null,
});
} catch (err) {
res.status(500).json({ ok: false, error: (err as Error).message });
}
});
gitRouter.get("/:project/git/log", async (req, res) => {
const p = projectParam.safeParse(req.params);
if (!p.success) {
res.status(400).json({ error: p.error.issues });
return;
}
const limit = z
.string()
.optional()
.default("20")
.transform(Number)
.parse(req.query.limit);
try {
const g = await gitFor(p.data.project);
const log = await g.log({ maxCount: limit });
res.json({
commits: log.all.map((c) => ({
sha: c.hash,
message: c.message,
author: c.author_name,
date: c.date,
})),
});
} catch (err) {
res.status(500).json({ ok: false, error: (err as Error).message });
}
});
gitRouter.post("/:project/git/commit", async (req, res) => {
const p = projectParam.safeParse(req.params);
const b = commitBody.safeParse(req.body);
if (!p.success || !b.success) {
res.status(400).json({
error: [...(p.error?.issues ?? []), ...(b.error?.issues ?? [])],
});
return;
}
try {
const g = await gitFor(p.data.project);
const { message, all: stageAll, identity } = b.data;
if (stageAll) {
await g.add(".");
}
// Always provide an identity so commits don't fail on machines without
// a global git config. Falls back to env vars, then to defaults.
const name =
identity?.name ||
process.env.GIT_AUTHOR_NAME ||
process.env.GIT_COMMITTER_NAME ||
"typst-leaf";
const email =
identity?.email ||
process.env.GIT_AUTHOR_EMAIL ||
process.env.GIT_COMMITTER_EMAIL ||
"typst-leaf@local";
await g.raw([
"-c",
`user.name=${name}`,
"-c",
`user.email=${email}`,
"commit",
"-m",
message,
]);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ ok: false, error: (err as Error).message });
}
});
gitRouter.put("/:project/git/remote", async (req, res) => {
const p = projectParam.safeParse(req.params);
const b = remoteBody.safeParse(req.body);
if (!p.success || !b.success) {
res.status(400).json({
error: [...(p.error?.issues ?? []), ...(b.error?.issues ?? [])],
});
return;
}
try {
const g = await gitFor(p.data.project);
const remotes = await g.getRemotes(true);
if (remotes.find((r) => r.name === "origin")) {
await g.remote(["set-url", "origin", b.data.url]);
} else {
await g.remote(["add", "origin", b.data.url]);
}
res.json({ ok: true });
} catch (err) {
res.status(500).json({ ok: false, error: (err as Error).message });
}
});
gitRouter.post("/:project/git/push", async (req, res) => {
const p = projectParam.safeParse(req.params);
if (!p.success) {
res.status(400).json({ error: p.error.issues });
return;
}
try {
const g = await gitFor(p.data.project);
const branches = await g.branch();
const current = branches.current;
const remotes = await g.getRemotes(true);
if (!remotes.find((r) => r.name === "origin")) {
res.status(400).json({ ok: false, error: "no remote 'origin' configured" });
return;
}
await g.push("origin", current);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ ok: false, error: (err as Error).message });
}
});
gitRouter.post("/:project/git/pull", async (req, res) => {
const p = projectParam.safeParse(req.params);
if (!p.success) {
res.status(400).json({ error: p.error.issues });
return;
}
try {
const g = await gitFor(p.data.project);
const branches = await g.branch();
const current = branches.current;
const remotes = await g.getRemotes(true);
if (!remotes.find((r) => r.name === "origin")) {
res.status(400).json({ ok: false, error: "no remote 'origin' configured" });
return;
}
await g.pull("origin", current);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ ok: false, error: (err as Error).message });
}
});
@@ -4,6 +4,7 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
import { PROJECTS_ROOT } from "../config.js";
import { projectDir, projectNameSchema } from "../paths.js";
import { gitInit } from "../git.js";
export const projectsRouter: ExpressRouter = Router();
@@ -45,6 +46,9 @@ projectsRouter.post("/:project", async (req, res) => {
"utf8",
);
}
await gitInit(name).catch((e) =>
console.warn(`[projects] git init failed for "${name}":`, e.message),
);
res.status(201).json({ project: name });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
+6
View File
@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { PROJECTS_ROOT } from "./config.js";
import { gitInit } from "./git.js";
const SEED_PROJECT = "hello";
const SEED_FILE = "main.typ";
@@ -41,4 +42,9 @@ export async function seedIfMissing(): Promise<void> {
await fs.writeFile(file, SEED_CONTENT, "utf8");
console.log(`[seed] created ${SEED_PROJECT}/${SEED_FILE}`);
}
// Initialize git for the seeded project if not already a repo (also handles
// projects that existed before git support was added).
await gitInit("hello").catch((e) =>
console.warn(`[seed] git init failed:`, e.message),
);
}
+5 -1
View File
@@ -1,8 +1,10 @@
import { AppProvider, useApp } from "./app-state";
import { EditorView } from "./components/Editor";
import { FileTree } from "./components/FileTree";
import { GitPanel } from "./components/GitPanel";
import { Preview } from "./components/Preview";
import { ProjectPicker } from "./components/ProjectPicker";
import { SettingsPanel } from "./components/SettingsPanel";
function Shell() {
const { state, lsp } = useApp();
@@ -11,7 +13,8 @@ function Shell() {
<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">
<span className="ml-auto flex items-center gap-2 text-xs font-normal text-zinc-400">
<SettingsPanel />
{lsp.status === "ready"
? "●"
: lsp.status === "connecting"
@@ -25,6 +28,7 @@ function Shell() {
<div className="flex-1 overflow-auto">
<FileTree />
</div>
<GitPanel />
{state.error && (
<div className="p-2 text-xs text-red-500 border-t border-zinc-200">
{state.error}
+67 -20
View File
@@ -1,5 +1,7 @@
import type {
CommitInfo,
FileResponse,
GitStatus,
Project,
TreeResponse,
} from "./types";
@@ -7,11 +9,25 @@ import type {
const API = "/api";
async function jsonOrThrow<T>(res: Response): Promise<T> {
const text = await res.text();
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status} ${res.statusText}: ${text}`);
let detail = `${res.status} ${res.statusText}`;
try {
const parsed = JSON.parse(text) as { error?: unknown };
if (parsed.error) detail = String(parsed.error);
} catch { /* ignore parse failures */ }
throw new Error(detail);
}
return res.json() as Promise<T>;
if (!text) return undefined as T;
return JSON.parse(text) as T;
}
function projectUrl(project: string): string {
return `${API}/projects/${encodeURIComponent(project)}`;
}
function gitUrl(project: string): string {
return `${projectUrl(project)}/git`;
}
export const api = {
@@ -20,34 +36,65 @@ export const api = {
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) =>
fetch(`${projectUrl(name)}`, { method: "POST" }).then((r) =>
jsonOrThrow<Project>(r),
),
getProject: (name: string) =>
fetch(`${projectUrl(name)}`).then((r) => jsonOrThrow<Project>(r)),
getTree: (name: string) =>
fetch(`${API}/projects/${encodeURIComponent(name)}/tree`).then((r) =>
fetch(`${projectUrl(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)),
fetch(`${projectUrl(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)),
fetch(`${projectUrl(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)),
// ---- Git ----
gitInit: (project: string) =>
fetch(`${gitUrl(project)}/init`, { method: "POST" }).then((r) =>
jsonOrThrow<{ ok: boolean }>(r),
),
gitStatus: (project: string) =>
fetch(`${gitUrl(project)}/status`).then((r) => jsonOrThrow<GitStatus>(r)),
gitLog: (project: string, limit = 20) =>
fetch(`${gitUrl(project)}/log?limit=${limit}`).then((r) =>
jsonOrThrow<{ commits: CommitInfo[] }>(r),
),
gitCommit: (
project: string,
message: string,
identity?: { name?: string; email?: string },
) =>
fetch(`${gitUrl(project)}/commit`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ message, all: true, identity }),
}).then((r) => jsonOrThrow<{ ok: boolean; error?: string }>(r)),
gitPush: (project: string) =>
fetch(`${gitUrl(project)}/push`, { method: "POST" }).then((r) =>
jsonOrThrow<{ ok: boolean; error?: string }>(r),
),
gitPull: (project: string) =>
fetch(`${gitUrl(project)}/pull`, { method: "POST" }).then((r) =>
jsonOrThrow<{ ok: boolean; error?: string }>(r),
),
setRemote: (project: string, url: string) =>
fetch(`${gitUrl(project)}/remote`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url }),
}).then((r) => jsonOrThrow<{ ok: boolean; error?: string }>(r)),
};
+104 -5
View File
@@ -11,7 +11,7 @@ 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";
import type { FileEntry, GitStatus } from "./types";
interface State {
projects: string[];
@@ -21,6 +21,16 @@ interface State {
openFile: string | null;
loading: boolean;
error: string | null;
// git
gitStatus: GitStatus | null;
gitVersion: number;
commitFocus: number;
// sync
jumpTarget: {
file: string;
start?: [number, number];
end?: [number, number];
} | null;
}
type Action =
@@ -29,7 +39,12 @@ type Action =
| { type: "setTree"; tree: FileEntry[] }
| { type: "openFile"; path: string | null }
| { type: "loading"; loading: boolean }
| { type: "error"; error: string | null };
| { type: "error"; error: string | null }
| { type: "setGitStatus"; status: GitStatus }
| { type: "bumpGitVersion" }
| { type: "focusCommit" }
| { type: "setJumpTarget"; target: State["jumpTarget"] }
| { type: "clearJumpTarget" };
const initial: State = {
projects: [],
@@ -39,6 +54,10 @@ const initial: State = {
openFile: null,
loading: false,
error: null,
gitStatus: null,
gitVersion: 0,
commitFocus: 0,
jumpTarget: null,
};
function reducer(state: State, action: Action): State {
@@ -52,6 +71,7 @@ function reducer(state: State, action: Action): State {
currentUri: action.uri,
tree: [],
openFile: null,
gitStatus: null,
};
case "setTree":
return { ...state, tree: action.tree };
@@ -61,6 +81,16 @@ function reducer(state: State, action: Action): State {
return { ...state, loading: action.loading };
case "error":
return { ...state, error: action.error };
case "setGitStatus":
return { ...state, gitStatus: action.status };
case "bumpGitVersion":
return { ...state, gitVersion: state.gitVersion + 1 };
case "focusCommit":
return { ...state, commitFocus: state.commitFocus + 1 };
case "setJumpTarget":
return { ...state, jumpTarget: action.target };
case "clearJumpTarget":
return { ...state, jumpTarget: null };
}
}
@@ -94,6 +124,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
error: null,
});
const sessionRef = useRef<{ dispose: () => void } | null>(null);
const openFileRef = useRef<string | null>(null);
const uriRef = useRef<string | null>(null);
useEffect(() => {
loadMonaco()
@@ -117,9 +149,47 @@ export function AppProvider({ children }: { children: ReactNode }) {
const project = state.currentProject;
const uri = state.currentUri!;
setLsp((s) => ({ ...s, status: "connecting", previewReady: false }));
// Normalize both URIs and native paths to a consistent forward-slash
// pathname. Windows drive-letter paths (file:///C:/... → /C:/...) get
// the leading "/" stripped so they match native paths (C:/...).
const normalizePath = (p: string): string => {
let result: string;
if (p.includes("://")) {
result = decodeURIComponent(new URL(p).pathname);
} else {
result = p.replaceAll("\\", "/");
}
if (/^\/[A-Za-z]:\//.test(result) || /^\/[A-Za-z]:$/.test(result)) {
result = result.slice(1);
}
return result;
};
const onJumpToSource = (info: {
filepath: string;
start?: [number, number];
end?: [number, number];
}) => {
const curUri = uriRef.current ?? uri;
const curOpenFile = openFileRef.current;
const filepath = normalizePath(info.filepath);
const projectPath = normalizePath(curUri);
const sep = projectPath.endsWith("/") ? "" : "/";
if (!filepath.startsWith(projectPath + sep)) {
return;
}
const relPath = filepath.slice(projectPath.length + 1);
if (relPath !== curOpenFile) {
dispatch({ type: "openFile", path: relPath });
}
dispatch({
type: "setJumpTarget",
target: { file: relPath, start: info.start, end: info.end },
});
};
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);
@@ -127,7 +197,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
} catch (e) {
console.error("preview start failed:", String(e));
}
})
}, onJumpToSource)
.then((session) => {
if (cancelled) {
session.dispose();
@@ -169,6 +239,35 @@ export function AppProvider({ children }: { children: ReactNode }) {
.catch((e) => dispatch({ type: "error", error: String(e) }));
}, [state.currentProject]);
// Keep refs current for the LSP callback closure
useEffect(() => {
openFileRef.current = state.openFile;
uriRef.current = state.currentUri;
}, [state.openFile, state.currentUri]);
// Refresh git status on project change and after saves
useEffect(() => {
if (!state.currentProject) return;
let cancelled = false;
const project = state.currentProject;
api
.gitStatus(project)
.then((s) => { if (!cancelled) dispatch({ type: "setGitStatus", status: s }); })
.catch(() =>
api
.gitInit(project)
.then(() => api.gitStatus(project))
.then((s) => {
if (!cancelled) {
dispatch({ type: "setGitStatus", status: s });
dispatch({ type: "bumpGitVersion" });
}
})
.catch(() => {}),
);
return () => { cancelled = true; };
}, [state.currentProject, state.gitVersion]);
return (
<AppContext.Provider value={{ state, dispatch, lsp }}>
{children}
+137 -16
View File
@@ -1,5 +1,5 @@
import Editor, { type Monaco, type OnMount } from "@monaco-editor/react";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../api";
import { useApp } from "../app-state";
@@ -7,7 +7,7 @@ type ITextModel = Monaco["editor"]["ITextModel"];
type IStandaloneCodeEditor = Monaco["editor"]["IStandaloneCodeEditor"];
export function EditorView() {
const { state, lsp } = useApp();
const { state, dispatch, lsp } = useApp();
const monaco = lsp.monaco;
const [model, setModel] = useState<ITextModel | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -17,9 +17,57 @@ export function EditorView() {
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.
// Vim mode
const [vimOn, setVimOn] = useState(
() => localStorage.getItem("typst-leaf.vim") === "true",
);
const [editorReady, setEditorReady] = useState(false);
const vimRef = useRef<{ dispose: () => void } | null>(null);
const vimStatusRef = useRef<HTMLDivElement | null>(null);
// Toggle vim
const toggleVim = useCallback(() => {
setVimOn((prev) => {
const next = !prev;
localStorage.setItem("typst-leaf.vim", String(next));
return next;
});
}, []);
// Init / dispose vim mode when editor or vimOn changes
useEffect(() => {
if (!editorReady) return;
if (vimOn && !vimRef.current) {
let cancelled = false;
import("monaco-vim").then(({ initVimMode }) => {
if (cancelled) return;
const statusNode = vimStatusRef.current;
if (!statusNode || !editorRef.current) return;
vimRef.current = initVimMode(editorRef.current, statusNode);
});
return () => { cancelled = true; };
} else if (!vimOn && vimRef.current) {
vimRef.current.dispose();
vimRef.current = null;
if (vimStatusRef.current) {
vimStatusRef.current.textContent = "";
}
}
}, [vimOn, editorReady]);
// Cleanup vim on unmount. Resetting editorReady ensures the vim
// effect re-fires after a StrictMode remount (Monaco creates a new
// editor instance, handleMount sets editorReady=true again, and the
// effect re-initializes vim on the new editor).
useEffect(() => {
return () => {
vimRef.current?.dispose();
vimRef.current = null;
setEditorReady(false);
};
}, []);
// Create/replace the Monaco model for the open file.
useEffect(() => {
if (!monaco || !project || !file || !uri) {
setModel((m: ITextModel | null) => {
@@ -35,11 +83,7 @@ export function EditorView() {
.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 docUri = base + file.split("/").map(encodeURIComponent).join("/");
const uriObj = monaco.Uri.parse(docUri);
monaco.editor.getModel(uriObj)?.dispose();
const next = monaco.editor.createModel(f.content, "typst", uriObj);
@@ -47,9 +91,6 @@ export function EditorView() {
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 () => {
@@ -68,6 +109,8 @@ export function EditorView() {
savingRef.current = true;
try {
await api.putFile(project, file, model.getValue());
// Bump git version so GitPanel refreshes
dispatch({ type: "bumpGitVersion" });
} catch (e) {
setError(String(e));
} finally {
@@ -75,14 +118,77 @@ export function EditorView() {
}
}
// Keep a ref to the latest save() so the Monaco command (registered
// once in handleMount) always calls the current version, not the one
// from the first render. Without this, Ctrl+S saves the wrong file
// after switching.
const saveRef = useRef(save);
saveRef.current = save;
const handleMount: OnMount = (editor, m) => {
editorRef.current = editor;
setEditorReady(true);
if (model) editor.setModel(model);
editor.addCommand(m.KeyMod.CtrlCmd | m.KeyCode.KeyS, () => {
void save();
void saveRef.current();
});
editor.addCommand(
m.KeyMod.CtrlCmd | m.KeyMod.Shift | m.KeyCode.KeyS,
() => {
dispatch({ type: "focusCommit" });
},
);
};
// Jump target effect — reactive (no polling)
const jumpHandledRef = useRef(false);
useEffect(() => {
if (!state.jumpTarget || !editorRef.current || !monaco || !model) return;
const target = state.jumpTarget;
const modelUri = model.uri.toString();
const base = uri?.endsWith("/") ? uri : uri + "/";
const expectedModelUri = target.file
? base + target.file.split("/").map(encodeURIComponent).join("/")
: null;
if (!expectedModelUri || modelUri !== expectedModelUri) return;
if (jumpHandledRef.current) return;
jumpHandledRef.current = true;
let selection: Monaco["Selection"] | null = null;
if (target.start && target.end) {
selection = new monaco.Selection(
target.start[0] + 1,
target.start[1] + 1,
target.end[0] + 1,
target.end[1] + 1,
);
} else if (target.start) {
selection = new monaco.Selection(
target.start[0] + 1,
target.start[1] + 1,
target.start[0] + 1,
target.start[1] + 1,
);
}
if (selection) {
editorRef.current.setSelection(selection);
editorRef.current.revealRangeInCenter(selection);
editorRef.current.focus();
}
dispatch({ type: "clearJumpTarget" });
}, [state.jumpTarget, model, monaco, uri]);
// Reset jumpHandledRef when a new jump target arrives
if (state.jumpTarget === null) {
jumpHandledRef.current = false;
}
if (!project) {
return (
<div className="flex-1 flex items-center justify-center text-zinc-400 text-sm">
@@ -108,8 +214,23 @@ export function EditorView() {
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">
<span className="truncate max-w-48">{file}</span>
<span className="ml-auto flex items-center gap-2">
<div
ref={vimStatusRef}
className="text-xs text-zinc-400 min-w-12"
/>
<button
type="button"
onClick={toggleVim}
className={`px-2 py-0.5 rounded border text-xs ${
vimOn
? "bg-emerald-100 border-emerald-300 text-emerald-700"
: "bg-white hover:bg-zinc-100 text-zinc-700 border-zinc-300"
}`}
>
Vim
</button>
<button
type="button"
onClick={() => void save()}
@@ -0,0 +1,255 @@
import { useEffect, useRef, useState } from "react";
import { api } from "../api";
import { useApp } from "../app-state";
import type { CommitInfo } from "../types";
export function GitPanel() {
const { state, dispatch } = useApp();
const [commitMsg, setCommitMsg] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const [pushBusy, setPushBusy] = useState(false);
const [pullBusy, setPullBusy] = useState(false);
const [remoteUrl, setRemoteUrl] = useState("");
const [showRemoteInput, setShowRemoteInput] = useState(false);
const [log, setLog] = useState<CommitInfo[]>([]);
const [logOpen, setLogOpen] = useState(false);
// Hooks must be called unconditionally (React Rules of Hooks)
useEffect(() => {
if (state.commitFocus > 0) {
inputRef.current?.focus();
}
}, [state.commitFocus]);
// Fetch commit log on git version change
useEffect(() => {
if (!state.currentProject) {
setLog([]);
return;
}
let cancelled = false;
const project = state.currentProject;
setLog([]);
api
.gitLog(project, 10)
.then((r) => { if (!cancelled) setLog(r.commits); })
.catch(() => {});
return () => { cancelled = true; };
}, [state.currentProject, state.gitVersion]);
const s = state.gitStatus;
if (!state.currentProject) return null;
async function handleCommit() {
if (!commitMsg.trim() || busy || !state.currentProject) return;
setBusy(true);
setError(null);
try {
let identity: { name?: string; email?: string } | undefined;
try {
const identityRaw = localStorage.getItem("typst-leaf.git-identity");
const parsed = identityRaw ? JSON.parse(identityRaw) : undefined;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
identity = {
name: typeof parsed.name === "string" ? parsed.name : undefined,
email: typeof parsed.email === "string" ? parsed.email : undefined,
};
}
} catch {
identity = undefined;
}
const res = await api.gitCommit(state.currentProject, commitMsg.trim(), identity);
if (!res.ok) {
setError(res.error ?? "commit failed");
} else {
setCommitMsg("");
dispatch({ type: "bumpGitVersion" });
}
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}
async function handlePush() {
if (!state.currentProject || pushBusy) return;
setPushBusy(true);
setError(null);
try {
if (!s?.hasRemote) {
setShowRemoteInput(true);
setPushBusy(false);
return;
}
const res = await api.gitPush(state.currentProject);
if (!res.ok) setError(res.error ?? "push failed");
else dispatch({ type: "bumpGitVersion" });
} catch (e) {
setError(String(e));
} finally {
setPushBusy(false);
}
}
async function handlePull() {
if (!state.currentProject || pullBusy) return;
setPullBusy(true);
setError(null);
try {
if (!s?.hasRemote) {
setShowRemoteInput(true);
setPullBusy(false);
return;
}
const res = await api.gitPull(state.currentProject);
if (!res.ok) setError(res.error ?? "pull failed");
else dispatch({ type: "bumpGitVersion" });
} catch (e) {
setError(String(e));
} finally {
setPullBusy(false);
}
}
async function handleSetRemote() {
if (!remoteUrl.trim() || !state.currentProject) return;
try {
await api.setRemote(state.currentProject, remoteUrl.trim());
setShowRemoteInput(false);
setRemoteUrl("");
dispatch({ type: "bumpGitVersion" });
} catch (e) {
setError(String(e));
}
}
return (
<div className="border-t border-zinc-200">
<div className="px-3 py-1.5 text-xs uppercase tracking-wide text-zinc-400 flex items-center gap-1">
<span>Git</span>
{s && (
<span className="ml-auto text-zinc-400 font-mono normal-case">
{s.branch}
{s.ahead > 0 && (
<span className="text-emerald-600 ml-1">{s.ahead}</span>
)}
{s.behind > 0 && (
<span className="text-amber-600 ml-1">{s.behind}</span>
)}
</span>
)}
</div>
<div className="px-3 pb-1.5 text-xs text-zinc-500">
{s && !s.clean && (
<span>
{s.unstaged.length + s.staged.length} uncommitted file
{s.unstaged.length + s.staged.length !== 1 ? "s" : ""}
</span>
)}
{s && s.clean && <span className="text-emerald-600">clean</span>}
</div>
<div className="px-3 pb-1.5 flex flex-col gap-1.5">
<div className="flex gap-1">
<input
ref={inputRef}
type="text"
value={commitMsg}
onChange={(e) => setCommitMsg(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleCommit();
}}
placeholder="Commit message"
className="flex-1 min-w-0 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"
/>
<button
type="button"
onClick={() => void handleCommit()}
disabled={!commitMsg.trim() || busy}
className="px-2 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed"
>
{busy ? "..." : "Commit"}
</button>
</div>
<div className="flex gap-1">
<button
type="button"
onClick={() => void handlePush()}
disabled={pushBusy}
className="flex-1 px-2 py-1 text-xs rounded bg-white hover:bg-zinc-100 text-zinc-700 border border-zinc-300 disabled:opacity-40"
>
{pushBusy ? "..." : "Push"}
</button>
<button
type="button"
onClick={() => void handlePull()}
disabled={pullBusy}
className="flex-1 px-2 py-1 text-xs rounded bg-white hover:bg-zinc-100 text-zinc-700 border border-zinc-300 disabled:opacity-40"
>
{pullBusy ? "..." : "Pull"}
</button>
</div>
{showRemoteInput && (
<div className="flex gap-1">
<input
type="text"
value={remoteUrl}
onChange={(e) => setRemoteUrl(e.target.value)}
placeholder="git remote URL"
className="flex-1 min-w-0 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"
/>
<button
type="button"
onClick={() => void handleSetRemote()}
disabled={!remoteUrl.trim()}
className="px-2 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40"
>
Set
</button>
</div>
)}
{error && (
<div className="text-xs text-red-500 break-words">{error}</div>
)}
</div>
{/* Commit log */}
{log.length > 0 && (
<div className="border-t border-zinc-200">
<button
type="button"
onClick={() => setLogOpen(!logOpen)}
className="w-full flex items-center gap-1 px-3 py-1 text-xs text-zinc-400 hover:text-zinc-600 uppercase tracking-wide"
>
<span>Log</span>
<span className="ml-auto">{logOpen ? "▾" : "▸"}</span>
</button>
{logOpen && (
<div className="max-h-32 overflow-y-auto px-3 pb-1.5 flex flex-col gap-1">
{log.map((c) => (
<div
key={c.sha}
className="text-xs text-zinc-500 leading-tight"
title={`${c.sha}${c.author} on ${c.date}`}
>
<span className="font-mono text-zinc-400">
{c.sha.slice(0, 7)}
</span>
{" "}
<span className="text-zinc-600">{c.message}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,117 @@
import { useCallback, useEffect, useState } from "react";
const STORAGE_KEY = "typst-leaf.git-identity";
interface Identity {
name: string;
email: string;
}
function loadIdentity(): Identity {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { name: "", email: "" };
const parsed = JSON.parse(raw);
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
return { name: "", email: "" };
}
const obj = parsed as Record<string, unknown>;
return {
name: typeof obj.name === "string" ? obj.name : "",
email: typeof obj.email === "string" ? obj.email : "",
};
} catch {
return { name: "", email: "" };
}
}
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 save = useCallback(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ name, email }));
setDirty(false);
}, [name, email]);
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="text-xs text-zinc-400 hover:text-zinc-600 px-1"
title="Settings"
>
{open ? "✕" : "⚙"}
</button>
{open && (
<div className="absolute right-0 top-full z-10 w-56 border-t border-zinc-200 bg-zinc-50 border-x border-b border-zinc-200 rounded-b shadow-sm px-3 py-2 flex flex-col gap-2">
<div className="text-xs uppercase tracking-wide text-zinc-400">
Git Identity
</div>
<input
type="text"
value={name}
onChange={(e) => {
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"
/>
<input
type="email"
value={email}
onChange={(e) => {
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"
/>
<div className="flex gap-1">
<button
type="button"
onClick={save}
disabled={!dirty}
className="px-2 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40"
>
Save
</button>
<button
type="button"
onClick={() => {
localStorage.removeItem(STORAGE_KEY);
setName("");
setEmail("");
setDirty(false);
}}
className="px-2 py-1 text-xs rounded bg-white hover:bg-zinc-100 text-zinc-500 border border-zinc-300"
>
Clear
</button>
</div>
<div className="text-xs text-zinc-400">
Used for Git commits. Stored locally in browser.
</div>
</div>
)}
</div>
);
}
+40
View File
@@ -412,11 +412,25 @@ function registerProviders(
// ---------- Session bootstrap ----------
interface DocToSrcJumpInfo {
filepath: string;
start?: [number, number];
end?: [number, number];
}
interface ShowDocumentParams {
uri: string;
external?: boolean;
takeFocus?: boolean;
selection?: { start: { line: number; character: number }; end: { line: number; character: number } };
}
export async function startLsp(
monaco: Monaco,
projectName: string,
projectUri: string,
onFirstOpen?: (client: RawLspClient) => void,
onJumpToSource?: (info: DocToSrcJumpInfo) => void,
): Promise<LspSession> {
const wsUrl = `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/lsp`;
const ws = new WebSocket(wsUrl);
@@ -467,6 +481,32 @@ export async function startLsp(
]);
client.onRequest("client/registerCapability", () => undefined);
client.onRequest("window/workDoneProgress/create", () => undefined);
client.onRequest("window/showDocument", (params) => {
const p = params as ShowDocumentParams;
if (p.external) return { success: false };
if (p.uri && onJumpToSource) {
const filepath = decodeURIComponent(new URL(p.uri).pathname);
const start = p.selection
? [p.selection.start.line, p.selection.start.character] as [number, number]
: undefined;
const end = p.selection
? [p.selection.end.line, p.selection.end.character] as [number, number]
: undefined;
onJumpToSource({ filepath, start, end });
}
return { success: true };
});
// SyncTeX: when user clicks in the preview, tinymist emits this notification
// with the source file and position. We forward it to the jump handler.
client.onNotification("tinymist/preview/scrollSource", (params) => {
const p = params as { filepath: string; start?: [number, number]; end?: [number, number] };
if (!onJumpToSource) return;
const filepath = p.filepath.includes("://")
? decodeURIComponent(new URL(p.filepath).pathname)
: p.filepath.replaceAll("\\", "/");
onJumpToSource({ filepath, start: p.start, end: p.end });
});
// LSP handshake
// Force typst-preview into light mode by passing `dark` theme as
+23
View File
@@ -22,3 +22,26 @@ export interface FileResponse {
content: string;
mtime: number;
}
export interface FileStatusInfo {
path: string;
status: string;
}
export interface CommitInfo {
sha: string;
message: string;
author: string;
date: string;
}
export interface GitStatus {
branch: string;
clean: boolean;
staged: FileStatusInfo[];
unstaged: FileStatusInfo[];
ahead: number;
behind: number;
hasRemote: boolean;
lastCommit: string | null;
}
+1 -1
View File
@@ -2,7 +2,7 @@
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,