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`:
|
- Tailwind only, light theme only, no dark variant.
|
||||||
- Always render `<div ref={vimStatusRef} />` (empty when vim is off, monaco-vim populates it when on).
|
- Browser-first; Tauri APIs only behind `window.__TAURI__` guards.
|
||||||
- On `handleMount`: if `localStorage.getItem("typst-leaf.vim") === "true"`, `initVimMode(editor, vimStatusRef.current)`.
|
- No databases; filesystem + `.git` remain the source of truth.
|
||||||
- Add "Vim" toggle button in the editor toolbar (emerald when active).
|
- No polling for file or preview changes.
|
||||||
- On toggle: persist to localStorage; init or dispose accordingly.
|
- Backend stays a thin proxy. No Typst or LSP reimplementation in TypeScript.
|
||||||
- Store `initVimMode`'s returned controller in a ref for disposal on unmount/toggle-off.
|
- `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
|
### 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))`.
|
All paths through `resolveFile`/`relPathSchema`. zod validation stays.
|
||||||
- `routes/git.ts` (mounted at `/api/projects/:project/git`):
|
|
||||||
- `POST /init` — `git init`, set default identity from env, initial commit, `.gitignore`.
|
|
||||||
- `GET /status` — branch, ahead/behind, staged/unstaged, clean.
|
|
||||||
- `GET /log?limit=20` — commit history.
|
|
||||||
- `POST /commit` — body `{ message, all?, identity? }`. Uses `-c user.name=... -c user.email=...` per-commit flags (not repo config).
|
|
||||||
- `PUT /remote` — body `{ url }` — `git remote add` or `set-url`.
|
|
||||||
- `POST /push` / `POST /pull` — to origin, return `{ ok, error? }`.
|
|
||||||
- `routes/projects.ts`: `POST /:project` calls `git.init` after seeding `main.typ`.
|
|
||||||
- `index.ts`: mount gitRouter.
|
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
`api.ts`:
|
||||||
|
- `createFile`, `deleteFile`, `renameFile`, `createFolder`.
|
||||||
|
|
||||||
- `api.ts`: gitInit, gitStatus, gitLog, gitCommit, gitPush, gitPull, setRemote.
|
`types.ts`:
|
||||||
- `types.ts`: FileStatus, CommitInfo, GitStatus.
|
- Add request types.
|
||||||
- `GitPanel.tsx` (sidebar, below FileTree):
|
|
||||||
- Branch name, ahead/behind badges, unstaged file count.
|
|
||||||
- Inline commit: message input + Commit button (Enter to submit).
|
|
||||||
- Push/Pull buttons; prompts for remote URL if none configured.
|
|
||||||
- Refreshes on project open + after saves + after commits.
|
|
||||||
- `SettingsPanel.tsx` (gear icon in sidebar header toggles panel):
|
|
||||||
- Git Author Name, Git Author Email inputs.
|
|
||||||
- Persisted to `localStorage["typst-leaf.git-identity"]`.
|
|
||||||
- Sent with every commit as `identity`.
|
|
||||||
- `Editor.tsx`: `CtrlCmd+Shift+KeyS` → dispatch `commitFocus` via app state.
|
|
||||||
- `app-state.tsx`: `gitStatus`, `gitVersion` (incremented on save to trigger refresh), `commitFocus` (incremented on shortcut).
|
|
||||||
|
|
||||||
## Part C — SyncTeX (Preview → Editor)
|
`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**:
|
(See Phase 1.) This phase covers the user-visible behavior of the jump path:
|
||||||
- Params: `{ filepath: string, start?: [number, number], end?: [number, number] }`
|
|
||||||
- Convert: `filepath` → relPath, `start/end` are 0-indexed [row, col]
|
|
||||||
- Call `onJumpToSource({ filepath, start, end })`
|
|
||||||
|
|
||||||
2. **`window/showDocument` request**:
|
- Stable IDs prevent dropped or duplicated jumps.
|
||||||
- Params: `{ uri: string, selection?: { start: {line, character}, end: {line, character} } }`
|
- External path is announced via toast instead of silently dropping.
|
||||||
- Convert: `uri` (file://) → relPath, selection is 0-indexed LspRange
|
- Vue of preview-after-resize: drag resize triggers `editor.layout()`. Preview iframe reload button re-syncs.
|
||||||
- Call `onJumpToSource(...)`, respond `{ success: true }`
|
- 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.
|
Backend
|
||||||
- In `startLsp`, pass `onJumpToSource` callback:
|
- `GET /api/projects/:project/git/diff` returning unified diffs (later; not blocking if skipped).
|
||||||
1. Convert absolute `filepath` → relPath (strip `new URL(projectUri).pathname` prefix)
|
- Optional `POST /api/projects/:project/git/stage` for partial staging (later).
|
||||||
2. **Ignore if filepath is not under the project** (e.g. imported package file)
|
|
||||||
3. If `relPath !== state.openFile`, dispatch `openFile`
|
|
||||||
4. Dispatch `setJumpTarget({ file: relPath, start, end })`
|
|
||||||
|
|
||||||
### Editor (`Editor.tsx`)
|
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]`
|
`types.ts`:
|
||||||
- When `jumpTarget` is truthy AND `model` is the correct file:
|
- Confirm `FileStatusInfo` carries `A|M|D|?` (already does).
|
||||||
- Convert 0-indexed [row, col] → Monaco 1-indexed (line = row + 1, column = col + 1)
|
|
||||||
- `editor.setSelection(range)`, `editor.revealRangeInCenter(range)`, focus editor
|
|
||||||
- Dispatch `clearJumpTarget`
|
|
||||||
|
|
||||||
## Part D — Doc Cleanup
|
## 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."
|
A completely self-hostable, high-performance editor for [Typst](https://typst.app/), built on the philosophy of "Git as the Database."
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
* **Instant Preview:** Sub-50ms incremental compilation using `tinymist`.
|
* **Instant Preview:** Incremental compilation using `tinymist`.
|
||||||
* **Live diagnostics & autocomplete** from the Typst language server.
|
* **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.
|
* **Hybrid:** Run it in a browser, or as a native desktop app via Tauri.
|
||||||
|
|
||||||
## 📂 Repository Structure
|
## 📂 Repository Structure
|
||||||
@@ -58,10 +60,15 @@ Then open <http://localhost:1420> in your browser. A `hello` project is auto-see
|
|||||||
|
|
||||||
## 🏗️ Architecture
|
## 🏗️ Architecture
|
||||||
|
|
||||||
* **Editor:** Monaco Editor
|
* **Editor:** Monaco Editor (bundled locally)
|
||||||
* **LSP:** `tinymist` over a WebSocket thin proxy
|
* **LSP:** Raw JSON-RPC client over WebSocket → backend thin proxy → `tinymist` stdio
|
||||||
* **Preview:** Backend spawns `tinymist preview <file>` on demand; the frontend iframes its webview for live SVG updates.
|
* **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:**
|
* **Data plane:**
|
||||||
* `ws://localhost:4000/lsp` → `tinymist lsp` (single active connection, takeover on reconnect)
|
* `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`).
|
* **Monorepo Structure:** Flat directory structure (`typst-leaf-backend`, `typst-leaf-frontend`).
|
||||||
* **Frontend:** React (Vite) + Monaco Editor.
|
* **Frontend:** React (Vite) + Monaco Editor.
|
||||||
* **Desktop Wrapper:** Tauri v2 (integrated into `typst-leaf-frontend`).
|
* **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.
|
* **Core Engine:** `tinymist` (Binary) for LSP and compilation.
|
||||||
|
|
||||||
### Component Breakdown
|
### 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`)
|
#### B. Frontend (`typst-leaf-frontend`)
|
||||||
A Single Page Application (SPA) that acts as the IDE interface.
|
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.
|
* **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.
|
* **Tauri Integration:** The frontend folder contains `src-tauri`, allowing it to build as a native desktop application.
|
||||||
|
|
||||||
## 3. Critical Technical Decisions
|
## 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.
|
*Rationale:* SVGs are selectable, zoomable without blur, and significantly lighter than streaming PDF binaries.
|
||||||
|
|
||||||
### 3.3 The LSP Connection
|
### 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
|
## 4. User Stories
|
||||||
* **As a User,** I want to type code and see the result instantly (<100ms lag).
|
* **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 projectParam = z.object({ project: projectNameSchema });
|
||||||
const fileQuery = z.object({ path: relPathSchema });
|
const fileQuery = z.object({ path: relPathSchema });
|
||||||
const writeBody = z.object({ content: z.string() });
|
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) => {
|
filesRouter.get("/:project/file", async (req, res) => {
|
||||||
const p = projectParam.safeParse(req.params);
|
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 });
|
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" });
|
res.status(404).json({ error: "project not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const out: { path: string; mtime: number }[] = [];
|
const files: { path: string; mtime: number }[] = [];
|
||||||
|
const folders: { path: string }[] = [];
|
||||||
const budget = { left: MAX_TREE_ENTRIES };
|
const budget = { left: MAX_TREE_ENTRIES };
|
||||||
|
|
||||||
async function walk(rel: string, depth: number): Promise<void> {
|
async function walk(rel: string, depth: number): Promise<void> {
|
||||||
if (depth > MAX_TREE_DEPTH || budget.left <= 0) return;
|
if (depth > MAX_TREE_DEPTH || budget.left <= 0) return;
|
||||||
const abs = path.join(dir, rel);
|
const abs = path.join(dir, rel);
|
||||||
const entries = await fs.readdir(abs, { withFileTypes: true });
|
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) {
|
for (const entry of entries) {
|
||||||
if (budget.left <= 0) return;
|
if (budget.left <= 0) return;
|
||||||
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
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);
|
const stat = await fs.stat(childAbs);
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
await walk(childRel, depth + 1);
|
await walk(childRel, depth + 1);
|
||||||
|
hasVisible = true;
|
||||||
|
if (budget.left <= 0) return;
|
||||||
} else if (entry.isFile() && entry.name.endsWith(".typ")) {
|
} 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;
|
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);
|
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.
|
||||||
|
|||||||
+105
-29
@@ -4,41 +4,117 @@ import { FileTree } from "./components/FileTree";
|
|||||||
import { GitPanel } from "./components/GitPanel";
|
import { GitPanel } from "./components/GitPanel";
|
||||||
import { Preview } from "./components/Preview";
|
import { Preview } from "./components/Preview";
|
||||||
import { ProjectPicker } from "./components/ProjectPicker";
|
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 { SettingsPanel } from "./components/SettingsPanel";
|
||||||
|
import { ResizableSplit } from "./components/ResizableSplit";
|
||||||
|
|
||||||
function Shell() {
|
function Shell() {
|
||||||
const { state, lsp } = useApp();
|
const { state, dispatch } = useApp();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen bg-white text-zinc-800 flex">
|
<div className="h-screen w-screen bg-stone-50 text-zinc-800 flex flex-col overflow-hidden">
|
||||||
<aside className="w-60 border-r border-zinc-200 flex flex-col bg-zinc-50">
|
<TopBar />
|
||||||
<div className="px-3 py-2 border-b border-zinc-200 text-sm font-semibold flex items-center gap-2">
|
|
||||||
<span>typst-leaf</span>
|
{state.error && (
|
||||||
<span className="ml-auto flex items-center gap-2 text-xs font-normal text-zinc-400">
|
<div className="bg-rose-50 text-rose-700 text-xs px-4 py-1.5 border-b border-rose-200 shrink-0">
|
||||||
<SettingsPanel />
|
{state.error}
|
||||||
{lsp.status === "ready"
|
|
||||||
? "●"
|
|
||||||
: lsp.status === "connecting"
|
|
||||||
? "◌"
|
|
||||||
: lsp.status === "error"
|
|
||||||
? "✕"
|
|
||||||
: "○"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<ProjectPicker />
|
)}
|
||||||
<div className="flex-1 overflow-auto">
|
|
||||||
<FileTree />
|
<div className="flex flex-1 min-h-0">
|
||||||
</div>
|
{/* Sidebar */}
|
||||||
<GitPanel />
|
<aside className="w-64 border-r border-zinc-200 bg-stone-50 flex flex-col min-h-0 shrink-0">
|
||||||
{state.error && (
|
<ProjectPicker />
|
||||||
<div className="p-2 text-xs text-red-500 border-t border-zinc-200">
|
|
||||||
{state.error}
|
{/* 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>
|
||||||
)}
|
|
||||||
</aside>
|
<div className="flex-1 overflow-auto">
|
||||||
<main className="flex-1 flex min-w-0">
|
{state.activePanel === "files" && (
|
||||||
<EditorView />
|
<PanelSection title="Files">
|
||||||
<Preview />
|
{state.currentProject ? <FileTree /> : (
|
||||||
</main>
|
<div className="px-3 py-2 text-xs text-zinc-400">select a project</div>
|
||||||
|
)}
|
||||||
|
</PanelSection>
|
||||||
|
)}
|
||||||
|
{state.activePanel === "git" && <GitPanel />}
|
||||||
|
{state.activePanel === "settings" && <SettingsPanel />}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main workspace */}
|
||||||
|
<main className="flex-1 flex min-w-0 bg-white">
|
||||||
|
{state.currentProject && state.openFile ? (
|
||||||
|
<ResizableSplit
|
||||||
|
left={<EditorView />}
|
||||||
|
right={<Preview />}
|
||||||
|
onResize={() => {
|
||||||
|
document.dispatchEvent(new CustomEvent("typst-leaf:layout"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex min-h-0">
|
||||||
|
{!state.currentProject ? (
|
||||||
|
<div className="flex-1 flex">
|
||||||
|
<div className="flex-1">
|
||||||
|
<EmptyState message="Select a project to begin." hint="Use the sidebar to pick or create a project." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : !state.openFile ? (
|
||||||
|
<div className="flex-1 flex">
|
||||||
|
<div className="flex-1">
|
||||||
|
<EmptyState message="Open a file from the sidebar." hint=".typ files are listed under the Files section." />
|
||||||
|
</div>
|
||||||
|
<div className="w-96 border-l border-zinc-200">
|
||||||
|
<Preview />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StatusBar />
|
||||||
|
<ToastHost />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,30 @@ export const api = {
|
|||||||
body: JSON.stringify({ port }),
|
body: JSON.stringify({ port }),
|
||||||
}).then((r) => jsonOrThrow<{ ok: boolean; port: number }>(r)),
|
}).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 ----
|
// ---- Git ----
|
||||||
gitInit: (project: string) =>
|
gitInit: (project: string) =>
|
||||||
fetch(`${gitUrl(project)}/init`, { method: "POST" }).then((r) =>
|
fetch(`${gitUrl(project)}/init`, { method: "POST" }).then((r) =>
|
||||||
|
|||||||
@@ -10,23 +10,40 @@ import {
|
|||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
import { loadMonaco } from "./monaco-setup";
|
import { loadMonaco } from "./monaco-setup";
|
||||||
import { startLsp, startPreview, type RawLspClient } from "./lsp";
|
import { startLsp, startPreview, type RawLspClient } from "./lsp";
|
||||||
|
import { relativePathFromProject } from "./path-utils";
|
||||||
import type { Monaco } from "@monaco-editor/react";
|
import type { Monaco } from "@monaco-editor/react";
|
||||||
import type { FileEntry, GitStatus } from "./types";
|
import type { FileEntry, GitStatus } from "./types";
|
||||||
|
|
||||||
|
interface Notice {
|
||||||
|
id: number;
|
||||||
|
kind: "success" | "error" | "info";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
projects: string[];
|
projects: string[];
|
||||||
currentProject: string | null;
|
currentProject: string | null;
|
||||||
currentUri: string | null;
|
currentUri: string | null;
|
||||||
tree: FileEntry[];
|
tree: FileEntry[];
|
||||||
|
folders: { path: string }[];
|
||||||
|
treeVersion: number;
|
||||||
openFile: string | null;
|
openFile: string | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
fileDirty: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
// sidebar
|
||||||
|
activePanel: "files" | "git" | "settings";
|
||||||
// git
|
// git
|
||||||
gitStatus: GitStatus | null;
|
gitStatus: GitStatus | null;
|
||||||
gitVersion: number;
|
gitVersion: number;
|
||||||
commitFocus: number;
|
commitFocus: number;
|
||||||
|
// vim
|
||||||
|
vimOn: boolean;
|
||||||
// sync
|
// sync
|
||||||
|
notice: Notice | null;
|
||||||
jumpTarget: {
|
jumpTarget: {
|
||||||
|
id: number;
|
||||||
file: string;
|
file: string;
|
||||||
start?: [number, number];
|
start?: [number, number];
|
||||||
end?: [number, number];
|
end?: [number, number];
|
||||||
@@ -36,14 +53,21 @@ interface State {
|
|||||||
type Action =
|
type Action =
|
||||||
| { type: "setProjects"; projects: string[] }
|
| { type: "setProjects"; projects: string[] }
|
||||||
| { type: "selectProject"; project: string; uri: 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: "openFile"; path: string | null }
|
||||||
| { type: "loading"; loading: boolean }
|
| { type: "loading"; loading: boolean }
|
||||||
| { type: "error"; error: string | null }
|
| { type: "error"; error: string | null }
|
||||||
|
| { type: "setFileDirty"; dirty: boolean }
|
||||||
|
| { type: "setSaving"; saving: boolean }
|
||||||
|
| { type: "setActivePanel"; panel: State["activePanel"] }
|
||||||
| { type: "setGitStatus"; status: GitStatus }
|
| { type: "setGitStatus"; status: GitStatus }
|
||||||
| { type: "bumpGitVersion" }
|
| { type: "bumpGitVersion" }
|
||||||
| { type: "focusCommit" }
|
| { 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" };
|
| { type: "clearJumpTarget" };
|
||||||
|
|
||||||
const initial: State = {
|
const initial: State = {
|
||||||
@@ -51,12 +75,19 @@ const initial: State = {
|
|||||||
currentProject: null,
|
currentProject: null,
|
||||||
currentUri: null,
|
currentUri: null,
|
||||||
tree: [],
|
tree: [],
|
||||||
|
folders: [],
|
||||||
|
treeVersion: 0,
|
||||||
openFile: null,
|
openFile: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
fileDirty: false,
|
||||||
|
saving: false,
|
||||||
|
activePanel: "files",
|
||||||
gitStatus: null,
|
gitStatus: null,
|
||||||
gitVersion: 0,
|
gitVersion: 0,
|
||||||
commitFocus: 0,
|
commitFocus: 0,
|
||||||
|
notice: null,
|
||||||
|
vimOn: false,
|
||||||
jumpTarget: null,
|
jumpTarget: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,27 +101,45 @@ function reducer(state: State, action: Action): State {
|
|||||||
currentProject: action.project,
|
currentProject: action.project,
|
||||||
currentUri: action.uri,
|
currentUri: action.uri,
|
||||||
tree: [],
|
tree: [],
|
||||||
|
folders: [],
|
||||||
openFile: null,
|
openFile: null,
|
||||||
gitStatus: null,
|
gitStatus: null,
|
||||||
|
fileDirty: false,
|
||||||
|
saving: false,
|
||||||
|
error: null,
|
||||||
};
|
};
|
||||||
case "setTree":
|
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":
|
case "openFile":
|
||||||
return { ...state, openFile: action.path };
|
return { ...state, openFile: action.path, fileDirty: false };
|
||||||
case "loading":
|
case "loading":
|
||||||
return { ...state, loading: action.loading };
|
return { ...state, loading: action.loading };
|
||||||
case "error":
|
case "error":
|
||||||
return { ...state, error: action.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":
|
case "setGitStatus":
|
||||||
return { ...state, gitStatus: action.status };
|
return { ...state, gitStatus: action.status };
|
||||||
case "bumpGitVersion":
|
case "bumpGitVersion":
|
||||||
return { ...state, gitVersion: state.gitVersion + 1 };
|
return { ...state, gitVersion: state.gitVersion + 1 };
|
||||||
case "focusCommit":
|
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":
|
case "setJumpTarget":
|
||||||
return { ...state, jumpTarget: action.target };
|
return { ...state, jumpTarget: action.target };
|
||||||
case "clearJumpTarget":
|
case "clearJumpTarget":
|
||||||
return { ...state, jumpTarget: null };
|
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 sessionRef = useRef<{ dispose: () => void } | null>(null);
|
||||||
const openFileRef = useRef<string | null>(null);
|
const openFileRef = useRef<string | null>(null);
|
||||||
const uriRef = useRef<string | null>(null);
|
const uriRef = useRef<string | null>(null);
|
||||||
|
const jumpIdRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMonaco()
|
loadMonaco()
|
||||||
@@ -142,6 +192,15 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
.catch((e) => dispatch({ type: "error", error: String(e) }));
|
.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(() => {
|
useEffect(() => {
|
||||||
if (!state.currentProject || !lsp.monaco) return;
|
if (!state.currentProject || !lsp.monaco) return;
|
||||||
const sessionRefLocal = sessionRef;
|
const sessionRefLocal = sessionRef;
|
||||||
@@ -150,22 +209,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
const uri = state.currentUri!;
|
const uri = state.currentUri!;
|
||||||
setLsp((s) => ({ ...s, status: "connecting", previewReady: false }));
|
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: {
|
const onJumpToSource = (info: {
|
||||||
filepath: string;
|
filepath: string;
|
||||||
start?: [number, number];
|
start?: [number, number];
|
||||||
@@ -173,19 +216,24 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||||||
}) => {
|
}) => {
|
||||||
const curUri = uriRef.current ?? uri;
|
const curUri = uriRef.current ?? uri;
|
||||||
const curOpenFile = openFileRef.current;
|
const curOpenFile = openFileRef.current;
|
||||||
const filepath = normalizePath(info.filepath);
|
const relPath = relativePathFromProject(curUri, info.filepath);
|
||||||
const projectPath = normalizePath(curUri);
|
|
||||||
const sep = projectPath.endsWith("/") ? "" : "/";
|
if (!relPath) {
|
||||||
if (!filepath.startsWith(projectPath + sep)) {
|
dispatch({
|
||||||
|
type: "pushNotice",
|
||||||
|
kind: "info",
|
||||||
|
message: "Preview source is outside this project",
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const relPath = filepath.slice(projectPath.length + 1);
|
|
||||||
|
const id = ++jumpIdRef.current;
|
||||||
if (relPath !== curOpenFile) {
|
if (relPath !== curOpenFile) {
|
||||||
dispatch({ type: "openFile", path: relPath });
|
dispatch({ type: "openFile", path: relPath });
|
||||||
}
|
}
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "setJumpTarget",
|
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;
|
if (!state.currentProject) return;
|
||||||
api
|
api
|
||||||
.getTree(state.currentProject)
|
.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) }));
|
.catch((e) => dispatch({ type: "error", error: String(e) }));
|
||||||
}, [state.currentProject]);
|
}, [state.currentProject, state.treeVersion]);
|
||||||
|
|
||||||
// Keep refs current for the LSP callback closure
|
// Keep refs current for the LSP callback closure
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Editor, { type Monaco, type OnMount } from "@monaco-editor/react";
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { useApp } from "../app-state";
|
import { useApp } from "../app-state";
|
||||||
|
import { encodeVirtualPathForFileUri } from "../path-utils";
|
||||||
|
|
||||||
type ITextModel = Monaco["editor"]["ITextModel"];
|
type ITextModel = Monaco["editor"]["ITextModel"];
|
||||||
type IStandaloneCodeEditor = Monaco["editor"]["IStandaloneCodeEditor"];
|
type IStandaloneCodeEditor = Monaco["editor"]["IStandaloneCodeEditor"];
|
||||||
@@ -16,28 +17,25 @@ export function EditorView() {
|
|||||||
const uri = state.currentUri;
|
const uri = state.currentUri;
|
||||||
const file = state.openFile;
|
const file = state.openFile;
|
||||||
const savingRef = useRef(false);
|
const savingRef = useRef(false);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
// Vim mode
|
// Vim mode
|
||||||
const [vimOn, setVimOn] = useState(
|
|
||||||
() => localStorage.getItem("typst-leaf.vim") === "true",
|
|
||||||
);
|
|
||||||
const [editorReady, setEditorReady] = useState(false);
|
const [editorReady, setEditorReady] = useState(false);
|
||||||
const vimRef = useRef<{ dispose: () => void } | null>(null);
|
const vimRef = useRef<{ dispose: () => void } | null>(null);
|
||||||
const vimStatusRef = useRef<HTMLDivElement | null>(null);
|
const vimStatusRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// Toggle vim
|
// Toggle vim
|
||||||
const toggleVim = useCallback(() => {
|
const toggleVim = useCallback(() => {
|
||||||
setVimOn((prev) => {
|
const next = !state.vimOn;
|
||||||
const next = !prev;
|
localStorage.setItem("typst-leaf.vim", String(next));
|
||||||
localStorage.setItem("typst-leaf.vim", String(next));
|
dispatch({ type: "setVimOn", on: next });
|
||||||
return next;
|
}, [state.vimOn, dispatch]);
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Init / dispose vim mode when editor or vimOn changes
|
// Init / dispose vim mode when editor or vimOn changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editorReady) return;
|
if (!editorReady) return;
|
||||||
if (vimOn && !vimRef.current) {
|
if (state.vimOn && !vimRef.current) {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
import("monaco-vim").then(({ initVimMode }) => {
|
import("monaco-vim").then(({ initVimMode }) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -46,24 +44,39 @@ export function EditorView() {
|
|||||||
vimRef.current = initVimMode(editorRef.current, statusNode);
|
vimRef.current = initVimMode(editorRef.current, statusNode);
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
} else if (!vimOn && vimRef.current) {
|
} else if (!state.vimOn && vimRef.current) {
|
||||||
vimRef.current.dispose();
|
vimRef.current.dispose();
|
||||||
vimRef.current = null;
|
vimRef.current = null;
|
||||||
if (vimStatusRef.current) {
|
if (vimStatusRef.current) {
|
||||||
vimStatusRef.current.textContent = "";
|
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
|
// Cleanup vim on unmount. Resetting editorReady ensures the vim
|
||||||
// effect re-fires after a StrictMode remount (Monaco creates a new
|
// effect re-fires after a StrictMode remount (Monaco creates a new
|
||||||
// editor instance, handleMount sets editorReady=true again, and the
|
// editor instance, handleMount sets editorReady=true again, and the
|
||||||
// effect re-initializes vim on the new editor).
|
// 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(() => {
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
return () => {
|
return () => {
|
||||||
vimRef.current?.dispose();
|
vimRef.current?.dispose();
|
||||||
vimRef.current = null;
|
vimRef.current = null;
|
||||||
setEditorReady(false);
|
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();
|
m?.dispose();
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
modelRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -83,7 +97,7 @@ export function EditorView() {
|
|||||||
.then((f) => {
|
.then((f) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const base = uri.endsWith("/") ? uri : uri + "/";
|
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);
|
const uriObj = monaco.Uri.parse(docUri);
|
||||||
monaco.editor.getModel(uriObj)?.dispose();
|
monaco.editor.getModel(uriObj)?.dispose();
|
||||||
const next = monaco.editor.createModel(f.content, "typst", uriObj);
|
const next = monaco.editor.createModel(f.content, "typst", uriObj);
|
||||||
@@ -91,6 +105,7 @@ export function EditorView() {
|
|||||||
prev?.dispose();
|
prev?.dispose();
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
modelRef.current = next;
|
||||||
})
|
})
|
||||||
.catch((e) => !cancelled && setError(String(e)));
|
.catch((e) => !cancelled && setError(String(e)));
|
||||||
return () => {
|
return () => {
|
||||||
@@ -107,13 +122,21 @@ export function EditorView() {
|
|||||||
async function save() {
|
async function save() {
|
||||||
if (!project || !file || !model || savingRef.current) return;
|
if (!project || !file || !model || savingRef.current) return;
|
||||||
savingRef.current = true;
|
savingRef.current = true;
|
||||||
|
dispatch({ type: "setSaving", saving: true });
|
||||||
try {
|
try {
|
||||||
await api.putFile(project, file, model.getValue());
|
await api.putFile(project, file, model.getValue());
|
||||||
// Bump git version so GitPanel refreshes
|
if (mountedRef.current) {
|
||||||
dispatch({ type: "bumpGitVersion" });
|
dispatch({ type: "bumpGitVersion" });
|
||||||
|
dispatch({ type: "setFileDirty", dirty: false });
|
||||||
|
dispatch({ type: "pushNotice", kind: "success", message: "Saved" });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
if (mountedRef.current) {
|
||||||
|
setError(String(e));
|
||||||
|
dispatch({ type: "pushNotice", kind: "error", message: String(e) });
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (mountedRef.current) dispatch({ type: "setSaving", saving: false });
|
||||||
savingRef.current = 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
|
// Resize observer keeps Monaco laid out when the flex container changes
|
||||||
// size (sidebar toggles, window resize, etc.). Without this, revealRange
|
// size (sidebar toggles, window resize, etc.). Without this, revealRange
|
||||||
// and cursor positioning can be off after a layout change.
|
// and cursor positioning can be off after a layout change.
|
||||||
@@ -157,27 +194,21 @@ export function EditorView() {
|
|||||||
}, [editorReady]);
|
}, [editorReady]);
|
||||||
|
|
||||||
// Jump target effect — reactive (no polling). Track the last handled
|
// Jump target effect — reactive (no polling). Track the last handled
|
||||||
// target by object identity so rapid clicks don't get swallowed by a stale
|
// jump by its numeric id so rapid clicks are not swallowed by a stale
|
||||||
// boolean flag, and call editor.layout() before reveal so container
|
// ref, and call editor.layout() before reveal so container resizes
|
||||||
// resizes don't break the scroll.
|
// don't break scroll position.
|
||||||
const lastHandledTargetRef = useRef<{
|
const lastHandledJumpId = useRef<number | null>(null);
|
||||||
file: string;
|
|
||||||
start?: [number, number];
|
|
||||||
end?: [number, number];
|
|
||||||
} | null>(null);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state.jumpTarget || !editorRef.current || !monaco || !model) return;
|
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 target = state.jumpTarget;
|
||||||
const modelUri = model.uri.toString();
|
if (!uri || !target.file) return;
|
||||||
const base = uri?.endsWith("/") ? uri : uri + "/";
|
const base = uri.endsWith("/") ? uri : uri + "/";
|
||||||
const expectedModelUri = target.file
|
const expectedUri = monaco.Uri.parse(base + encodeVirtualPathForFileUri(target.file));
|
||||||
? base + target.file.split("/").map(encodeURIComponent).join("/")
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!expectedModelUri || modelUri !== expectedModelUri) return;
|
if (!monaco.Uri.equals(model.uri, expectedUri)) return;
|
||||||
lastHandledTargetRef.current = target;
|
lastHandledJumpId.current = target.id;
|
||||||
|
|
||||||
let selection: Monaco["Selection"] | null = null;
|
let selection: Monaco["Selection"] | null = null;
|
||||||
if (target.start && target.end) {
|
if (target.start && target.end) {
|
||||||
@@ -206,20 +237,6 @@ export function EditorView() {
|
|||||||
dispatch({ type: "clearJumpTarget" });
|
dispatch({ type: "clearJumpTarget" });
|
||||||
}, [state.jumpTarget, model, monaco, uri]);
|
}, [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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center text-red-400 text-sm p-4">
|
<div className="flex-1 flex items-center justify-center text-red-400 text-sm p-4">
|
||||||
@@ -229,7 +246,7 @@ export function EditorView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<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="truncate max-w-48">{file}</span>
|
||||||
<span className="ml-auto flex items-center gap-2">
|
<span className="ml-auto flex items-center gap-2">
|
||||||
@@ -241,20 +258,13 @@ export function EditorView() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={toggleVim}
|
onClick={toggleVim}
|
||||||
className={`px-2 py-0.5 rounded border text-xs ${
|
className={`px-2 py-0.5 rounded border text-xs ${
|
||||||
vimOn
|
state.vimOn
|
||||||
? "bg-emerald-100 border-emerald-300 text-emerald-700"
|
? "bg-emerald-100 border-emerald-300 text-emerald-700"
|
||||||
: "bg-white hover:bg-zinc-100 text-zinc-700 border-zinc-300"
|
: "bg-white hover:bg-zinc-100 text-zinc-700 border-zinc-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Vim
|
Vim
|
||||||
</button>
|
</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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div ref={editorContainerRef} className="flex-1 min-h-0">
|
<div ref={editorContainerRef} className="flex-1 min-h-0">
|
||||||
@@ -262,11 +272,13 @@ export function EditorView() {
|
|||||||
defaultLanguage="typst"
|
defaultLanguage="typst"
|
||||||
theme="vs"
|
theme="vs"
|
||||||
onMount={handleMount}
|
onMount={handleMount}
|
||||||
|
onChange={() => {
|
||||||
|
if (!state.fileDirty) dispatch({ type: "setFileDirty", dirty: true });
|
||||||
|
}}
|
||||||
options={{
|
options={{
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
minimap: { enabled: false },
|
minimap: { enabled: false },
|
||||||
wordWrap: "on",
|
wordWrap: "on",
|
||||||
automaticLayout: true,
|
|
||||||
tabSize: 2,
|
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";
|
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() {
|
export function FileTree() {
|
||||||
const { state, dispatch } = useApp();
|
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) {
|
const dirtyFiles = new Set<string>();
|
||||||
return (
|
if (s) {
|
||||||
<div className="p-2 text-xs text-zinc-400">select a project</div>
|
for (const f of s.staged) dirtyFiles.add(f.path);
|
||||||
);
|
for (const f of s.unstaged) dirtyFiles.add(f.path);
|
||||||
}
|
}
|
||||||
if (state.tree.length === 0) {
|
|
||||||
return <div className="p-2 text-xs text-zinc-400">no .typ files</div>;
|
const tree = buildTree(
|
||||||
|
state.tree.map((f) => f.path),
|
||||||
|
state.folders.map((f) => f.path),
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleOpen(file: string) {
|
||||||
|
dispatch({ type: "openFile", path: file });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRename(oldPath: string) {
|
||||||
|
if (submittingRef.current) return;
|
||||||
|
let newName = renameValue.trim();
|
||||||
|
if (!newName || newName.includes("/") || newName.includes("\\") || newName === "." || newName === "..") {
|
||||||
|
dispatch({ type: "pushNotice", kind: "error", message: "Invalid name" });
|
||||||
|
setRenaming(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Keep .typ files visible in the tree: require the extension to stay present.
|
||||||
|
if (oldPath.endsWith(".typ") && !newName.endsWith(".typ")) {
|
||||||
|
newName += ".typ";
|
||||||
|
}
|
||||||
|
if (newName === oldPath.split("/").pop()) { setRenaming(null); return; }
|
||||||
|
submittingRef.current = true;
|
||||||
|
const parts = oldPath.split("/");
|
||||||
|
parts[parts.length - 1] = newName;
|
||||||
|
const newPath = parts.join("/");
|
||||||
|
try {
|
||||||
|
await api.renameFile(state.currentProject!, oldPath, newPath);
|
||||||
|
dispatch({ type: "bumpTreeVersion" });
|
||||||
|
dispatch({ type: "bumpGitVersion" });
|
||||||
|
if (state.openFile === oldPath) dispatch({ type: "openFile", path: newPath });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: "pushNotice", kind: "error", message: String(e) });
|
||||||
|
} finally {
|
||||||
|
submittingRef.current = false;
|
||||||
|
}
|
||||||
|
setRenaming(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(file: string) {
|
||||||
|
try {
|
||||||
|
await api.deleteFile(state.currentProject!, file);
|
||||||
|
dispatch({ type: "bumpTreeVersion" });
|
||||||
|
dispatch({ type: "bumpGitVersion" });
|
||||||
|
if (state.openFile === file) dispatch({ type: "openFile", path: null });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: "pushNotice", kind: "error", message: String(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (submittingRef.current) return;
|
||||||
|
if (!creating || !createValue.trim()) {
|
||||||
|
setCreating(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submittingRef.current = true;
|
||||||
|
const name = createValue.trim();
|
||||||
|
const parentPath = creating.parent ? creating.parent + "/" : "";
|
||||||
|
const fullPath = parentPath + name;
|
||||||
|
let createdFilePath: string | null = null;
|
||||||
|
try {
|
||||||
|
if (creating.type === "folder") {
|
||||||
|
await api.createFolder(state.currentProject!, fullPath);
|
||||||
|
} else {
|
||||||
|
const fileName = name.endsWith(".typ") ? name : name + ".typ";
|
||||||
|
const filePath = parentPath + fileName;
|
||||||
|
await api.createFile(state.currentProject!, filePath);
|
||||||
|
createdFilePath = filePath;
|
||||||
|
}
|
||||||
|
dispatch({ type: "bumpTreeVersion" });
|
||||||
|
dispatch({ type: "bumpGitVersion" });
|
||||||
|
setCreating(null);
|
||||||
|
setCreateValue("");
|
||||||
|
if (createdFilePath) dispatch({ type: "openFile", path: createdFilePath });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: "pushNotice", kind: "error", message: String(e) });
|
||||||
|
} finally {
|
||||||
|
submittingRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRename(file: string) {
|
||||||
|
const parts = file.split("/");
|
||||||
|
setRenaming(file);
|
||||||
|
setRenameValue(parts[parts.length - 1]!);
|
||||||
|
setTimeout(() => inputRef.current?.select(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCreate(parent: string, type: "file" | "folder") {
|
||||||
|
setCreating({ parent, type });
|
||||||
|
setCreateValue("");
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNode(node: TreeNode, depth: number, parentPath: string) {
|
||||||
|
const fullPath = parentPath ? parentPath + "/" + node.name : node.name;
|
||||||
|
// Directory if it has children or is an explicitly-known empty folder
|
||||||
|
const isDir = (node.children.length > 0 && !node.file) || !!node.folder;
|
||||||
|
|
||||||
|
if (isDir) {
|
||||||
|
return (
|
||||||
|
<div key={fullPath} className="group">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 px-2 py-0.5 text-xs text-zinc-400 font-medium tracking-wide"
|
||||||
|
style={{ paddingLeft: `${8 + depth * 12}px` }}
|
||||||
|
>
|
||||||
|
<span>{node.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => startCreate(fullPath, "file")}
|
||||||
|
className="ml-1 text-zinc-300 hover:text-zinc-600 opacity-0 group-hover:opacity-100 p-0.5"
|
||||||
|
title="New file"
|
||||||
|
aria-label="New file"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
|
||||||
|
<path d="M8.75 1.75a.75.75 0 0 0-1.5 0v5.5h-5.5a.75.75 0 0 0 0 1.5h5.5v5.5a.75.75 0 0 0 1.5 0v-5.5h5.5a.75.75 0 0 0 0-1.5h-5.5v-5.5Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => startCreate(fullPath, "folder")}
|
||||||
|
className="text-zinc-300 hover:text-zinc-600 opacity-0 group-hover:opacity-100 p-0.5"
|
||||||
|
title="New folder"
|
||||||
|
aria-label="New folder"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
|
||||||
|
<path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{node.children.map((child) => renderNode(child, depth + 1, fullPath))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node.file) return null;
|
||||||
|
|
||||||
|
const isDirty = dirtyFiles.has(node.file);
|
||||||
|
const isActive = state.openFile === node.file;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={node.file} className="group flex items-center gap-1">
|
||||||
|
{renaming === node.file ? (
|
||||||
|
<form
|
||||||
|
className="flex-1 flex"
|
||||||
|
style={{ paddingLeft: `${8 + depth * 12}px` }}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleRename(node.file!);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
onBlur={() => void handleRename(node.file!)}
|
||||||
|
onKeyDown={(e) => e.key === "Escape" && setRenaming(null)}
|
||||||
|
className="flex-1 text-xs px-1 py-0.5 border border-emerald-400 rounded bg-white outline-none"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleOpen(node.file!)}
|
||||||
|
className={`flex-1 text-left text-sm px-2 py-1 rounded font-mono truncate ${
|
||||||
|
isActive
|
||||||
|
? "bg-emerald-50 text-emerald-800 font-medium"
|
||||||
|
: "hover:bg-stone-100 text-zinc-600"
|
||||||
|
}`}
|
||||||
|
style={{ paddingLeft: `${8 + depth * 12}px` }}
|
||||||
|
>
|
||||||
|
{node.name}
|
||||||
|
{isDirty && (
|
||||||
|
<span className="ml-1 text-amber-500" aria-label="modified">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" fill="currentColor" className="size-1.5">
|
||||||
|
<circle cx="4" cy="4" r="4" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!renaming && (
|
||||||
|
<span className="flex items-center gap-0.5 pr-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => startRename(node.file!)}
|
||||||
|
className="text-zinc-400 hover:text-zinc-600 p-0.5"
|
||||||
|
title="Rename"
|
||||||
|
aria-label="Rename"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
|
||||||
|
<path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064l6.286-6.286Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Delete "${node.file}"?`)) void handleDelete(node.file!);
|
||||||
|
}}
|
||||||
|
className="text-zinc-400 hover:text-rose-500 p-0.5"
|
||||||
|
title="Delete"
|
||||||
|
aria-label="Delete"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
|
||||||
|
<path fillRule="evenodd" d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.152A2 2 0 0 0 5.852 15.5h4.296a2 2 0 0 0 1.987-1.848l.815-8.152h.3a.75.75 0 0 0 0-1.5H11v-.75A1.75 1.75 0 0 0 9.25 1.75h-2.5A1.75 1.75 0 0 0 5 3.25Zm2.25-.75a.25.25 0 0 0-.25.25V4h2V2.75a.25.25 0 0 0-.25-.25h-2.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .787-.712Z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5 p-2">
|
<div className="flex flex-col gap-0.5 py-1">
|
||||||
<div className="text-xs uppercase tracking-wide text-zinc-400 mb-1">
|
{/* Create buttons at root */}
|
||||||
Files
|
{state.currentProject && (
|
||||||
</div>
|
<div className="flex items-center gap-1 px-3 py-1">
|
||||||
{state.tree.map((f) => (
|
<button
|
||||||
<button
|
type="button"
|
||||||
key={f.path}
|
onClick={() => startCreate("", "file")}
|
||||||
type="button"
|
className="text-xs text-zinc-400 hover:text-zinc-600 px-1 py-0.5"
|
||||||
onClick={() => dispatch({ type: "openFile", path: f.path })}
|
>
|
||||||
className={`text-left text-sm px-2 py-1 rounded font-mono ${
|
New file
|
||||||
f.path === state.openFile
|
</button>
|
||||||
? "bg-emerald-100 text-emerald-800 font-medium"
|
<button
|
||||||
: "hover:bg-zinc-100 text-zinc-600"
|
type="button"
|
||||||
}`}
|
onClick={() => startCreate("", "folder")}
|
||||||
|
className="text-xs text-zinc-400 hover:text-zinc-600 px-1 py-0.5"
|
||||||
|
>
|
||||||
|
New folder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<form
|
||||||
|
className="flex px-3 py-1"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleCreate();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{f.path}
|
<input
|
||||||
</button>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,19 @@ import { api } from "../api";
|
|||||||
import { useApp } from "../app-state";
|
import { useApp } from "../app-state";
|
||||||
import type { CommitInfo } from "../types";
|
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() {
|
export function GitPanel() {
|
||||||
const { state, dispatch } = useApp();
|
const { state, dispatch } = useApp();
|
||||||
const [commitMsg, setCommitMsg] = useState("");
|
const [commitMsg, setCommitMsg] = useState("");
|
||||||
@@ -16,14 +29,12 @@ export function GitPanel() {
|
|||||||
const [log, setLog] = useState<CommitInfo[]>([]);
|
const [log, setLog] = useState<CommitInfo[]>([]);
|
||||||
const [logOpen, setLogOpen] = useState(false);
|
const [logOpen, setLogOpen] = useState(false);
|
||||||
|
|
||||||
// Hooks must be called unconditionally (React Rules of Hooks)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.commitFocus > 0) {
|
if (state.commitFocus > 0) {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}
|
}
|
||||||
}, [state.commitFocus]);
|
}, [state.commitFocus]);
|
||||||
|
|
||||||
// Fetch commit log on git version change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state.currentProject) {
|
if (!state.currentProject) {
|
||||||
setLog([]);
|
setLog([]);
|
||||||
@@ -42,15 +53,18 @@ export function GitPanel() {
|
|||||||
const s = state.gitStatus;
|
const s = state.gitStatus;
|
||||||
if (!state.currentProject) return null;
|
if (!state.currentProject) return null;
|
||||||
|
|
||||||
|
const hasChanges = s && !s.clean;
|
||||||
|
const canCommit = commitMsg.trim() && hasChanges && !busy;
|
||||||
|
|
||||||
async function handleCommit() {
|
async function handleCommit() {
|
||||||
if (!commitMsg.trim() || busy || !state.currentProject) return;
|
if (!canCommit || !state.currentProject) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
let identity: { name?: string; email?: string } | undefined;
|
let identity: { name?: string; email?: string } | undefined;
|
||||||
try {
|
try {
|
||||||
const identityRaw = localStorage.getItem("typst-leaf.git-identity");
|
const raw = localStorage.getItem("typst-leaf.git-identity");
|
||||||
const parsed = identityRaw ? JSON.parse(identityRaw) : undefined;
|
const parsed = raw ? JSON.parse(raw) : undefined;
|
||||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||||
identity = {
|
identity = {
|
||||||
name: typeof parsed.name === "string" ? parsed.name : undefined,
|
name: typeof parsed.name === "string" ? parsed.name : undefined,
|
||||||
@@ -66,6 +80,7 @@ export function GitPanel() {
|
|||||||
} else {
|
} else {
|
||||||
setCommitMsg("");
|
setCommitMsg("");
|
||||||
dispatch({ type: "bumpGitVersion" });
|
dispatch({ type: "bumpGitVersion" });
|
||||||
|
dispatch({ type: "pushNotice", kind: "success", message: "Committed" });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
@@ -86,7 +101,10 @@ export function GitPanel() {
|
|||||||
}
|
}
|
||||||
const res = await api.gitPush(state.currentProject);
|
const res = await api.gitPush(state.currentProject);
|
||||||
if (!res.ok) setError(res.error ?? "push failed");
|
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) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -106,7 +124,10 @@ export function GitPanel() {
|
|||||||
}
|
}
|
||||||
const res = await api.gitPull(state.currentProject);
|
const res = await api.gitPull(state.currentProject);
|
||||||
if (!res.ok) setError(res.error ?? "pull failed");
|
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) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<div className="border-t border-zinc-200">
|
<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">
|
<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 && (
|
{s && (
|
||||||
<span className="ml-auto text-zinc-400 font-mono normal-case">
|
<span className="ml-auto text-zinc-400 font-mono normal-case">
|
||||||
{s.branch}
|
{s.branch}
|
||||||
{s.ahead > 0 && (
|
{s.ahead > 0 && <span className="text-emerald-600 ml-1">↑{s.ahead}</span>}
|
||||||
<span className="text-emerald-600 ml-1">↑{s.ahead}</span>
|
{s.behind > 0 && <span className="text-amber-600 ml-1">↓{s.behind}</span>}
|
||||||
)}
|
|
||||||
{s.behind > 0 && (
|
|
||||||
<span className="text-amber-600 ml-1">↓{s.behind}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-3 pb-1.5 text-xs text-zinc-500">
|
<div className="px-3 pb-1.5 text-xs text-zinc-500">
|
||||||
{s && !s.clean && (
|
{s && !s.clean && (
|
||||||
<span>
|
<span className="text-amber-600">
|
||||||
{s.unstaged.length + s.staged.length} uncommitted file
|
{s.unstaged.length + s.staged.length} uncommitted
|
||||||
{s.unstaged.length + s.staged.length !== 1 ? "s" : ""}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{s && s.clean && <span className="text-emerald-600">clean</span>}
|
{s && s.clean && <span className="text-emerald-600">clean</span>}
|
||||||
</div>
|
</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="px-3 pb-1.5 flex flex-col gap-1.5">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<input
|
<input
|
||||||
@@ -163,13 +216,14 @@ export function GitPanel() {
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") void handleCommit();
|
if (e.key === "Enter") void handleCommit();
|
||||||
}}
|
}}
|
||||||
placeholder="Commit message"
|
placeholder={hasChanges ? "Commit message" : "No changes to commit"}
|
||||||
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={!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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleCommit()}
|
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"
|
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"}
|
{busy ? "..." : "Commit"}
|
||||||
@@ -181,7 +235,7 @@ export function GitPanel() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handlePush()}
|
onClick={() => void handlePush()}
|
||||||
disabled={pushBusy}
|
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"}
|
{pushBusy ? "..." : "Push"}
|
||||||
</button>
|
</button>
|
||||||
@@ -189,7 +243,7 @@ export function GitPanel() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handlePull()}
|
onClick={() => void handlePull()}
|
||||||
disabled={pullBusy}
|
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"}
|
{pullBusy ? "..." : "Pull"}
|
||||||
</button>
|
</button>
|
||||||
@@ -216,7 +270,7 @@ export function GitPanel() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-xs text-red-500 break-words">{error}</div>
|
<div className="text-xs text-rose-600 break-words">{error}</div>
|
||||||
)}
|
)}
|
||||||
</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"
|
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>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>
|
</button>
|
||||||
{logOpen && (
|
{logOpen && (
|
||||||
<div className="max-h-32 overflow-y-auto px-3 pb-1.5 flex flex-col gap-1">
|
<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"
|
className="text-xs text-zinc-500 leading-tight"
|
||||||
title={`${c.sha} — ${c.author} on ${c.date}`}
|
title={`${c.sha} — ${c.author} on ${c.date}`}
|
||||||
>
|
>
|
||||||
<span className="font-mono text-zinc-400">
|
<span className="font-mono text-zinc-400">{c.sha.slice(0, 7)}</span>
|
||||||
{c.sha.slice(0, 7)}
|
|
||||||
</span>
|
|
||||||
{" "}
|
{" "}
|
||||||
<span className="text-zinc-600">{c.message}</span>
|
<span className="text-zinc-600">{c.message}</span>
|
||||||
</div>
|
</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";
|
import { useApp } from "../app-state";
|
||||||
|
|
||||||
export function Preview() {
|
export function Preview() {
|
||||||
const { state, lsp } = useApp();
|
const { state, lsp } = useApp();
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||||
|
|
||||||
let status = "idle";
|
const statusText = () => {
|
||||||
if (!state.currentProject) status = "no project";
|
if (!state.currentProject) return "no project";
|
||||||
else if (lsp.status !== "ready") status = `LSP ${lsp.status}`;
|
if (!state.openFile) return "open a file";
|
||||||
else if (!lsp.previewReady) status = "starting preview…";
|
if (lsp.status !== "ready") return `LSP ${lsp.status}`;
|
||||||
else status = "live";
|
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 (
|
return (
|
||||||
<div className="w-1/2 border-l border-zinc-200 flex flex-col bg-white">
|
<div className="h-full 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">
|
{/* Toolbar */}
|
||||||
<span>Preview</span>
|
<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="ml-auto text-zinc-400">{status}</span>
|
<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>
|
||||||
<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
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
key={state.currentProject}
|
key={state.currentProject}
|
||||||
src="/preview-app"
|
src="/preview-app"
|
||||||
title="Typst preview"
|
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">
|
<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 && (
|
{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}
|
{lsp.error}
|
||||||
</pre>
|
</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() {
|
export function SettingsPanel() {
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
const id = loadIdentity();
|
||||||
const id = loadIdentity();
|
setName(id.name);
|
||||||
setName(id.name ?? "");
|
setEmail(id.email);
|
||||||
setEmail(id.email ?? "");
|
setDirty(false);
|
||||||
setDirty(false);
|
}, []);
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const save = useCallback(() => {
|
const save = useCallback(() => {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ name, email }));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({ name, email }));
|
||||||
@@ -46,72 +43,59 @@ export function SettingsPanel() {
|
|||||||
}, [name, email]);
|
}, [name, email]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="px-3 py-2 flex flex-col gap-2">
|
||||||
<button
|
<div className="text-xs uppercase tracking-wide text-zinc-400">
|
||||||
type="button"
|
Git Identity
|
||||||
onClick={() => setOpen(!open)}
|
</div>
|
||||||
className="text-xs text-zinc-400 hover:text-zinc-600 px-1"
|
|
||||||
title="Settings"
|
|
||||||
>
|
|
||||||
{open ? "✕" : "⚙"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
<input
|
||||||
<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">
|
type="text"
|
||||||
<div className="text-xs uppercase tracking-wide text-zinc-400">
|
value={name}
|
||||||
Git Identity
|
onChange={(e) => {
|
||||||
</div>
|
setName(e.target.value);
|
||||||
|
setDirty(true);
|
||||||
|
}}
|
||||||
|
placeholder="Author name"
|
||||||
|
className="w-full px-2 py-1 text-xs border border-zinc-300 rounded bg-white text-zinc-800 placeholder-zinc-400 outline-none focus:border-emerald-500"
|
||||||
|
/>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="email"
|
||||||
value={name}
|
value={email}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setName(e.target.value);
|
setEmail(e.target.value);
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
}}
|
}}
|
||||||
placeholder="Author name"
|
placeholder="Author email"
|
||||||
className="w-full px-2 py-1 text-xs border border-zinc-300 rounded bg-white text-zinc-800 placeholder-zinc-400 outline-none focus:border-emerald-500"
|
className="w-full px-2 py-1 text-xs border border-zinc-300 rounded bg-white text-zinc-800 placeholder-zinc-400 outline-none focus:border-emerald-500"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<div className="flex gap-1">
|
||||||
type="email"
|
<button
|
||||||
value={email}
|
type="button"
|
||||||
onChange={(e) => {
|
onClick={save}
|
||||||
setEmail(e.target.value);
|
disabled={!dirty}
|
||||||
setDirty(true);
|
className="px-2 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40"
|
||||||
}}
|
>
|
||||||
placeholder="Author email"
|
Save
|
||||||
className="w-full px-2 py-1 text-xs border border-zinc-300 rounded bg-white text-zinc-800 placeholder-zinc-400 outline-none focus:border-emerald-500"
|
</button>
|
||||||
/>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
setName("");
|
||||||
|
setEmail("");
|
||||||
|
setDirty(false);
|
||||||
|
}}
|
||||||
|
className="px-2 py-1 text-xs rounded bg-white hover:bg-stone-100 text-zinc-500 border border-zinc-300"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1">
|
<div className="text-xs text-zinc-400">
|
||||||
<button
|
Used for Git commits. Stored locally in browser.
|
||||||
type="button"
|
</div>
|
||||||
onClick={save}
|
|
||||||
disabled={!dirty}
|
|
||||||
className="px-2 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
|
||||||
setName("");
|
|
||||||
setEmail("");
|
|
||||||
setDirty(false);
|
|
||||||
}}
|
|
||||||
className="px-2 py-1 text-xs rounded bg-white hover:bg-zinc-100 text-zinc-500 border border-zinc-300"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-xs text-zinc-400">
|
|
||||||
Used for Git commits. Stored locally in browser.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</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 type { Monaco } from "@monaco-editor/react";
|
||||||
|
import { normalizeFsPath } from "./path-utils";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Raw LSP client over WebSocket.
|
// Raw LSP client over WebSocket.
|
||||||
@@ -425,6 +426,15 @@ interface ShowDocumentParams {
|
|||||||
selection?: { start: { line: number; character: number }; end: { line: number; character: number } };
|
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(
|
export async function startLsp(
|
||||||
monaco: Monaco,
|
monaco: Monaco,
|
||||||
projectName: string,
|
projectName: string,
|
||||||
@@ -485,7 +495,13 @@ export async function startLsp(
|
|||||||
const p = params as ShowDocumentParams;
|
const p = params as ShowDocumentParams;
|
||||||
if (p.external) return { success: false };
|
if (p.external) return { success: false };
|
||||||
if (p.uri && onJumpToSource) {
|
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
|
const start = p.selection
|
||||||
? [p.selection.start.line, p.selection.start.character] as [number, number]
|
? [p.selection.start.line, p.selection.start.character] as [number, number]
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -502,18 +518,18 @@ export async function startLsp(
|
|||||||
client.onNotification("tinymist/preview/scrollSource", (params) => {
|
client.onNotification("tinymist/preview/scrollSource", (params) => {
|
||||||
const p = params as {
|
const p = params as {
|
||||||
filepath?: string;
|
filepath?: string;
|
||||||
start?: [number, number];
|
start?: unknown;
|
||||||
end?: [number, number];
|
end?: unknown;
|
||||||
};
|
};
|
||||||
if (!onJumpToSource) return;
|
if (!onJumpToSource) return;
|
||||||
if (typeof p.filepath !== "string") {
|
if (typeof p.filepath !== "string" || !p.filepath.trim()) {
|
||||||
console.warn("[lsp] tinymist/preview/scrollSource missing filepath", params);
|
console.warn("[lsp] tinymist/preview/scrollSource missing or empty filepath", params);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const filepath = p.filepath.includes("://")
|
const filepath = normalizeFsPath(p.filepath);
|
||||||
? decodeURIComponent(new URL(p.filepath).pathname)
|
const start = toPosPair(p.start);
|
||||||
: p.filepath.replaceAll("\\", "/");
|
const end = toPosPair(p.end);
|
||||||
onJumpToSource({ filepath, start: p.start, end: p.end });
|
onJumpToSource({ filepath, start, end });
|
||||||
});
|
});
|
||||||
|
|
||||||
// LSP handshake
|
// 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 {
|
export interface TreeResponse {
|
||||||
project: string;
|
project: string;
|
||||||
files: FileEntry[];
|
files: FileEntry[];
|
||||||
|
folders: { path: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileResponse {
|
export interface FileResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user