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

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

Highlights:

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

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

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

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

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

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

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

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

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

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

Verification:
  * pnpm -r typecheck passes.
  * pnpm --filter typst-leaf-frontend build passes.
This commit is contained in:
2026-07-02 02:05:04 +05:30
parent 7618476b60
commit 008dab1cf9
24 changed files with 1727 additions and 331 deletions
+9 -4
View File
@@ -1,7 +1,12 @@
# Tauri + React + Typescript
# typst-leaf-frontend
This template should help get you started developing with Tauri, React and Typescript in Vite.
React + Vite + Monaco Editor frontend for typst-leaf.
## Recommended IDE Setup
- **Port:** `1420` (mandated by Tauri; see `vite.config.ts`)
- **Dev:** `pnpm --filter typst-leaf-frontend dev`
- **Build:** `pnpm --filter typst-leaf-frontend build`
- **Tauri:** `pnpm --filter typst-leaf-frontend tauri dev`
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
## Editor
Monaco Editor with a raw JSON-RPC LSP client over WebSocket. Vim mode via `monaco-vim` (toggleable). Preview via tinymist SVG streaming.
+105 -29
View File
@@ -4,41 +4,117 @@ import { FileTree } from "./components/FileTree";
import { GitPanel } from "./components/GitPanel";
import { Preview } from "./components/Preview";
import { ProjectPicker } from "./components/ProjectPicker";
import { TopBar } from "./components/TopBar";
import { StatusBar } from "./components/StatusBar";
import { ToastHost } from "./components/ToastHost";
import { EmptyState } from "./components/EmptyState";
import { PanelSection } from "./components/PanelSection";
import { SettingsPanel } from "./components/SettingsPanel";
import { ResizableSplit } from "./components/ResizableSplit";
function Shell() {
const { state, lsp } = useApp();
const { state, dispatch } = useApp();
return (
<div className="h-screen w-screen bg-white text-zinc-800 flex">
<aside className="w-60 border-r border-zinc-200 flex flex-col bg-zinc-50">
<div className="px-3 py-2 border-b border-zinc-200 text-sm font-semibold flex items-center gap-2">
<span>typst-leaf</span>
<span className="ml-auto flex items-center gap-2 text-xs font-normal text-zinc-400">
<SettingsPanel />
{lsp.status === "ready"
? "●"
: lsp.status === "connecting"
? "◌"
: lsp.status === "error"
? "✕"
: "○"}
</span>
<div className="h-screen w-screen bg-stone-50 text-zinc-800 flex flex-col overflow-hidden">
<TopBar />
{state.error && (
<div className="bg-rose-50 text-rose-700 text-xs px-4 py-1.5 border-b border-rose-200 shrink-0">
{state.error}
</div>
<ProjectPicker />
<div className="flex-1 overflow-auto">
<FileTree />
</div>
<GitPanel />
{state.error && (
<div className="p-2 text-xs text-red-500 border-t border-zinc-200">
{state.error}
)}
<div className="flex flex-1 min-h-0">
{/* Sidebar */}
<aside className="w-64 border-r border-zinc-200 bg-stone-50 flex flex-col min-h-0 shrink-0">
<ProjectPicker />
{/* Sidebar tabs */}
<div className="flex border-b border-zinc-200 text-xs">
<button
type="button"
onClick={() => dispatch({ type: "setActivePanel", panel: "files" })}
className={`flex-1 py-1.5 text-center uppercase tracking-wider ${
state.activePanel === "files"
? "text-emerald-700 border-b-2 border-emerald-500 bg-white"
: "text-zinc-400 hover:text-zinc-600 hover:bg-stone-100"
}`}
>
Files
</button>
<button
type="button"
onClick={() => dispatch({ type: "setActivePanel", panel: "git" })}
className={`flex-1 py-1.5 text-center uppercase tracking-wider ${
state.activePanel === "git"
? "text-emerald-700 border-b-2 border-emerald-500 bg-white"
: "text-zinc-400 hover:text-zinc-600 hover:bg-stone-100"
}`}
>
Git
</button>
<button
type="button"
onClick={() => dispatch({ type: "setActivePanel", panel: "settings" })}
className={`flex-1 py-1.5 text-center uppercase tracking-wider ${
state.activePanel === "settings"
? "text-emerald-700 border-b-2 border-emerald-500 bg-white"
: "text-zinc-400 hover:text-zinc-600 hover:bg-stone-100"
}`}
>
Settings
</button>
</div>
)}
</aside>
<main className="flex-1 flex min-w-0">
<EditorView />
<Preview />
</main>
<div className="flex-1 overflow-auto">
{state.activePanel === "files" && (
<PanelSection title="Files">
{state.currentProject ? <FileTree /> : (
<div className="px-3 py-2 text-xs text-zinc-400">select a project</div>
)}
</PanelSection>
)}
{state.activePanel === "git" && <GitPanel />}
{state.activePanel === "settings" && <SettingsPanel />}
</div>
</aside>
{/* Main workspace */}
<main className="flex-1 flex min-w-0 bg-white">
{state.currentProject && state.openFile ? (
<ResizableSplit
left={<EditorView />}
right={<Preview />}
onResize={() => {
document.dispatchEvent(new CustomEvent("typst-leaf:layout"));
}}
/>
) : (
<div className="flex-1 flex min-h-0">
{!state.currentProject ? (
<div className="flex-1 flex">
<div className="flex-1">
<EmptyState message="Select a project to begin." hint="Use the sidebar to pick or create a project." />
</div>
</div>
) : !state.openFile ? (
<div className="flex-1 flex">
<div className="flex-1">
<EmptyState message="Open a file from the sidebar." hint=".typ files are listed under the Files section." />
</div>
<div className="w-96 border-l border-zinc-200">
<Preview />
</div>
</div>
) : null}
</div>
)}
</main>
</div>
<StatusBar />
<ToastHost />
</div>
);
}
+24
View File
@@ -62,6 +62,30 @@ export const api = {
body: JSON.stringify({ port }),
}).then((r) => jsonOrThrow<{ ok: boolean; port: number }>(r)),
// ---- File CRUD ----
createFile: (project: string, path: string, content?: string) =>
fetch(`${projectUrl(project)}/file`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ path, content }),
}).then((r) => jsonOrThrow<{ ok: boolean; path: string }>(r)),
deleteFile: (project: string, path: string) =>
fetch(`${projectUrl(project)}/file?path=${encodeURIComponent(path)}`, {
method: "DELETE",
}).then((r) => jsonOrThrow<{ ok: boolean }>(r)),
renameFile: (project: string, from: string, to: string) =>
fetch(`${projectUrl(project)}/file`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ from, to }),
}).then((r) => jsonOrThrow<{ ok: boolean }>(r)),
createFolder: (project: string, path: string) =>
fetch(`${projectUrl(project)}/folder`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ path }),
}).then((r) => jsonOrThrow<{ ok: boolean; path: string }>(r)),
// ---- Git ----
gitInit: (project: string) =>
fetch(`${gitUrl(project)}/init`, { method: "POST" }).then((r) =>
+80 -29
View File
@@ -10,23 +10,40 @@ import {
import { api } from "./api";
import { loadMonaco } from "./monaco-setup";
import { startLsp, startPreview, type RawLspClient } from "./lsp";
import { relativePathFromProject } from "./path-utils";
import type { Monaco } from "@monaco-editor/react";
import type { FileEntry, GitStatus } from "./types";
interface Notice {
id: number;
kind: "success" | "error" | "info";
message: string;
}
interface State {
projects: string[];
currentProject: string | null;
currentUri: string | null;
tree: FileEntry[];
folders: { path: string }[];
treeVersion: number;
openFile: string | null;
loading: boolean;
error: string | null;
fileDirty: boolean;
saving: boolean;
// sidebar
activePanel: "files" | "git" | "settings";
// git
gitStatus: GitStatus | null;
gitVersion: number;
commitFocus: number;
// vim
vimOn: boolean;
// sync
notice: Notice | null;
jumpTarget: {
id: number;
file: string;
start?: [number, number];
end?: [number, number];
@@ -36,14 +53,21 @@ interface State {
type Action =
| { type: "setProjects"; projects: string[] }
| { type: "selectProject"; project: string; uri: string }
| { type: "setTree"; tree: FileEntry[] }
| { type: "setTree"; tree: FileEntry[]; folders: { path: string }[] }
| { type: "bumpTreeVersion" }
| { type: "openFile"; path: string | null }
| { type: "loading"; loading: boolean }
| { type: "error"; error: string | null }
| { type: "setFileDirty"; dirty: boolean }
| { type: "setSaving"; saving: boolean }
| { type: "setActivePanel"; panel: State["activePanel"] }
| { type: "setGitStatus"; status: GitStatus }
| { type: "bumpGitVersion" }
| { type: "focusCommit" }
| { type: "setJumpTarget"; target: State["jumpTarget"] }
| { type: "pushNotice"; kind: Notice["kind"]; message: string }
| { type: "dismissNotice" }
| { type: "setVimOn"; on: boolean }
| { type: "setJumpTarget"; target: NonNullable<State["jumpTarget"]> }
| { type: "clearJumpTarget" };
const initial: State = {
@@ -51,12 +75,19 @@ const initial: State = {
currentProject: null,
currentUri: null,
tree: [],
folders: [],
treeVersion: 0,
openFile: null,
loading: false,
error: null,
fileDirty: false,
saving: false,
activePanel: "files",
gitStatus: null,
gitVersion: 0,
commitFocus: 0,
notice: null,
vimOn: false,
jumpTarget: null,
};
@@ -70,27 +101,45 @@ function reducer(state: State, action: Action): State {
currentProject: action.project,
currentUri: action.uri,
tree: [],
folders: [],
openFile: null,
gitStatus: null,
fileDirty: false,
saving: false,
error: null,
};
case "setTree":
return { ...state, tree: action.tree };
return { ...state, tree: action.tree, folders: action.folders };
case "bumpTreeVersion":
return { ...state, treeVersion: state.treeVersion + 1 };
case "openFile":
return { ...state, openFile: action.path };
return { ...state, openFile: action.path, fileDirty: false };
case "loading":
return { ...state, loading: action.loading };
case "error":
return { ...state, error: action.error };
case "setFileDirty":
return { ...state, fileDirty: action.dirty };
case "setSaving":
return { ...state, saving: action.saving };
case "setActivePanel":
return { ...state, activePanel: action.panel };
case "setGitStatus":
return { ...state, gitStatus: action.status };
case "bumpGitVersion":
return { ...state, gitVersion: state.gitVersion + 1 };
case "focusCommit":
return { ...state, commitFocus: state.commitFocus + 1 };
return { ...state, commitFocus: state.commitFocus + 1, activePanel: "git" };
case "pushNotice":
return { ...state, notice: { id: Date.now(), kind: action.kind, message: action.message } };
case "dismissNotice":
return { ...state, notice: null };
case "setJumpTarget":
return { ...state, jumpTarget: action.target };
case "clearJumpTarget":
return { ...state, jumpTarget: null };
case "setVimOn":
return { ...state, vimOn: action.on };
}
}
@@ -126,6 +175,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
const sessionRef = useRef<{ dispose: () => void } | null>(null);
const openFileRef = useRef<string | null>(null);
const uriRef = useRef<string | null>(null);
const jumpIdRef = useRef(0);
useEffect(() => {
loadMonaco()
@@ -142,6 +192,15 @@ export function AppProvider({ children }: { children: ReactNode }) {
.catch((e) => dispatch({ type: "error", error: String(e) }));
}, []);
useEffect(() => {
try {
const v = localStorage.getItem("typst-leaf.vim") === "true";
dispatch({ type: "setVimOn", on: v });
} catch {
dispatch({ type: "setVimOn", on: false });
}
}, []);
useEffect(() => {
if (!state.currentProject || !lsp.monaco) return;
const sessionRefLocal = sessionRef;
@@ -150,22 +209,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
const uri = state.currentUri!;
setLsp((s) => ({ ...s, status: "connecting", previewReady: false }));
// Normalize both URIs and native paths to a consistent forward-slash
// pathname. Windows drive-letter paths (file:///C:/... → /C:/...) get
// the leading "/" stripped so they match native paths (C:/...).
const normalizePath = (p: string): string => {
let result: string;
if (p.includes("://")) {
result = decodeURIComponent(new URL(p).pathname);
} else {
result = p.replaceAll("\\", "/");
}
if (/^\/[A-Za-z]:\//.test(result) || /^\/[A-Za-z]:$/.test(result)) {
result = result.slice(1);
}
return result;
};
const onJumpToSource = (info: {
filepath: string;
start?: [number, number];
@@ -173,19 +216,24 @@ export function AppProvider({ children }: { children: ReactNode }) {
}) => {
const curUri = uriRef.current ?? uri;
const curOpenFile = openFileRef.current;
const filepath = normalizePath(info.filepath);
const projectPath = normalizePath(curUri);
const sep = projectPath.endsWith("/") ? "" : "/";
if (!filepath.startsWith(projectPath + sep)) {
const relPath = relativePathFromProject(curUri, info.filepath);
if (!relPath) {
dispatch({
type: "pushNotice",
kind: "info",
message: "Preview source is outside this project",
});
return;
}
const relPath = filepath.slice(projectPath.length + 1);
const id = ++jumpIdRef.current;
if (relPath !== curOpenFile) {
dispatch({ type: "openFile", path: relPath });
}
dispatch({
type: "setJumpTarget",
target: { file: relPath, start: info.start, end: info.end },
target: { id, file: relPath, start: info.start, end: info.end },
});
};
@@ -235,9 +283,12 @@ export function AppProvider({ children }: { children: ReactNode }) {
if (!state.currentProject) return;
api
.getTree(state.currentProject)
.then((t) => dispatch({ type: "setTree", tree: t.files }))
.then((t) => {
dispatch({ type: "setTree", tree: t.files, folders: t.folders ?? [] });
if (state.error) dispatch({ type: "error", error: null });
})
.catch((e) => dispatch({ type: "error", error: String(e) }));
}, [state.currentProject]);
}, [state.currentProject, state.treeVersion]);
// Keep refs current for the LSP callback closure
useEffect(() => {
+68 -56
View File
@@ -2,6 +2,7 @@ import Editor, { type Monaco, type OnMount } from "@monaco-editor/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../api";
import { useApp } from "../app-state";
import { encodeVirtualPathForFileUri } from "../path-utils";
type ITextModel = Monaco["editor"]["ITextModel"];
type IStandaloneCodeEditor = Monaco["editor"]["IStandaloneCodeEditor"];
@@ -16,28 +17,25 @@ export function EditorView() {
const uri = state.currentUri;
const file = state.openFile;
const savingRef = useRef(false);
const mountedRef = useRef(true);
// Vim mode
const [vimOn, setVimOn] = useState(
() => localStorage.getItem("typst-leaf.vim") === "true",
);
const [editorReady, setEditorReady] = useState(false);
const vimRef = useRef<{ dispose: () => void } | null>(null);
const vimStatusRef = useRef<HTMLDivElement | null>(null);
// Toggle vim
const toggleVim = useCallback(() => {
setVimOn((prev) => {
const next = !prev;
localStorage.setItem("typst-leaf.vim", String(next));
return next;
});
}, []);
const next = !state.vimOn;
localStorage.setItem("typst-leaf.vim", String(next));
dispatch({ type: "setVimOn", on: next });
}, [state.vimOn, dispatch]);
// Init / dispose vim mode when editor or vimOn changes
useEffect(() => {
if (!editorReady) return;
if (vimOn && !vimRef.current) {
if (state.vimOn && !vimRef.current) {
let cancelled = false;
import("monaco-vim").then(({ initVimMode }) => {
if (cancelled) return;
@@ -46,24 +44,39 @@ export function EditorView() {
vimRef.current = initVimMode(editorRef.current, statusNode);
});
return () => { cancelled = true; };
} else if (!vimOn && vimRef.current) {
} else if (!state.vimOn && vimRef.current) {
vimRef.current.dispose();
vimRef.current = null;
if (vimStatusRef.current) {
vimStatusRef.current.textContent = "";
}
}
}, [vimOn, editorReady]);
}, [state.vimOn, editorReady]);
// Keep a ref to the current model so the unmount cleanup can dispose it
// without depending on stale `model` state captured in a closure.
const modelRef = useRef<ITextModel | null>(null);
// Cleanup vim on unmount. Resetting editorReady ensures the vim
// effect re-fires after a StrictMode remount (Monaco creates a new
// editor instance, handleMount sets editorReady=true again, and the
// effect re-initializes vim on the new editor).
// Also dispose the Monaco model so LSP sees didClose.
// Mark mountedRef=true at setup so it survives StrictMode's simulated
// unmount/remount cycle (effects run setup → cleanup → setup again;
// without re-setting true the cleanup would leave the ref stuck at false).
useEffect(() => {
mountedRef.current = true;
return () => {
vimRef.current?.dispose();
vimRef.current = null;
setEditorReady(false);
mountedRef.current = false;
const m = modelRef.current;
if (m) {
m.dispose();
modelRef.current = null;
}
};
}, []);
@@ -74,6 +87,7 @@ export function EditorView() {
m?.dispose();
return null;
});
modelRef.current = null;
return;
}
let cancelled = false;
@@ -83,7 +97,7 @@ export function EditorView() {
.then((f) => {
if (cancelled) return;
const base = uri.endsWith("/") ? uri : uri + "/";
const docUri = base + file.split("/").map(encodeURIComponent).join("/");
const docUri = base + encodeVirtualPathForFileUri(file);
const uriObj = monaco.Uri.parse(docUri);
monaco.editor.getModel(uriObj)?.dispose();
const next = monaco.editor.createModel(f.content, "typst", uriObj);
@@ -91,6 +105,7 @@ export function EditorView() {
prev?.dispose();
return next;
});
modelRef.current = next;
})
.catch((e) => !cancelled && setError(String(e)));
return () => {
@@ -107,13 +122,21 @@ export function EditorView() {
async function save() {
if (!project || !file || !model || savingRef.current) return;
savingRef.current = true;
dispatch({ type: "setSaving", saving: true });
try {
await api.putFile(project, file, model.getValue());
// Bump git version so GitPanel refreshes
dispatch({ type: "bumpGitVersion" });
if (mountedRef.current) {
dispatch({ type: "bumpGitVersion" });
dispatch({ type: "setFileDirty", dirty: false });
dispatch({ type: "pushNotice", kind: "success", message: "Saved" });
}
} catch (e) {
setError(String(e));
if (mountedRef.current) {
setError(String(e));
dispatch({ type: "pushNotice", kind: "error", message: String(e) });
}
} finally {
if (mountedRef.current) dispatch({ type: "setSaving", saving: false });
savingRef.current = false;
}
}
@@ -142,6 +165,20 @@ export function EditorView() {
);
};
// Global save listener for TopBar button
useEffect(() => {
const handler = () => { void saveRef.current(); };
document.addEventListener("typst-leaf:save", handler);
return () => document.removeEventListener("typst-leaf:save", handler);
}, []);
// Global layout listener for ResizableSplit drag
useEffect(() => {
const handler = () => { editorRef.current?.layout(); };
document.addEventListener("typst-leaf:layout", handler);
return () => document.removeEventListener("typst-leaf:layout", handler);
}, []);
// Resize observer keeps Monaco laid out when the flex container changes
// size (sidebar toggles, window resize, etc.). Without this, revealRange
// and cursor positioning can be off after a layout change.
@@ -157,27 +194,21 @@ export function EditorView() {
}, [editorReady]);
// Jump target effect — reactive (no polling). Track the last handled
// target by object identity so rapid clicks don't get swallowed by a stale
// boolean flag, and call editor.layout() before reveal so container
// resizes don't break the scroll.
const lastHandledTargetRef = useRef<{
file: string;
start?: [number, number];
end?: [number, number];
} | null>(null);
// jump by its numeric id so rapid clicks are not swallowed by a stale
// ref, and call editor.layout() before reveal so container resizes
// don't break scroll position.
const lastHandledJumpId = useRef<number | null>(null);
useEffect(() => {
if (!state.jumpTarget || !editorRef.current || !monaco || !model) return;
if (state.jumpTarget === lastHandledTargetRef.current) return;
if (state.jumpTarget.id === lastHandledJumpId.current) return;
const target = state.jumpTarget;
const modelUri = model.uri.toString();
const base = uri?.endsWith("/") ? uri : uri + "/";
const expectedModelUri = target.file
? base + target.file.split("/").map(encodeURIComponent).join("/")
: null;
if (!uri || !target.file) return;
const base = uri.endsWith("/") ? uri : uri + "/";
const expectedUri = monaco.Uri.parse(base + encodeVirtualPathForFileUri(target.file));
if (!expectedModelUri || modelUri !== expectedModelUri) return;
lastHandledTargetRef.current = target;
if (!monaco.Uri.equals(model.uri, expectedUri)) return;
lastHandledJumpId.current = target.id;
let selection: Monaco["Selection"] | null = null;
if (target.start && target.end) {
@@ -206,20 +237,6 @@ export function EditorView() {
dispatch({ type: "clearJumpTarget" });
}, [state.jumpTarget, model, monaco, uri]);
if (!project) {
return (
<div className="flex-1 flex items-center justify-center text-zinc-400 text-sm">
Select a project to begin.
</div>
);
}
if (!file) {
return (
<div className="flex-1 flex items-center justify-center text-zinc-400 text-sm">
Open a file from the sidebar.
</div>
);
}
if (error) {
return (
<div className="flex-1 flex items-center justify-center text-red-400 text-sm p-4">
@@ -229,7 +246,7 @@ export function EditorView() {
}
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="h-full flex flex-col min-h-0">
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-zinc-200 text-xs text-zinc-500 font-mono">
<span className="truncate max-w-48">{file}</span>
<span className="ml-auto flex items-center gap-2">
@@ -241,20 +258,13 @@ export function EditorView() {
type="button"
onClick={toggleVim}
className={`px-2 py-0.5 rounded border text-xs ${
vimOn
state.vimOn
? "bg-emerald-100 border-emerald-300 text-emerald-700"
: "bg-white hover:bg-zinc-100 text-zinc-700 border-zinc-300"
}`}
>
Vim
</button>
<button
type="button"
onClick={() => void save()}
className="bg-white hover:bg-zinc-100 text-zinc-700 border border-zinc-300 px-2 py-0.5 rounded"
>
Save (Ctrl+S)
</button>
</span>
</div>
<div ref={editorContainerRef} className="flex-1 min-h-0">
@@ -262,11 +272,13 @@ export function EditorView() {
defaultLanguage="typst"
theme="vs"
onMount={handleMount}
onChange={() => {
if (!state.fileDirty) dispatch({ type: "setFileDirty", dirty: true });
}}
options={{
fontSize: 14,
minimap: { enabled: false },
wordWrap: "on",
automaticLayout: true,
tabSize: 2,
}}
/>
@@ -0,0 +1,16 @@
export function EmptyState({
message,
hint,
}: {
message: string;
hint?: string;
}) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-zinc-400 gap-1 p-4 text-center">
<div className="text-sm">{message}</div>
{hint && (
<div className="text-xs text-zinc-500">{hint}</div>
)}
</div>
);
}
+324 -23
View File
@@ -1,36 +1,337 @@
import { useRef, useState } from "react";
import { api } from "../api";
import { useApp } from "../app-state";
interface TreeNode {
name: string;
children: TreeNode[];
file?: string; // present for leaf nodes → virtual path
folder?: string; // present for empty directory nodes → virtual path
}
function buildTree(filePaths: string[], folderPaths: string[]): TreeNode[] {
const root: TreeNode[] = [];
function ensure(parts: string[], isLeaf: boolean, fullPath: string) {
let level = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i]!;
const isLast = i === parts.length - 1;
let node = level.find((n) => n.name === part);
if (!node) {
node = { name: part, children: [] };
level.push(node);
}
if (isLast) {
if (isLeaf) node.file = fullPath;
else node.folder = fullPath;
}
level = node.children;
}
}
for (const p of filePaths) ensure(p.split("/"), true, p);
for (const p of folderPaths) {
const parts = p.split("/");
// Only create a node if the folder is not already represented as an
// ancestor of a known file (those are inferred directory nodes).
let exists = false;
let level = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i]!;
const isLast = i === parts.length - 1;
const node = level.find((n) => n.name === part);
if (!node) {
exists = false;
break;
}
if (isLast) { exists = true; break; }
level = node.children;
}
if (!exists) ensure(parts, false, p);
}
return root;
}
export function FileTree() {
const { state, dispatch } = useApp();
const s = state.gitStatus;
const [renaming, setRenaming] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [creating, setCreating] = useState<{
parent: string;
type: "file" | "folder";
} | null>(null);
const [createValue, setCreateValue] = useState("");
const inputRef = useRef<HTMLInputElement | null>(null);
// Guard against double-submit (Enter + blur both call the handler).
const submittingRef = useRef(false);
if (!state.currentProject) {
return (
<div className="p-2 text-xs text-zinc-400">select a project</div>
);
const dirtyFiles = new Set<string>();
if (s) {
for (const f of s.staged) dirtyFiles.add(f.path);
for (const f of s.unstaged) dirtyFiles.add(f.path);
}
if (state.tree.length === 0) {
return <div className="p-2 text-xs text-zinc-400">no .typ files</div>;
const tree = buildTree(
state.tree.map((f) => f.path),
state.folders.map((f) => f.path),
);
function handleOpen(file: string) {
dispatch({ type: "openFile", path: file });
}
async function handleRename(oldPath: string) {
if (submittingRef.current) return;
let newName = renameValue.trim();
if (!newName || newName.includes("/") || newName.includes("\\") || newName === "." || newName === "..") {
dispatch({ type: "pushNotice", kind: "error", message: "Invalid name" });
setRenaming(null);
return;
}
// Keep .typ files visible in the tree: require the extension to stay present.
if (oldPath.endsWith(".typ") && !newName.endsWith(".typ")) {
newName += ".typ";
}
if (newName === oldPath.split("/").pop()) { setRenaming(null); return; }
submittingRef.current = true;
const parts = oldPath.split("/");
parts[parts.length - 1] = newName;
const newPath = parts.join("/");
try {
await api.renameFile(state.currentProject!, oldPath, newPath);
dispatch({ type: "bumpTreeVersion" });
dispatch({ type: "bumpGitVersion" });
if (state.openFile === oldPath) dispatch({ type: "openFile", path: newPath });
} catch (e) {
dispatch({ type: "pushNotice", kind: "error", message: String(e) });
} finally {
submittingRef.current = false;
}
setRenaming(null);
}
async function handleDelete(file: string) {
try {
await api.deleteFile(state.currentProject!, file);
dispatch({ type: "bumpTreeVersion" });
dispatch({ type: "bumpGitVersion" });
if (state.openFile === file) dispatch({ type: "openFile", path: null });
} catch (e) {
dispatch({ type: "pushNotice", kind: "error", message: String(e) });
}
}
async function handleCreate() {
if (submittingRef.current) return;
if (!creating || !createValue.trim()) {
setCreating(null);
return;
}
submittingRef.current = true;
const name = createValue.trim();
const parentPath = creating.parent ? creating.parent + "/" : "";
const fullPath = parentPath + name;
let createdFilePath: string | null = null;
try {
if (creating.type === "folder") {
await api.createFolder(state.currentProject!, fullPath);
} else {
const fileName = name.endsWith(".typ") ? name : name + ".typ";
const filePath = parentPath + fileName;
await api.createFile(state.currentProject!, filePath);
createdFilePath = filePath;
}
dispatch({ type: "bumpTreeVersion" });
dispatch({ type: "bumpGitVersion" });
setCreating(null);
setCreateValue("");
if (createdFilePath) dispatch({ type: "openFile", path: createdFilePath });
} catch (e) {
dispatch({ type: "pushNotice", kind: "error", message: String(e) });
} finally {
submittingRef.current = false;
}
}
function startRename(file: string) {
const parts = file.split("/");
setRenaming(file);
setRenameValue(parts[parts.length - 1]!);
setTimeout(() => inputRef.current?.select(), 0);
}
function startCreate(parent: string, type: "file" | "folder") {
setCreating({ parent, type });
setCreateValue("");
setTimeout(() => inputRef.current?.focus(), 0);
}
function renderNode(node: TreeNode, depth: number, parentPath: string) {
const fullPath = parentPath ? parentPath + "/" + node.name : node.name;
// Directory if it has children or is an explicitly-known empty folder
const isDir = (node.children.length > 0 && !node.file) || !!node.folder;
if (isDir) {
return (
<div key={fullPath} className="group">
<div
className="flex items-center gap-1 px-2 py-0.5 text-xs text-zinc-400 font-medium tracking-wide"
style={{ paddingLeft: `${8 + depth * 12}px` }}
>
<span>{node.name}</span>
<button
type="button"
onClick={() => startCreate(fullPath, "file")}
className="ml-1 text-zinc-300 hover:text-zinc-600 opacity-0 group-hover:opacity-100 p-0.5"
title="New file"
aria-label="New file"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
<path d="M8.75 1.75a.75.75 0 0 0-1.5 0v5.5h-5.5a.75.75 0 0 0 0 1.5h5.5v5.5a.75.75 0 0 0 1.5 0v-5.5h5.5a.75.75 0 0 0 0-1.5h-5.5v-5.5Z" />
</svg>
</button>
<button
type="button"
onClick={() => startCreate(fullPath, "folder")}
className="text-zinc-300 hover:text-zinc-600 opacity-0 group-hover:opacity-100 p-0.5"
title="New folder"
aria-label="New folder"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
<path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z" />
</svg>
</button>
</div>
{node.children.map((child) => renderNode(child, depth + 1, fullPath))}
</div>
);
}
if (!node.file) return null;
const isDirty = dirtyFiles.has(node.file);
const isActive = state.openFile === node.file;
return (
<div key={node.file} className="group flex items-center gap-1">
{renaming === node.file ? (
<form
className="flex-1 flex"
style={{ paddingLeft: `${8 + depth * 12}px` }}
onSubmit={(e) => {
e.preventDefault();
void handleRename(node.file!);
}}
>
<input
ref={inputRef}
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => void handleRename(node.file!)}
onKeyDown={(e) => e.key === "Escape" && setRenaming(null)}
className="flex-1 text-xs px-1 py-0.5 border border-emerald-400 rounded bg-white outline-none"
autoFocus
/>
</form>
) : (
<button
type="button"
onClick={() => handleOpen(node.file!)}
className={`flex-1 text-left text-sm px-2 py-1 rounded font-mono truncate ${
isActive
? "bg-emerald-50 text-emerald-800 font-medium"
: "hover:bg-stone-100 text-zinc-600"
}`}
style={{ paddingLeft: `${8 + depth * 12}px` }}
>
{node.name}
{isDirty && (
<span className="ml-1 text-amber-500" aria-label="modified">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" fill="currentColor" className="size-1.5">
<circle cx="4" cy="4" r="4" />
</svg>
</span>
)}
</button>
)}
{!renaming && (
<span className="flex items-center gap-0.5 pr-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => startRename(node.file!)}
className="text-zinc-400 hover:text-zinc-600 p-0.5"
title="Rename"
aria-label="Rename"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
<path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064l6.286-6.286Z" />
</svg>
</button>
<button
type="button"
onClick={() => {
if (window.confirm(`Delete "${node.file}"?`)) void handleDelete(node.file!);
}}
className="text-zinc-400 hover:text-rose-500 p-0.5"
title="Delete"
aria-label="Delete"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
<path fillRule="evenodd" d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.152A2 2 0 0 0 5.852 15.5h4.296a2 2 0 0 0 1.987-1.848l.815-8.152h.3a.75.75 0 0 0 0-1.5H11v-.75A1.75 1.75 0 0 0 9.25 1.75h-2.5A1.75 1.75 0 0 0 5 3.25Zm2.25-.75a.25.25 0 0 0-.25.25V4h2V2.75a.25.25 0 0 0-.25-.25h-2.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .787-.712Z" clipRule="evenodd" />
</svg>
</button>
</span>
)}
</div>
);
}
return (
<div className="flex flex-col gap-0.5 p-2">
<div className="text-xs uppercase tracking-wide text-zinc-400 mb-1">
Files
</div>
{state.tree.map((f) => (
<button
key={f.path}
type="button"
onClick={() => dispatch({ type: "openFile", path: f.path })}
className={`text-left text-sm px-2 py-1 rounded font-mono ${
f.path === state.openFile
? "bg-emerald-100 text-emerald-800 font-medium"
: "hover:bg-zinc-100 text-zinc-600"
}`}
<div className="flex flex-col gap-0.5 py-1">
{/* Create buttons at root */}
{state.currentProject && (
<div className="flex items-center gap-1 px-3 py-1">
<button
type="button"
onClick={() => startCreate("", "file")}
className="text-xs text-zinc-400 hover:text-zinc-600 px-1 py-0.5"
>
New file
</button>
<button
type="button"
onClick={() => startCreate("", "folder")}
className="text-xs text-zinc-400 hover:text-zinc-600 px-1 py-0.5"
>
New folder
</button>
</div>
)}
{creating && (
<form
className="flex px-3 py-1"
onSubmit={(e) => {
e.preventDefault();
void handleCreate();
}}
>
{f.path}
</button>
))}
<input
ref={inputRef}
type="text"
value={createValue}
onChange={(e) => setCreateValue(e.target.value)}
onBlur={() => void handleCreate()}
onKeyDown={(e) => e.key === "Escape" && setCreating(null)}
placeholder={creating.type === "file" ? "file.typ" : "folder-name"}
className="flex-1 text-xs px-1 py-0.5 border border-emerald-400 rounded bg-white outline-none"
autoFocus
/>
</form>
)}
{tree.map((node) => renderNode(node, 0, ""))}
</div>
);
}
+88 -26
View File
@@ -3,6 +3,19 @@ import { api } from "../api";
import { useApp } from "../app-state";
import type { CommitInfo } from "../types";
const statusIcon: Record<string, string> = {
A: "+",
M: "~",
D: "",
"?": "?",
};
const statusColor: Record<string, string> = {
staged: "text-emerald-600",
unstaged: "text-amber-600",
untracked: "text-zinc-400",
};
export function GitPanel() {
const { state, dispatch } = useApp();
const [commitMsg, setCommitMsg] = useState("");
@@ -16,14 +29,12 @@ export function GitPanel() {
const [log, setLog] = useState<CommitInfo[]>([]);
const [logOpen, setLogOpen] = useState(false);
// Hooks must be called unconditionally (React Rules of Hooks)
useEffect(() => {
if (state.commitFocus > 0) {
inputRef.current?.focus();
}
}, [state.commitFocus]);
// Fetch commit log on git version change
useEffect(() => {
if (!state.currentProject) {
setLog([]);
@@ -42,15 +53,18 @@ export function GitPanel() {
const s = state.gitStatus;
if (!state.currentProject) return null;
const hasChanges = s && !s.clean;
const canCommit = commitMsg.trim() && hasChanges && !busy;
async function handleCommit() {
if (!commitMsg.trim() || busy || !state.currentProject) return;
if (!canCommit || !state.currentProject) return;
setBusy(true);
setError(null);
try {
let identity: { name?: string; email?: string } | undefined;
try {
const identityRaw = localStorage.getItem("typst-leaf.git-identity");
const parsed = identityRaw ? JSON.parse(identityRaw) : undefined;
const raw = localStorage.getItem("typst-leaf.git-identity");
const parsed = raw ? JSON.parse(raw) : undefined;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
identity = {
name: typeof parsed.name === "string" ? parsed.name : undefined,
@@ -66,6 +80,7 @@ export function GitPanel() {
} else {
setCommitMsg("");
dispatch({ type: "bumpGitVersion" });
dispatch({ type: "pushNotice", kind: "success", message: "Committed" });
}
} catch (e) {
setError(String(e));
@@ -86,7 +101,10 @@ export function GitPanel() {
}
const res = await api.gitPush(state.currentProject);
if (!res.ok) setError(res.error ?? "push failed");
else dispatch({ type: "bumpGitVersion" });
else {
dispatch({ type: "bumpGitVersion" });
dispatch({ type: "pushNotice", kind: "success", message: "Pushed" });
}
} catch (e) {
setError(String(e));
} finally {
@@ -106,7 +124,10 @@ export function GitPanel() {
}
const res = await api.gitPull(state.currentProject);
if (!res.ok) setError(res.error ?? "pull failed");
else dispatch({ type: "bumpGitVersion" });
else {
dispatch({ type: "bumpGitVersion" });
dispatch({ type: "pushNotice", kind: "success", message: "Pulled latest" });
}
} catch (e) {
setError(String(e));
} finally {
@@ -126,6 +147,24 @@ export function GitPanel() {
}
}
function renderFileRow(
f: { path: string; status: string },
group: "staged" | "unstaged" | "untracked",
) {
return (
<div
key={f.path}
className="flex items-center gap-1 text-xs font-mono cursor-pointer hover:bg-stone-100 px-1 py-0.5 rounded"
onClick={() => dispatch({ type: "openFile", path: f.path })}
>
<span className={`w-3 text-center ${statusColor[group]}`}>
{statusIcon[f.status] ?? "?"}
</span>
<span className="truncate text-zinc-600">{f.path}</span>
</div>
);
}
return (
<div className="border-t border-zinc-200">
<div className="px-3 py-1.5 text-xs uppercase tracking-wide text-zinc-400 flex items-center gap-1">
@@ -133,26 +172,40 @@ export function GitPanel() {
{s && (
<span className="ml-auto text-zinc-400 font-mono normal-case">
{s.branch}
{s.ahead > 0 && (
<span className="text-emerald-600 ml-1">{s.ahead}</span>
)}
{s.behind > 0 && (
<span className="text-amber-600 ml-1">{s.behind}</span>
)}
{s.ahead > 0 && <span className="text-emerald-600 ml-1">{s.ahead}</span>}
{s.behind > 0 && <span className="text-amber-600 ml-1">{s.behind}</span>}
</span>
)}
</div>
<div className="px-3 pb-1.5 text-xs text-zinc-500">
{s && !s.clean && (
<span>
{s.unstaged.length + s.staged.length} uncommitted file
{s.unstaged.length + s.staged.length !== 1 ? "s" : ""}
<span className="text-amber-600">
{s.unstaged.length + s.staged.length} uncommitted
</span>
)}
{s && s.clean && <span className="text-emerald-600">clean</span>}
</div>
{/* Changed files — dedupe by path; unstaged wins for paths in both
buckets (a file can be both staged and unstaged for different
parts, but unstaged is what the user still needs to act on). */}
{s && !s.clean && (() => {
const byPath = new Map<string, { path: string; status: string; group: "staged" | "unstaged" }>();
for (const f of s.unstaged) byPath.set(f.path, { ...f, group: "unstaged" });
for (const f of s.staged) if (!byPath.has(f.path)) byPath.set(f.path, { ...f, group: "staged" });
const rows = Array.from(byPath.values());
return (
<div className="px-3 pb-1">
<div className="text-[10px] uppercase tracking-wider text-zinc-400 mb-0.5">
Changed files
</div>
{rows.map((f) => renderFileRow(f, f.group))}
</div>
);
})()}
{/* Commit compose */}
<div className="px-3 pb-1.5 flex flex-col gap-1.5">
<div className="flex gap-1">
<input
@@ -163,13 +216,14 @@ export function GitPanel() {
onKeyDown={(e) => {
if (e.key === "Enter") void handleCommit();
}}
placeholder="Commit message"
className="flex-1 min-w-0 px-2 py-1 text-xs border border-zinc-300 rounded bg-white text-zinc-800 placeholder-zinc-400 outline-none focus:border-emerald-500"
placeholder={hasChanges ? "Commit message" : "No changes to commit"}
disabled={!hasChanges}
className="flex-1 min-w-0 px-2 py-1 text-xs border border-zinc-300 rounded bg-white text-zinc-800 placeholder-zinc-400 outline-none focus:border-emerald-500 disabled:opacity-40 disabled:cursor-not-allowed"
/>
<button
type="button"
onClick={() => void handleCommit()}
disabled={!commitMsg.trim() || busy}
disabled={!canCommit}
className="px-2 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed"
>
{busy ? "..." : "Commit"}
@@ -181,7 +235,7 @@ export function GitPanel() {
type="button"
onClick={() => void handlePush()}
disabled={pushBusy}
className="flex-1 px-2 py-1 text-xs rounded bg-white hover:bg-zinc-100 text-zinc-700 border border-zinc-300 disabled:opacity-40"
className="flex-1 px-2 py-1 text-xs rounded bg-white hover:bg-stone-100 text-zinc-700 border border-zinc-300 disabled:opacity-40"
>
{pushBusy ? "..." : "Push"}
</button>
@@ -189,7 +243,7 @@ export function GitPanel() {
type="button"
onClick={() => void handlePull()}
disabled={pullBusy}
className="flex-1 px-2 py-1 text-xs rounded bg-white hover:bg-zinc-100 text-zinc-700 border border-zinc-300 disabled:opacity-40"
className="flex-1 px-2 py-1 text-xs rounded bg-white hover:bg-stone-100 text-zinc-700 border border-zinc-300 disabled:opacity-40"
>
{pullBusy ? "..." : "Pull"}
</button>
@@ -216,7 +270,7 @@ export function GitPanel() {
)}
{error && (
<div className="text-xs text-red-500 break-words">{error}</div>
<div className="text-xs text-rose-600 break-words">{error}</div>
)}
</div>
@@ -229,7 +283,17 @@ export function GitPanel() {
className="w-full flex items-center gap-1 px-3 py-1 text-xs text-zinc-400 hover:text-zinc-600 uppercase tracking-wide"
>
<span>Log</span>
<span className="ml-auto">{logOpen ? "▾" : "▸"}</span>
<span className="ml-auto">
{logOpen ? (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
<path d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
<path d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 0 1 0-1.06Z" />
</svg>
)}
</span>
</button>
{logOpen && (
<div className="max-h-32 overflow-y-auto px-3 pb-1.5 flex flex-col gap-1">
@@ -239,9 +303,7 @@ export function GitPanel() {
className="text-xs text-zinc-500 leading-tight"
title={`${c.sha}${c.author} on ${c.date}`}
>
<span className="font-mono text-zinc-400">
{c.sha.slice(0, 7)}
</span>
<span className="font-mono text-zinc-400">{c.sha.slice(0, 7)}</span>
{" "}
<span className="text-zinc-600">{c.message}</span>
</div>
@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
export function PanelSection({
title,
children,
actions,
}: {
title: string;
children: ReactNode;
actions?: ReactNode;
}) {
return (
<div className="border-b border-zinc-200 last:border-b-0">
<div className="flex items-center gap-1 px-3 py-1.5 text-[11px] uppercase tracking-wider text-zinc-400">
<span>{title}</span>
{actions && <span className="ml-auto flex items-center gap-1">{actions}</span>}
</div>
{children}
</div>
);
}
+47 -13
View File
@@ -1,23 +1,57 @@
import { useRef } from "react";
import { useApp } from "../app-state";
export function Preview() {
const { state, lsp } = useApp();
const iframeRef = useRef<HTMLIFrameElement | null>(null);
let status = "idle";
if (!state.currentProject) status = "no project";
else if (lsp.status !== "ready") status = `LSP ${lsp.status}`;
else if (!lsp.previewReady) status = "starting preview…";
else status = "live";
const statusText = () => {
if (!state.currentProject) return "no project";
if (!state.openFile) return "open a file";
if (lsp.status !== "ready") return `LSP ${lsp.status}`;
if (!lsp.previewReady) return "starting…";
return "live";
};
const statusColor = () => {
if (lsp.previewReady) return "text-emerald-600 bg-emerald-50";
if (lsp.status === "ready") return "text-zinc-400 bg-stone-50";
return "text-amber-600 bg-amber-50";
};
const statusDot = () => {
if (lsp.previewReady) return "bg-emerald-500";
return "bg-zinc-300";
};
return (
<div className="w-1/2 border-l border-zinc-200 flex flex-col bg-white">
<div className="px-3 py-1.5 border-b border-zinc-200 text-xs text-zinc-500 font-mono flex items-center gap-2">
<span>Preview</span>
<span className="ml-auto text-zinc-400">{status}</span>
<div className="h-full flex flex-col bg-white">
{/* Toolbar */}
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-zinc-200 text-xs text-zinc-500 shrink-0">
<span className="font-medium">Preview</span>
<span className={`ml-auto flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] ${statusColor()}`}>
<span className={`inline-block size-1.5 rounded-full ${statusDot()}`} />
{statusText()}
</span>
{lsp.previewReady && (
<button
type="button"
onClick={() => {
iframeRef.current?.contentWindow?.location.reload();
}}
className="px-2 py-0.5 rounded border border-zinc-200 hover:bg-stone-100 text-zinc-500"
>
Reload
</button>
)}
<span className="text-zinc-300 text-[11px] hidden sm:inline">Click preview to jump to source</span>
</div>
<div className="flex-1 bg-zinc-50 relative">
{state.currentProject && lsp.status === "ready" && lsp.previewReady ? (
{/* Content */}
<div className="flex-1 bg-zinc-50 relative min-h-0">
{state.currentProject && state.openFile && lsp.status === "ready" && lsp.previewReady ? (
<iframe
ref={iframeRef}
key={state.currentProject}
src="/preview-app"
title="Typst preview"
@@ -25,9 +59,9 @@ export function Preview() {
/>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-zinc-400 text-sm p-6 gap-2">
<span>{status}</span>
<span>{statusText()}</span>
{lsp.status === "error" && lsp.error && (
<pre className="mt-2 max-w-full overflow-auto text-xs text-red-500 bg-red-50 p-3 rounded whitespace-pre-wrap break-all">
<pre className="mt-2 max-w-full overflow-auto text-xs text-rose-600 bg-rose-50 p-3 rounded whitespace-pre-wrap break-all">
{lsp.error}
</pre>
)}
@@ -0,0 +1,83 @@
import { useCallback, useRef, useState, type ReactNode } from "react";
const STORAGE_KEY = "typst-leaf.split";
const MIN_PCT = 25;
const DEFAULT_PCT = 55;
function loadRatio(): number {
try {
const v = Number(localStorage.getItem(STORAGE_KEY));
return v >= MIN_PCT && v <= 100 - MIN_PCT ? v : DEFAULT_PCT;
} catch {
return DEFAULT_PCT;
}
}
function persistRatio(v: number) {
try {
localStorage.setItem(STORAGE_KEY, String(v));
} catch { /* ignore */ }
}
function clampPct(v: number): number {
return Math.max(MIN_PCT, Math.min(100 - MIN_PCT, v));
}
export function ResizableSplit({
left,
right,
onResize,
}: {
left: ReactNode;
right: ReactNode;
onResize?: () => void;
}) {
const [leftPct, setLeftPct] = useState(loadRatio);
const leftPctRef = useRef(leftPct);
const containerRef = useRef<HTMLDivElement | null>(null);
const startDrag = useCallback((e: React.PointerEvent) => {
e.preventDefault();
const el = e.currentTarget as HTMLElement;
el.setPointerCapture(e.pointerId);
const onMove = (ev: PointerEvent) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const pct = clampPct(((ev.clientX - rect.left) / rect.width) * 100);
leftPctRef.current = pct;
setLeftPct(pct);
onResize?.();
};
const onUp = () => {
persistRatio(leftPctRef.current);
onResize?.();
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
}, [onResize]);
return (
<div ref={containerRef} className="flex h-full min-h-0 w-full flex-1">
<div className="min-w-[280px] overflow-hidden" style={{ width: `${leftPct}%` }}>
{left}
</div>
<div
role="separator"
aria-orientation="vertical"
className="w-1 bg-zinc-200 hover:bg-emerald-300 cursor-col-resize shrink-0 transition-colors duration-75"
onPointerDown={startDrag}
onDoubleClick={() => {
const pct = DEFAULT_PCT;
leftPctRef.current = pct;
setLeftPct(pct);
persistRatio(pct);
}}
/>
<div className="flex-1 min-w-[280px] overflow-hidden">
{right}
</div>
</div>
);
}
@@ -26,19 +26,16 @@ function loadIdentity(): Identity {
}
export function SettingsPanel() {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [dirty, setDirty] = useState(false);
useEffect(() => {
if (open) {
const id = loadIdentity();
setName(id.name ?? "");
setEmail(id.email ?? "");
setDirty(false);
}
}, [open]);
const id = loadIdentity();
setName(id.name);
setEmail(id.email);
setDirty(false);
}, []);
const save = useCallback(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ name, email }));
@@ -46,72 +43,59 @@ export function SettingsPanel() {
}, [name, email]);
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="text-xs text-zinc-400 hover:text-zinc-600 px-1"
title="Settings"
>
{open ? "✕" : "⚙"}
</button>
<div className="px-3 py-2 flex flex-col gap-2">
<div className="text-xs uppercase tracking-wide text-zinc-400">
Git Identity
</div>
{open && (
<div className="absolute right-0 top-full z-10 w-56 border-t border-zinc-200 bg-zinc-50 border-x border-b border-zinc-200 rounded-b shadow-sm px-3 py-2 flex flex-col gap-2">
<div className="text-xs uppercase tracking-wide text-zinc-400">
Git Identity
</div>
<input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
setDirty(true);
}}
placeholder="Author name"
className="w-full px-2 py-1 text-xs border border-zinc-300 rounded bg-white text-zinc-800 placeholder-zinc-400 outline-none focus:border-emerald-500"
/>
<input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
setDirty(true);
}}
placeholder="Author name"
className="w-full px-2 py-1 text-xs border border-zinc-300 rounded bg-white text-zinc-800 placeholder-zinc-400 outline-none focus:border-emerald-500"
/>
<input
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setDirty(true);
}}
placeholder="Author email"
className="w-full px-2 py-1 text-xs border border-zinc-300 rounded bg-white text-zinc-800 placeholder-zinc-400 outline-none focus:border-emerald-500"
/>
<input
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setDirty(true);
}}
placeholder="Author email"
className="w-full px-2 py-1 text-xs border border-zinc-300 rounded bg-white text-zinc-800 placeholder-zinc-400 outline-none focus:border-emerald-500"
/>
<div className="flex gap-1">
<button
type="button"
onClick={save}
disabled={!dirty}
className="px-2 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40"
>
Save
</button>
<button
type="button"
onClick={() => {
localStorage.removeItem(STORAGE_KEY);
setName("");
setEmail("");
setDirty(false);
}}
className="px-2 py-1 text-xs rounded bg-white hover:bg-stone-100 text-zinc-500 border border-zinc-300"
>
Clear
</button>
</div>
<div className="flex gap-1">
<button
type="button"
onClick={save}
disabled={!dirty}
className="px-2 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40"
>
Save
</button>
<button
type="button"
onClick={() => {
localStorage.removeItem(STORAGE_KEY);
setName("");
setEmail("");
setDirty(false);
}}
className="px-2 py-1 text-xs rounded bg-white hover:bg-zinc-100 text-zinc-500 border border-zinc-300"
>
Clear
</button>
</div>
<div className="text-xs text-zinc-400">
Used for Git commits. Stored locally in browser.
</div>
</div>
)}
<div className="text-xs text-zinc-400">
Used for Git commits. Stored locally in browser.
</div>
</div>
);
}
@@ -0,0 +1,53 @@
import { useApp } from "../app-state";
export function StatusBar() {
const { state, lsp } = useApp();
const s = state.gitStatus;
const gitSummary = () => {
if (!s) return <span className="text-xs text-zinc-400">git · --</span>;
return (
<span className="text-xs text-zinc-500 flex items-center gap-1">
<span className="font-mono">{s.branch}</span>
{s.clean ? (
<span className="text-emerald-600">· clean</span>
) : (
<span className="text-amber-600">· {s.unstaged.length + s.staged.length} uncommitted</span>
)}
{s.ahead > 0 && <span className="text-emerald-600">· {s.ahead}</span>}
{s.behind > 0 && <span className="text-amber-600">· {s.behind}</span>}
</span>
);
};
const lspStatus = () => {
if (lsp.status === "ready") return <span className="text-xs text-emerald-600">LSP · ready</span>;
if (lsp.status === "connecting") return <span className="text-xs text-amber-600">LSP · connecting</span>;
if (lsp.status === "error") return <span className="text-xs text-rose-600">LSP · error</span>;
return <span className="text-xs text-zinc-400">LSP · idle</span>;
};
const previewStatus = () => {
if (!state.openFile) return null;
if (lsp.previewReady) return <span className="text-xs text-emerald-600">Preview · live</span>;
if (lsp.status === "ready" && !lsp.previewReady) return <span className="text-xs text-zinc-400">Preview · starting</span>;
return null;
};
return (
<footer className="flex items-center gap-4 border-t border-zinc-200 bg-white px-4 h-7 shrink-0">
<div className="flex items-center gap-2">
{gitSummary()}
</div>
<span className="text-zinc-200 select-none">|</span>
<div className="flex items-center gap-2">
{lspStatus()}
{previewStatus()}
{state.vimOn && <span className="text-xs text-zinc-400">vim</span>}
</div>
<span className="ml-auto text-xs text-zinc-400">
{state.saving && <span className="text-amber-500">saving</span>}
</span>
</footer>
);
}
@@ -0,0 +1,46 @@
import { useEffect } from "react";
import { useApp } from "../app-state";
export function ToastHost() {
const { state, dispatch } = useApp();
useEffect(() => {
if (!state.notice) return;
const timer = setTimeout(() => dispatch({ type: "dismissNotice" }), 4000);
return () => clearTimeout(timer);
}, [state.notice, dispatch]);
if (!state.notice) return null;
const colors = {
success: "bg-emerald-50 text-emerald-800 border-emerald-200",
error: "bg-rose-50 text-rose-800 border-rose-200",
info: "bg-stone-50 text-zinc-700 border-zinc-200",
};
const dots = {
success: "bg-emerald-500",
error: "bg-rose-500",
info: "bg-zinc-400",
};
return (
<div className="fixed bottom-8 right-4 z-50 flex flex-col gap-2">
<div
className={`border rounded-md shadow-sm px-3 py-2 text-sm flex items-center gap-2 ${colors[state.notice.kind]}`}
>
<span className={`inline-block size-2 rounded-full shrink-0 ${dots[state.notice.kind]}`} />
{state.notice.message}
<button
type="button"
onClick={() => dispatch({ type: "dismissNotice" })}
className="ml-2 text-zinc-400 hover:text-zinc-600"
aria-label="Dismiss"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" />
</svg>
</button>
</div>
</div>
);
}
@@ -0,0 +1,105 @@
import { useApp } from "../app-state";
export function TopBar() {
const { state, dispatch, lsp } = useApp();
const lspChip = () => {
switch (lsp.status) {
case "ready":
return (
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200 flex items-center gap-1">
<span className="inline-block size-1.5 rounded-full bg-emerald-500" />
LSP
</span>
);
case "connecting":
return (
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-50 text-amber-700 border border-amber-200 flex items-center gap-1">
<span className="inline-block size-1.5 rounded-full bg-amber-400 animate-pulse" />
LSP
</span>
);
case "error":
return (
<span className="text-xs px-2 py-0.5 rounded-full bg-rose-50 text-rose-700 border border-rose-200 flex items-center gap-1">
<span className="inline-block size-1.5 rounded-full bg-rose-500" />
LSP
</span>
);
default:
return null;
}
};
const previewChip = () => {
// No preview without an open file (tinymist previews a document).
if (!state.openFile) return null;
if (lsp.previewReady) {
return (
<span className="text-xs px-2 py-0.5 rounded-full bg-stone-100 text-zinc-600 border border-zinc-200 flex items-center gap-1">
<span className="inline-block size-1.5 rounded-full bg-emerald-500" />
Preview
</span>
);
}
if (lsp.status === "ready") {
return (
<span className="text-xs px-2 py-0.5 rounded-full bg-stone-100 text-zinc-400 border border-zinc-200 flex items-center gap-1">
<span className="inline-block size-1.5 rounded-full bg-zinc-300" />
Preview · starting
</span>
);
}
return null;
};
return (
<header className="flex items-center gap-3 border-b border-zinc-200 bg-white px-4 h-11 shrink-0">
<span className="font-semibold tracking-tight text-zinc-800">typst-leaf</span>
<span className="text-zinc-300 select-none">·</span>
{state.currentProject ? (
<>
<span className="text-sm text-zinc-800 font-medium truncate max-w-36">
{state.currentProject}
</span>
{state.openFile && (
<>
<span className="text-zinc-300 select-none">/</span>
<span className="text-sm font-mono text-zinc-500 truncate max-w-48">
{state.openFile}
{state.fileDirty && <span className="text-amber-500 ml-0.5">*</span>}
</span>
</>
)}
<div className="ml-auto flex items-center gap-2">
{lspChip()}
{previewChip()}
<button
type="button"
onClick={() => {
document.dispatchEvent(new CustomEvent("typst-leaf:save"));
}}
disabled={!state.openFile}
className="px-3 py-1 text-sm rounded bg-emerald-600 hover:bg-emerald-700 text-white disabled:opacity-40 disabled:cursor-not-allowed"
>
Save
</button>
<button
type="button"
onClick={() => dispatch({ type: "setActivePanel", panel: state.activePanel === "settings" ? "files" : "settings" })}
className={`p-1.5 rounded-md ${state.activePanel === "settings" ? "text-emerald-700 bg-emerald-50" : "text-zinc-500 hover:bg-stone-100"}`}
title="Settings"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="size-4">
<path d="M10 3.75a2.25 2.25 0 0 0-4.312.75H2.5a.75.75 0 0 0 0 1.5h3.188A2.25 2.25 0 0 0 10 6.25a2.25 2.25 0 0 0 4.312-.75h3.188a.75.75 0 0 0 0-1.5h-3.188A2.25 2.25 0 0 0 10 3.75zM10 13.75a2.25 2.25 0 0 0-4.312.75H2.5a.75.75 0 0 0 0 1.5h3.188A2.25 2.25 0 0 0 10 16.25a2.25 2.25 0 0 0 4.312.75h3.188a.75.75 0 0 0 0-1.5h-3.188A2.25 2.25 0 0 0 10 13.75z" />
</svg>
</button>
</div>
</>
) : (
<span className="text-sm text-zinc-400">Select a project to begin</span>
)}
</header>
);
}
+25 -9
View File
@@ -1,4 +1,5 @@
import type { Monaco } from "@monaco-editor/react";
import { normalizeFsPath } from "./path-utils";
// ============================================================================
// Raw LSP client over WebSocket.
@@ -425,6 +426,15 @@ interface ShowDocumentParams {
selection?: { start: { line: number; character: number }; end: { line: number; character: number } };
}
/** Validate an LSP position tuple, returning undefined for malformed values. */
function toPosPair(v: unknown): [number, number] | undefined {
if (!Array.isArray(v) || v.length < 2) return undefined;
const a = Number(v[0]);
const b = Number(v[1]);
if (!Number.isFinite(a) || !Number.isFinite(b)) return undefined;
return [a, b];
}
export async function startLsp(
monaco: Monaco,
projectName: string,
@@ -485,7 +495,13 @@ export async function startLsp(
const p = params as ShowDocumentParams;
if (p.external) return { success: false };
if (p.uri && onJumpToSource) {
const filepath = decodeURIComponent(new URL(p.uri).pathname);
let filepath: string;
try {
filepath = normalizeFsPath(p.uri);
} catch {
console.warn("[lsp] window/showDocument: invalid uri", p.uri);
return { success: false };
}
const start = p.selection
? [p.selection.start.line, p.selection.start.character] as [number, number]
: undefined;
@@ -502,18 +518,18 @@ export async function startLsp(
client.onNotification("tinymist/preview/scrollSource", (params) => {
const p = params as {
filepath?: string;
start?: [number, number];
end?: [number, number];
start?: unknown;
end?: unknown;
};
if (!onJumpToSource) return;
if (typeof p.filepath !== "string") {
console.warn("[lsp] tinymist/preview/scrollSource missing filepath", params);
if (typeof p.filepath !== "string" || !p.filepath.trim()) {
console.warn("[lsp] tinymist/preview/scrollSource missing or empty filepath", params);
return;
}
const filepath = p.filepath.includes("://")
? decodeURIComponent(new URL(p.filepath).pathname)
: p.filepath.replaceAll("\\", "/");
onJumpToSource({ filepath, start: p.start, end: p.end });
const filepath = normalizeFsPath(p.filepath);
const start = toPosPair(p.start);
const end = toPosPair(p.end);
onJumpToSource({ filepath, start, end });
});
// LSP handshake
+77
View File
@@ -0,0 +1,77 @@
/**
* Shared path utilities for Typst frontend.
*
* There is a fundamental path duality in this app:
* - The **frontend** works with virtual slash-separated paths relative to the
* project root (e.g. "sub/main.typ" or "main.typ").
* - The **backend** resolves to absolute OS paths under PROJECTS_ROOT.
* - The **LSP** speaks file:// URIs with absolute OS paths.
*
* All three must be converted cleanly. This module centralises those rules so
* that app-state.tsx, Editor.tsx, lsp.ts, etc. do not each re-implement them.
*/
// ---------- Normalization ----------
/**
* Take any path-ish input (file:// URI, native backslash path, forward-slash
* pathname) and return a normalised forward-slash pathname.
*
* file:///home/user/projects/hello/sub/main.typ
* → /home/user/projects/hello/sub/main.typ
*
* file:///C:/Users/user/projects/hello/sub/main.typ
* → C:/Users/user/projects/hello/sub/main.typ (leading "/" stripped)
*
* C:\Users\user\projects\hello\sub\main.typ
* → C:/Users/user/projects/hello/sub/main.typ
*/
export function normalizeFsPath(input: string): string {
let result: string;
if (input.includes("://")) {
try {
result = decodeURIComponent(new URL(input).pathname);
} catch {
result = input.replaceAll("\\", "/");
}
} else {
result = input.replaceAll("\\", "/");
}
// Strip the leading "/" from Windows drive-letter paths (file:///C:/… → /C:/… → C:/…)
if (/^\/[A-Za-z]:\//.test(result) || /^\/[A-Za-z]:$/.test(result)) {
result = result.slice(1);
}
return result;
}
/**
* Encode a virtual relative path so it can be appended to a file:// base URI.
* Each path segment is encoded individually so that "/" separators survive.
*
* "sub/main.typ" → "sub/main.typ"
* "my file.typ" → "my%20file.typ"
* "sub/nested/two.typ" → "sub/nested/two.typ"
*/
export function encodeVirtualPathForFileUri(virtualPath: string): string {
return virtualPath.split("/").map(encodeURIComponent).join("/");
}
// ---------- Relative path extraction ----------
/**
* Given a `fileUri` (the project file:// URI from app state) and an absolute
* filesystem path string (from tinymist), return the virtual relative path
* under the project, or `null` if the path is outside the project root.
*/
export function relativePathFromProject(
projectUri: string,
absolutePath: string,
): string | null {
const filepath = normalizeFsPath(absolutePath);
const projectPath = normalizeFsPath(projectUri);
const sep = projectPath.endsWith("/") ? "" : "/";
if (!filepath.startsWith(projectPath + sep)) {
return null;
}
return filepath.slice(projectPath.length + 1);
}
+1
View File
@@ -15,6 +15,7 @@ export interface FileEntry {
export interface TreeResponse {
project: string;
files: FileEntry[];
folders: { path: string }[];
}
export interface FileResponse {