mirror of
https://github.com/delibae/claude-prism.git
synced 2026-08-28 05:14:59 +00:00
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:
parent
d6df454736
commit
2430aeca7c
6 changed files with 537 additions and 345 deletions
40
apps/desktop/src/components/ui/popover.tsx
Normal file
40
apps/desktop/src/components/ui/popover.tsx
Normal 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 };
|
||||
|
|
@ -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*/, "")} · {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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue