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:
@@ -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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,6 +62,30 @@ export const api = {
|
||||
body: JSON.stringify({ port }),
|
||||
}).then((r) => jsonOrThrow<{ ok: boolean; port: number }>(r)),
|
||||
|
||||
// ---- File CRUD ----
|
||||
createFile: (project: string, path: string, content?: string) =>
|
||||
fetch(`${projectUrl(project)}/file`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ path, content }),
|
||||
}).then((r) => jsonOrThrow<{ ok: boolean; path: string }>(r)),
|
||||
deleteFile: (project: string, path: string) =>
|
||||
fetch(`${projectUrl(project)}/file?path=${encodeURIComponent(path)}`, {
|
||||
method: "DELETE",
|
||||
}).then((r) => jsonOrThrow<{ ok: boolean }>(r)),
|
||||
renameFile: (project: string, from: string, to: string) =>
|
||||
fetch(`${projectUrl(project)}/file`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ from, to }),
|
||||
}).then((r) => jsonOrThrow<{ ok: boolean }>(r)),
|
||||
createFolder: (project: string, path: string) =>
|
||||
fetch(`${projectUrl(project)}/folder`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ path }),
|
||||
}).then((r) => jsonOrThrow<{ ok: boolean; path: string }>(r)),
|
||||
|
||||
// ---- Git ----
|
||||
gitInit: (project: string) =>
|
||||
fetch(`${gitUrl(project)}/init`, { method: "POST" }).then((r) =>
|
||||
|
||||
@@ -10,23 +10,40 @@ import {
|
||||
import { api } from "./api";
|
||||
import { loadMonaco } from "./monaco-setup";
|
||||
import { startLsp, startPreview, type RawLspClient } from "./lsp";
|
||||
import { relativePathFromProject } from "./path-utils";
|
||||
import type { Monaco } from "@monaco-editor/react";
|
||||
import type { FileEntry, GitStatus } from "./types";
|
||||
|
||||
interface Notice {
|
||||
id: number;
|
||||
kind: "success" | "error" | "info";
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
projects: string[];
|
||||
currentProject: string | null;
|
||||
currentUri: string | null;
|
||||
tree: FileEntry[];
|
||||
folders: { path: string }[];
|
||||
treeVersion: number;
|
||||
openFile: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
fileDirty: boolean;
|
||||
saving: boolean;
|
||||
// sidebar
|
||||
activePanel: "files" | "git" | "settings";
|
||||
// git
|
||||
gitStatus: GitStatus | null;
|
||||
gitVersion: number;
|
||||
commitFocus: number;
|
||||
// vim
|
||||
vimOn: boolean;
|
||||
// sync
|
||||
notice: Notice | null;
|
||||
jumpTarget: {
|
||||
id: number;
|
||||
file: string;
|
||||
start?: [number, number];
|
||||
end?: [number, number];
|
||||
@@ -36,14 +53,21 @@ interface State {
|
||||
type Action =
|
||||
| { type: "setProjects"; projects: string[] }
|
||||
| { type: "selectProject"; project: string; uri: string }
|
||||
| { type: "setTree"; tree: FileEntry[] }
|
||||
| { type: "setTree"; tree: FileEntry[]; folders: { path: string }[] }
|
||||
| { type: "bumpTreeVersion" }
|
||||
| { type: "openFile"; path: string | null }
|
||||
| { type: "loading"; loading: boolean }
|
||||
| { type: "error"; error: string | null }
|
||||
| { type: "setFileDirty"; dirty: boolean }
|
||||
| { type: "setSaving"; saving: boolean }
|
||||
| { type: "setActivePanel"; panel: State["activePanel"] }
|
||||
| { type: "setGitStatus"; status: GitStatus }
|
||||
| { type: "bumpGitVersion" }
|
||||
| { type: "focusCommit" }
|
||||
| { type: "setJumpTarget"; target: State["jumpTarget"] }
|
||||
| { type: "pushNotice"; kind: Notice["kind"]; message: string }
|
||||
| { type: "dismissNotice" }
|
||||
| { type: "setVimOn"; on: boolean }
|
||||
| { type: "setJumpTarget"; target: NonNullable<State["jumpTarget"]> }
|
||||
| { type: "clearJumpTarget" };
|
||||
|
||||
const initial: State = {
|
||||
@@ -51,12 +75,19 @@ const initial: State = {
|
||||
currentProject: null,
|
||||
currentUri: null,
|
||||
tree: [],
|
||||
folders: [],
|
||||
treeVersion: 0,
|
||||
openFile: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
fileDirty: false,
|
||||
saving: false,
|
||||
activePanel: "files",
|
||||
gitStatus: null,
|
||||
gitVersion: 0,
|
||||
commitFocus: 0,
|
||||
notice: null,
|
||||
vimOn: false,
|
||||
jumpTarget: null,
|
||||
};
|
||||
|
||||
@@ -70,27 +101,45 @@ function reducer(state: State, action: Action): State {
|
||||
currentProject: action.project,
|
||||
currentUri: action.uri,
|
||||
tree: [],
|
||||
folders: [],
|
||||
openFile: null,
|
||||
gitStatus: null,
|
||||
fileDirty: false,
|
||||
saving: false,
|
||||
error: null,
|
||||
};
|
||||
case "setTree":
|
||||
return { ...state, tree: action.tree };
|
||||
return { ...state, tree: action.tree, folders: action.folders };
|
||||
case "bumpTreeVersion":
|
||||
return { ...state, treeVersion: state.treeVersion + 1 };
|
||||
case "openFile":
|
||||
return { ...state, openFile: action.path };
|
||||
return { ...state, openFile: action.path, fileDirty: false };
|
||||
case "loading":
|
||||
return { ...state, loading: action.loading };
|
||||
case "error":
|
||||
return { ...state, error: action.error };
|
||||
case "setFileDirty":
|
||||
return { ...state, fileDirty: action.dirty };
|
||||
case "setSaving":
|
||||
return { ...state, saving: action.saving };
|
||||
case "setActivePanel":
|
||||
return { ...state, activePanel: action.panel };
|
||||
case "setGitStatus":
|
||||
return { ...state, gitStatus: action.status };
|
||||
case "bumpGitVersion":
|
||||
return { ...state, gitVersion: state.gitVersion + 1 };
|
||||
case "focusCommit":
|
||||
return { ...state, commitFocus: state.commitFocus + 1 };
|
||||
return { ...state, commitFocus: state.commitFocus + 1, activePanel: "git" };
|
||||
case "pushNotice":
|
||||
return { ...state, notice: { id: Date.now(), kind: action.kind, message: action.message } };
|
||||
case "dismissNotice":
|
||||
return { ...state, notice: null };
|
||||
case "setJumpTarget":
|
||||
return { ...state, jumpTarget: action.target };
|
||||
case "clearJumpTarget":
|
||||
return { ...state, jumpTarget: null };
|
||||
case "setVimOn":
|
||||
return { ...state, vimOn: action.on };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +175,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
const sessionRef = useRef<{ dispose: () => void } | null>(null);
|
||||
const openFileRef = useRef<string | null>(null);
|
||||
const uriRef = useRef<string | null>(null);
|
||||
const jumpIdRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
loadMonaco()
|
||||
@@ -142,6 +192,15 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
.catch((e) => dispatch({ type: "error", error: String(e) }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const v = localStorage.getItem("typst-leaf.vim") === "true";
|
||||
dispatch({ type: "setVimOn", on: v });
|
||||
} catch {
|
||||
dispatch({ type: "setVimOn", on: false });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.currentProject || !lsp.monaco) return;
|
||||
const sessionRefLocal = sessionRef;
|
||||
@@ -150,22 +209,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
const uri = state.currentUri!;
|
||||
setLsp((s) => ({ ...s, status: "connecting", previewReady: false }));
|
||||
|
||||
// Normalize both URIs and native paths to a consistent forward-slash
|
||||
// pathname. Windows drive-letter paths (file:///C:/... → /C:/...) get
|
||||
// the leading "/" stripped so they match native paths (C:/...).
|
||||
const normalizePath = (p: string): string => {
|
||||
let result: string;
|
||||
if (p.includes("://")) {
|
||||
result = decodeURIComponent(new URL(p).pathname);
|
||||
} else {
|
||||
result = p.replaceAll("\\", "/");
|
||||
}
|
||||
if (/^\/[A-Za-z]:\//.test(result) || /^\/[A-Za-z]:$/.test(result)) {
|
||||
result = result.slice(1);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const onJumpToSource = (info: {
|
||||
filepath: string;
|
||||
start?: [number, number];
|
||||
@@ -173,19 +216,24 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
}) => {
|
||||
const curUri = uriRef.current ?? uri;
|
||||
const curOpenFile = openFileRef.current;
|
||||
const filepath = normalizePath(info.filepath);
|
||||
const projectPath = normalizePath(curUri);
|
||||
const sep = projectPath.endsWith("/") ? "" : "/";
|
||||
if (!filepath.startsWith(projectPath + sep)) {
|
||||
const relPath = relativePathFromProject(curUri, info.filepath);
|
||||
|
||||
if (!relPath) {
|
||||
dispatch({
|
||||
type: "pushNotice",
|
||||
kind: "info",
|
||||
message: "Preview source is outside this project",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const relPath = filepath.slice(projectPath.length + 1);
|
||||
|
||||
const id = ++jumpIdRef.current;
|
||||
if (relPath !== curOpenFile) {
|
||||
dispatch({ type: "openFile", path: relPath });
|
||||
}
|
||||
dispatch({
|
||||
type: "setJumpTarget",
|
||||
target: { file: relPath, start: info.start, end: info.end },
|
||||
target: { id, file: relPath, start: info.start, end: info.end },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -235,9 +283,12 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
if (!state.currentProject) return;
|
||||
api
|
||||
.getTree(state.currentProject)
|
||||
.then((t) => dispatch({ type: "setTree", tree: t.files }))
|
||||
.then((t) => {
|
||||
dispatch({ type: "setTree", tree: t.files, folders: t.folders ?? [] });
|
||||
if (state.error) dispatch({ type: "error", error: null });
|
||||
})
|
||||
.catch((e) => dispatch({ type: "error", error: String(e) }));
|
||||
}, [state.currentProject]);
|
||||
}, [state.currentProject, state.treeVersion]);
|
||||
|
||||
// Keep refs current for the LSP callback closure
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import Editor, { type Monaco, type OnMount } from "@monaco-editor/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { useApp } from "../app-state";
|
||||
import { encodeVirtualPathForFileUri } from "../path-utils";
|
||||
|
||||
type ITextModel = Monaco["editor"]["ITextModel"];
|
||||
type IStandaloneCodeEditor = Monaco["editor"]["IStandaloneCodeEditor"];
|
||||
@@ -16,28 +17,25 @@ export function EditorView() {
|
||||
const uri = state.currentUri;
|
||||
const file = state.openFile;
|
||||
const savingRef = useRef(false);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// Vim mode
|
||||
const [vimOn, setVimOn] = useState(
|
||||
() => localStorage.getItem("typst-leaf.vim") === "true",
|
||||
);
|
||||
|
||||
const [editorReady, setEditorReady] = useState(false);
|
||||
const vimRef = useRef<{ dispose: () => void } | null>(null);
|
||||
const vimStatusRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Toggle vim
|
||||
const toggleVim = useCallback(() => {
|
||||
setVimOn((prev) => {
|
||||
const next = !prev;
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,19 @@ import { api } from "../api";
|
||||
import { useApp } from "../app-state";
|
||||
import type { CommitInfo } from "../types";
|
||||
|
||||
const statusIcon: Record<string, string> = {
|
||||
A: "+",
|
||||
M: "~",
|
||||
D: "–",
|
||||
"?": "?",
|
||||
};
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
staged: "text-emerald-600",
|
||||
unstaged: "text-amber-600",
|
||||
untracked: "text-zinc-400",
|
||||
};
|
||||
|
||||
export function GitPanel() {
|
||||
const { state, dispatch } = useApp();
|
||||
const [commitMsg, setCommitMsg] = useState("");
|
||||
@@ -16,14 +29,12 @@ export function GitPanel() {
|
||||
const [log, setLog] = useState<CommitInfo[]>([]);
|
||||
const [logOpen, setLogOpen] = useState(false);
|
||||
|
||||
// Hooks must be called unconditionally (React Rules of Hooks)
|
||||
useEffect(() => {
|
||||
if (state.commitFocus > 0) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [state.commitFocus]);
|
||||
|
||||
// Fetch commit log on git version change
|
||||
useEffect(() => {
|
||||
if (!state.currentProject) {
|
||||
setLog([]);
|
||||
@@ -42,15 +53,18 @@ export function GitPanel() {
|
||||
const s = state.gitStatus;
|
||||
if (!state.currentProject) return null;
|
||||
|
||||
const hasChanges = s && !s.clean;
|
||||
const canCommit = commitMsg.trim() && hasChanges && !busy;
|
||||
|
||||
async function handleCommit() {
|
||||
if (!commitMsg.trim() || busy || !state.currentProject) return;
|
||||
if (!canCommit || !state.currentProject) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
let identity: { name?: string; email?: string } | undefined;
|
||||
try {
|
||||
const identityRaw = localStorage.getItem("typst-leaf.git-identity");
|
||||
const parsed = identityRaw ? JSON.parse(identityRaw) : undefined;
|
||||
const raw = localStorage.getItem("typst-leaf.git-identity");
|
||||
const parsed = raw ? JSON.parse(raw) : undefined;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
identity = {
|
||||
name: typeof parsed.name === "string" ? parsed.name : undefined,
|
||||
@@ -66,6 +80,7 @@ export function GitPanel() {
|
||||
} else {
|
||||
setCommitMsg("");
|
||||
dispatch({ type: "bumpGitVersion" });
|
||||
dispatch({ type: "pushNotice", kind: "success", message: "Committed" });
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
@@ -86,7 +101,10 @@ export function GitPanel() {
|
||||
}
|
||||
const res = await api.gitPush(state.currentProject);
|
||||
if (!res.ok) setError(res.error ?? "push failed");
|
||||
else dispatch({ type: "bumpGitVersion" });
|
||||
else {
|
||||
dispatch({ type: "bumpGitVersion" });
|
||||
dispatch({ type: "pushNotice", kind: "success", message: "Pushed" });
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
@@ -106,7 +124,10 @@ export function GitPanel() {
|
||||
}
|
||||
const res = await api.gitPull(state.currentProject);
|
||||
if (!res.ok) setError(res.error ?? "pull failed");
|
||||
else dispatch({ type: "bumpGitVersion" });
|
||||
else {
|
||||
dispatch({ type: "bumpGitVersion" });
|
||||
dispatch({ type: "pushNotice", kind: "success", message: "Pulled latest" });
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
@@ -126,6 +147,24 @@ export function GitPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
function renderFileRow(
|
||||
f: { path: string; status: string },
|
||||
group: "staged" | "unstaged" | "untracked",
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
key={f.path}
|
||||
className="flex items-center gap-1 text-xs font-mono cursor-pointer hover:bg-stone-100 px-1 py-0.5 rounded"
|
||||
onClick={() => dispatch({ type: "openFile", path: f.path })}
|
||||
>
|
||||
<span className={`w-3 text-center ${statusColor[group]}`}>
|
||||
{statusIcon[f.status] ?? "?"}
|
||||
</span>
|
||||
<span className="truncate text-zinc-600">{f.path}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-zinc-200">
|
||||
<div className="px-3 py-1.5 text-xs uppercase tracking-wide text-zinc-400 flex items-center gap-1">
|
||||
@@ -133,26 +172,40 @@ export function GitPanel() {
|
||||
{s && (
|
||||
<span className="ml-auto text-zinc-400 font-mono normal-case">
|
||||
{s.branch}
|
||||
{s.ahead > 0 && (
|
||||
<span className="text-emerald-600 ml-1">↑{s.ahead}</span>
|
||||
)}
|
||||
{s.behind > 0 && (
|
||||
<span className="text-amber-600 ml-1">↓{s.behind}</span>
|
||||
)}
|
||||
{s.ahead > 0 && <span className="text-emerald-600 ml-1">↑{s.ahead}</span>}
|
||||
{s.behind > 0 && <span className="text-amber-600 ml-1">↓{s.behind}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-1.5 text-xs text-zinc-500">
|
||||
{s && !s.clean && (
|
||||
<span>
|
||||
{s.unstaged.length + s.staged.length} uncommitted file
|
||||
{s.unstaged.length + s.staged.length !== 1 ? "s" : ""}
|
||||
<span className="text-amber-600">
|
||||
{s.unstaged.length + s.staged.length} uncommitted
|
||||
</span>
|
||||
)}
|
||||
{s && s.clean && <span className="text-emerald-600">clean</span>}
|
||||
</div>
|
||||
|
||||
{/* Changed files — dedupe by path; unstaged wins for paths in both
|
||||
buckets (a file can be both staged and unstaged for different
|
||||
parts, but unstaged is what the user still needs to act on). */}
|
||||
{s && !s.clean && (() => {
|
||||
const byPath = new Map<string, { path: string; status: string; group: "staged" | "unstaged" }>();
|
||||
for (const f of s.unstaged) byPath.set(f.path, { ...f, group: "unstaged" });
|
||||
for (const f of s.staged) if (!byPath.has(f.path)) byPath.set(f.path, { ...f, group: "staged" });
|
||||
const rows = Array.from(byPath.values());
|
||||
return (
|
||||
<div className="px-3 pb-1">
|
||||
<div className="text-[10px] uppercase tracking-wider text-zinc-400 mb-0.5">
|
||||
Changed files
|
||||
</div>
|
||||
{rows.map((f) => renderFileRow(f, f.group))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Commit compose */}
|
||||
<div className="px-3 pb-1.5 flex flex-col gap-1.5">
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
@@ -163,13 +216,14 @@ export function GitPanel() {
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleCommit();
|
||||
}}
|
||||
placeholder="Commit message"
|
||||
className="flex-1 min-w-0 px-2 py-1 text-xs border border-zinc-300 rounded bg-white text-zinc-800 placeholder-zinc-400 outline-none focus:border-emerald-500"
|
||||
placeholder={hasChanges ? "Commit message" : "No changes to commit"}
|
||||
disabled={!hasChanges}
|
||||
className="flex-1 min-w-0 px-2 py-1 text-xs border border-zinc-300 rounded bg-white text-zinc-800 placeholder-zinc-400 outline-none focus:border-emerald-500 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCommit()}
|
||||
disabled={!commitMsg.trim() || busy}
|
||||
disabled={!canCommit}
|
||||
className="px-2 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy ? "..." : "Commit"}
|
||||
@@ -181,7 +235,7 @@ export function GitPanel() {
|
||||
type="button"
|
||||
onClick={() => void handlePush()}
|
||||
disabled={pushBusy}
|
||||
className="flex-1 px-2 py-1 text-xs rounded bg-white hover:bg-zinc-100 text-zinc-700 border border-zinc-300 disabled:opacity-40"
|
||||
className="flex-1 px-2 py-1 text-xs rounded bg-white hover:bg-stone-100 text-zinc-700 border border-zinc-300 disabled:opacity-40"
|
||||
>
|
||||
{pushBusy ? "..." : "Push"}
|
||||
</button>
|
||||
@@ -189,7 +243,7 @@ export function GitPanel() {
|
||||
type="button"
|
||||
onClick={() => void handlePull()}
|
||||
disabled={pullBusy}
|
||||
className="flex-1 px-2 py-1 text-xs rounded bg-white hover:bg-zinc-100 text-zinc-700 border border-zinc-300 disabled:opacity-40"
|
||||
className="flex-1 px-2 py-1 text-xs rounded bg-white hover:bg-stone-100 text-zinc-700 border border-zinc-300 disabled:opacity-40"
|
||||
>
|
||||
{pullBusy ? "..." : "Pull"}
|
||||
</button>
|
||||
@@ -216,7 +270,7 @@ export function GitPanel() {
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-red-500 break-words">{error}</div>
|
||||
<div className="text-xs text-rose-600 break-words">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -229,7 +283,17 @@ export function GitPanel() {
|
||||
className="w-full flex items-center gap-1 px-3 py-1 text-xs text-zinc-400 hover:text-zinc-600 uppercase tracking-wide"
|
||||
>
|
||||
<span>Log</span>
|
||||
<span className="ml-auto">{logOpen ? "▾" : "▸"}</span>
|
||||
<span className="ml-auto">
|
||||
{logOpen ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
|
||||
<path d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="size-3">
|
||||
<path d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 0 1 0-1.06Z" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{logOpen && (
|
||||
<div className="max-h-32 overflow-y-auto px-3 pb-1.5 flex flex-col gap-1">
|
||||
@@ -239,9 +303,7 @@ export function GitPanel() {
|
||||
className="text-xs text-zinc-500 leading-tight"
|
||||
title={`${c.sha} — ${c.author} on ${c.date}`}
|
||||
>
|
||||
<span className="font-mono text-zinc-400">
|
||||
{c.sha.slice(0, 7)}
|
||||
</span>
|
||||
<span className="font-mono text-zinc-400">{c.sha.slice(0, 7)}</span>
|
||||
{" "}
|
||||
<span className="text-zinc-600">{c.message}</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function PanelSection({
|
||||
title,
|
||||
children,
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-zinc-200 last:border-b-0">
|
||||
<div className="flex items-center gap-1 px-3 py-1.5 text-[11px] uppercase tracking-wider text-zinc-400">
|
||||
<span>{title}</span>
|
||||
{actions && <span className="ml-auto flex items-center gap-1">{actions}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,57 @@
|
||||
import { useRef } from "react";
|
||||
import { useApp } from "../app-state";
|
||||
|
||||
export function Preview() {
|
||||
const { state, lsp } = useApp();
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
|
||||
let status = "idle";
|
||||
if (!state.currentProject) status = "no project";
|
||||
else if (lsp.status !== "ready") status = `LSP ${lsp.status}`;
|
||||
else if (!lsp.previewReady) status = "starting preview…";
|
||||
else status = "live";
|
||||
const statusText = () => {
|
||||
if (!state.currentProject) return "no project";
|
||||
if (!state.openFile) return "open a file";
|
||||
if (lsp.status !== "ready") return `LSP ${lsp.status}`;
|
||||
if (!lsp.previewReady) return "starting…";
|
||||
return "live";
|
||||
};
|
||||
|
||||
const statusColor = () => {
|
||||
if (lsp.previewReady) return "text-emerald-600 bg-emerald-50";
|
||||
if (lsp.status === "ready") return "text-zinc-400 bg-stone-50";
|
||||
return "text-amber-600 bg-amber-50";
|
||||
};
|
||||
|
||||
const statusDot = () => {
|
||||
if (lsp.previewReady) return "bg-emerald-500";
|
||||
return "bg-zinc-300";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-1/2 border-l border-zinc-200 flex flex-col bg-white">
|
||||
<div className="px-3 py-1.5 border-b border-zinc-200 text-xs text-zinc-500 font-mono flex items-center gap-2">
|
||||
<span>Preview</span>
|
||||
<span className="ml-auto text-zinc-400">{status}</span>
|
||||
<div className="h-full flex flex-col bg-white">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-zinc-200 text-xs text-zinc-500 shrink-0">
|
||||
<span className="font-medium">Preview</span>
|
||||
<span className={`ml-auto flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] ${statusColor()}`}>
|
||||
<span className={`inline-block size-1.5 rounded-full ${statusDot()}`} />
|
||||
{statusText()}
|
||||
</span>
|
||||
{lsp.previewReady && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
iframeRef.current?.contentWindow?.location.reload();
|
||||
}}
|
||||
className="px-2 py-0.5 rounded border border-zinc-200 hover:bg-stone-100 text-zinc-500"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
)}
|
||||
<span className="text-zinc-300 text-[11px] hidden sm:inline">Click preview to jump to source</span>
|
||||
</div>
|
||||
<div className="flex-1 bg-zinc-50 relative">
|
||||
{state.currentProject && lsp.status === "ready" && lsp.previewReady ? (
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 bg-zinc-50 relative min-h-0">
|
||||
{state.currentProject && state.openFile && lsp.status === "ready" && lsp.previewReady ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
key={state.currentProject}
|
||||
src="/preview-app"
|
||||
title="Typst preview"
|
||||
@@ -25,9 +59,9 @@ export function Preview() {
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-zinc-400 text-sm p-6 gap-2">
|
||||
<span>{status}</span>
|
||||
<span>{statusText()}</span>
|
||||
{lsp.status === "error" && lsp.error && (
|
||||
<pre className="mt-2 max-w-full overflow-auto text-xs text-red-500 bg-red-50 p-3 rounded whitespace-pre-wrap break-all">
|
||||
<pre className="mt-2 max-w-full overflow-auto text-xs text-rose-600 bg-rose-50 p-3 rounded whitespace-pre-wrap break-all">
|
||||
{lsp.error}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
const STORAGE_KEY = "typst-leaf.split";
|
||||
const MIN_PCT = 25;
|
||||
const DEFAULT_PCT = 55;
|
||||
|
||||
function loadRatio(): number {
|
||||
try {
|
||||
const v = Number(localStorage.getItem(STORAGE_KEY));
|
||||
return v >= MIN_PCT && v <= 100 - MIN_PCT ? v : DEFAULT_PCT;
|
||||
} catch {
|
||||
return DEFAULT_PCT;
|
||||
}
|
||||
}
|
||||
|
||||
function persistRatio(v: number) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, String(v));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function clampPct(v: number): number {
|
||||
return Math.max(MIN_PCT, Math.min(100 - MIN_PCT, v));
|
||||
}
|
||||
|
||||
export function ResizableSplit({
|
||||
left,
|
||||
right,
|
||||
onResize,
|
||||
}: {
|
||||
left: ReactNode;
|
||||
right: ReactNode;
|
||||
onResize?: () => void;
|
||||
}) {
|
||||
const [leftPct, setLeftPct] = useState(loadRatio);
|
||||
const leftPctRef = useRef(leftPct);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const startDrag = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.setPointerCapture(e.pointerId);
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const pct = clampPct(((ev.clientX - rect.left) / rect.width) * 100);
|
||||
leftPctRef.current = pct;
|
||||
setLeftPct(pct);
|
||||
onResize?.();
|
||||
};
|
||||
const onUp = () => {
|
||||
persistRatio(leftPctRef.current);
|
||||
onResize?.();
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
};
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
}, [onResize]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex h-full min-h-0 w-full flex-1">
|
||||
<div className="min-w-[280px] overflow-hidden" style={{ width: `${leftPct}%` }}>
|
||||
{left}
|
||||
</div>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
className="w-1 bg-zinc-200 hover:bg-emerald-300 cursor-col-resize shrink-0 transition-colors duration-75"
|
||||
onPointerDown={startDrag}
|
||||
onDoubleClick={() => {
|
||||
const pct = DEFAULT_PCT;
|
||||
leftPctRef.current = pct;
|
||||
setLeftPct(pct);
|
||||
persistRatio(pct);
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1 min-w-[280px] overflow-hidden">
|
||||
{right}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,19 +26,16 @@ function loadIdentity(): Identity {
|
||||
}
|
||||
|
||||
export function SettingsPanel() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const id = loadIdentity();
|
||||
setName(id.name ?? "");
|
||||
setEmail(id.email ?? "");
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Monaco } from "@monaco-editor/react";
|
||||
import { normalizeFsPath } from "./path-utils";
|
||||
|
||||
// ============================================================================
|
||||
// Raw LSP client over WebSocket.
|
||||
@@ -425,6 +426,15 @@ interface ShowDocumentParams {
|
||||
selection?: { start: { line: number; character: number }; end: { line: number; character: number } };
|
||||
}
|
||||
|
||||
/** Validate an LSP position tuple, returning undefined for malformed values. */
|
||||
function toPosPair(v: unknown): [number, number] | undefined {
|
||||
if (!Array.isArray(v) || v.length < 2) return undefined;
|
||||
const a = Number(v[0]);
|
||||
const b = Number(v[1]);
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b)) return undefined;
|
||||
return [a, b];
|
||||
}
|
||||
|
||||
export async function startLsp(
|
||||
monaco: Monaco,
|
||||
projectName: string,
|
||||
@@ -485,7 +495,13 @@ export async function startLsp(
|
||||
const p = params as ShowDocumentParams;
|
||||
if (p.external) return { success: false };
|
||||
if (p.uri && onJumpToSource) {
|
||||
const filepath = decodeURIComponent(new URL(p.uri).pathname);
|
||||
let filepath: string;
|
||||
try {
|
||||
filepath = normalizeFsPath(p.uri);
|
||||
} catch {
|
||||
console.warn("[lsp] window/showDocument: invalid uri", p.uri);
|
||||
return { success: false };
|
||||
}
|
||||
const start = p.selection
|
||||
? [p.selection.start.line, p.selection.start.character] as [number, number]
|
||||
: undefined;
|
||||
@@ -502,18 +518,18 @@ export async function startLsp(
|
||||
client.onNotification("tinymist/preview/scrollSource", (params) => {
|
||||
const p = params as {
|
||||
filepath?: string;
|
||||
start?: [number, number];
|
||||
end?: [number, number];
|
||||
start?: unknown;
|
||||
end?: unknown;
|
||||
};
|
||||
if (!onJumpToSource) return;
|
||||
if (typeof p.filepath !== "string") {
|
||||
console.warn("[lsp] tinymist/preview/scrollSource missing filepath", params);
|
||||
if (typeof p.filepath !== "string" || !p.filepath.trim()) {
|
||||
console.warn("[lsp] tinymist/preview/scrollSource missing or empty filepath", params);
|
||||
return;
|
||||
}
|
||||
const filepath = p.filepath.includes("://")
|
||||
? decodeURIComponent(new URL(p.filepath).pathname)
|
||||
: p.filepath.replaceAll("\\", "/");
|
||||
onJumpToSource({ filepath, start: p.start, end: p.end });
|
||||
const filepath = normalizeFsPath(p.filepath);
|
||||
const start = toPosPair(p.start);
|
||||
const end = toPosPair(p.end);
|
||||
onJumpToSource({ filepath, start, end });
|
||||
});
|
||||
|
||||
// LSP handshake
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Shared path utilities for Typst frontend.
|
||||
*
|
||||
* There is a fundamental path duality in this app:
|
||||
* - The **frontend** works with virtual slash-separated paths relative to the
|
||||
* project root (e.g. "sub/main.typ" or "main.typ").
|
||||
* - The **backend** resolves to absolute OS paths under PROJECTS_ROOT.
|
||||
* - The **LSP** speaks file:// URIs with absolute OS paths.
|
||||
*
|
||||
* All three must be converted cleanly. This module centralises those rules so
|
||||
* that app-state.tsx, Editor.tsx, lsp.ts, etc. do not each re-implement them.
|
||||
*/
|
||||
|
||||
// ---------- Normalization ----------
|
||||
|
||||
/**
|
||||
* Take any path-ish input (file:// URI, native backslash path, forward-slash
|
||||
* pathname) and return a normalised forward-slash pathname.
|
||||
*
|
||||
* file:///home/user/projects/hello/sub/main.typ
|
||||
* → /home/user/projects/hello/sub/main.typ
|
||||
*
|
||||
* file:///C:/Users/user/projects/hello/sub/main.typ
|
||||
* → C:/Users/user/projects/hello/sub/main.typ (leading "/" stripped)
|
||||
*
|
||||
* C:\Users\user\projects\hello\sub\main.typ
|
||||
* → C:/Users/user/projects/hello/sub/main.typ
|
||||
*/
|
||||
export function normalizeFsPath(input: string): string {
|
||||
let result: string;
|
||||
if (input.includes("://")) {
|
||||
try {
|
||||
result = decodeURIComponent(new URL(input).pathname);
|
||||
} catch {
|
||||
result = input.replaceAll("\\", "/");
|
||||
}
|
||||
} else {
|
||||
result = input.replaceAll("\\", "/");
|
||||
}
|
||||
// Strip the leading "/" from Windows drive-letter paths (file:///C:/… → /C:/… → C:/…)
|
||||
if (/^\/[A-Za-z]:\//.test(result) || /^\/[A-Za-z]:$/.test(result)) {
|
||||
result = result.slice(1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a virtual relative path so it can be appended to a file:// base URI.
|
||||
* Each path segment is encoded individually so that "/" separators survive.
|
||||
*
|
||||
* "sub/main.typ" → "sub/main.typ"
|
||||
* "my file.typ" → "my%20file.typ"
|
||||
* "sub/nested/two.typ" → "sub/nested/two.typ"
|
||||
*/
|
||||
export function encodeVirtualPathForFileUri(virtualPath: string): string {
|
||||
return virtualPath.split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
// ---------- Relative path extraction ----------
|
||||
|
||||
/**
|
||||
* Given a `fileUri` (the project file:// URI from app state) and an absolute
|
||||
* filesystem path string (from tinymist), return the virtual relative path
|
||||
* under the project, or `null` if the path is outside the project root.
|
||||
*/
|
||||
export function relativePathFromProject(
|
||||
projectUri: string,
|
||||
absolutePath: string,
|
||||
): string | null {
|
||||
const filepath = normalizeFsPath(absolutePath);
|
||||
const projectPath = normalizeFsPath(projectUri);
|
||||
const sep = projectPath.endsWith("/") ? "" : "/";
|
||||
if (!filepath.startsWith(projectPath + sep)) {
|
||||
return null;
|
||||
}
|
||||
return filepath.slice(projectPath.length + 1);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export interface FileEntry {
|
||||
export interface TreeResponse {
|
||||
project: string;
|
||||
files: FileEntry[];
|
||||
folders: { path: string }[];
|
||||
}
|
||||
|
||||
export interface FileResponse {
|
||||
|
||||
Reference in New Issue
Block a user