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:
@@ -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"]);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user