From bfb4d8bef80b6b6ba8aa3fde12b6626d9ffab13d Mon Sep 17 00:00:00 2001 From: Navraj Sikand Date: Thu, 2 Jul 2026 00:35:32 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20Git=20workflow,=20Vim=20mode,=20and=20p?= =?UTF-8?q?review=E2=86=92editor=20SyncTeX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- AGENTS.md | 4 +- PLAN.md | 80 ++++++ typst-leaf-backend/src/git.ts | 67 +++++ typst-leaf-backend/src/index.ts | 2 + typst-leaf-backend/src/routes/git.ts | 213 +++++++++++++++ typst-leaf-backend/src/routes/projects.ts | 4 + typst-leaf-backend/src/seed.ts | 6 + typst-leaf-frontend/src/App.tsx | 6 +- typst-leaf-frontend/src/api.ts | 87 ++++-- typst-leaf-frontend/src/app-state.tsx | 109 +++++++- typst-leaf-frontend/src/components/Editor.tsx | 153 +++++++++-- .../src/components/GitPanel.tsx | 255 ++++++++++++++++++ .../src/components/SettingsPanel.tsx | 117 ++++++++ typst-leaf-frontend/src/lsp.ts | 40 +++ typst-leaf-frontend/src/types.ts | 23 ++ typst-leaf-frontend/tsconfig.json | 2 +- 16 files changed, 1123 insertions(+), 45 deletions(-) create mode 100644 PLAN.md create mode 100644 typst-leaf-backend/src/git.ts create mode 100644 typst-leaf-backend/src/routes/git.ts create mode 100644 typst-leaf-frontend/src/components/GitPanel.tsx create mode 100644 typst-leaf-frontend/src/components/SettingsPanel.tsx diff --git a/AGENTS.md b/AGENTS.md index 9207cf6..c3486d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..62bb670 --- /dev/null +++ b/PLAN.md @@ -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 `
` (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). diff --git a/typst-leaf-backend/src/git.ts b/typst-leaf-backend/src/git.ts new file mode 100644 index 0000000..fe7fe05 --- /dev/null +++ b/typst-leaf-backend/src/git.ts @@ -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 { + 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"]); +} diff --git a/typst-leaf-backend/src/index.ts b/typst-leaf-backend/src/index.ts index e5da939..ffacd35 100644 --- a/typst-leaf-backend/src/index.ts +++ b/typst-leaf-backend/src/index.ts @@ -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); diff --git a/typst-leaf-backend/src/routes/git.ts b/typst-leaf-backend/src/routes/git.ts new file mode 100644 index 0000000..85e8f54 --- /dev/null +++ b/typst-leaf-backend/src/routes/git.ts @@ -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 }); + } +}); diff --git a/typst-leaf-backend/src/routes/projects.ts b/typst-leaf-backend/src/routes/projects.ts index 9cb733b..a11b543 100644 --- a/typst-leaf-backend/src/routes/projects.ts +++ b/typst-leaf-backend/src/routes/projects.ts @@ -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 }); diff --git a/typst-leaf-backend/src/seed.ts b/typst-leaf-backend/src/seed.ts index cf80169..179618b 100644 --- a/typst-leaf-backend/src/seed.ts +++ b/typst-leaf-backend/src/seed.ts @@ -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 { 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), + ); } diff --git a/typst-leaf-frontend/src/App.tsx b/typst-leaf-frontend/src/App.tsx index da1ec21..0ca604e 100644 --- a/typst-leaf-frontend/src/App.tsx +++ b/typst-leaf-frontend/src/App.tsx @@ -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() {