bfb4d8bef8
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
112 lines
3.5 KiB
TypeScript
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 });
|
|
});
|