feat: IDE shell, file management, UI rehaul, reliability, and git workflow

This is the next major chunk on top of the barebones MVP. It implements
the IDE shell, resizable editor/preview workspace, file CRUD, Git workflow
UI upgrade, SyncTeX/LSP reliability hardening, and documentation updates.

Highlights:

* New shared path utilities (typst-leaf-frontend/src/path-utils.ts):
  normalizeFsPath, encodeVirtualPathForFileUri, relativePathFromProject.

* SyncTeX reliability:
  * Jump targets now carry stable numeric IDs for deduplication.
  * tinymist/preview/scrollSource and window/showDocument payloads are
    validated and normalized through shared path utilities.
  * External preview paths are reported via a toast instead of silent drop.
  * Editor uses monaco.Uri.equals for URI comparison.

* IDE shell rehaul:
  * New components: TopBar, StatusBar, PanelSection, ResizableSplit,
    ToastHost, EmptyState.
  * App.tsx now uses a layered layout: top bar, sidebar tabs
    (Files/Git/Settings), resizable main workspace, status bar, toasts.
  * Sidebar tabs replace the old inline Git/settings panels.

* Resizable workspace:
  * Pointer-driven resizable split between editor and preview.
  * Split ratio persisted to localStorage (typst-leaf.split).
  * Double-click splitter resets to 55%.
  * onResize prop dispatches typst-leaf:layout so Monaco re-layouts.

* File management:
  * Backend CRUD: POST /file, DELETE /file, PATCH /file, POST /folder.
  * Frontend API wrappers and inline FileTree create/rename/delete.
  * Backend tree endpoint returns empty directories in a folders array.
  * Rename protects the .typ extension and rejects overwriting existing
    files atomically on POSIX.
  * Newly created files are opened automatically.
  * Dirty file badges in the tree.

* Git workflow upgrade:
  * GitPanel now shows changed files grouped by staged/unstaged, deduped
    by path, with per-file status icons.
  * Click a changed file to open it.
  * Commit disabled when clean or no message.
  * Push/pull with success/error toasts.
  * FileTree shows dirty badges from git status.

* Preview UX:
  * Preview toolbar with status chip, reload button, and SyncTeX hint.
  * Preview hidden/marked as "open a file" when no file is open.

* State/UX infrastructure (app-state.tsx):
  * activePanel, fileDirty, saving, notice (toast), vimOn, treeVersion,
    folders, and stable jumpTarget.id.
  * focusCommit switches to the Git panel.
  * selectProject resets more state to avoid stale data.

* Editor improvements:
  * Monaco model disposed on unmount so LSP sees didClose.
  * mountedRef survives React StrictMode remounts.
  * Global save/layout events bridge TopBar and ResizableSplit to the
    editor instance.
  * Vim mode state moved to global state/localStorage.
  * Removed redundant automaticLayout and inline Save button.

* Documentation:
  * Added DESIGN.md with the full UI aesthetic guide.
  * Updated PLAN.md, README.md, VISION.md, and the frontend README.

Verification:
  * pnpm -r typecheck passes.
  * pnpm --filter typst-leaf-frontend build passes.
