Files
typst-leaf/typst-leaf-backend/src/routes/projects.ts
T
nav.sikand bfb4d8bef8 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
2026-07-02 00:35:32 +05:30

112 lines
3.5 KiB
TypeScript

import { Router, type Router as ExpressRouter } from "express";
import fs from "node:fs/promises";
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();
const MAX_TREE_DEPTH = 6;
const MAX_TREE_ENTRIES = 2000;
projectsRouter.get("/", async (_req, res) => {
try {
await fs.mkdir(PROJECTS_ROOT, { recursive: true });
const entries = await fs.readdir(PROJECTS_ROOT, { withFileTypes: true });
const projects = entries
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
.map((e) => e.name)
.sort();
res.json({ projects });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
projectsRouter.post("/:project", async (req, res) => {
const parsed = projectNameSchema.safeParse(req.params.project);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.issues });
return;
}
const name = parsed.data;
const dir = path.join(PROJECTS_ROOT, name);
try {
await fs.mkdir(dir, { recursive: true });
const main = path.join(dir, "main.typ");
try {
await fs.access(main);
} catch {
await fs.writeFile(
main,
// The Typst heading `=` must be followed by a space, then the title.
`= ${name}\n\nHello from a fresh project.\n`,
"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 });
}
});
projectsRouter.get("/:project", async (req, res) => {
const parsed = projectNameSchema.safeParse(req.params.project);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.issues });
return;
}
const dir = projectDir(parsed.data);
try {
await fs.access(dir);
} catch {
res.status(404).json({ error: "project not found" });
return;
}
res.json({ project: parsed.data, uri: pathToFileURL(dir).href });
});
projectsRouter.get("/:project/tree", async (req, res) => {
const parsed = projectNameSchema.safeParse(req.params.project);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.issues });
return;
}
const dir = projectDir(parsed.data);
try {
await fs.access(dir);
} catch {
res.status(404).json({ error: "project not found" });
return;
}
const out: { path: string; mtime: number }[] = [];
const budget = { left: MAX_TREE_ENTRIES };
async function walk(rel: string, depth: number): Promise<void> {
if (depth > MAX_TREE_DEPTH || budget.left <= 0) return;
const abs = path.join(dir, rel);
const entries = await fs.readdir(abs, { withFileTypes: true });
for (const entry of entries) {
if (budget.left <= 0) return;
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
const childAbs = path.join(abs, entry.name);
const stat = await fs.stat(childAbs);
if (entry.isDirectory()) {
await walk(childRel, depth + 1);
} else if (entry.isFile() && entry.name.endsWith(".typ")) {
out.push({ path: childRel, mtime: stat.mtimeMs });
budget.left -= 1;
}
}
}
await walk("", 0);
res.json({ project: parsed.data, files: out });
});