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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user