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
+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),
);
}