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