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:
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
+100
-24
@@ -4,42 +4,118 @@ 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>
|
||||
<ProjectPicker />
|
||||
<div className="flex-1 overflow-auto">
|
||||
<FileTree />
|
||||
</div>
|
||||
<GitPanel />
|
||||
<div className="h-screen w-screen bg-stone-50 text-zinc-800 flex flex-col overflow-hidden">
|
||||
<TopBar />
|
||||
|
||||
{state.error && (
|
||||
<div className="p-2 text-xs text-red-500 border-t border-zinc-200">
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
<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 className="flex-1 flex min-w-0">
|
||||
<EditorView />
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
const next = !state.vimOn;
|
||||
localStorage.setItem("typst-leaf.vim", String(next));
|
||||
return 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
|
||||
if (mountedRef.current) {
|
||||
dispatch({ type: "bumpGitVersion" });
|
||||
dispatch({ type: "setFileDirty", dirty: false });
|
||||
dispatch({ type: "pushNotice", kind: "success", message: "Saved" });
|
||||
}
|
||||
} catch (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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
const tree = buildTree(
|
||||
state.tree.map((f) => f.path),
|
||||
state.folders.map((f) => f.path),
|
||||
);
|
||||
}
|
||||
if (state.tree.length === 0) {
|
||||
return <div className="p-2 text-xs text-zinc-400">no .typ files</div>;
|
||||
|
||||
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 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 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` }}
|
||||
>
|
||||
{f.path}
|
||||
<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 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();
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ?? "");
|
||||
setName(id.name);
|
||||
setEmail(id.email);
|
||||
setDirty(false);
|
||||
}
|
||||
}, [open]);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ name, email }));
|
||||
@@ -46,18 +43,7 @@ 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>
|
||||
|
||||
{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="px-3 py-2 flex flex-col gap-2">
|
||||
<div className="text-xs uppercase tracking-wide text-zinc-400">
|
||||
Git Identity
|
||||
</div>
|
||||
@@ -101,7 +87,7 @@ export function SettingsPanel() {
|
||||
setEmail("");
|
||||
setDirty(false);
|
||||
}}
|
||||
className="px-2 py-1 text-xs rounded bg-white hover:bg-zinc-100 text-zinc-500 border border-zinc-300"
|
||||
className="px-2 py-1 text-xs rounded bg-white hover:bg-stone-100 text-zinc-500 border border-zinc-300"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
@@ -111,7 +97,5 @@ export function SettingsPanel() {
|
||||
Used for Git commits. Stored locally in browser.
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export interface FileEntry {
|
||||
export interface TreeResponse {
|
||||
project: string;
|
||||
files: FileEntry[];
|
||||
folders: { path: string }[];
|
||||
}
|
||||
|
||||
export interface FileResponse {
|
||||
|
||||
Reference in New Issue
Block a user