diff --git a/AGENTS.md b/AGENTS.md index 9207cf6..c3486d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,8 +26,8 @@ typst-leaf-backend/ Node.js/TS — stateless orchestration: spawns tinymist, typst-leaf-frontend/ React (Vite) + Monaco — the IDE SPA; also contains src-tauri/ for the desktop build ``` -* **Backend** (`typst-leaf-backend/`): ESM (`"type": "module"`), strict TS with `noUncheckedIndexedAccess`. Entry: `src/index.ts`. Dev via `tsx watch`; build via `tsc` → `dist/`. Uses `express` + `ws` + `simple-git` + `execa` + `zod`. -* **Frontend** (`typst-leaf-frontend/`): React 19 + Vite. Monaco Editor wired to LSP via `monaco-languageclient` + `vscode-ws-jsonrpc`. Styling is **Tailwind v4** via the `@tailwindcss/vite` plugin (import with `@import "tailwindcss"`; there is no `tailwind.config.js`). +* **Backend** (`typst-leaf-backend/`): ESM (`"type": "module"`), strict TS with `noUncheckedIndexedAccess`. Entry: `src/index.ts`. Dev via `tsx watch`; build via `tsc` → `dist/`. Uses `express` + `ws` + `simple-git` + `zod`. +* **Frontend** (`typst-leaf-frontend/`): React 19 + Vite. Monaco Editor wired to LSP via a raw JSON-RPC WebSocket client (`src/lsp.ts`). Styling is **Tailwind v4** via the `@tailwindcss/vite` plugin (import with `@import "tailwindcss"`; there is no `tailwind.config.js`). * **Tauri v2** lives at `typst-leaf-frontend/src-tauri/`. It shells out to `pnpm dev`/`pnpm build` and serves `../dist`. * **Data plane:** `ws://localhost:4000/lsp` (LSP), `ws://localhost:4000/preview` (SVG stream). diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..62bb670 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,80 @@ +# Next Chunk: Git + Vim + SyncTeX (Core VISION) + +## Part A — Vim Mode (frontend-only) + +**Goal:** Off-by-default vim toggle, persisted to localStorage. + +- `Editor.tsx`: + - Always render `
` (empty when vim is off, monaco-vim populates it when on). + - On `handleMount`: if `localStorage.getItem("typst-leaf.vim") === "true"`, `initVimMode(editor, vimStatusRef.current)`. + - Add "Vim" toggle button in the editor toolbar (emerald when active). + - On toggle: persist to localStorage; init or dispose accordingly. + - Store `initVimMode`'s returned controller in a ref for disposal on unmount/toggle-off. + +## Part B — Git + +### Backend + +- `git.ts`: factory `gitFor(project: string)` → `simpleGit(projectDir(project))`. +- `routes/git.ts` (mounted at `/api/projects/:project/git`): + - `POST /init` — `git init`, set default identity from env, initial commit, `.gitignore`. + - `GET /status` — branch, ahead/behind, staged/unstaged, clean. + - `GET /log?limit=20` — commit history. + - `POST /commit` — body `{ message, all?, identity? }`. Uses `-c user.name=... -c user.email=...` per-commit flags (not repo config). + - `PUT /remote` — body `{ url }` — `git remote add` or `set-url`. + - `POST /push` / `POST /pull` — to origin, return `{ ok, error? }`. +- `routes/projects.ts`: `POST /:project` calls `git.init` after seeding `main.typ`. +- `index.ts`: mount gitRouter. + +### Frontend + +- `api.ts`: gitInit, gitStatus, gitLog, gitCommit, gitPush, gitPull, setRemote. +- `types.ts`: FileStatus, CommitInfo, GitStatus. +- `GitPanel.tsx` (sidebar, below FileTree): + - Branch name, ahead/behind badges, unstaged file count. + - Inline commit: message input + Commit button (Enter to submit). + - Push/Pull buttons; prompts for remote URL if none configured. + - Refreshes on project open + after saves + after commits. +- `SettingsPanel.tsx` (gear icon in sidebar header toggles panel): + - Git Author Name, Git Author Email inputs. + - Persisted to `localStorage["typst-leaf.git-identity"]`. + - Sent with every commit as `identity`. +- `Editor.tsx`: `CtrlCmd+Shift+KeyS` → dispatch `commitFocus` via app state. +- `app-state.tsx`: `gitStatus`, `gitVersion` (incremented on save to trigger refresh), `commitFocus` (incremented on shortcut). + +## Part C — SyncTeX (Preview → Editor) + +### LSP Client (`lsp.ts`) + +Two LSP message paths from tinymist, handle both: + +1. **`tinymist/preview/scrollSource` notification**: + - Params: `{ filepath: string, start?: [number, number], end?: [number, number] }` + - Convert: `filepath` → relPath, `start/end` are 0-indexed [row, col] + - Call `onJumpToSource({ filepath, start, end })` + +2. **`window/showDocument` request**: + - Params: `{ uri: string, selection?: { start: {line, character}, end: {line, character} } }` + - Convert: `uri` (file://) → relPath, selection is 0-indexed LspRange + - Call `onJumpToSource(...)`, respond `{ success: true }` + +### App State (`app-state.tsx`) + +- Add `jumpTarget: { file: string; start?: [number, number]; end?: [number, number] } | null` to state. +- In `startLsp`, pass `onJumpToSource` callback: + 1. Convert absolute `filepath` → relPath (strip `new URL(projectUri).pathname` prefix) + 2. **Ignore if filepath is not under the project** (e.g. imported package file) + 3. If `relPath !== state.openFile`, dispatch `openFile` + 4. Dispatch `setJumpTarget({ file: relPath, start, end })` + +### Editor (`Editor.tsx`) + +- Effect deps: `[jumpTarget, model]` +- When `jumpTarget` is truthy AND `model` is the correct file: + - Convert 0-indexed [row, col] → Monaco 1-indexed (line = row + 1, column = col + 1) + - `editor.setSelection(range)`, `editor.revealRangeInCenter(range)`, focus editor + - Dispatch `clearJumpTarget` + +## Part D — Doc Cleanup + +- `AGENTS.md`: Remove stale `execa` (backend has simple-git, not execa). Update Architecture & Boundaries to reflect that frontend uses raw LSP, not `monaco-languageclient`. Fix stale `vscode-ws-jsonrpc` references (frontend removed it; backend still uses it). diff --git a/typst-leaf-backend/src/git.ts b/typst-leaf-backend/src/git.ts new file mode 100644 index 0000000..fe7fe05 --- /dev/null +++ b/typst-leaf-backend/src/git.ts @@ -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