This commit is contained in:
2026-07-02 02:05:04 +05:30
parent 7618476b60
commit 008dab1cf9
24 changed files with 1727 additions and 331 deletions
+171
View File
@@ -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
<header class="flex items-center gap-3 border-b border-zinc-200 bg-white px-4 h-11">
<span class="font-semibold tracking-tight">typst-leaf</span>
<span class="text-zinc-300">·</span>
<button class="text-sm hover:bg-stone-100 px-2 py-1 rounded-md">project </button>
<span class="ml-2 text-sm font-mono text-zinc-500 truncate">main.typ</span>
<span class="ml-auto flex items-center gap-2">
<span class="text-xs px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200">LSP · ready</span>
<span class="text-xs px-2 py-0.5 rounded-full bg-stone-100 text-zinc-600 border border-zinc-200">PRV · starting</span>
<button class="text-sm bg-emerald-600 hover:bg-emerald-700 text-white rounded-md px-3 py-1">Save</button>
<button class="text-sm text-zinc-500 hover:bg-stone-100 px-2 py-1 rounded-md"></button>
</span>
</header>
```
### Sidebar (`PanelSection` + tabs)
```tsx
<aside class="w-64 border-r border-zinc-200 bg-stone-50 flex flex-col min-h-0">
<div class="px-3 py-2 text-[11px] uppercase tracking-wider text-zinc-400">Files</div>
<nav class="flex-1 overflow-auto p-1">
<button class="w-full text-left text-sm px-2 py-1 rounded-md bg-emerald-50 text-emerald-800">main.typ</button>
<button class="w-full text-left text-sm px-2 py-1 rounded-md hover:bg-stone-100">lib/util.typ</button>
</nav>
<div class="border-t border-zinc-200 p-2 text-xs text-zinc-500">12 uncommitted</div>
</aside>
```
### ResizableSplit
```tsx
<div class="flex h-full min-h-0">
<div class="min-w-[280px]" style={{ width: `${left}%` }}>/* editor */</div>
<div
role="separator"
aria-orientation="vertical"
class="w-1 bg-zinc-200 hover:bg-emerald-300 cursor-col-resize"
onPointerDown={startDrag}
onDoubleClick={reset}
/>
<div class="flex-1 min-w-[280px]">/* preview */</div>
</div>
```
### EmptyState
```tsx
<div class="flex-1 flex flex-col items-center justify-center text-zinc-400 gap-2">
<div class="text-sm">Open a file from the sidebar.</div>
<div class="text-xs text-zinc-500">Tip: P to jump, S to save.</div>
</div>
```
### Toast
```tsx
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
<div class="bg-white border border-zinc-200 rounded-md shadow-sm px-3 py-2 text-sm">
<span class="text-emerald-700 mr-2"></span>
Committed <span class="font-mono">a1b2c3d</span>
</div>
</div>
```
### Status chips
```tsx
<span class="text-[11px] px-2 py-0.5 rounded-full border">
<span class="inline-block size-1.5 rounded-full bg-emerald-500 mr-1" />
LSP · ready
</span>
```
## 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.
+179 -59
View File
@@ -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 `<div ref={vimStatusRef} />` (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.
+13 -6
View File
@@ -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 <http://localhost:1420> 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 <file>` 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`
+4 -4
View File
@@ -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).
+118
View File
@@ -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 });
}
});
+16 -3
View File
@@ -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<void> {
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 });
});
+9 -4
View File
@@ -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.
+105 -29
View File
@@ -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 (
<div className="h-screen w-screen bg-white text-zinc-800 flex">
<aside className="w-60 border-r border-zinc-200 flex flex-col bg-zinc-50">
<div className="px-3 py-2 border-b border-zinc-200 text-sm font-semibold flex items-center gap-2">
<span>typst-leaf</span>
<span className="ml-auto flex items-center gap-2 text-xs font-normal text-zinc-400">
<SettingsPanel />
{lsp.status === "ready"
? "●"
: lsp.status === "connecting"
? "◌"
: lsp.status === "error"
? "✕"
: "○"}
</span>
<div className="h-screen w-screen bg-stone-50 text-zinc-800 flex flex-col overflow-hidden">
<TopBar />
{state.error && (
<div className="bg-rose-50 text-rose-700 text-xs px-4 py-1.5 border-b border-rose-200 shrink-0">
{state.error}
</div>
<ProjectPicker />
<div className="flex-1 overflow-auto">
<FileTree />
</div>
<GitPanel />
{state.error && (
<div className="p-2 text-xs text-red-500 border-t border-zinc-200">
{state.error}
)}
<div className="flex flex-1 min-h-0">
{/* Sidebar */}
<aside className="w-64 border-r border-zinc-200 bg-stone-50 flex flex-col min-h-0 shrink-0">
<ProjectPicker />
{/* Sidebar tabs */}
<div className="flex border-b border-zinc-200 text-xs">
<button
type="button"
onClick={() => dispatch({ type: "setActivePanel", panel: "files" })}
className={`flex-1 py-1.5 text-center uppercase tracking-wider ${
state.activePanel === "files"
? "text-emerald-700 border-b-2 border-emerald-500 bg-white"
: "text-zinc-400 hover:text-zinc-600 hover:bg-stone-100"
}`}
>
Files
</button>
<button
type="button"
onClick={() => dispatch({ type: "setActivePanel", panel: "git" })}
className={`flex-1 py-1.5 text-center uppercase tracking-wider ${
state.activePanel === "git"
? "text-emerald-700 border-b-2 border-emerald-500 bg-white"
: "text-zinc-400 hover:text-zinc-600 hover:bg-stone-100"
}`}
>
Git
</button>
<button
type="button"
onClick={() => dispatch({ type: "setActivePanel", panel: "settings" })}
className={`flex-1 py-1.5 text-center uppercase tracking-wider ${
state.activePanel === "settings"
? "text-emerald-700 border-b-2 border-emerald-500 bg-white"
: "text-zinc-400 hover:text-zinc-600 hover:bg-stone-100"
}`}
>
Settings
</button>
</div>
)}
</aside>
<main className="flex-1 flex min-w-0">
<EditorView />
<Preview />
</main>
<div className="flex-1 overflow-auto">
{state.activePanel === "files" && (
<PanelSection title="Files">
{state.currentProject ? <FileTree /> : (
<div className="px-3 py-2 text-xs text-zinc-400">select a project</div>
)}
</PanelSection>
)}
{state.activePanel === "git" && <GitPanel />}
{state.activePanel === "settings" && <SettingsPanel />}
</div>
</aside>
{/* Main workspace */}
<main className="flex-1 flex min-w-0 bg-white">
{state.currentProject && state.openFile ? (
<ResizableSplit
left={<EditorView />}
right={<Preview />}
onResize={() => {
document.dispatchEvent(new CustomEvent("typst-leaf:layout"));
}}
/>
) : (
<div className="flex-1 flex min-h-0">
{!state.currentProject ? (
<div className="flex-1 flex">
<div className="flex-1">
<EmptyState message="Select a project to begin." hint="Use the sidebar to pick or create a project." />
</div>
</div>
) : !state.openFile ? (
<div className="flex-1 flex">
<div className="flex-1">
<EmptyState message="Open a file from the sidebar." hint=".typ files are listed under the Files section." />
</div>
<div className="w-96 border-l border-zinc-200">
<Preview />
</div>
</div>
) : null}
</div>
)}
</main>
</div>
<StatusBar />
<ToastHost />
</div>
);
}
+24
View File
@@ -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) =>
+80 -29
View File
@@ -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<State["jumpTarget"]> }
| { 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<string | null>(null);
const uriRef = useRef<string | null>(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(() => {
+68 -56
View File
@@ -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<HTMLDivElement | null>(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<ITextModel | null>(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<number | null>(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 (
<div className="flex-1 flex items-center justify-center text-zinc-400 text-sm">
Select a project to begin.
</div>
);
}
if (!file) {
return (
<div className="flex-1 flex items-center justify-center text-zinc-400 text-sm">
Open a file from the sidebar.
</div>
);
}
if (error) {
return (
<div className="flex-1 flex items-center justify-center text-red-400 text-sm p-4">
@@ -229,7 +246,7 @@ export function EditorView() {
}
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="h-full flex flex-col min-h-0">
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-zinc-200 text-xs text-zinc-500 font-mono">
<span className="truncate max-w-48">{file}</span>
<span className="ml-auto flex items-center gap-2">
@@ -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
</button>
<button
type="button"
onClick={() => void save()}
className="bg-white hover:bg-zinc-100 text-zinc-700 border border-zinc-300 px-2 py-0.5 rounded"
>
Save (Ctrl+S)
</button>
</span>
</div>
<div ref={editorContainerRef} className="flex-1 min-h-0">
@@ -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,
}}
/>
@@ -0,0 +1,16 @@
export function EmptyState({
message,
hint,
}: {
message: string;
hint?: string;
}) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-zinc-400 gap-1 p-4 text-center">
<div className="text-sm">{message}</div>
{hint && (
<div className="text-xs text-zinc-500">{hint}</div>
)}
</div>
);
}
+324 -23
View File
@@ -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<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [creating, setCreating] = useState<{
parent: string;
type: "file" | "folder";
} | null>(null);
const [createValue, setCreateValue] = useState("");
const inputRef = useRef<HTMLInputElement | null>(null);
// Guard against double-submit (Enter + blur both call the handler).
const submittingRef = useRef(false);
if (!state.currentProject) {
return (
<div className="p-2 text-xs text-zinc-400">select a project</div>
);
const dirtyFiles = new Set<string>();
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 <div className="p-2 text-xs text-zinc-400">no .typ files</div>;
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 (
<div key={fullPath} className="group">
<div
className="flex items-center gap-1 px-2 py-0.5 text-xs text-zinc-400 font-medium tracking-wide"
style={{ paddingLeft: `${8 + depth * 12}px` }}
>
<span>{node.name}</span>
<button
type="button"
onClick={() => startCreate(fullPath, "file")}
className="ml-1 text-zinc-300 hover:text-zinc-600 opacity-0 group-hover:opacity-100 p-0.5"
title="New file"
aria-label="New file"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
<path d="M8.75 1.75a.75.75 0 0 0-1.5 0v5.5h-5.5a.75.75 0 0 0 0 1.5h5.5v5.5a.75.75 0 0 0 1.5 0v-5.5h5.5a.75.75 0 0 0 0-1.5h-5.5v-5.5Z" />
</svg>
</button>
<button
type="button"
onClick={() => startCreate(fullPath, "folder")}
className="text-zinc-300 hover:text-zinc-600 opacity-0 group-hover:opacity-100 p-0.5"
title="New folder"
aria-label="New folder"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
<path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z" />
</svg>
</button>
</div>
{node.children.map((child) => renderNode(child, depth + 1, fullPath))}
</div>
);
}
if (!node.file) return null;
const isDirty = dirtyFiles.has(node.file);
const isActive = state.openFile === node.file;
return (
<div key={node.file} className="group flex items-center gap-1">
{renaming === node.file ? (
<form
className="flex-1 flex"
style={{ paddingLeft: `${8 + depth * 12}px` }}
onSubmit={(e) => {
e.preventDefault();
void handleRename(node.file!);
}}
>
<input
ref={inputRef}
type="text"
value={renameValue}
onChange={(e) => 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
/>
</form>
) : (
<button
type="button"
onClick={() => handleOpen(node.file!)}
className={`flex-1 text-left text-sm px-2 py-1 rounded font-mono truncate ${
isActive
? "bg-emerald-50 text-emerald-800 font-medium"
: "hover:bg-stone-100 text-zinc-600"
}`}
style={{ paddingLeft: `${8 + depth * 12}px` }}
>
{node.name}
{isDirty && (
<span className="ml-1 text-amber-500" aria-label="modified">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" fill="currentColor" className="size-1.5">
<circle cx="4" cy="4" r="4" />
</svg>
</span>
)}
</button>
)}
{!renaming && (
<span className="flex items-center gap-0.5 pr-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => startRename(node.file!)}
className="text-zinc-400 hover:text-zinc-600 p-0.5"
title="Rename"
aria-label="Rename"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
<path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064l6.286-6.286Z" />
</svg>
</button>
<button
type="button"
onClick={() => {
if (window.confirm(`Delete "${node.file}"?`)) void handleDelete(node.file!);
}}
className="text-zinc-400 hover:text-rose-500 p-0.5"
title="Delete"
aria-label="Delete"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
<path fillRule="evenodd" d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.152A2 2 0 0 0 5.852 15.5h4.296a2 2 0 0 0 1.987-1.848l.815-8.152h.3a.75.75 0 0 0 0-1.5H11v-.75A1.75 1.75 0 0 0 9.25 1.75h-2.5A1.75 1.75 0 0 0 5 3.25Zm2.25-.75a.25.25 0 0 0-.25.25V4h2V2.75a.25.25 0 0 0-.25-.25h-2.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .787-.712Z" clipRule="evenodd" />
</svg>
</button>
</span>
)}
</div>
);
}
return (
<div className="flex flex-col gap-0.5 p-2">
<div className="text-xs uppercase tracking-wide text-zinc-400 mb-1">
Files
</div>
{state.tree.map((f) => (
<button
key={f.path}
type="button"
onClick={() => dispatch({ type: "openFile", path: f.path })}
className={`text-left text-sm px-2 py-1 rounded font-mono ${
f.path === state.openFile
? "bg-emerald-100 text-emerald-800 font-medium"
: "hover:bg-zinc-100 text-zinc-600"
}`}
<div className="flex flex-col gap-0.5 py-1">
{/* Create buttons at root */}
{state.currentProject && (
<div className="flex items-center gap-1 px-3 py-1">
<button
type="button"
onClick={() => startCreate("", "file")}
className="text-xs text-zinc-400 hover:text-zinc-600 px-1 py-0.5"
>
New file
</button>
<button
type="button"
onClick={() => startCreate("", "folder")}
className="text-xs text-zinc-400 hover:text-zinc-600 px-1 py-0.5"
>
New folder
</button>
</div>
)}
{creating && (
<form
className="flex px-3 py-1"
onSubmit={(e) => {
e.preventDefault();
void handleCreate();
}}
>
{f.path}
</button>
))}
<input
ref={inputRef}
type="text"
value={createValue}
onChange={(e) => 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
/>
</form>
)}
{tree.map((node) => renderNode(node, 0, ""))}
</div>
);
}
+88 -26
View File
@@ -3,6 +3,19 @@ import { api } from "../api";
import { useApp } from "../app-state";
import type { CommitInfo } from "../types";
const statusIcon: Record<string, string> = {
A: "+",
M: "~",
D: "",
"?": "?",
};
const statusColor: Record<string, string> = {
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<CommitInfo[]>([]);
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 (
<div
key={f.path}
className="flex items-center gap-1 text-xs font-mono cursor-pointer hover:bg-stone-100 px-1 py-0.5 rounded"
onClick={() => dispatch({ type: "openFile", path: f.path })}
>
<span className={`w-3 text-center ${statusColor[group]}`}>
{statusIcon[f.status] ?? "?"}
</span>
<span className="truncate text-zinc-600">{f.path}</span>
</div>
);
}
return (
<div className="border-t border-zinc-200">
<div className="px-3 py-1.5 text-xs uppercase tracking-wide text-zinc-400 flex items-center gap-1">
@@ -133,26 +172,40 @@ export function GitPanel() {
{s && (
<span className="ml-auto text-zinc-400 font-mono normal-case">
{s.branch}
{s.ahead > 0 && (
<span className="text-emerald-600 ml-1">{s.ahead}</span>
)}
{s.behind > 0 && (
<span className="text-amber-600 ml-1">{s.behind}</span>
)}
{s.ahead > 0 && <span className="text-emerald-600 ml-1">{s.ahead}</span>}
{s.behind > 0 && <span className="text-amber-600 ml-1">{s.behind}</span>}
</span>
)}
</div>
<div className="px-3 pb-1.5 text-xs text-zinc-500">
{s && !s.clean && (
<span>
{s.unstaged.length + s.staged.length} uncommitted file
{s.unstaged.length + s.staged.length !== 1 ? "s" : ""}
<span className="text-amber-600">
{s.unstaged.length + s.staged.length} uncommitted
</span>
)}
{s && s.clean && <span className="text-emerald-600">clean</span>}
</div>
{/* 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<string, { path: string; status: string; group: "staged" | "unstaged" }>();
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 (
<div className="px-3 pb-1">
<div className="text-[10px] uppercase tracking-wider text-zinc-400 mb-0.5">
Changed files
</div>
{rows.map((f) => renderFileRow(f, f.group))}
</div>
);
})()}
{/* Commit compose */}
<div className="px-3 pb-1.5 flex flex-col gap-1.5">
<div className="flex gap-1">
<input
@@ -163,13 +216,14 @@ export function GitPanel() {
onKeyDown={(e) => {
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"
/>
<button
type="button"
onClick={() => void handleCommit()}
disabled={!commitMsg.trim() || busy}
disabled={!canCommit}
className="px-2 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed"
>
{busy ? "..." : "Commit"}
@@ -181,7 +235,7 @@ export function GitPanel() {
type="button"
onClick={() => void handlePush()}
disabled={pushBusy}
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"
>
{pushBusy ? "..." : "Push"}
</button>
@@ -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"}
</button>
@@ -216,7 +270,7 @@ export function GitPanel() {
)}
{error && (
<div className="text-xs text-red-500 break-words">{error}</div>
<div className="text-xs text-rose-600 break-words">{error}</div>
)}
</div>
@@ -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"
>
<span>Log</span>
<span className="ml-auto">{logOpen ? "▾" : "▸"}</span>
<span className="ml-auto">
{logOpen ? (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
<path d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
<path d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 0 1 0-1.06Z" />
</svg>
)}
</span>
</button>
{logOpen && (
<div className="max-h-32 overflow-y-auto px-3 pb-1.5 flex flex-col gap-1">
@@ -239,9 +303,7 @@ export function GitPanel() {
className="text-xs text-zinc-500 leading-tight"
title={`${c.sha}${c.author} on ${c.date}`}
>
<span className="font-mono text-zinc-400">
{c.sha.slice(0, 7)}
</span>
<span className="font-mono text-zinc-400">{c.sha.slice(0, 7)}</span>
{" "}
<span className="text-zinc-600">{c.message}</span>
</div>
@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
export function PanelSection({
title,
children,
actions,
}: {
title: string;
children: ReactNode;
actions?: ReactNode;
}) {
return (
<div className="border-b border-zinc-200 last:border-b-0">
<div className="flex items-center gap-1 px-3 py-1.5 text-[11px] uppercase tracking-wider text-zinc-400">
<span>{title}</span>
{actions && <span className="ml-auto flex items-center gap-1">{actions}</span>}
</div>
{children}
</div>
);
}
+47 -13
View File
@@ -1,23 +1,57 @@
import { useRef } from "react";
import { useApp } from "../app-state";
export function Preview() {
const { state, lsp } = useApp();
const iframeRef = useRef<HTMLIFrameElement | null>(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 (
<div className="w-1/2 border-l border-zinc-200 flex flex-col bg-white">
<div className="px-3 py-1.5 border-b border-zinc-200 text-xs text-zinc-500 font-mono flex items-center gap-2">
<span>Preview</span>
<span className="ml-auto text-zinc-400">{status}</span>
<div className="h-full flex flex-col bg-white">
{/* Toolbar */}
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-zinc-200 text-xs text-zinc-500 shrink-0">
<span className="font-medium">Preview</span>
<span className={`ml-auto flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] ${statusColor()}`}>
<span className={`inline-block size-1.5 rounded-full ${statusDot()}`} />
{statusText()}
</span>
{lsp.previewReady && (
<button
type="button"
onClick={() => {
iframeRef.current?.contentWindow?.location.reload();
}}
className="px-2 py-0.5 rounded border border-zinc-200 hover:bg-stone-100 text-zinc-500"
>
Reload
</button>
)}
<span className="text-zinc-300 text-[11px] hidden sm:inline">Click preview to jump to source</span>
</div>
<div className="flex-1 bg-zinc-50 relative">
{state.currentProject && lsp.status === "ready" && lsp.previewReady ? (
{/* Content */}
<div className="flex-1 bg-zinc-50 relative min-h-0">
{state.currentProject && state.openFile && lsp.status === "ready" && lsp.previewReady ? (
<iframe
ref={iframeRef}
key={state.currentProject}
src="/preview-app"
title="Typst preview"
@@ -25,9 +59,9 @@ export function Preview() {
/>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-zinc-400 text-sm p-6 gap-2">
<span>{status}</span>
<span>{statusText()}</span>
{lsp.status === "error" && lsp.error && (
<pre className="mt-2 max-w-full overflow-auto text-xs text-red-500 bg-red-50 p-3 rounded whitespace-pre-wrap break-all">
<pre className="mt-2 max-w-full overflow-auto text-xs text-rose-600 bg-rose-50 p-3 rounded whitespace-pre-wrap break-all">
{lsp.error}
</pre>
)}
@@ -0,0 +1,83 @@
import { useCallback, useRef, useState, type ReactNode } from "react";
const STORAGE_KEY = "typst-leaf.split";
const MIN_PCT = 25;
const DEFAULT_PCT = 55;
function loadRatio(): number {
try {
const v = Number(localStorage.getItem(STORAGE_KEY));
return v >= MIN_PCT && v <= 100 - MIN_PCT ? v : DEFAULT_PCT;
} catch {
return DEFAULT_PCT;
}
}
function persistRatio(v: number) {
try {
localStorage.setItem(STORAGE_KEY, String(v));
} catch { /* ignore */ }
}
function clampPct(v: number): number {
return Math.max(MIN_PCT, Math.min(100 - MIN_PCT, v));
}
export function ResizableSplit({
left,
right,
onResize,
}: {
left: ReactNode;
right: ReactNode;
onResize?: () => void;
}) {
const [leftPct, setLeftPct] = useState(loadRatio);
const leftPctRef = useRef(leftPct);
const containerRef = useRef<HTMLDivElement | null>(null);
const startDrag = useCallback((e: React.PointerEvent) => {
e.preventDefault();
const el = e.currentTarget as HTMLElement;
el.setPointerCapture(e.pointerId);
const onMove = (ev: PointerEvent) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const pct = clampPct(((ev.clientX - rect.left) / rect.width) * 100);
leftPctRef.current = pct;
setLeftPct(pct);
onResize?.();
};
const onUp = () => {
persistRatio(leftPctRef.current);
onResize?.();
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
}, [onResize]);
return (
<div ref={containerRef} className="flex h-full min-h-0 w-full flex-1">
<div className="min-w-[280px] overflow-hidden" style={{ width: `${leftPct}%` }}>
{left}
</div>
<div
role="separator"
aria-orientation="vertical"
className="w-1 bg-zinc-200 hover:bg-emerald-300 cursor-col-resize shrink-0 transition-colors duration-75"
onPointerDown={startDrag}
onDoubleClick={() => {
const pct = DEFAULT_PCT;
leftPctRef.current = pct;
setLeftPct(pct);
persistRatio(pct);
}}
/>
<div className="flex-1 min-w-[280px] overflow-hidden">
{right}
</div>
</div>
);
}
@@ -26,19 +26,16 @@ function loadIdentity(): Identity {
}
export function SettingsPanel() {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [dirty, setDirty] = useState(false);
useEffect(() => {
if (open) {
const id = loadIdentity();
setName(id.name ?? "");
setEmail(id.email ?? "");
setDirty(false);
}
}, [open]);
const id = loadIdentity();
setName(id.name);
setEmail(id.email);
setDirty(false);
}, []);
const save = useCallback(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ name, email }));
@@ -46,72 +43,59 @@ export function SettingsPanel() {
}, [name, email]);
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="text-xs text-zinc-400 hover:text-zinc-600 px-1"
title="Settings"
>
{open ? "✕" : "⚙"}
</button>
<div className="px-3 py-2 flex flex-col gap-2">
<div className="text-xs uppercase tracking-wide text-zinc-400">
Git Identity
</div>
{open && (
<div className="absolute right-0 top-full z-10 w-56 border-t border-zinc-200 bg-zinc-50 border-x border-b border-zinc-200 rounded-b shadow-sm px-3 py-2 flex flex-col gap-2">
<div className="text-xs uppercase tracking-wide text-zinc-400">
Git Identity
</div>
<input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
setDirty(true);
}}
placeholder="Author name"
className="w-full 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"
/>
<input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
setDirty(true);
}}
placeholder="Author name"
className="w-full 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"
/>
<input
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setDirty(true);
}}
placeholder="Author email"
className="w-full 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"
/>
<input
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setDirty(true);
}}
placeholder="Author email"
className="w-full 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"
/>
<div className="flex gap-1">
<button
type="button"
onClick={save}
disabled={!dirty}
className="px-2 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40"
>
Save
</button>
<button
type="button"
onClick={() => {
localStorage.removeItem(STORAGE_KEY);
setName("");
setEmail("");
setDirty(false);
}}
className="px-2 py-1 text-xs rounded bg-white hover:bg-stone-100 text-zinc-500 border border-zinc-300"
>
Clear
</button>
</div>
<div className="flex gap-1">
<button
type="button"
onClick={save}
disabled={!dirty}
className="px-2 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40"
>
Save
</button>
<button
type="button"
onClick={() => {
localStorage.removeItem(STORAGE_KEY);
setName("");
setEmail("");
setDirty(false);
}}
className="px-2 py-1 text-xs rounded bg-white hover:bg-zinc-100 text-zinc-500 border border-zinc-300"
>
Clear
</button>
</div>
<div className="text-xs text-zinc-400">
Used for Git commits. Stored locally in browser.
</div>
</div>
)}
<div className="text-xs text-zinc-400">
Used for Git commits. Stored locally in browser.
</div>
</div>
);
}
@@ -0,0 +1,53 @@
import { useApp } from "../app-state";
export function StatusBar() {
const { state, lsp } = useApp();
const s = state.gitStatus;
const gitSummary = () => {
if (!s) return <span className="text-xs text-zinc-400">git · --</span>;
return (
<span className="text-xs text-zinc-500 flex items-center gap-1">
<span className="font-mono">{s.branch}</span>
{s.clean ? (
<span className="text-emerald-600">· clean</span>
) : (
<span className="text-amber-600">· {s.unstaged.length + s.staged.length} uncommitted</span>
)}
{s.ahead > 0 && <span className="text-emerald-600">· {s.ahead}</span>}
{s.behind > 0 && <span className="text-amber-600">· {s.behind}</span>}
</span>
);
};
const lspStatus = () => {
if (lsp.status === "ready") return <span className="text-xs text-emerald-600">LSP · ready</span>;
if (lsp.status === "connecting") return <span className="text-xs text-amber-600">LSP · connecting</span>;
if (lsp.status === "error") return <span className="text-xs text-rose-600">LSP · error</span>;
return <span className="text-xs text-zinc-400">LSP · idle</span>;
};
const previewStatus = () => {
if (!state.openFile) return null;
if (lsp.previewReady) return <span className="text-xs text-emerald-600">Preview · live</span>;
if (lsp.status === "ready" && !lsp.previewReady) return <span className="text-xs text-zinc-400">Preview · starting</span>;
return null;
};
return (
<footer className="flex items-center gap-4 border-t border-zinc-200 bg-white px-4 h-7 shrink-0">
<div className="flex items-center gap-2">
{gitSummary()}
</div>
<span className="text-zinc-200 select-none">|</span>
<div className="flex items-center gap-2">
{lspStatus()}
{previewStatus()}
{state.vimOn && <span className="text-xs text-zinc-400">vim</span>}
</div>
<span className="ml-auto text-xs text-zinc-400">
{state.saving && <span className="text-amber-500">saving</span>}
</span>
</footer>
);
}
@@ -0,0 +1,46 @@
import { useEffect } from "react";
import { useApp } from "../app-state";
export function ToastHost() {
const { state, dispatch } = useApp();
useEffect(() => {
if (!state.notice) return;
const timer = setTimeout(() => dispatch({ type: "dismissNotice" }), 4000);
return () => clearTimeout(timer);
}, [state.notice, dispatch]);
if (!state.notice) return null;
const colors = {
success: "bg-emerald-50 text-emerald-800 border-emerald-200",
error: "bg-rose-50 text-rose-800 border-rose-200",
info: "bg-stone-50 text-zinc-700 border-zinc-200",
};
const dots = {
success: "bg-emerald-500",
error: "bg-rose-500",
info: "bg-zinc-400",
};
return (
<div className="fixed bottom-8 right-4 z-50 flex flex-col gap-2">
<div
className={`border rounded-md shadow-sm px-3 py-2 text-sm flex items-center gap-2 ${colors[state.notice.kind]}`}
>
<span className={`inline-block size-2 rounded-full shrink-0 ${dots[state.notice.kind]}`} />
{state.notice.message}
<button
type="button"
onClick={() => dispatch({ type: "dismissNotice" })}
className="ml-2 text-zinc-400 hover:text-zinc-600"
aria-label="Dismiss"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" />
</svg>
</button>
</div>
</div>
);
}
@@ -0,0 +1,105 @@
import { useApp } from "../app-state";
export function TopBar() {
const { state, dispatch, lsp } = useApp();
const lspChip = () => {
switch (lsp.status) {
case "ready":
return (
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200 flex items-center gap-1">
<span className="inline-block size-1.5 rounded-full bg-emerald-500" />
LSP
</span>
);
case "connecting":
return (
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-50 text-amber-700 border border-amber-200 flex items-center gap-1">
<span className="inline-block size-1.5 rounded-full bg-amber-400 animate-pulse" />
LSP
</span>
);
case "error":
return (
<span className="text-xs px-2 py-0.5 rounded-full bg-rose-50 text-rose-700 border border-rose-200 flex items-center gap-1">
<span className="inline-block size-1.5 rounded-full bg-rose-500" />
LSP
</span>
);
default:
return null;
}
};
const previewChip = () => {
// No preview without an open file (tinymist previews a document).
if (!state.openFile) return null;
if (lsp.previewReady) {
return (
<span className="text-xs px-2 py-0.5 rounded-full bg-stone-100 text-zinc-600 border border-zinc-200 flex items-center gap-1">
<span className="inline-block size-1.5 rounded-full bg-emerald-500" />
Preview
</span>
);
}
if (lsp.status === "ready") {
return (
<span className="text-xs px-2 py-0.5 rounded-full bg-stone-100 text-zinc-400 border border-zinc-200 flex items-center gap-1">
<span className="inline-block size-1.5 rounded-full bg-zinc-300" />
Preview · starting
</span>
);
}
return null;
};
return (
<header className="flex items-center gap-3 border-b border-zinc-200 bg-white px-4 h-11 shrink-0">
<span className="font-semibold tracking-tight text-zinc-800">typst-leaf</span>
<span className="text-zinc-300 select-none">·</span>
{state.currentProject ? (
<>
<span className="text-sm text-zinc-800 font-medium truncate max-w-36">
{state.currentProject}
</span>
{state.openFile && (
<>
<span className="text-zinc-300 select-none">/</span>
<span className="text-sm font-mono text-zinc-500 truncate max-w-48">
{state.openFile}
{state.fileDirty && <span className="text-amber-500 ml-0.5">*</span>}
</span>
</>
)}
<div className="ml-auto flex items-center gap-2">
{lspChip()}
{previewChip()}
<button
type="button"
onClick={() => {
document.dispatchEvent(new CustomEvent("typst-leaf:save"));
}}
disabled={!state.openFile}
className="px-3 py-1 text-sm rounded bg-emerald-600 hover:bg-emerald-700 text-white disabled:opacity-40 disabled:cursor-not-allowed"
>
Save
</button>
<button
type="button"
onClick={() => dispatch({ type: "setActivePanel", panel: state.activePanel === "settings" ? "files" : "settings" })}
className={`p-1.5 rounded-md ${state.activePanel === "settings" ? "text-emerald-700 bg-emerald-50" : "text-zinc-500 hover:bg-stone-100"}`}
title="Settings"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="size-4">
<path d="M10 3.75a2.25 2.25 0 0 0-4.312.75H2.5a.75.75 0 0 0 0 1.5h3.188A2.25 2.25 0 0 0 10 6.25a2.25 2.25 0 0 0 4.312-.75h3.188a.75.75 0 0 0 0-1.5h-3.188A2.25 2.25 0 0 0 10 3.75zM10 13.75a2.25 2.25 0 0 0-4.312.75H2.5a.75.75 0 0 0 0 1.5h3.188A2.25 2.25 0 0 0 10 16.25a2.25 2.25 0 0 0 4.312.75h3.188a.75.75 0 0 0 0-1.5h-3.188A2.25 2.25 0 0 0 10 13.75z" />
</svg>
</button>
</div>
</>
) : (
<span className="text-sm text-zinc-400">Select a project to begin</span>
)}
</header>
);
}
+25 -9
View File
@@ -1,4 +1,5 @@
import type { Monaco } from "@monaco-editor/react";
import { normalizeFsPath } from "./path-utils";
// ============================================================================
// Raw LSP client over WebSocket.
@@ -425,6 +426,15 @@ interface ShowDocumentParams {
selection?: { start: { line: number; character: number }; end: { line: number; character: number } };
}
/** Validate an LSP position tuple, returning undefined for malformed values. */
function toPosPair(v: unknown): [number, number] | undefined {
if (!Array.isArray(v) || v.length < 2) return undefined;
const a = Number(v[0]);
const b = Number(v[1]);
if (!Number.isFinite(a) || !Number.isFinite(b)) return undefined;
return [a, b];
}
export async function startLsp(
monaco: Monaco,
projectName: string,
@@ -485,7 +495,13 @@ export async function startLsp(
const p = params as ShowDocumentParams;
if (p.external) return { success: false };
if (p.uri && onJumpToSource) {
const filepath = decodeURIComponent(new URL(p.uri).pathname);
let filepath: string;
try {
filepath = normalizeFsPath(p.uri);
} catch {
console.warn("[lsp] window/showDocument: invalid uri", p.uri);
return { success: false };
}
const start = p.selection
? [p.selection.start.line, p.selection.start.character] as [number, number]
: undefined;
@@ -502,18 +518,18 @@ export async function startLsp(
client.onNotification("tinymist/preview/scrollSource", (params) => {
const p = params as {
filepath?: string;
start?: [number, number];
end?: [number, number];
start?: unknown;
end?: unknown;
};
if (!onJumpToSource) return;
if (typeof p.filepath !== "string") {
console.warn("[lsp] tinymist/preview/scrollSource missing filepath", params);
if (typeof p.filepath !== "string" || !p.filepath.trim()) {
console.warn("[lsp] tinymist/preview/scrollSource missing or empty filepath", params);
return;
}
const filepath = p.filepath.includes("://")
? decodeURIComponent(new URL(p.filepath).pathname)
: p.filepath.replaceAll("\\", "/");
onJumpToSource({ filepath, start: p.start, end: p.end });
const filepath = normalizeFsPath(p.filepath);
const start = toPosPair(p.start);
const end = toPosPair(p.end);
onJumpToSource({ filepath, start, end });
});
// LSP handshake
+77
View File
@@ -0,0 +1,77 @@
/**
* Shared path utilities for Typst frontend.
*
* There is a fundamental path duality in this app:
* - The **frontend** works with virtual slash-separated paths relative to the
* project root (e.g. "sub/main.typ" or "main.typ").
* - The **backend** resolves to absolute OS paths under PROJECTS_ROOT.
* - The **LSP** speaks file:// URIs with absolute OS paths.
*
* All three must be converted cleanly. This module centralises those rules so
* that app-state.tsx, Editor.tsx, lsp.ts, etc. do not each re-implement them.
*/
// ---------- Normalization ----------
/**
* Take any path-ish input (file:// URI, native backslash path, forward-slash
* pathname) and return a normalised forward-slash pathname.
*
* file:///home/user/projects/hello/sub/main.typ
* → /home/user/projects/hello/sub/main.typ
*
* file:///C:/Users/user/projects/hello/sub/main.typ
* → C:/Users/user/projects/hello/sub/main.typ (leading "/" stripped)
*
* C:\Users\user\projects\hello\sub\main.typ
* → C:/Users/user/projects/hello/sub/main.typ
*/
export function normalizeFsPath(input: string): string {
let result: string;
if (input.includes("://")) {
try {
result = decodeURIComponent(new URL(input).pathname);
} catch {
result = input.replaceAll("\\", "/");
}
} else {
result = input.replaceAll("\\", "/");
}
// Strip the leading "/" from Windows drive-letter paths (file:///C:/… → /C:/… → C:/…)
if (/^\/[A-Za-z]:\//.test(result) || /^\/[A-Za-z]:$/.test(result)) {
result = result.slice(1);
}
return result;
}
/**
* Encode a virtual relative path so it can be appended to a file:// base URI.
* Each path segment is encoded individually so that "/" separators survive.
*
* "sub/main.typ" → "sub/main.typ"
* "my file.typ" → "my%20file.typ"
* "sub/nested/two.typ" → "sub/nested/two.typ"
*/
export function encodeVirtualPathForFileUri(virtualPath: string): string {
return virtualPath.split("/").map(encodeURIComponent).join("/");
}
// ---------- Relative path extraction ----------
/**
* Given a `fileUri` (the project file:// URI from app state) and an absolute
* filesystem path string (from tinymist), return the virtual relative path
* under the project, or `null` if the path is outside the project root.
*/
export function relativePathFromProject(
projectUri: string,
absolutePath: string,
): string | null {
const filepath = normalizeFsPath(absolutePath);
const projectPath = normalizeFsPath(projectUri);
const sep = projectPath.endsWith("/") ? "" : "/";
if (!filepath.startsWith(projectPath + sep)) {
return null;
}
return filepath.slice(projectPath.length + 1);
}
+1
View File
@@ -15,6 +15,7 @@ export interface FileEntry {
export interface TreeResponse {
project: string;
files: FileEntry[];
folders: { path: string }[];
}
export interface FileResponse {