feat: Git workflow, Vim mode, and preview→editor SyncTeX
Add three core VISION features on top of the barebones MVP: Git workflow (backend + frontend): - Backend: simple-git wrapper with per-project repo isolation, init (idempotent .gitignore reconciliation, handles empty repos), status, log, commit (per-commit identity via -c flags), remote, push, pull - gitFor() now refuses to run without the project's own .git, preventing accidental parent-repo detection when projects/ lives inside the typst-leaf worktree - Frontend: GitPanel (branch, ahead/behind, inline commit, push/pull, remote prompt, collapsible log), SettingsPanel (git identity in localStorage), Ctrl+Shift+S focuses commit input - Status/log refresh driven by gitVersion bumps (no polling) with cancellation guards across project switches - Auto-init on status failure for pre-existing projects Vim mode: - Off-by-default toggle persisted to localStorage - Dynamic monaco-vim import, status bar div, StrictMode-safe lifecycle via editorReady reset on unmount SyncTeX (preview → editor): - Handle tinymist/preview/scrollSource notification and window/showDocument request - Cross-platform path normalization (Windows drive letters, native backslash paths, URL-decoded paths) - Editor jump effect with Monaco selection/reveal/focus, clears on completion Robustness fixes across review cycles: - Cancellation guards on git status/log effects to prevent stale writes - bumpGitVersion after auto-init so log viewer updates - jsonOrThrow extracts structured backend errors, handles empty bodies - SettingsPanel/GitPanel validate localStorage identity shape - GitPanel clears log state on project switch - File path URI encoding preserves path separators (segment-wise encode) - tsconfig lib bumped to ES2021 for String.replaceAll AGENTS.md: removed stale execa/monaco-languageclient references PLAN.md: implementation plan for this chunk
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import { useApp } from "../app-state";
|
||||
import type { CommitInfo } from "../types";
|
||||
|
||||
export function GitPanel() {
|
||||
const { state, dispatch } = useApp();
|
||||
const [commitMsg, setCommitMsg] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [pushBusy, setPushBusy] = useState(false);
|
||||
const [pullBusy, setPullBusy] = useState(false);
|
||||
const [remoteUrl, setRemoteUrl] = useState("");
|
||||
const [showRemoteInput, setShowRemoteInput] = useState(false);
|
||||
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([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const project = state.currentProject;
|
||||
setLog([]);
|
||||
api
|
||||
.gitLog(project, 10)
|
||||
.then((r) => { if (!cancelled) setLog(r.commits); })
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [state.currentProject, state.gitVersion]);
|
||||
|
||||
const s = state.gitStatus;
|
||||
if (!state.currentProject) return null;
|
||||
|
||||
async function handleCommit() {
|
||||
if (!commitMsg.trim() || busy || !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;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
identity = {
|
||||
name: typeof parsed.name === "string" ? parsed.name : undefined,
|
||||
email: typeof parsed.email === "string" ? parsed.email : undefined,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
identity = undefined;
|
||||
}
|
||||
const res = await api.gitCommit(state.currentProject, commitMsg.trim(), identity);
|
||||
if (!res.ok) {
|
||||
setError(res.error ?? "commit failed");
|
||||
} else {
|
||||
setCommitMsg("");
|
||||
dispatch({ type: "bumpGitVersion" });
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePush() {
|
||||
if (!state.currentProject || pushBusy) return;
|
||||
setPushBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (!s?.hasRemote) {
|
||||
setShowRemoteInput(true);
|
||||
setPushBusy(false);
|
||||
return;
|
||||
}
|
||||
const res = await api.gitPush(state.currentProject);
|
||||
if (!res.ok) setError(res.error ?? "push failed");
|
||||
else dispatch({ type: "bumpGitVersion" });
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setPushBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePull() {
|
||||
if (!state.currentProject || pullBusy) return;
|
||||
setPullBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (!s?.hasRemote) {
|
||||
setShowRemoteInput(true);
|
||||
setPullBusy(false);
|
||||
return;
|
||||
}
|
||||
const res = await api.gitPull(state.currentProject);
|
||||
if (!res.ok) setError(res.error ?? "pull failed");
|
||||
else dispatch({ type: "bumpGitVersion" });
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setPullBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetRemote() {
|
||||
if (!remoteUrl.trim() || !state.currentProject) return;
|
||||
try {
|
||||
await api.setRemote(state.currentProject, remoteUrl.trim());
|
||||
setShowRemoteInput(false);
|
||||
setRemoteUrl("");
|
||||
dispatch({ type: "bumpGitVersion" });
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<span>Git</span>
|
||||
{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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
{s && s.clean && <span className="text-emerald-600">clean</span>}
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-1.5 flex flex-col gap-1.5">
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={commitMsg}
|
||||
onChange={(e) => setCommitMsg(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCommit()}
|
||||
disabled={!commitMsg.trim() || busy}
|
||||
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"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{pushBusy ? "..." : "Push"}
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{pullBusy ? "..." : "Pull"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showRemoteInput && (
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={remoteUrl}
|
||||
onChange={(e) => setRemoteUrl(e.target.value)}
|
||||
placeholder="git remote URL"
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSetRemote()}
|
||||
disabled={!remoteUrl.trim()}
|
||||
className="px-2 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40"
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-red-500 break-words">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Commit log */}
|
||||
{log.length > 0 && (
|
||||
<div className="border-t border-zinc-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLogOpen(!logOpen)}
|
||||
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>
|
||||
</button>
|
||||
{logOpen && (
|
||||
<div className="max-h-32 overflow-y-auto px-3 pb-1.5 flex flex-col gap-1">
|
||||
{log.map((c) => (
|
||||
<div
|
||||
key={c.sha}
|
||||
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="text-zinc-600">{c.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user