feat: implement history review feature with popover UI

- Add Popover component for displaying history in a popover.
- Integrate history review functionality in the LatexEditor component.
- Create HistoryDiffView for displaying differences between snapshots.
- Update HistoryPanel to manage linear history and snapshot restoration.
- Refactor sidebar to remove direct history panel integration.
- Enhance PDF preview with history button to access history panel.
- Update history store to manage reviewing snapshots and related actions.
This commit is contained in:
delibae 2026-02-22 04:02:42 +09:00
parent d6df454736
commit 2430aeca7c
6 changed files with 537 additions and 345 deletions

View file

@ -0,0 +1,40 @@
import * as React from "react";
import { Popover as PopoverPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-0 text-popover-foreground shadow-md outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
export { Popover, PopoverTrigger, PopoverContent };

View file

@ -31,11 +31,26 @@ import { linter, lintGutter, forEachDiagnostic, type Diagnostic } from "@codemir
import { useDocumentStore, type ProjectFile } from "@/stores/document-store";
import { useProposedChangesStore, type ProposedChange } from "@/stores/proposed-changes-store";
import { useClaudeChatStore } from "@/stores/claude-chat-store";
import { useHistoryStore } from "@/stores/history-store";
import { useHistoryStore, type FileDiff } from "@/stores/history-store";
import { compileLatex } from "@/lib/latex-compiler";
import { EditorToolbar } from "./editor-toolbar";
import { SelectionToolbar, type ToolbarAction } from "./selection-toolbar";
import { SpellCheckIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
SpellCheckIcon,
RotateCcwIcon,
TagIcon,
CopyIcon,
XIcon,
} from "lucide-react";
import { ClaudeChatDrawer } from "@/components/claude-chat/claude-chat-drawer";
import { ProposedChangesPanel } from "@/components/claude-chat/proposed-changes-panel";
import { ImagePreview } from "./image-preview";
@ -72,6 +87,10 @@ export function LatexEditor() {
const isTextFile = activeFile?.type === "tex" || activeFile?.type === "bib" || activeFile?.type === "style" || activeFile?.type === "other";
const activeFileContent = activeFile?.content;
// History review state
const reviewingSnapshot = useHistoryStore((s) => s.reviewingSnapshot);
const historyDiffResult = useHistoryStore((s) => s.diffResult);
const [imageScale, setImageScale] = useState(1.0);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@ -599,6 +618,35 @@ export function LatexEditor() {
setSelectionCoords(null);
}, []);
// History review action handlers
const handleHistoryRestore = useCallback(async () => {
if (!reviewingSnapshot || !projectRoot) return;
useHistoryStore.getState().stopReview();
await useHistoryStore.getState().restoreSnapshot(projectRoot, reviewingSnapshot.id);
await useDocumentStore.getState().openProject(projectRoot);
await useHistoryStore.getState().loadSnapshots(projectRoot);
}, [reviewingSnapshot, projectRoot]);
const [historyLabelDialogOpen, setHistoryLabelDialogOpen] = useState(false);
const [historyLabelValue, setHistoryLabelValue] = useState("");
const handleHistoryAddLabel = useCallback(async () => {
const label = historyLabelValue.trim();
if (!label || !reviewingSnapshot || !projectRoot) return;
await useHistoryStore.getState().addLabel(projectRoot, reviewingSnapshot.id, label);
setHistoryLabelDialogOpen(false);
setHistoryLabelValue("");
}, [reviewingSnapshot, projectRoot, historyLabelValue]);
const handleHistoryCopySha = useCallback(() => {
if (!reviewingSnapshot) return;
navigator.clipboard.writeText(reviewingSnapshot.id);
}, [reviewingSnapshot]);
const handleHistoryClose = useCallback(() => {
useHistoryStore.getState().stopReview();
}, []);
if (activeFile?.type === "pdf") {
return <InlinePdfViewer file={activeFile} editorView={viewRef} imageScale={imageScale} onImageScaleChange={setImageScale} />;
}
@ -629,8 +677,42 @@ export function LatexEditor() {
currentMatch={currentMatch}
/>
)}
{/* History review bar */}
{reviewingSnapshot && (
<div className="flex h-9 shrink-0 items-center justify-between border-b border-border bg-amber-500/10 px-3">
<div className="flex items-center gap-2 text-xs">
<RotateCcwIcon className="size-3.5 text-amber-600 dark:text-amber-400" />
<span className="font-medium text-amber-700 dark:text-amber-300">Reviewing history</span>
<span className="text-muted-foreground">
{reviewingSnapshot.message.replace(/^\[.*?\]\s*/, "")} &middot; {reviewingSnapshot.id.slice(0, 7)}
</span>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-6 gap-1 px-2 text-xs" onClick={handleHistoryRestore}>
<RotateCcwIcon className="size-3" />
Restore
</Button>
<Button variant="ghost" size="sm" className="h-6 gap-1 px-2 text-xs" onClick={() => { setHistoryLabelDialogOpen(true); setHistoryLabelValue(""); }}>
<TagIcon className="size-3" />
Label
</Button>
<Button variant="ghost" size="sm" className="h-6 gap-1 px-2 text-xs" onClick={handleHistoryCopySha}>
<CopyIcon className="size-3" />
SHA
</Button>
<div className="mx-0.5 h-4 w-px bg-border" />
<Button variant="ghost" size="icon" className="size-6" onClick={handleHistoryClose}>
<XIcon className="size-3.5" />
</Button>
</div>
</div>
)}
<div ref={parentRef} className="relative min-h-0 flex-1 overflow-hidden">
<div ref={containerRef} className="absolute inset-0" />
<div ref={containerRef} className={reviewingSnapshot ? "hidden" : "absolute inset-0"} />
{/* History diff overlay */}
{reviewingSnapshot && historyDiffResult && (
<HistoryDiffView diffs={historyDiffResult} />
)}
<ClaudeChatDrawer />
{/* Selection toolbar */}
{toolbarPosition && selectionLabel && !isMergeActiveRef.current && (
@ -720,6 +802,27 @@ export function LatexEditor() {
onUndo={() => handleUndoAllRef.current()}
/>
)}
{/* History label dialog */}
<Dialog open={historyLabelDialogOpen} onOpenChange={setHistoryLabelDialogOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Add Label</DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
placeholder="e.g. Draft v1"
value={historyLabelValue}
onChange={(e) => setHistoryLabelValue(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleHistoryAddLabel(); }}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setHistoryLabelDialogOpen(false)}>Cancel</Button>
<Button onClick={handleHistoryAddLabel} disabled={!historyLabelValue.trim()}>Add</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@ -781,3 +884,237 @@ function InlinePdfViewer({
</div>
);
}
// ─── History Diff View (git-diff style combined view) ───
function HistoryDiffView({ diffs }: { diffs: FileDiff[] }) {
return (
<div className="absolute inset-0 overflow-y-auto bg-background font-mono text-xs leading-relaxed">
{diffs.map((diff) => (
<div key={diff.file_path} className="border-b border-border">
{/* File header */}
<div className="sticky top-0 z-10 flex items-center gap-2 border-b border-border bg-muted/80 px-4 py-1.5 backdrop-blur-sm">
<span className={
diff.status === "added" ? "font-bold text-green-600 dark:text-green-400" :
diff.status === "deleted" ? "font-bold text-red-600 dark:text-red-400" :
"font-bold text-blue-600 dark:text-blue-400"
}>
{diff.status === "added" ? "+" : diff.status === "deleted" ? "" : "~"}
</span>
<span className="font-medium text-foreground">{diff.file_path}</span>
<span className="text-muted-foreground">({diff.status})</span>
</div>
{/* Diff lines */}
<DiffLines diff={diff} />
</div>
))}
{diffs.length === 0 && (
<div className="flex h-full items-center justify-center text-muted-foreground">
No changes in this snapshot
</div>
)}
</div>
);
}
function DiffLines({ diff }: { diff: FileDiff }) {
const oldLines = diff.old_content?.split("\n") ?? [];
const newLines = diff.new_content?.split("\n") ?? [];
if (diff.status === "added") {
return (
<div className="px-1">
{newLines.map((line, i) => (
<div key={i} className="flex bg-green-500/10">
<span className="w-12 shrink-0 select-none pr-2 text-right text-green-500/50">{i + 1}</span>
<span className="mr-1 select-none text-green-500/50">+</span>
<span className="text-green-700 dark:text-green-400">{line || " "}</span>
</div>
))}
</div>
);
}
if (diff.status === "deleted") {
return (
<div className="px-1">
{oldLines.map((line, i) => (
<div key={i} className="flex bg-red-500/10">
<span className="w-12 shrink-0 select-none pr-2 text-right text-red-500/50">{i + 1}</span>
<span className="mr-1 select-none text-red-500/50"></span>
<span className="text-red-700 dark:text-red-400">{line || " "}</span>
</div>
))}
</div>
);
}
// Modified: compute unified diff with context
const hunks = computeUnifiedHunks(oldLines, newLines, 3);
return (
<div className="px-1">
{hunks.map((hunk, hi) => (
<div key={hi}>
{/* Hunk header */}
<div className="bg-blue-500/10 px-1 text-blue-600 dark:text-blue-400">
@@ -{hunk.oldStart},{hunk.oldCount} +{hunk.newStart},{hunk.newCount} @@
</div>
{hunk.lines.map((line, li) => (
<div
key={li}
className={
line.type === "del" ? "flex bg-red-500/10" :
line.type === "add" ? "flex bg-green-500/10" :
"flex"
}
>
<span className={`w-12 shrink-0 select-none pr-2 text-right ${
line.type === "del" ? "text-red-500/50" :
line.type === "add" ? "text-green-500/50" :
"text-muted-foreground/50"
}`}>
{line.type !== "add" ? line.oldNum : ""}
</span>
<span className={`w-12 shrink-0 select-none pr-2 text-right ${
line.type === "del" ? "text-red-500/50" :
line.type === "add" ? "text-green-500/50" :
"text-muted-foreground/50"
}`}>
{line.type !== "del" ? line.newNum : ""}
</span>
<span className={`mr-1 select-none ${
line.type === "del" ? "text-red-500/50" :
line.type === "add" ? "text-green-500/50" :
"text-muted-foreground/30"
}`}>
{line.type === "del" ? "" : line.type === "add" ? "+" : " "}
</span>
<span className={
line.type === "del" ? "text-red-700 dark:text-red-400" :
line.type === "add" ? "text-green-700 dark:text-green-400" :
"text-muted-foreground"
}>
{line.text || " "}
</span>
</div>
))}
</div>
))}
</div>
);
}
interface DiffLine {
type: "ctx" | "del" | "add";
text: string;
oldNum?: number;
newNum?: number;
}
interface Hunk {
oldStart: number;
oldCount: number;
newStart: number;
newCount: number;
lines: DiffLine[];
}
function computeUnifiedHunks(oldLines: string[], newLines: string[], context: number): Hunk[] {
// Simple line-by-line diff to find changed regions
const ops: { type: "eq" | "del" | "add"; oldIdx?: number; newIdx?: number; text: string }[] = [];
let i = 0;
let j = 0;
while (i < oldLines.length || j < newLines.length) {
if (i < oldLines.length && j < newLines.length && oldLines[i] === newLines[j]) {
ops.push({ type: "eq", oldIdx: i, newIdx: j, text: oldLines[i] });
i++;
j++;
} else {
// Find the next matching line
let foundOld = -1;
let foundNew = -1;
const searchLimit = Math.min(50, Math.max(oldLines.length - i, newLines.length - j));
for (let look = 1; look <= searchLimit; look++) {
if (i + look < oldLines.length && j < newLines.length && oldLines[i + look] === newLines[j]) {
foundOld = i + look;
break;
}
if (j + look < newLines.length && i < oldLines.length && newLines[j + look] === oldLines[i]) {
foundNew = j + look;
break;
}
}
if (foundOld >= 0) {
// Delete lines from old until match
while (i < foundOld) {
ops.push({ type: "del", oldIdx: i, text: oldLines[i] });
i++;
}
} else if (foundNew >= 0) {
// Add lines from new until match
while (j < foundNew) {
ops.push({ type: "add", newIdx: j, text: newLines[j] });
j++;
}
} else {
// No match found nearby, emit del+add
if (i < oldLines.length) {
ops.push({ type: "del", oldIdx: i, text: oldLines[i] });
i++;
}
if (j < newLines.length) {
ops.push({ type: "add", newIdx: j, text: newLines[j] });
j++;
}
}
}
}
// Group into hunks with context lines
const changedIndices = new Set<number>();
ops.forEach((op, idx) => {
if (op.type !== "eq") {
for (let c = Math.max(0, idx - context); c <= Math.min(ops.length - 1, idx + context); c++) {
changedIndices.add(c);
}
}
});
const hunks: Hunk[] = [];
let currentHunk: Hunk | null = null;
for (let idx = 0; idx < ops.length; idx++) {
if (!changedIndices.has(idx)) {
if (currentHunk) {
hunks.push(currentHunk);
currentHunk = null;
}
continue;
}
const op = ops[idx];
if (!currentHunk) {
const oldStart = op.type !== "add" ? (op.oldIdx ?? 0) + 1 : (ops[idx + 1]?.oldIdx ?? 0) + 1;
const newStart = op.type !== "del" ? (op.newIdx ?? 0) + 1 : (ops[idx + 1]?.newIdx ?? 0) + 1;
currentHunk = { oldStart, oldCount: 0, newStart, newCount: 0, lines: [] };
}
if (op.type === "eq") {
currentHunk.lines.push({ type: "ctx", text: op.text, oldNum: (op.oldIdx ?? 0) + 1, newNum: (op.newIdx ?? 0) + 1 });
currentHunk.oldCount++;
currentHunk.newCount++;
} else if (op.type === "del") {
currentHunk.lines.push({ type: "del", text: op.text, oldNum: (op.oldIdx ?? 0) + 1 });
currentHunk.oldCount++;
} else {
currentHunk.lines.push({ type: "add", text: op.text, newNum: (op.newIdx ?? 0) + 1 });
currentHunk.newCount++;
}
}
if (currentHunk) hunks.push(currentHunk);
return hunks;
}

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import {
HistoryIcon,
LoaderIcon,
@ -7,11 +7,8 @@ import {
CopyIcon,
PlusIcon,
XIcon,
FileTextIcon,
ChevronDownIcon,
ChevronRightIcon,
} from "lucide-react";
import { useHistoryStore, type SnapshotInfo, type FileDiff } from "@/stores/history-store";
import { useHistoryStore, type SnapshotInfo } from "@/stores/history-store";
import { useDocumentStore } from "@/stores/document-store";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
@ -62,61 +59,54 @@ function snapshotTypeBadgeColor(message: string): string {
return "bg-muted text-muted-foreground";
}
function diffStatusColor(status: string): string {
if (status === "added") return "text-green-600 dark:text-green-400";
if (status === "deleted") return "text-red-600 dark:text-red-400";
return "text-blue-600 dark:text-blue-400";
}
function diffStatusPrefix(status: string): string {
if (status === "added") return "+";
if (status === "deleted") return "";
return "~";
}
// ─── Header (rendered by Sidebar) ───
export function HistoryHeader() {
const isLoading = useHistoryStore((s) => s.isLoading);
const projectRoot = useDocumentStore((s) => s.projectRoot);
const loadSnapshots = useHistoryStore((s) => s.loadSnapshots);
return (
<div className="flex w-full items-center justify-between px-3">
<div className="flex items-center gap-2">
<HistoryIcon className="size-3.5 text-muted-foreground" />
<span className="font-medium text-xs">History</span>
</div>
<button
className="rounded p-1 text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-foreground"
onClick={() => projectRoot && loadSnapshots(projectRoot)}
title="Refresh"
>
<RotateCcwIcon className={cn("size-3.5", isLoading && "animate-spin")} />
</button>
</div>
);
}
// ─── Panel ───
export function HistoryPanel() {
export function HistoryPanel({ maxHeight }: { maxHeight?: string }) {
const projectRoot = useDocumentStore((s) => s.projectRoot);
const snapshots = useHistoryStore((s) => s.snapshots);
const isLoading = useHistoryStore((s) => s.isLoading);
const isRestoring = useHistoryStore((s) => s.isRestoring);
const diffResult = useHistoryStore((s) => s.diffResult);
const isDiffLoading = useHistoryStore((s) => s.isDiffLoading);
const reviewingSnapshot = useHistoryStore((s) => s.reviewingSnapshot);
const init = useHistoryStore((s) => s.init);
const loadSnapshots = useHistoryStore((s) => s.loadSnapshots);
const loadMoreSnapshots = useHistoryStore((s) => s.loadMoreSnapshots);
const loadDiff = useHistoryStore((s) => s.loadDiff);
const startReview = useHistoryStore((s) => s.startReview);
const restoreSnapshot = useHistoryStore((s) => s.restoreSnapshot);
const addLabel = useHistoryStore((s) => s.addLabel);
const removeLabel = useHistoryStore((s) => s.removeLabel);
const openProject = useDocumentStore((s) => s.openProject);
const [expandedId, setExpandedId] = useState<string | null>(null);
// Compute linear history: when a [restore] snapshot appears,
// skip all snapshots between it and the restored target
const linearSnapshots = useMemo(() => {
const result: SnapshotInfo[] = [];
let skipUntilSha: string | null = null;
for (const snap of snapshots) {
if (skipUntilSha) {
// Skip until we find the snapshot that was restored to
if (snap.id.startsWith(skipUntilSha)) {
skipUntilSha = null;
result.push(snap);
}
continue;
}
result.push(snap);
// If this is a restore snapshot, extract the target SHA and start skipping
if (snap.message.startsWith("[restore]")) {
const match = snap.message.match(/Restored to ([a-f0-9]+)/);
if (match) {
skipUntilSha = match[1];
}
}
}
return result;
}, [snapshots]);
const [labelDialogOpen, setLabelDialogOpen] = useState(false);
const [labelTargetId, setLabelTargetId] = useState<string | null>(null);
const [labelValue, setLabelValue] = useState("");
@ -137,33 +127,37 @@ export function HistoryPanel() {
}
}, [projectRoot, isLoading, loadMoreSnapshots]);
// Double-click to expand and load diff
// Double-click to show diff in editor
const handleDoubleClick = useCallback(
async (snap: SnapshotInfo) => {
if (!projectRoot) return;
if (expandedId === snap.id) {
setExpandedId(null);
// Toggle off if already reviewing this snapshot
if (reviewingSnapshot?.id === snap.id) {
useHistoryStore.getState().stopReview();
return;
}
setExpandedId(snap.id);
// Find parent snapshot (the one right after in the list)
const idx = snapshots.findIndex((s) => s.id === snap.id);
const parent = snapshots[idx + 1];
// Find parent snapshot (the one right after in the linear list)
const idx = linearSnapshots.findIndex((s) => s.id === snap.id);
const parent = linearSnapshots[idx + 1];
if (parent) {
await loadDiff(projectRoot, parent.id, snap.id);
startReview(snap);
}
},
[projectRoot, snapshots, expandedId, loadDiff],
[projectRoot, linearSnapshots, reviewingSnapshot, loadDiff, startReview],
);
const handleRestore = useCallback(
async (snapshotId: string) => {
if (!projectRoot) return;
// Stop any active review
useHistoryStore.getState().stopReview();
await restoreSnapshot(projectRoot, snapshotId);
// Re-open project to fully reload all file contents into editor
// Re-open project and reload snapshot list
await openProject(projectRoot);
await loadSnapshots(projectRoot);
},
[projectRoot, restoreSnapshot, openProject],
[projectRoot, restoreSnapshot, openProject, loadSnapshots],
);
const handleAddLabel = useCallback(async () => {
@ -190,26 +184,38 @@ export function HistoryPanel() {
}
return (
<div className="flex h-full flex-col">
<div className={cn("flex flex-col", maxHeight || "h-full")}>
{/* Header */}
<div className="flex shrink-0 items-center justify-between border-b px-3 py-1.5">
<div className="flex items-center gap-2">
<HistoryIcon className="size-3.5 text-muted-foreground" />
<span className="font-medium text-xs">History</span>
</div>
<button
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => projectRoot && loadSnapshots(projectRoot)}
title="Refresh"
>
<RotateCcwIcon className={cn("size-3.5", isLoading && "animate-spin")} />
</button>
</div>
<div
ref={scrollRef}
className="min-h-0 flex-1 overflow-y-auto"
onScroll={handleScroll}
>
{snapshots.length === 0 && !isLoading ? (
{linearSnapshots.length === 0 && !isLoading ? (
<div className="px-3 py-4 text-center text-[11px] text-muted-foreground">
No history yet
</div>
) : (
<div className="py-0.5">
{snapshots.map((snap) => (
{linearSnapshots.map((snap) => (
<SnapshotRow
key={snap.id}
snapshot={snap}
isExpanded={expandedId === snap.id}
isSelected={reviewingSnapshot?.id === snap.id}
isRestoring={isRestoring}
diffResult={expandedId === snap.id ? diffResult : null}
isDiffLoading={expandedId === snap.id && isDiffLoading}
onDoubleClick={() => handleDoubleClick(snap)}
onRestore={() => handleRestore(snap.id)}
onAddLabel={() => openLabelDialog(snap.id)}
@ -256,10 +262,8 @@ export function HistoryPanel() {
function SnapshotRow({
snapshot,
isExpanded,
isSelected,
isRestoring,
diffResult,
isDiffLoading,
onDoubleClick,
onRestore,
onAddLabel,
@ -267,10 +271,8 @@ function SnapshotRow({
onCopySha,
}: {
snapshot: SnapshotInfo;
isExpanded: boolean;
isSelected: boolean;
isRestoring: boolean;
diffResult: FileDiff[] | null;
isDiffLoading: boolean;
onDoubleClick: () => void;
onRestore: () => void;
onAddLabel: () => void;
@ -282,93 +284,57 @@ function SnapshotRow({
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div>
<button
className={cn(
"group flex w-full items-start px-2 py-1 text-left transition-colors",
isExpanded ? "bg-sidebar-accent" : "hover:bg-sidebar-accent/50",
)}
onDoubleClick={onDoubleClick}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1">
<span
className={cn(
"rounded px-1 py-px text-[10px] leading-tight",
snapshotTypeBadgeColor(snapshot.message),
)}
>
{snapshotTypeLabel(snapshot.message)}
</span>
<span className="text-[10px] text-muted-foreground">
{formatRelativeTime(snapshot.timestamp)}
</span>
</div>
{/* Labels */}
{snapshot.labels.length > 0 && (
<div className="mt-0.5 flex flex-wrap gap-0.5">
{snapshot.labels.map((label) => (
<span
key={label}
className="inline-flex items-center gap-0.5 rounded bg-amber-500/15 px-1 py-px text-[10px] text-amber-600 dark:text-amber-400"
>
<TagIcon className="size-2" />
{label}
<button
className="ml-0.5 rounded-sm opacity-0 hover:text-destructive group-hover:opacity-100"
onClick={(e) => { e.stopPropagation(); onRemoveLabel(label); }}
>
<XIcon className="size-2" />
</button>
</span>
))}
</div>
)}
{/* Changed files summary */}
{hasFiles && (
<div className="mt-0.5 flex items-center gap-0.5 text-[10px] text-muted-foreground">
{isExpanded ? (
<ChevronDownIcon className="size-2.5 shrink-0" />
) : (
<ChevronRightIcon className="size-2.5 shrink-0" />
)}
<span className="truncate">
{snapshot.changed_files.map((f) => f.split("/").pop()).join(", ")}
</span>
</div>
)}
</div>
</button>
{/* Expanded diff view */}
{isExpanded && hasFiles && (
<div className="ml-3 border-border border-l pl-2">
{isDiffLoading ? (
<div className="flex items-center gap-1 py-1 text-[10px] text-muted-foreground">
<LoaderIcon className="size-2.5 animate-spin" />
Loading diff...
</div>
) : diffResult ? (
<div className="py-0.5">
{diffResult.map((diff) => (
<DiffFileRow key={diff.file_path} diff={diff} />
))}
</div>
) : (
<div className="py-0.5">
{snapshot.changed_files.map((filePath) => (
<div key={filePath} className="flex items-center gap-1 px-1 py-0.5 text-[10px] text-muted-foreground">
<FileTextIcon className="size-2.5 shrink-0" />
<span className="truncate">{filePath}</span>
</div>
))}
</div>
)}
</div>
<button
className={cn(
"group flex w-full items-start px-2 py-1.5 text-left transition-colors",
isSelected ? "bg-accent" : "hover:bg-accent/50",
)}
</div>
onDoubleClick={onDoubleClick}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1">
<span
className={cn(
"rounded px-1 py-px text-[10px] leading-tight",
snapshotTypeBadgeColor(snapshot.message),
)}
>
{snapshotTypeLabel(snapshot.message)}
</span>
<span className="text-[10px] text-muted-foreground">
{formatRelativeTime(snapshot.timestamp)}
</span>
</div>
{/* Labels */}
{snapshot.labels.length > 0 && (
<div className="mt-0.5 flex flex-wrap gap-0.5">
{snapshot.labels.map((label) => (
<span
key={label}
className="inline-flex items-center gap-0.5 rounded bg-amber-500/15 px-1 py-px text-[10px] text-amber-600 dark:text-amber-400"
>
<TagIcon className="size-2" />
{label}
<button
className="ml-0.5 rounded-sm opacity-0 hover:text-destructive group-hover:opacity-100"
onClick={(e) => { e.stopPropagation(); onRemoveLabel(label); }}
>
<XIcon className="size-2" />
</button>
</span>
))}
</div>
)}
{/* Changed files summary */}
{hasFiles && (
<div className="mt-0.5 text-[10px] text-muted-foreground truncate">
{snapshot.changed_files.map((f) => f.split("/").pop()).join(", ")}
</div>
)}
</div>
</button>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={onRestore} disabled={isRestoring}>
@ -389,165 +355,3 @@ function SnapshotRow({
);
}
// ─── Diff File Row ───
function DiffFileRow({ diff }: { diff: FileDiff }) {
const [expanded, setExpanded] = useState(false);
const fileName = diff.file_path.split("/").pop() || diff.file_path;
return (
<div>
<button
className="flex w-full items-center gap-1 rounded px-1 py-0.5 text-left text-[10px] transition-colors hover:bg-sidebar-accent/50"
onClick={() => setExpanded(!expanded)}
>
<span className={cn("font-mono font-bold", diffStatusColor(diff.status))}>
{diffStatusPrefix(diff.status)}
</span>
<FileTextIcon className="size-2.5 shrink-0 text-muted-foreground" />
<span className="truncate text-muted-foreground">{fileName}</span>
</button>
{expanded && (
<div className="mx-1 mb-1 max-h-48 overflow-auto rounded border border-border bg-muted/30 p-1 font-mono text-[9px] leading-relaxed">
{renderInlineDiff(diff)}
</div>
)}
</div>
);
}
// ─── Inline Diff Renderer ───
function renderInlineDiff(diff: FileDiff) {
const oldLines = diff.old_content?.split("\n") ?? [];
const newLines = diff.new_content?.split("\n") ?? [];
if (diff.status === "added") {
return (
<div>
{newLines.slice(0, 50).map((line, i) => (
<div key={i} className="bg-green-500/10 text-green-700 dark:text-green-400">
<span className="mr-1 select-none text-green-500/50">+</span>{line}
</div>
))}
{newLines.length > 50 && (
<div className="text-muted-foreground">... {newLines.length - 50} more lines</div>
)}
</div>
);
}
if (diff.status === "deleted") {
return (
<div>
{oldLines.slice(0, 50).map((line, i) => (
<div key={i} className="bg-red-500/10 text-red-700 dark:text-red-400">
<span className="mr-1 select-none text-red-500/50"></span>{line}
</div>
))}
{oldLines.length > 50 && (
<div className="text-muted-foreground">... {oldLines.length - 50} more lines</div>
)}
</div>
);
}
// Modified: simple line-by-line comparison
const maxLen = Math.max(oldLines.length, newLines.length);
const diffLines: { type: "ctx" | "del" | "add"; text: string }[] = [];
let i = 0;
let j = 0;
// Simple LCS-like comparison: show removed then added for changed regions
while (i < oldLines.length || j < newLines.length) {
if (i < oldLines.length && j < newLines.length && oldLines[i] === newLines[j]) {
diffLines.push({ type: "ctx", text: oldLines[i] });
i++;
j++;
} else {
// Collect differing lines
const startI = i;
const startJ = j;
// Advance until we find a common line or exhaust both
while (i < oldLines.length && j < newLines.length && oldLines[i] !== newLines[j]) {
i++;
j++;
}
// If still not matching, try to find next match
if (i < oldLines.length && j < newLines.length) {
// Both advanced same amount, output as changes
}
for (let k = startI; k < i; k++) {
diffLines.push({ type: "del", text: oldLines[k] });
}
for (let k = startJ; k < j; k++) {
diffLines.push({ type: "add", text: newLines[k] });
}
if (i >= oldLines.length && j < newLines.length) {
while (j < newLines.length) {
diffLines.push({ type: "add", text: newLines[j] });
j++;
}
}
if (j >= newLines.length && i < oldLines.length) {
while (i < oldLines.length) {
diffLines.push({ type: "del", text: oldLines[i] });
i++;
}
}
}
if (diffLines.length > 100) break;
}
// Trim to show only changed regions with context
const relevant: typeof diffLines = [];
const CONTEXT = 2;
const changedIndices = new Set<number>();
diffLines.forEach((line, idx) => {
if (line.type !== "ctx") {
for (let c = Math.max(0, idx - CONTEXT); c <= Math.min(diffLines.length - 1, idx + CONTEXT); c++) {
changedIndices.add(c);
}
}
});
let lastShown = -1;
for (let idx = 0; idx < diffLines.length; idx++) {
if (changedIndices.has(idx)) {
if (lastShown >= 0 && idx - lastShown > 1) {
relevant.push({ type: "ctx", text: "···" });
}
relevant.push(diffLines[idx]);
lastShown = idx;
}
}
if (relevant.length === 0) {
return <div className="text-muted-foreground">No visible changes</div>;
}
return (
<div>
{relevant.map((line, i) => (
<div
key={i}
className={cn(
line.type === "del" && "bg-red-500/10 text-red-700 dark:text-red-400",
line.type === "add" && "bg-green-500/10 text-green-700 dark:text-green-400",
line.type === "ctx" && "text-muted-foreground",
)}
>
<span className={cn("mr-1 select-none", {
"text-red-500/50": line.type === "del",
"text-green-500/50": line.type === "add",
"text-muted-foreground/50": line.type === "ctx",
})}>
{line.type === "del" ? "" : line.type === "add" ? "+" : " "}
</span>
{line.text}
</div>
))}
</div>
);
}

View file

@ -8,6 +8,7 @@ import {
MinusIcon,
PlusIcon,
DownloadIcon,
HistoryIcon,
MousePointerClickIcon,
} from "lucide-react";
import { useDocumentStore } from "@/stores/document-store";
@ -20,6 +21,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { HistoryPanel } from "@/components/workspace/history-panel";
import { compileLatex, synctexEdit } from "@/lib/latex-compiler";
import { SelectionToolbar, type ToolbarAction } from "@/components/workspace/editor/selection-toolbar";
import type { PdfTextSelection } from "./pdf-viewer";
@ -461,19 +464,29 @@ export function PdfPreview() {
</>
)}
</div>
{pdfData && (
<div className="flex items-center gap-0.5">
<span className="mr-2 text-muted-foreground text-xs">{numPages} {numPages === 1 ? "page" : "pages"}</span>
<Button variant="ghost" size="icon" className="size-6" onClick={zoomOut} disabled={scale <= 0.25}><MinusIcon className="size-3.5" /></Button>
<Button variant="ghost" size="icon" className="size-6" onClick={zoomIn} disabled={scale >= 4}><PlusIcon className="size-3.5" /></Button>
<Select value={scale.toString()} onValueChange={(v) => setScale(Number(v))}>
<SelectTrigger size="sm" className="h-6! w-auto text-xs"><SelectValue>{Math.round(scale * 100)}%</SelectValue></SelectTrigger>
<SelectContent>{ZOOM_OPTIONS.map((opt) => (<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>))}</SelectContent>
</Select>
<div className="mx-0.5 h-4 w-px bg-border" />
<Button variant="ghost" size="icon" className="size-6" onClick={handleDownload} title="Download PDF"><DownloadIcon className="size-3.5" /></Button>
</div>
)}
<div className="flex items-center gap-0.5">
{pdfData && (
<>
<span className="mr-2 text-muted-foreground text-xs">{numPages} {numPages === 1 ? "page" : "pages"}</span>
<Button variant="ghost" size="icon" className="size-6" onClick={zoomOut} disabled={scale <= 0.25}><MinusIcon className="size-3.5" /></Button>
<Button variant="ghost" size="icon" className="size-6" onClick={zoomIn} disabled={scale >= 4}><PlusIcon className="size-3.5" /></Button>
<Select value={scale.toString()} onValueChange={(v) => setScale(Number(v))}>
<SelectTrigger size="sm" className="h-6! w-auto text-xs"><SelectValue>{Math.round(scale * 100)}%</SelectValue></SelectTrigger>
<SelectContent>{ZOOM_OPTIONS.map((opt) => (<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>))}</SelectContent>
</Select>
<div className="mx-0.5 h-4 w-px bg-border" />
<Button variant="ghost" size="icon" className="size-6" onClick={handleDownload} title="Download PDF"><DownloadIcon className="size-3.5" /></Button>
</>
)}
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="size-6" title="History"><HistoryIcon className="size-3.5" /></Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-96">
<HistoryPanel maxHeight="max-h-[32rem]" />
</PopoverContent>
</Popover>
</div>
</div>
{renderContent()}
{/* PDF selection toolbar */}

View file

@ -38,7 +38,6 @@ import { useTheme } from "next-themes";
import { useDocumentStore, type ProjectFile } from "@/stores/document-store";
import { cn } from "@/lib/utils";
import { ZoteroPanel, ZoteroHeader } from "@/components/workspace/zotero-panel";
import { HistoryPanel, HistoryHeader } from "@/components/workspace/history-panel";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -662,20 +661,6 @@ export function Sidebar() {
<PanelResizeHandle className="h-px bg-sidebar-border transition-colors hover:bg-ring data-resize-handle-active:bg-ring" />
{/* History */}
<Panel defaultSize={15} minSize={10}>
<div className="flex h-full flex-col">
<div className="flex h-8 shrink-0 items-center">
<HistoryHeader />
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<HistoryPanel />
</div>
</div>
</Panel>
<PanelResizeHandle className="h-px bg-sidebar-border transition-colors hover:bg-ring data-resize-handle-active:bg-ring" />
{/* Zotero */}
<Panel defaultSize={15} minSize={10}>
<div className="flex h-full flex-col">

View file

@ -25,6 +25,7 @@ interface HistoryState {
diffResult: FileDiff[] | null;
isDiffLoading: boolean;
isRestoring: boolean;
reviewingSnapshot: SnapshotInfo | null;
init: (projectRoot: string) => Promise<void>;
createSnapshot: (projectRoot: string, message: string) => Promise<SnapshotInfo | null>;
@ -36,6 +37,8 @@ interface HistoryState {
restoreSnapshot: (projectRoot: string, snapshotId: string) => Promise<SnapshotInfo>;
addLabel: (projectRoot: string, snapshotId: string, label: string) => Promise<void>;
removeLabel: (projectRoot: string, label: string) => Promise<void>;
startReview: (snapshot: SnapshotInfo) => void;
stopReview: () => void;
reset: () => void;
}
@ -48,6 +51,15 @@ export const useHistoryStore = create<HistoryState>()((set, get) => ({
diffResult: null,
isDiffLoading: false,
isRestoring: false,
reviewingSnapshot: null,
startReview: (snapshot) => {
set({ reviewingSnapshot: snapshot });
},
stopReview: () => {
set({ reviewingSnapshot: null, diffResult: null });
},
init: async (projectRoot) => {
await invoke("history_init", { projectRoot });
@ -165,5 +177,6 @@ export const useHistoryStore = create<HistoryState>()((set, get) => ({
diffResult: null,
isDiffLoading: false,
isRestoring: false,
reviewingSnapshot: null,
}),
}));