diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..d2c2a34 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,171 @@ +# DESIGN.md — typst-leaf UI Rehaul + +This document is the aesthetic reference for the next chunk in `PLAN.md`. Every UI decision in `PLAN.md` should match this guide. Direction is "document editor" — warmth, restraint, a thin layer of leaf. + +## Brand + +- Name: **typst-leaf** — warm, organic, paper-and-ink. +- Personality: quiet, considered, a serious editor for prose, papers, and beautiful documents. +- Not "developer marketing." Not "dashboard SaaS." Closer to a focused notebook. + +## Aesthetic Pillars + +1. **Paper, not glass.** + Surfaces are warm off-white, not slate. Borders separate panels; we don't blur or float. +2. **Functional color, decorative restraint.** + Greens only when communicating success/active/leaf. Use amber for warnings, slate for muted text, rose for errors. No gradients on chrome. +3. **Typography is the interface.** + System sans for chrome, monospace for paths/git/lsp status. We say things in words, not icons. +4. **A whisper, not a shout.** + Rounded corners are subtle (`rounded-md`). Focus rings are visible but quiet. + +## Palette (Tailwind v4 via CSS-first) + +| Token | Use | Tailwind class | +|---|---|---| +| Page background | Shell chrome | `bg-stone-50` | +| Panel surface | Cards, panels | `bg-white` | +| Sidebar | Activity + sections | `bg-stone-50` with inner panels `bg-white` | +| Text primary | Body | `text-zinc-800` | +| Text muted | Meta | `text-zinc-500` | +| Text subtle | File paths | `text-zinc-400` | +| Divider | 1px borders | `border-zinc-200` | +| Hover surface | Buttons/list rows | `hover:bg-stone-100` | +| Accent (primary) | Save, commit | `bg-emerald-600 hover:bg-emerald-700 text-white` | +| Accent (soft) | Active file, focus | `bg-emerald-50 text-emerald-800 ring-emerald-200` | +| Warning | Behind | `text-amber-600` | +| Error | Dirty error | `text-rose-600` | +| Pill bg (status) | Status chips | `bg-white border-zinc-200` | + +We do not introduce dark classes. Tailwind v4 is in CSS-first mode (`@import "tailwindcss"`); tokens live in plain CSS, not `tailwind.config.js`. + +## Typography + +- UI: `font-sans` (system stack already from Tailwind). +- Monospace: `font-mono` for paths, git refs, LSP logs, status messages. +- Toolbar/header: `text-sm font-medium tracking-tight`. +- Meta and toasts: `text-xs text-zinc-500`. +- File names: `text-sm font-mono`. + +## Layout Skeleton + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ TopBar: leaf · [project ▾] · main.typ · Save · ●LSP ◌PRV │ +├──────────┬───────────────────────────────────────────────────────────┤ +│ Sidebar │ │ +│ Files │ │ +│ ▣ main │ Monaco Editor │ +│ ▣ lib/ │ │ +│ Git │ │ +│ ⚙ set │ │ +│ ├─────────────────────────────────────┬─────────────────────┤ +│ │ Editor (resizable) │ Preview │ +│ │ │ (iframe) │ +│ │ │ │ +└──────────┴─────────────────────────────────────┴─────────────────────┘ +│ StatusBar: main · clean · ready · vim:n · 1:23 │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +## Component Specs (Tailwind) + +### TopBar +```tsx +
+ typst-leaf + · + + main.typ + + LSP · ready + PRV · starting + + + +
+``` + +### Sidebar (`PanelSection` + tabs) +```tsx + +``` + +### ResizableSplit +```tsx +
+
/* editor */
+ +``` + +### EmptyState +```tsx +
+
Open a file from the sidebar.
+
Tip: ⌘P to jump, ⌘S to save.
+
+``` + +### Toast +```tsx +
+
+ + Committed a1b2c3d +
+
+``` + +### Status chips +```tsx + + + LSP · ready + +``` + +## States + +- **No project:** large empty state in main area; sidebar shows "No project yet." +- **Connecting:** preview shows "starting…" with a quiet spinner (CSS, not library). +- **External preview source:** toast "Source outside project — ignored." +- **Save error:** rose-600 message in toast + small dot in TopBar file path. +- **Git dirty:** emerald-50 row tint + small dot next to filename in tree. + +## Motion + +- Hover: `transition-colors duration-100`. +- Splitter drag: no transition. +- Toast in/out: 150ms opacity, no spring. + +## Accessibility + +- Visible focus rings: `focus-visible:ring-2 focus-visible:ring-emerald-300 focus-visible:ring-offset-1`. +- All interactive controls reachable by keyboard. +- Status communicated by text + color, never color alone. + +## Don'ts + +- No gradients on chrome. +- No shadows on whitespace panels. +- No animated chrome unless it's a real state change. +- No emoji decorations in product UI (only as icons in narrow toolbars). + +## Thumbnail: vibe check + +> Imagine a warm, slightly off-white IDE with a single green accent for "commit ready" and a quiet three-dot status footer. It's the IDE equivalent of a blank page of cream paper, not a glass dashboard. diff --git a/PLAN.md b/PLAN.md index 62bb670..e959392 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,80 +1,200 @@ -# Next Chunk: Git + Vim + SyncTeX (Core VISION) +# Next Chunk: IDE Shell, File Management, UI Rehaul, Reliability, Git Workflow -## Part A — Vim Mode (frontend-only) +This chunk is the next deliverable on top of the `bfb4d8b` commit. It picks **options 1, 2, and 3**: IDE shell + file management + UI rehaul, reliability and LSP/Preview hardening, and Git workflow upgrade. Direction and aesthetic decisions live in `DESIGN.md` — every UI decision below should read against that doc. -**Goal:** Off-by-default vim toggle, persisted to localStorage. +## Guiding Constraints -- `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. +- Tailwind only, light theme only, no dark variant. +- Browser-first; Tauri APIs only behind `window.__TAURI__` guards. +- No databases; filesystem + `.git` remain the source of truth. +- No polling for file or preview changes. +- Backend stays a thin proxy. No Typst or LSP reimplementation in TypeScript. +- `tinymist` remains the LSP/preview source of truth. +- Keep the raw JSON-RPC LSP client. Do not reintroduce `monaco-languageclient`. +- Strict TypeScript with zod at API boundaries. -## Part B — Git +## Aesthetic Direction (see DESIGN.md) + +- Document-editor tone: warm paper background, subtle emerald accents, no marketing gradients. +- Dense-but-readable developer layout. System sans for chrome, monospace for paths/git. +- Rounded corners and borders restrained to "panel" and "card"; default to `rounded-md`. +- Status indicators must read clearly (label + color, never color alone). +- Splitter, focus rings, etc. all designed in DESIGN.md; the implementation must match. + +## Phase 1 — Path Utilities and Jump Reliability + +Move path/jump helpers into a single module so all callers use the same normalization rules. + +**New file** +- `typst-leaf-frontend/src/path-utils.ts`: + - `normalizeFsPath(input: string): string` + - `encodeVirtualPathForFileUri(path: string): string` + - `relativePathFromProject(projectUri: string, filepath: string): string | null` + +**State changes** (`app-state.tsx`) +- `jumpTarget: { id: number; file: string; start?: [number,number]; end?: [number,number] } | null` +- `setJumpTarget` action carries an `id`; reducer increments counter and stamps. +- New `pushNotice` action for transient messages ("Preview source outside project"). + +**LSP hardening** (`lsp.ts`) +- Validate `tinymist/preview/scrollSource` payload strictly: `filepath` string, optional numeric `start/end`. +- Forward only well-formed paths to `onJumpToSource`. + +**Editor** (`Editor.tsx`) +- Use shared path utility. +- Compare URIs via `monaco.Uri.equals` after parsing both sides. +- Dedupe by `jump.id`, not object identity. +- Keep the existing `ResizeObserver` and `editor.layout()` call before reveal. + +**Verification** +- External preview path ignored with non-blocking message. +- Numeric start/end coercion. +- Rapid clicks keep latest, no swallowed targets. + +## Phase 2 — UI Foundation (Rehaul) + +Replace the current sparse shell with the layered "IDE" layout in DESIGN.md. + +**Files** +- `typst-leaf-frontend/src/App.tsx` (restructure) +- `typst-leaf-frontend/src/index.css` (extend `tailwindcss` import; nothing else without need) +- New components: + - `TopBar.tsx` + - `StatusBar.tsx` + - `Sidebar.tsx` + - `PanelSection.tsx` + - `ActivityBar.tsx` + - `ResizableSplit.tsx` + - `ToastHost.tsx` + - `EmptyState.tsx` + +**Layout** +- Top bar: project switcher, current file/dirty indicator, save, LSP/preview/git chips, settings. +- Sidebar tabs: Files / Git / Settings. +- Main: resizable editor / preview split. +- Status bar: branch, clean/dirty, LSP, preview, vim state, cursor (optional). + +**Aesthetic tokens** (mirror DESIGN.md) +- `bg-stone-50` for chrome, `bg-white` for editor surface, `bg-emerald-600` for primary actions, `border-zinc-200` for panel dividers, `text-zinc-500` for muted metadata. +- Tailwind reference snippets are kept in DESIGN.md. + +**QOL** +- Toasts for save/commit/push/pull, configured via `pushNotice`. +- Clear empty states ("Select a project", "Open a file", "No changes to commit"). +- Keyboard shortcuts surfaced in `StatusBar` hints later if time. + +## Phase 3 — Resizable Workspace and Preview UX + +- Build `ResizableSplit.tsx` (pointer-driven, double-click reset, persisted ratio). +- Persist `typst-leaf.split` in `localStorage`. +- Add `PreviewToolbar` on top of iframe: status chip, reload button, "Click to jump" hint. +- During drag and on drag-end, call `editor.layout()` so Monaco keeps accurate viewports. + +## Phase 4 — File Management + +Make projects usable from the UI. ### Backend +`typst-leaf-backend/src/routes/files.ts`: +- `POST /api/projects/:project/file` +- `DELETE /api/projects/:project/file?path=…` +- `PATCH /api/projects/:project/file` +- `POST /api/projects/:project/folder` -- `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. +All paths through `resolveFile`/`relPathSchema`. zod validation stays. ### Frontend +`api.ts`: +- `createFile`, `deleteFile`, `renameFile`, `createFolder`. -- `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). +`types.ts`: +- Add request types. -## Part C — SyncTeX (Preview → Editor) +`FileTree.tsx`: +- Group tree view; folders from existing file paths. +- Inline create/rename/delete actions; confirm destructive deletes. +- After mutation: refresh tree, dispatch `bumpGitVersion`, open new file or close deleted open file (with model disposal). -### LSP Client (`lsp.ts`) +`app-state.tsx`: +- Action `setTree` already exists. Add `bumpFileTree` counter or reuse `setProjects` invalidation by re-fetching tree on a `treeVersion`. -Two LSP message paths from tinymist, handle both: +## Phase 5 — SyncTeX/LSP Reliability Hardening -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 })` +(See Phase 1.) This phase covers the user-visible behavior of the jump path: -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 }` +- Stable IDs prevent dropped or duplicated jumps. +- External path is announced via toast instead of silently dropping. +- Vue of preview-after-resize: drag resize triggers `editor.layout()`. Preview iframe reload button re-syncs. +- A small dev-only "Diagnostics" panel hidden behind `?debug=1` later is optional. -### App State (`app-state.tsx`) +## Phase 6 — Git Workflow Upgrade -- 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 })` +Backend +- `GET /api/projects/:project/git/diff` returning unified diffs (later; not blocking if skipped). +- Optional `POST /api/projects/:project/git/stage` for partial staging (later). -### Editor (`Editor.tsx`) +Frontend +- `GitPanel.tsx` becomes a workflow panel: + - Branch + ahead/behind + clean state + - Changed files list grouped by staged/unstaged/untracked (icons per status) + - Commit composer + - Push/Pull controls + - Remote inline input + - Recent commits list (keep existing) +- `FileTree.tsx` shows dirty file badges. +- Toasts on success/failure: "Committed abc1234", "Pushed to origin/main", etc. +- Disable commit when clean; message should explain why. -- 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` +`types.ts`: +- Confirm `FileStatusInfo` carries `A|M|D|?` (already does). -## Part D — Doc Cleanup +## Phase 7 — State and UX Infrastructure -- `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). +Keep state in `app-state.tsx` (no Zustand yet). Add UI actions: +- `setActivePanel` (files | git | settings) +- `setFileDirty` +- `setSaving` +- `pushNotice` / `dismissNotice` +- `setSplitRatio` (effect persists via localStorage directly; not strict state) + +Toast component lives once at `App.tsx`, reads notices via context. + +## Phase 8 — Doc and Metadata Cleanup + +- `README.md`: port 1420, raw LSP client, preview-via-LSP-and-proxy model. +- `VISION.md`: note raw LSP client; soften `monaco-languageclient` claim. +- `typst-leaf-frontend/README.md`: replace Tauri-template text. +- `PLAN.md`: replace with this plan at commit time. Keep `DESIGN.md` separate and referenced. + +## Implementation Order + +1. Shared path utilities + jump IDs. +2. SyncTeX hardening (rely on shared utils, not yet aesthetic). +3. UI shell rehaul (TopBar, Sidebar, ResizableSplit, StatusBar, ToastHost). +4. File CRUD backend + frontend integration, FileTree rebuild. +5. Git workflow UI rebuild + badges in tree. +6. Preview toolbar and reload. +7. Doc cleanup. +8. Verification: + - `pnpm -r typecheck` + - `pnpm --filter typst-leaf-frontend build` + - Manual checks: file CRUD, resize-during-jump, Git flow, external preview path ignored. + +## Risks + +- File URI/path encoding is still the highest-risk area; reuse existing helpers in `Editor.tsx`. +- Rename/delete of currently open file must dispose Monaco model. +- Git status can stale if a file mutation does not bump `gitVersion`. +- Resizable panes must call `layout()` during and after drag. +- Preview iframe behavior is owned by typst-preview; do not over-customize it. + +## Scope Boundary + +In: +- IDE shell rehaul, resizable split, file CRUD, Git workflow UI upgrade, SyncTeX reliability, toasts, status bar. + +Out: +- Per-file staging workflow. +- Rich diff viewer. +- Drag/drop reordering, multi-tab editor, collaborative cursors. +- Tauri native file picker. diff --git a/README.md b/README.md index a0a0b5b..7047202 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,11 @@ A completely self-hostable, high-performance editor for [Typst](https://typst.app/), built on the philosophy of "Git as the Database." **Features:** -* **Instant Preview:** Sub-50ms incremental compilation using `tinymist`. +* **Instant Preview:** Incremental compilation using `tinymist`. * **Live diagnostics & autocomplete** from the Typst language server. -* **Git Native:** No hidden databases. Your projects are just folders. +* **Git Native:** No hidden databases. Your projects are just folders with `.git`. +* **Vim Mode:** Toggleable via toolbar. +* **SyncTeX:** Click in the preview to jump to source in the editor. * **Hybrid:** Run it in a browser, or as a native desktop app via Tauri. ## 📂 Repository Structure @@ -58,10 +60,15 @@ Then open in your browser. A `hello` project is auto-see ## 🏗️ Architecture -* **Editor:** Monaco Editor -* **LSP:** `tinymist` over a WebSocket thin proxy -* **Preview:** Backend spawns `tinymist preview ` on demand; the frontend iframes its webview for live SVG updates. +* **Editor:** Monaco Editor (bundled locally) +* **LSP:** Raw JSON-RPC client over WebSocket → backend thin proxy → `tinymist` stdio +* **Preview:** Frontend calls `tinymist.startDefaultPreview` via LSP, discovers random data-plane port, relays to backend via `POST /api/preview/sink`; backend proxies the preview webview and its WebSocket. +* **Frontend LSP:** Custom ~300-line `RawLspClient` class (no `monaco-languageclient`). +* **Git:** `simple-git` wrapper, per-project `.git`, commit identity via `-c` flags (no mutation of repo config). +* **Styling:** Tailwind v4 via `@tailwindcss/vite` plugin. Light theme only. * **Data plane:** * `ws://localhost:4000/lsp` → `tinymist lsp` (single active connection, takeover on reconnect) - * `GET /api/projects`, `GET/PUT /api/projects/:p/file?path=…`, `POST /api/projects/:p/focus?path=…` + * `ws://localhost:4000/preview` → tinymist preview server (SVG stream) + * `GET/POST/DELETE/PATCH /api/projects/:project/file?path=…` + * `POST /api/projects/:project/git/init`, `GET …/status`, `GET …/log`, `POST …/commit`, `POST …/push`, `POST …/pull`, `PUT …/remote` diff --git a/VISION.md b/VISION.md index 2602ea4..c400b2e 100644 --- a/VISION.md +++ b/VISION.md @@ -15,7 +15,7 @@ The system follows a strict **Client-Server** model, even when running locally. * **Monorepo Structure:** Flat directory structure (`typst-leaf-backend`, `typst-leaf-frontend`). * **Frontend:** React (Vite) + Monaco Editor. * **Desktop Wrapper:** Tauri v2 (integrated into `typst-leaf-frontend`). -* **Backend:** TypeScript (Node.js/Bun) + Express/Fastify. +* **Backend:** TypeScript (Node.js) + Express. * **Core Engine:** `tinymist` (Binary) for LSP and compilation. ### Component Breakdown @@ -31,9 +31,9 @@ The backend is a stateless orchestration layer that sits on top of the file syst #### B. Frontend (`typst-leaf-frontend`) A Single Page Application (SPA) that acts as the IDE interface. -* **Editor:** Monaco Editor configured with `monaco-languageclient` to talk to the backend LSP. +* **Editor:** Monaco Editor configured with a custom ~300-line raw JSON-RPC LSP client (no `monaco-languageclient`). * **Preview:** A high-performance SVG renderer that receives delta updates from Tinymist. -* **Vim Mode:** `monaco-vim` enabled by default or toggleable. +* **Vim Mode:** `monaco-vim` toggleable via toolbar, persisted to `localStorage`. * **Tauri Integration:** The frontend folder contains `src-tauri`, allowing it to build as a native desktop application. ## 3. Critical Technical Decisions @@ -54,7 +54,7 @@ We **do not** render PDFs in the browser for preview. *Rationale:* SVGs are selectable, zoomable without blur, and significantly lighter than streaming PDF binaries. ### 3.3 The LSP Connection -We use `monaco-languageclient` to connect Monaco to the backend via WebSocket. The backend simply pipes this socket to the stdin/stdout of the running `tinymist` binary. We do not reimplement LSP logic in TypeScript. +We use a raw JSON-RPC WebSocket client (no `monaco-languageclient`) to connect Monaco to the backend. The backend simply pipes this socket to the stdin/stdout of the running `tinymist` binary. We do not reimplement LSP logic in TypeScript. ## 4. User Stories * **As a User,** I want to type code and see the result instantly (<100ms lag). diff --git a/typst-leaf-backend/src/routes/files.ts b/typst-leaf-backend/src/routes/files.ts index 69619f0..2759afb 100644 --- a/typst-leaf-backend/src/routes/files.ts +++ b/typst-leaf-backend/src/routes/files.ts @@ -9,6 +9,12 @@ export const filesRouter: ExpressRouter = Router(); const projectParam = z.object({ project: projectNameSchema }); const fileQuery = z.object({ path: relPathSchema }); const writeBody = z.object({ content: z.string() }); +const renameBody = z.object({ from: relPathSchema, to: relPathSchema }); +const createFileBody = z.object({ + path: relPathSchema, + content: z.string().optional().default(""), +}); +const createFolderBody = z.object({ path: relPathSchema }); filesRouter.get("/:project/file", async (req, res) => { const p = projectParam.safeParse(req.params); @@ -53,3 +59,115 @@ filesRouter.put("/:project/file", async (req, res) => { res.status(500).json({ error: (err as Error).message }); } }); + +// Create a new file (optional content, defaults to empty) +filesRouter.post("/:project/file", async (req, res) => { + const p = projectParam.safeParse(req.params); + const b = createFileBody.safeParse(req.body); + if (!p.success || !b.success) { + res.status(400).json({ + error: [...(p.error?.issues ?? []), ...(b.error?.issues ?? [])], + }); + return; + } + try { + const { abs } = resolveFile(p.data.project, b.data.path); + await fs.mkdir(path.dirname(abs), { recursive: true }); + const fd = await fs.open(abs, "wx").catch((e) => { + if (e.code === "EEXIST") { + const wrapped = new Error("file already exists") as NodeJS.ErrnoException; + wrapped.code = "EEXIST"; + throw wrapped; + } + throw e; + }); + if (b.data.content) await fd.writeFile(b.data.content, "utf8"); + await fd.close(); + res.status(201).json({ path: b.data.path, ok: true }); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + res.status(code === "EEXIST" || code === "ENOENT" ? 409 : 500).json({ + ok: false, + error: (err as Error).message, + }); + } +}); + +// Delete a file +filesRouter.delete("/:project/file", async (req, res) => { + const p = projectParam.safeParse(req.params); + const q = fileQuery.safeParse(req.query); + if (!p.success || !q.success) { + res.status(400).json({ + error: [...(p.error?.issues ?? []), ...(q.error?.issues ?? [])], + }); + return; + } + try { + const { abs } = resolveFile(p.data.project, q.data.path); + await fs.unlink(abs); + res.json({ ok: true }); + } catch (err) { + res.status(404).json({ ok: false, error: (err as Error).message }); + } +}); + +// Rename / move a file +filesRouter.patch("/:project/file", async (req, res) => { + const p = projectParam.safeParse(req.params); + const b = renameBody.safeParse(req.body); + if (!p.success || !b.success) { + res.status(400).json({ + error: [...(p.error?.issues ?? []), ...(b.error?.issues ?? [])], + }); + return; + } + try { + const { abs: fromAbs } = resolveFile(p.data.project, b.data.from); + const { abs: toAbs } = resolveFile(p.data.project, b.data.to); + await fs.mkdir(path.dirname(toAbs), { recursive: true }); + // Atomically guard against overwriting an existing file. On POSIX + // link() fails if the destination exists, and rename() overwrites + // the destination only after we have created a hard link, so there + // is no window for another process to create the target in between. + const tempAbs = `${toAbs}.tmp-${Date.now()}`; + try { + await fs.link(fromAbs, tempAbs); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + res + .status(code === "EEXIST" ? 409 : 500) + .json({ ok: false, error: code === "EEXIST" ? "destination already exists" : (err as Error).message }); + return; + } + try { + await fs.rename(tempAbs, toAbs); + } catch (err) { + await fs.unlink(tempAbs).catch(() => {}); + throw err; + } + await fs.unlink(fromAbs).catch(() => {}); + res.json({ ok: true, from: b.data.from, to: b.data.to }); + } catch (err) { + res.status(500).json({ ok: false, error: (err as Error).message }); + } +}); + +// Create a folder +filesRouter.post("/:project/folder", async (req, res) => { + const p = projectParam.safeParse(req.params); + const b = createFolderBody.safeParse(req.body); + if (!p.success || !b.success) { + res.status(400).json({ + error: [...(p.error?.issues ?? []), ...(b.error?.issues ?? [])], + }); + return; + } + try { + const dirPath = resolveFile(p.data.project, b.data.path).abs; + await fs.mkdir(dirPath, { recursive: true }); + res.status(201).json({ path: b.data.path, ok: true }); + } catch (err) { + res.status(500).json({ ok: false, error: (err as Error).message }); + } +}); diff --git a/typst-leaf-backend/src/routes/projects.ts b/typst-leaf-backend/src/routes/projects.ts index a11b543..810ddd3 100644 --- a/typst-leaf-backend/src/routes/projects.ts +++ b/typst-leaf-backend/src/routes/projects.ts @@ -84,13 +84,18 @@ projectsRouter.get("/:project/tree", async (req, res) => { res.status(404).json({ error: "project not found" }); return; } - const out: { path: string; mtime: number }[] = []; + const files: { path: string; mtime: number }[] = []; + const folders: { path: string }[] = []; const budget = { left: MAX_TREE_ENTRIES }; async function walk(rel: string, depth: number): Promise { if (depth > MAX_TREE_DEPTH || budget.left <= 0) return; const abs = path.join(dir, rel); const entries = await fs.readdir(abs, { withFileTypes: true }); + // Track whether this directory contains any user-visible entries so + // we only emit empty directories (matches IDE expectations and + // keeps noise from hidden entries to a minimum). + let hasVisible = false; for (const entry of entries) { if (budget.left <= 0) return; if (entry.name.startsWith(".") || entry.name === "node_modules") continue; @@ -99,13 +104,21 @@ projectsRouter.get("/:project/tree", async (req, res) => { const stat = await fs.stat(childAbs); if (entry.isDirectory()) { await walk(childRel, depth + 1); + hasVisible = true; + if (budget.left <= 0) return; } else if (entry.isFile() && entry.name.endsWith(".typ")) { - out.push({ path: childRel, mtime: stat.mtimeMs }); + files.push({ path: childRel, mtime: stat.mtimeMs }); budget.left -= 1; + hasVisible = true; } } + // After walking, emit empty directories so the UI can show them. + if (!hasVisible && rel !== "" && budget.left > 0) { + folders.push({ path: rel }); + budget.left -= 1; + } } await walk("", 0); - res.json({ project: parsed.data, files: out }); + res.json({ project: parsed.data, files, folders }); }); diff --git a/typst-leaf-frontend/README.md b/typst-leaf-frontend/README.md index 102e366..2fb7f33 100644 --- a/typst-leaf-frontend/README.md +++ b/typst-leaf-frontend/README.md @@ -1,7 +1,12 @@ -# Tauri + React + Typescript +# typst-leaf-frontend -This template should help get you started developing with Tauri, React and Typescript in Vite. +React + Vite + Monaco Editor frontend for typst-leaf. -## Recommended IDE Setup +- **Port:** `1420` (mandated by Tauri; see `vite.config.ts`) +- **Dev:** `pnpm --filter typst-leaf-frontend dev` +- **Build:** `pnpm --filter typst-leaf-frontend build` +- **Tauri:** `pnpm --filter typst-leaf-frontend tauri dev` -- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) +## Editor + +Monaco Editor with a raw JSON-RPC LSP client over WebSocket. Vim mode via `monaco-vim` (toggleable). Preview via tinymist SVG streaming. diff --git a/typst-leaf-frontend/src/App.tsx b/typst-leaf-frontend/src/App.tsx index 0ca604e..960c36c 100644 --- a/typst-leaf-frontend/src/App.tsx +++ b/typst-leaf-frontend/src/App.tsx @@ -4,41 +4,117 @@ import { FileTree } from "./components/FileTree"; import { GitPanel } from "./components/GitPanel"; import { Preview } from "./components/Preview"; import { ProjectPicker } from "./components/ProjectPicker"; +import { TopBar } from "./components/TopBar"; +import { StatusBar } from "./components/StatusBar"; +import { ToastHost } from "./components/ToastHost"; +import { EmptyState } from "./components/EmptyState"; +import { PanelSection } from "./components/PanelSection"; import { SettingsPanel } from "./components/SettingsPanel"; +import { ResizableSplit } from "./components/ResizableSplit"; function Shell() { - const { state, lsp } = useApp(); + const { state, dispatch } = useApp(); + return ( -
- + + {/* Main workspace */} +
+ {state.currentProject && state.openFile ? ( + } + right={} + onResize={() => { + document.dispatchEvent(new CustomEvent("typst-leaf:layout")); + }} + /> + ) : ( +
+ {!state.currentProject ? ( +
+
+ +
+
+ ) : !state.openFile ? ( +
+
+ +
+
+ +
+
+ ) : null} +
+ )} +
+
+ + +
); } diff --git a/typst-leaf-frontend/src/api.ts b/typst-leaf-frontend/src/api.ts index 9ab3bdc..409f840 100644 --- a/typst-leaf-frontend/src/api.ts +++ b/typst-leaf-frontend/src/api.ts @@ -62,6 +62,30 @@ export const api = { body: JSON.stringify({ port }), }).then((r) => jsonOrThrow<{ ok: boolean; port: number }>(r)), + // ---- File CRUD ---- + createFile: (project: string, path: string, content?: string) => + fetch(`${projectUrl(project)}/file`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path, content }), + }).then((r) => jsonOrThrow<{ ok: boolean; path: string }>(r)), + deleteFile: (project: string, path: string) => + fetch(`${projectUrl(project)}/file?path=${encodeURIComponent(path)}`, { + method: "DELETE", + }).then((r) => jsonOrThrow<{ ok: boolean }>(r)), + renameFile: (project: string, from: string, to: string) => + fetch(`${projectUrl(project)}/file`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ from, to }), + }).then((r) => jsonOrThrow<{ ok: boolean }>(r)), + createFolder: (project: string, path: string) => + fetch(`${projectUrl(project)}/folder`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path }), + }).then((r) => jsonOrThrow<{ ok: boolean; path: string }>(r)), + // ---- Git ---- gitInit: (project: string) => fetch(`${gitUrl(project)}/init`, { method: "POST" }).then((r) => diff --git a/typst-leaf-frontend/src/app-state.tsx b/typst-leaf-frontend/src/app-state.tsx index 3e88a4f..580df74 100644 --- a/typst-leaf-frontend/src/app-state.tsx +++ b/typst-leaf-frontend/src/app-state.tsx @@ -10,23 +10,40 @@ import { import { api } from "./api"; import { loadMonaco } from "./monaco-setup"; import { startLsp, startPreview, type RawLspClient } from "./lsp"; +import { relativePathFromProject } from "./path-utils"; import type { Monaco } from "@monaco-editor/react"; import type { FileEntry, GitStatus } from "./types"; +interface Notice { + id: number; + kind: "success" | "error" | "info"; + message: string; +} + interface State { projects: string[]; currentProject: string | null; currentUri: string | null; tree: FileEntry[]; + folders: { path: string }[]; + treeVersion: number; openFile: string | null; loading: boolean; error: string | null; + fileDirty: boolean; + saving: boolean; + // sidebar + activePanel: "files" | "git" | "settings"; // git gitStatus: GitStatus | null; gitVersion: number; commitFocus: number; + // vim + vimOn: boolean; // sync + notice: Notice | null; jumpTarget: { + id: number; file: string; start?: [number, number]; end?: [number, number]; @@ -36,14 +53,21 @@ interface State { type Action = | { type: "setProjects"; projects: string[] } | { type: "selectProject"; project: string; uri: string } - | { type: "setTree"; tree: FileEntry[] } + | { type: "setTree"; tree: FileEntry[]; folders: { path: string }[] } + | { type: "bumpTreeVersion" } | { type: "openFile"; path: string | null } | { type: "loading"; loading: boolean } | { type: "error"; error: string | null } + | { type: "setFileDirty"; dirty: boolean } + | { type: "setSaving"; saving: boolean } + | { type: "setActivePanel"; panel: State["activePanel"] } | { type: "setGitStatus"; status: GitStatus } | { type: "bumpGitVersion" } | { type: "focusCommit" } - | { type: "setJumpTarget"; target: State["jumpTarget"] } + | { type: "pushNotice"; kind: Notice["kind"]; message: string } + | { type: "dismissNotice" } + | { type: "setVimOn"; on: boolean } + | { type: "setJumpTarget"; target: NonNullable } | { type: "clearJumpTarget" }; const initial: State = { @@ -51,12 +75,19 @@ const initial: State = { currentProject: null, currentUri: null, tree: [], + folders: [], + treeVersion: 0, openFile: null, loading: false, error: null, + fileDirty: false, + saving: false, + activePanel: "files", gitStatus: null, gitVersion: 0, commitFocus: 0, + notice: null, + vimOn: false, jumpTarget: null, }; @@ -70,27 +101,45 @@ function reducer(state: State, action: Action): State { currentProject: action.project, currentUri: action.uri, tree: [], + folders: [], openFile: null, gitStatus: null, + fileDirty: false, + saving: false, + error: null, }; case "setTree": - return { ...state, tree: action.tree }; + return { ...state, tree: action.tree, folders: action.folders }; + case "bumpTreeVersion": + return { ...state, treeVersion: state.treeVersion + 1 }; case "openFile": - return { ...state, openFile: action.path }; + return { ...state, openFile: action.path, fileDirty: false }; case "loading": return { ...state, loading: action.loading }; case "error": return { ...state, error: action.error }; + case "setFileDirty": + return { ...state, fileDirty: action.dirty }; + case "setSaving": + return { ...state, saving: action.saving }; + case "setActivePanel": + return { ...state, activePanel: action.panel }; case "setGitStatus": return { ...state, gitStatus: action.status }; case "bumpGitVersion": return { ...state, gitVersion: state.gitVersion + 1 }; case "focusCommit": - return { ...state, commitFocus: state.commitFocus + 1 }; + return { ...state, commitFocus: state.commitFocus + 1, activePanel: "git" }; + case "pushNotice": + return { ...state, notice: { id: Date.now(), kind: action.kind, message: action.message } }; + case "dismissNotice": + return { ...state, notice: null }; case "setJumpTarget": return { ...state, jumpTarget: action.target }; case "clearJumpTarget": return { ...state, jumpTarget: null }; + case "setVimOn": + return { ...state, vimOn: action.on }; } } @@ -126,6 +175,7 @@ export function AppProvider({ children }: { children: ReactNode }) { const sessionRef = useRef<{ dispose: () => void } | null>(null); const openFileRef = useRef(null); const uriRef = useRef(null); + const jumpIdRef = useRef(0); useEffect(() => { loadMonaco() @@ -142,6 +192,15 @@ export function AppProvider({ children }: { children: ReactNode }) { .catch((e) => dispatch({ type: "error", error: String(e) })); }, []); + useEffect(() => { + try { + const v = localStorage.getItem("typst-leaf.vim") === "true"; + dispatch({ type: "setVimOn", on: v }); + } catch { + dispatch({ type: "setVimOn", on: false }); + } + }, []); + useEffect(() => { if (!state.currentProject || !lsp.monaco) return; const sessionRefLocal = sessionRef; @@ -150,22 +209,6 @@ export function AppProvider({ children }: { children: ReactNode }) { const uri = state.currentUri!; setLsp((s) => ({ ...s, status: "connecting", previewReady: false })); - // Normalize both URIs and native paths to a consistent forward-slash - // pathname. Windows drive-letter paths (file:///C:/... → /C:/...) get - // the leading "/" stripped so they match native paths (C:/...). - const normalizePath = (p: string): string => { - let result: string; - if (p.includes("://")) { - result = decodeURIComponent(new URL(p).pathname); - } else { - result = p.replaceAll("\\", "/"); - } - if (/^\/[A-Za-z]:\//.test(result) || /^\/[A-Za-z]:$/.test(result)) { - result = result.slice(1); - } - return result; - }; - const onJumpToSource = (info: { filepath: string; start?: [number, number]; @@ -173,19 +216,24 @@ export function AppProvider({ children }: { children: ReactNode }) { }) => { const curUri = uriRef.current ?? uri; const curOpenFile = openFileRef.current; - const filepath = normalizePath(info.filepath); - const projectPath = normalizePath(curUri); - const sep = projectPath.endsWith("/") ? "" : "/"; - if (!filepath.startsWith(projectPath + sep)) { + const relPath = relativePathFromProject(curUri, info.filepath); + + if (!relPath) { + dispatch({ + type: "pushNotice", + kind: "info", + message: "Preview source is outside this project", + }); return; } - const relPath = filepath.slice(projectPath.length + 1); + + const id = ++jumpIdRef.current; if (relPath !== curOpenFile) { dispatch({ type: "openFile", path: relPath }); } dispatch({ type: "setJumpTarget", - target: { file: relPath, start: info.start, end: info.end }, + target: { id, file: relPath, start: info.start, end: info.end }, }); }; @@ -235,9 +283,12 @@ export function AppProvider({ children }: { children: ReactNode }) { if (!state.currentProject) return; api .getTree(state.currentProject) - .then((t) => dispatch({ type: "setTree", tree: t.files })) + .then((t) => { + dispatch({ type: "setTree", tree: t.files, folders: t.folders ?? [] }); + if (state.error) dispatch({ type: "error", error: null }); + }) .catch((e) => dispatch({ type: "error", error: String(e) })); - }, [state.currentProject]); + }, [state.currentProject, state.treeVersion]); // Keep refs current for the LSP callback closure useEffect(() => { diff --git a/typst-leaf-frontend/src/components/Editor.tsx b/typst-leaf-frontend/src/components/Editor.tsx index 77459c3..0478066 100644 --- a/typst-leaf-frontend/src/components/Editor.tsx +++ b/typst-leaf-frontend/src/components/Editor.tsx @@ -2,6 +2,7 @@ import Editor, { type Monaco, type OnMount } from "@monaco-editor/react"; import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "../api"; import { useApp } from "../app-state"; +import { encodeVirtualPathForFileUri } from "../path-utils"; type ITextModel = Monaco["editor"]["ITextModel"]; type IStandaloneCodeEditor = Monaco["editor"]["IStandaloneCodeEditor"]; @@ -16,28 +17,25 @@ export function EditorView() { const uri = state.currentUri; const file = state.openFile; const savingRef = useRef(false); + const mountedRef = useRef(true); // Vim mode - const [vimOn, setVimOn] = useState( - () => localStorage.getItem("typst-leaf.vim") === "true", - ); + const [editorReady, setEditorReady] = useState(false); const vimRef = useRef<{ dispose: () => void } | null>(null); const vimStatusRef = useRef(null); // Toggle vim const toggleVim = useCallback(() => { - setVimOn((prev) => { - const next = !prev; - localStorage.setItem("typst-leaf.vim", String(next)); - return next; - }); - }, []); + const next = !state.vimOn; + localStorage.setItem("typst-leaf.vim", String(next)); + dispatch({ type: "setVimOn", on: next }); + }, [state.vimOn, dispatch]); // Init / dispose vim mode when editor or vimOn changes useEffect(() => { if (!editorReady) return; - if (vimOn && !vimRef.current) { + if (state.vimOn && !vimRef.current) { let cancelled = false; import("monaco-vim").then(({ initVimMode }) => { if (cancelled) return; @@ -46,24 +44,39 @@ export function EditorView() { vimRef.current = initVimMode(editorRef.current, statusNode); }); return () => { cancelled = true; }; - } else if (!vimOn && vimRef.current) { + } else if (!state.vimOn && vimRef.current) { vimRef.current.dispose(); vimRef.current = null; if (vimStatusRef.current) { vimStatusRef.current.textContent = ""; } } - }, [vimOn, editorReady]); + }, [state.vimOn, editorReady]); + + // Keep a ref to the current model so the unmount cleanup can dispose it + // without depending on stale `model` state captured in a closure. + const modelRef = useRef(null); // Cleanup vim on unmount. Resetting editorReady ensures the vim // effect re-fires after a StrictMode remount (Monaco creates a new // editor instance, handleMount sets editorReady=true again, and the // effect re-initializes vim on the new editor). + // Also dispose the Monaco model so LSP sees didClose. + // Mark mountedRef=true at setup so it survives StrictMode's simulated + // unmount/remount cycle (effects run setup → cleanup → setup again; + // without re-setting true the cleanup would leave the ref stuck at false). useEffect(() => { + mountedRef.current = true; return () => { vimRef.current?.dispose(); vimRef.current = null; setEditorReady(false); + mountedRef.current = false; + const m = modelRef.current; + if (m) { + m.dispose(); + modelRef.current = null; + } }; }, []); @@ -74,6 +87,7 @@ export function EditorView() { m?.dispose(); return null; }); + modelRef.current = null; return; } let cancelled = false; @@ -83,7 +97,7 @@ export function EditorView() { .then((f) => { if (cancelled) return; const base = uri.endsWith("/") ? uri : uri + "/"; - const docUri = base + file.split("/").map(encodeURIComponent).join("/"); + const docUri = base + encodeVirtualPathForFileUri(file); const uriObj = monaco.Uri.parse(docUri); monaco.editor.getModel(uriObj)?.dispose(); const next = monaco.editor.createModel(f.content, "typst", uriObj); @@ -91,6 +105,7 @@ export function EditorView() { prev?.dispose(); return next; }); + modelRef.current = next; }) .catch((e) => !cancelled && setError(String(e))); return () => { @@ -107,13 +122,21 @@ export function EditorView() { async function save() { if (!project || !file || !model || savingRef.current) return; savingRef.current = true; + dispatch({ type: "setSaving", saving: true }); try { await api.putFile(project, file, model.getValue()); - // Bump git version so GitPanel refreshes - dispatch({ type: "bumpGitVersion" }); + if (mountedRef.current) { + dispatch({ type: "bumpGitVersion" }); + dispatch({ type: "setFileDirty", dirty: false }); + dispatch({ type: "pushNotice", kind: "success", message: "Saved" }); + } } catch (e) { - setError(String(e)); + if (mountedRef.current) { + setError(String(e)); + dispatch({ type: "pushNotice", kind: "error", message: String(e) }); + } } finally { + if (mountedRef.current) dispatch({ type: "setSaving", saving: false }); savingRef.current = false; } } @@ -142,6 +165,20 @@ export function EditorView() { ); }; + // Global save listener for TopBar button + useEffect(() => { + const handler = () => { void saveRef.current(); }; + document.addEventListener("typst-leaf:save", handler); + return () => document.removeEventListener("typst-leaf:save", handler); + }, []); + + // Global layout listener for ResizableSplit drag + useEffect(() => { + const handler = () => { editorRef.current?.layout(); }; + document.addEventListener("typst-leaf:layout", handler); + return () => document.removeEventListener("typst-leaf:layout", handler); + }, []); + // Resize observer keeps Monaco laid out when the flex container changes // size (sidebar toggles, window resize, etc.). Without this, revealRange // and cursor positioning can be off after a layout change. @@ -157,27 +194,21 @@ export function EditorView() { }, [editorReady]); // Jump target effect — reactive (no polling). Track the last handled - // target by object identity so rapid clicks don't get swallowed by a stale - // boolean flag, and call editor.layout() before reveal so container - // resizes don't break the scroll. - const lastHandledTargetRef = useRef<{ - file: string; - start?: [number, number]; - end?: [number, number]; - } | null>(null); + // jump by its numeric id so rapid clicks are not swallowed by a stale + // ref, and call editor.layout() before reveal so container resizes + // don't break scroll position. + const lastHandledJumpId = useRef(null); useEffect(() => { if (!state.jumpTarget || !editorRef.current || !monaco || !model) return; - if (state.jumpTarget === lastHandledTargetRef.current) return; + if (state.jumpTarget.id === lastHandledJumpId.current) return; const target = state.jumpTarget; - const modelUri = model.uri.toString(); - const base = uri?.endsWith("/") ? uri : uri + "/"; - const expectedModelUri = target.file - ? base + target.file.split("/").map(encodeURIComponent).join("/") - : null; + if (!uri || !target.file) return; + const base = uri.endsWith("/") ? uri : uri + "/"; + const expectedUri = monaco.Uri.parse(base + encodeVirtualPathForFileUri(target.file)); - if (!expectedModelUri || modelUri !== expectedModelUri) return; - lastHandledTargetRef.current = target; + if (!monaco.Uri.equals(model.uri, expectedUri)) return; + lastHandledJumpId.current = target.id; let selection: Monaco["Selection"] | null = null; if (target.start && target.end) { @@ -206,20 +237,6 @@ export function EditorView() { dispatch({ type: "clearJumpTarget" }); }, [state.jumpTarget, model, monaco, uri]); - if (!project) { - return ( -
- Select a project to begin. -
- ); - } - if (!file) { - return ( -
- Open a file from the sidebar. -
- ); - } if (error) { return (
@@ -229,7 +246,7 @@ export function EditorView() { } return ( -
+
{file} @@ -241,20 +258,13 @@ export function EditorView() { type="button" onClick={toggleVim} className={`px-2 py-0.5 rounded border text-xs ${ - vimOn + state.vimOn ? "bg-emerald-100 border-emerald-300 text-emerald-700" : "bg-white hover:bg-zinc-100 text-zinc-700 border-zinc-300" }`} > Vim -
@@ -262,11 +272,13 @@ export function EditorView() { defaultLanguage="typst" theme="vs" onMount={handleMount} + onChange={() => { + if (!state.fileDirty) dispatch({ type: "setFileDirty", dirty: true }); + }} options={{ fontSize: 14, minimap: { enabled: false }, wordWrap: "on", - automaticLayout: true, tabSize: 2, }} /> diff --git a/typst-leaf-frontend/src/components/EmptyState.tsx b/typst-leaf-frontend/src/components/EmptyState.tsx new file mode 100644 index 0000000..5e77efa --- /dev/null +++ b/typst-leaf-frontend/src/components/EmptyState.tsx @@ -0,0 +1,16 @@ +export function EmptyState({ + message, + hint, +}: { + message: string; + hint?: string; +}) { + return ( +
+
{message}
+ {hint && ( +
{hint}
+ )} +
+ ); +} diff --git a/typst-leaf-frontend/src/components/FileTree.tsx b/typst-leaf-frontend/src/components/FileTree.tsx index 1c9d2f2..1213498 100644 --- a/typst-leaf-frontend/src/components/FileTree.tsx +++ b/typst-leaf-frontend/src/components/FileTree.tsx @@ -1,36 +1,337 @@ +import { useRef, useState } from "react"; +import { api } from "../api"; import { useApp } from "../app-state"; +interface TreeNode { + name: string; + children: TreeNode[]; + file?: string; // present for leaf nodes → virtual path + folder?: string; // present for empty directory nodes → virtual path +} + +function buildTree(filePaths: string[], folderPaths: string[]): TreeNode[] { + const root: TreeNode[] = []; + function ensure(parts: string[], isLeaf: boolean, fullPath: string) { + let level = root; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]!; + const isLast = i === parts.length - 1; + let node = level.find((n) => n.name === part); + if (!node) { + node = { name: part, children: [] }; + level.push(node); + } + if (isLast) { + if (isLeaf) node.file = fullPath; + else node.folder = fullPath; + } + level = node.children; + } + } + for (const p of filePaths) ensure(p.split("/"), true, p); + for (const p of folderPaths) { + const parts = p.split("/"); + // Only create a node if the folder is not already represented as an + // ancestor of a known file (those are inferred directory nodes). + let exists = false; + let level = root; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]!; + const isLast = i === parts.length - 1; + const node = level.find((n) => n.name === part); + if (!node) { + exists = false; + break; + } + if (isLast) { exists = true; break; } + level = node.children; + } + if (!exists) ensure(parts, false, p); + } + return root; +} + export function FileTree() { const { state, dispatch } = useApp(); + const s = state.gitStatus; + const [renaming, setRenaming] = useState(null); + const [renameValue, setRenameValue] = useState(""); + const [creating, setCreating] = useState<{ + parent: string; + type: "file" | "folder"; + } | null>(null); + const [createValue, setCreateValue] = useState(""); + const inputRef = useRef(null); + // Guard against double-submit (Enter + blur both call the handler). + const submittingRef = useRef(false); - if (!state.currentProject) { - return ( -
select a project
- ); + const dirtyFiles = new Set(); + if (s) { + for (const f of s.staged) dirtyFiles.add(f.path); + for (const f of s.unstaged) dirtyFiles.add(f.path); } - if (state.tree.length === 0) { - return
no .typ files
; + + const tree = buildTree( + state.tree.map((f) => f.path), + state.folders.map((f) => f.path), + ); + + function handleOpen(file: string) { + dispatch({ type: "openFile", path: file }); + } + + async function handleRename(oldPath: string) { + if (submittingRef.current) return; + let newName = renameValue.trim(); + if (!newName || newName.includes("/") || newName.includes("\\") || newName === "." || newName === "..") { + dispatch({ type: "pushNotice", kind: "error", message: "Invalid name" }); + setRenaming(null); + return; + } + // Keep .typ files visible in the tree: require the extension to stay present. + if (oldPath.endsWith(".typ") && !newName.endsWith(".typ")) { + newName += ".typ"; + } + if (newName === oldPath.split("/").pop()) { setRenaming(null); return; } + submittingRef.current = true; + const parts = oldPath.split("/"); + parts[parts.length - 1] = newName; + const newPath = parts.join("/"); + try { + await api.renameFile(state.currentProject!, oldPath, newPath); + dispatch({ type: "bumpTreeVersion" }); + dispatch({ type: "bumpGitVersion" }); + if (state.openFile === oldPath) dispatch({ type: "openFile", path: newPath }); + } catch (e) { + dispatch({ type: "pushNotice", kind: "error", message: String(e) }); + } finally { + submittingRef.current = false; + } + setRenaming(null); + } + + async function handleDelete(file: string) { + try { + await api.deleteFile(state.currentProject!, file); + dispatch({ type: "bumpTreeVersion" }); + dispatch({ type: "bumpGitVersion" }); + if (state.openFile === file) dispatch({ type: "openFile", path: null }); + } catch (e) { + dispatch({ type: "pushNotice", kind: "error", message: String(e) }); + } + } + + async function handleCreate() { + if (submittingRef.current) return; + if (!creating || !createValue.trim()) { + setCreating(null); + return; + } + submittingRef.current = true; + const name = createValue.trim(); + const parentPath = creating.parent ? creating.parent + "/" : ""; + const fullPath = parentPath + name; + let createdFilePath: string | null = null; + try { + if (creating.type === "folder") { + await api.createFolder(state.currentProject!, fullPath); + } else { + const fileName = name.endsWith(".typ") ? name : name + ".typ"; + const filePath = parentPath + fileName; + await api.createFile(state.currentProject!, filePath); + createdFilePath = filePath; + } + dispatch({ type: "bumpTreeVersion" }); + dispatch({ type: "bumpGitVersion" }); + setCreating(null); + setCreateValue(""); + if (createdFilePath) dispatch({ type: "openFile", path: createdFilePath }); + } catch (e) { + dispatch({ type: "pushNotice", kind: "error", message: String(e) }); + } finally { + submittingRef.current = false; + } + } + + function startRename(file: string) { + const parts = file.split("/"); + setRenaming(file); + setRenameValue(parts[parts.length - 1]!); + setTimeout(() => inputRef.current?.select(), 0); + } + + function startCreate(parent: string, type: "file" | "folder") { + setCreating({ parent, type }); + setCreateValue(""); + setTimeout(() => inputRef.current?.focus(), 0); + } + + function renderNode(node: TreeNode, depth: number, parentPath: string) { + const fullPath = parentPath ? parentPath + "/" + node.name : node.name; + // Directory if it has children or is an explicitly-known empty folder + const isDir = (node.children.length > 0 && !node.file) || !!node.folder; + + if (isDir) { + return ( +
+
+ {node.name} + + +
+ {node.children.map((child) => renderNode(child, depth + 1, fullPath))} +
+ ); + } + + if (!node.file) return null; + + const isDirty = dirtyFiles.has(node.file); + const isActive = state.openFile === node.file; + + return ( +
+ {renaming === node.file ? ( +
{ + e.preventDefault(); + void handleRename(node.file!); + }} + > + setRenameValue(e.target.value)} + onBlur={() => void handleRename(node.file!)} + onKeyDown={(e) => e.key === "Escape" && setRenaming(null)} + className="flex-1 text-xs px-1 py-0.5 border border-emerald-400 rounded bg-white outline-none" + autoFocus + /> +
+ ) : ( + + )} + {!renaming && ( + + + + + )} +
+ ); } return ( -
-
- Files -
- {state.tree.map((f) => ( - + +
+ )} + + {creating && ( +
{ + e.preventDefault(); + void handleCreate(); + }} > - {f.path} - - ))} + setCreateValue(e.target.value)} + onBlur={() => void handleCreate()} + onKeyDown={(e) => e.key === "Escape" && setCreating(null)} + placeholder={creating.type === "file" ? "file.typ" : "folder-name"} + className="flex-1 text-xs px-1 py-0.5 border border-emerald-400 rounded bg-white outline-none" + autoFocus + /> +
+ )} + + {tree.map((node) => renderNode(node, 0, ""))}
); } diff --git a/typst-leaf-frontend/src/components/GitPanel.tsx b/typst-leaf-frontend/src/components/GitPanel.tsx index faaf383..bb0a04f 100644 --- a/typst-leaf-frontend/src/components/GitPanel.tsx +++ b/typst-leaf-frontend/src/components/GitPanel.tsx @@ -3,6 +3,19 @@ import { api } from "../api"; import { useApp } from "../app-state"; import type { CommitInfo } from "../types"; +const statusIcon: Record = { + A: "+", + M: "~", + D: "–", + "?": "?", +}; + +const statusColor: Record = { + staged: "text-emerald-600", + unstaged: "text-amber-600", + untracked: "text-zinc-400", +}; + export function GitPanel() { const { state, dispatch } = useApp(); const [commitMsg, setCommitMsg] = useState(""); @@ -16,14 +29,12 @@ export function GitPanel() { const [log, setLog] = useState([]); const [logOpen, setLogOpen] = useState(false); - // Hooks must be called unconditionally (React Rules of Hooks) useEffect(() => { if (state.commitFocus > 0) { inputRef.current?.focus(); } }, [state.commitFocus]); - // Fetch commit log on git version change useEffect(() => { if (!state.currentProject) { setLog([]); @@ -42,15 +53,18 @@ export function GitPanel() { const s = state.gitStatus; if (!state.currentProject) return null; + const hasChanges = s && !s.clean; + const canCommit = commitMsg.trim() && hasChanges && !busy; + async function handleCommit() { - if (!commitMsg.trim() || busy || !state.currentProject) return; + if (!canCommit || !state.currentProject) return; setBusy(true); setError(null); try { let identity: { name?: string; email?: string } | undefined; try { - const identityRaw = localStorage.getItem("typst-leaf.git-identity"); - const parsed = identityRaw ? JSON.parse(identityRaw) : undefined; + const raw = localStorage.getItem("typst-leaf.git-identity"); + const parsed = raw ? JSON.parse(raw) : undefined; if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { identity = { name: typeof parsed.name === "string" ? parsed.name : undefined, @@ -66,6 +80,7 @@ export function GitPanel() { } else { setCommitMsg(""); dispatch({ type: "bumpGitVersion" }); + dispatch({ type: "pushNotice", kind: "success", message: "Committed" }); } } catch (e) { setError(String(e)); @@ -86,7 +101,10 @@ export function GitPanel() { } const res = await api.gitPush(state.currentProject); if (!res.ok) setError(res.error ?? "push failed"); - else dispatch({ type: "bumpGitVersion" }); + else { + dispatch({ type: "bumpGitVersion" }); + dispatch({ type: "pushNotice", kind: "success", message: "Pushed" }); + } } catch (e) { setError(String(e)); } finally { @@ -106,7 +124,10 @@ export function GitPanel() { } const res = await api.gitPull(state.currentProject); if (!res.ok) setError(res.error ?? "pull failed"); - else dispatch({ type: "bumpGitVersion" }); + else { + dispatch({ type: "bumpGitVersion" }); + dispatch({ type: "pushNotice", kind: "success", message: "Pulled latest" }); + } } catch (e) { setError(String(e)); } finally { @@ -126,6 +147,24 @@ export function GitPanel() { } } + function renderFileRow( + f: { path: string; status: string }, + group: "staged" | "unstaged" | "untracked", + ) { + return ( +
dispatch({ type: "openFile", path: f.path })} + > + + {statusIcon[f.status] ?? "?"} + + {f.path} +
+ ); + } + return (
@@ -133,26 +172,40 @@ export function GitPanel() { {s && ( {s.branch} - {s.ahead > 0 && ( - ↑{s.ahead} - )} - {s.behind > 0 && ( - ↓{s.behind} - )} + {s.ahead > 0 && ↑{s.ahead}} + {s.behind > 0 && ↓{s.behind}} )}
{s && !s.clean && ( - - {s.unstaged.length + s.staged.length} uncommitted file - {s.unstaged.length + s.staged.length !== 1 ? "s" : ""} + + {s.unstaged.length + s.staged.length} uncommitted )} {s && s.clean && clean}
+ {/* Changed files — dedupe by path; unstaged wins for paths in both + buckets (a file can be both staged and unstaged for different + parts, but unstaged is what the user still needs to act on). */} + {s && !s.clean && (() => { + const byPath = new Map(); + for (const f of s.unstaged) byPath.set(f.path, { ...f, group: "unstaged" }); + for (const f of s.staged) if (!byPath.has(f.path)) byPath.set(f.path, { ...f, group: "staged" }); + const rows = Array.from(byPath.values()); + return ( +
+
+ Changed files +
+ {rows.map((f) => renderFileRow(f, f.group))} +
+ ); + })()} + + {/* Commit compose */}
{ if (e.key === "Enter") void handleCommit(); }} - placeholder="Commit message" - className="flex-1 min-w-0 px-2 py-1 text-xs border border-zinc-300 rounded bg-white text-zinc-800 placeholder-zinc-400 outline-none focus:border-emerald-500" + placeholder={hasChanges ? "Commit message" : "No changes to commit"} + disabled={!hasChanges} + className="flex-1 min-w-0 px-2 py-1 text-xs border border-zinc-300 rounded bg-white text-zinc-800 placeholder-zinc-400 outline-none focus:border-emerald-500 disabled:opacity-40 disabled:cursor-not-allowed" /> @@ -189,7 +243,7 @@ export function GitPanel() { type="button" onClick={() => void handlePull()} disabled={pullBusy} - className="flex-1 px-2 py-1 text-xs rounded bg-white hover:bg-zinc-100 text-zinc-700 border border-zinc-300 disabled:opacity-40" + className="flex-1 px-2 py-1 text-xs rounded bg-white hover:bg-stone-100 text-zinc-700 border border-zinc-300 disabled:opacity-40" > {pullBusy ? "..." : "Pull"} @@ -216,7 +270,7 @@ export function GitPanel() { )} {error && ( -
{error}
+
{error}
)}
@@ -229,7 +283,17 @@ export function GitPanel() { className="w-full flex items-center gap-1 px-3 py-1 text-xs text-zinc-400 hover:text-zinc-600 uppercase tracking-wide" > Log - {logOpen ? "▾" : "▸"} + + {logOpen ? ( + + + + ) : ( + + + + )} + {logOpen && (
@@ -239,9 +303,7 @@ export function GitPanel() { className="text-xs text-zinc-500 leading-tight" title={`${c.sha} — ${c.author} on ${c.date}`} > - - {c.sha.slice(0, 7)} - + {c.sha.slice(0, 7)} {" "} {c.message}
diff --git a/typst-leaf-frontend/src/components/PanelSection.tsx b/typst-leaf-frontend/src/components/PanelSection.tsx new file mode 100644 index 0000000..c702fc8 --- /dev/null +++ b/typst-leaf-frontend/src/components/PanelSection.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react"; + +export function PanelSection({ + title, + children, + actions, +}: { + title: string; + children: ReactNode; + actions?: ReactNode; +}) { + return ( +
+
+ {title} + {actions && {actions}} +
+ {children} +
+ ); +} diff --git a/typst-leaf-frontend/src/components/Preview.tsx b/typst-leaf-frontend/src/components/Preview.tsx index d51580b..f85130c 100644 --- a/typst-leaf-frontend/src/components/Preview.tsx +++ b/typst-leaf-frontend/src/components/Preview.tsx @@ -1,23 +1,57 @@ +import { useRef } from "react"; import { useApp } from "../app-state"; export function Preview() { const { state, lsp } = useApp(); + const iframeRef = useRef(null); - let status = "idle"; - if (!state.currentProject) status = "no project"; - else if (lsp.status !== "ready") status = `LSP ${lsp.status}`; - else if (!lsp.previewReady) status = "starting preview…"; - else status = "live"; + const statusText = () => { + if (!state.currentProject) return "no project"; + if (!state.openFile) return "open a file"; + if (lsp.status !== "ready") return `LSP ${lsp.status}`; + if (!lsp.previewReady) return "starting…"; + return "live"; + }; + + const statusColor = () => { + if (lsp.previewReady) return "text-emerald-600 bg-emerald-50"; + if (lsp.status === "ready") return "text-zinc-400 bg-stone-50"; + return "text-amber-600 bg-amber-50"; + }; + + const statusDot = () => { + if (lsp.previewReady) return "bg-emerald-500"; + return "bg-zinc-300"; + }; return ( -
-
- Preview - {status} +
+ {/* Toolbar */} +
+ Preview + + + {statusText()} + + {lsp.previewReady && ( + + )} + Click preview to jump to source
-
- {state.currentProject && lsp.status === "ready" && lsp.previewReady ? ( + + {/* Content */} +
+ {state.currentProject && state.openFile && lsp.status === "ready" && lsp.previewReady ? (