diff --git a/surfsense_backend/app/tasks/celery_tasks/document_tasks.py b/surfsense_backend/app/tasks/celery_tasks/document_tasks.py index 16ac50967..506f8118c 100644 --- a/surfsense_backend/app/tasks/celery_tasks/document_tasks.py +++ b/surfsense_backend/app/tasks/celery_tasks/document_tasks.py @@ -1321,6 +1321,7 @@ async def _index_local_folder_async( doc_name = folder_name notification = None + notification_id: int | None = None heartbeat_task = None async with get_celery_session_maker()() as session: @@ -1334,9 +1335,10 @@ async def _index_local_folder_async( search_space_id=search_space_id, ) ) - _start_heartbeat(notification.id) + notification_id = notification.id + _start_heartbeat(notification_id) heartbeat_task = asyncio.create_task( - _run_heartbeat_loop(notification.id) + _run_heartbeat_loop(notification_id) ) except Exception: logger.warning( @@ -1374,6 +1376,7 @@ async def _index_local_folder_async( if notification: try: + await session.refresh(notification) if err: await NotificationService.document_processing.notify_processing_completed( session=session, @@ -1395,6 +1398,7 @@ async def _index_local_folder_async( logger.exception(f"Local folder indexing failed: {e}") if notification: try: + await session.refresh(notification) await NotificationService.document_processing.notify_processing_completed( session=session, notification=notification, @@ -1406,5 +1410,5 @@ async def _index_local_folder_async( finally: if heartbeat_task: heartbeat_task.cancel() - if notification: - _stop_heartbeat(notification.id) + if notification_id is not None: + _stop_heartbeat(notification_id) diff --git a/surfsense_web/components/documents/DocumentNode.tsx b/surfsense_web/components/documents/DocumentNode.tsx index dc92109b1..31d1bc7ca 100644 --- a/surfsense_web/components/documents/DocumentNode.tsx +++ b/surfsense_web/components/documents/DocumentNode.tsx @@ -5,6 +5,7 @@ import { Clock, Download, Eye, + History, MoreHorizontal, Move, PenLine, @@ -38,6 +39,7 @@ import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import { cn } from "@/lib/utils"; +import { isVersionableType } from "./version-history"; import { DND_TYPES } from "./FolderNode"; const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]); @@ -60,6 +62,7 @@ interface DocumentNodeProps { onDelete: (doc: DocumentNodeDoc) => void; onMove: (doc: DocumentNodeDoc) => void; onExport?: (doc: DocumentNodeDoc, format: string) => void; + onVersionHistory?: (doc: DocumentNodeDoc) => void; contextMenuOpen?: boolean; onContextMenuOpenChange?: (open: boolean) => void; } @@ -74,6 +77,7 @@ export const DocumentNode = React.memo(function DocumentNode({ onDelete, onMove, onExport, + onVersionHistory, contextMenuOpen, onContextMenuOpenChange, }: DocumentNodeProps) { @@ -246,6 +250,15 @@ export const DocumentNode = React.memo(function DocumentNode({ )} + {onVersionHistory && isVersionableType(doc.document_type) && ( + onVersionHistory(doc)} + > + + Versions + + )} )} + {onVersionHistory && isVersionableType(doc.document_type) && ( + onVersionHistory(doc)} + > + + Versions + + )} void; onMoveDocument: (doc: DocumentNodeDoc) => void; onExportDocument?: (doc: DocumentNodeDoc, format: string) => void; + onVersionHistory?: (doc: DocumentNodeDoc) => void; activeTypes: DocumentTypeEnum[]; searchQuery?: string; onDropIntoFolder?: ( @@ -73,6 +74,7 @@ export function FolderTreeView({ onDeleteDocument, onMoveDocument, onExportDocument, + onVersionHistory, activeTypes, searchQuery, onDropIntoFolder, @@ -267,6 +269,7 @@ export function FolderTreeView({ onDelete={onDeleteDocument} onMove={onMoveDocument} onExport={onExportDocument} + onVersionHistory={onVersionHistory} contextMenuOpen={openContextMenuId === `doc-${d.id}`} onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `doc-${d.id}` : null)} /> diff --git a/surfsense_web/components/documents/version-history.tsx b/surfsense_web/components/documents/version-history.tsx index 29740e079..f438a7190 100644 --- a/surfsense_web/components/documents/version-history.tsx +++ b/surfsense_web/components/documents/version-history.tsx @@ -1,16 +1,17 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { Clock, RotateCcw } from "lucide-react"; +import { Check, ChevronRight, Clock, Copy, RotateCcw } from "lucide-react"; import { Button } from "@/components/ui/button"; import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetTrigger, -} from "@/components/ui/sheet"; + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Separator } from "@/components/ui/separator"; import { Spinner } from "@/components/ui/spinner"; +import { cn } from "@/lib/utils"; import { documentsApiService } from "@/lib/apis/documents-api.service"; import { toast } from "sonner"; @@ -26,28 +27,72 @@ interface VersionHistoryProps { documentType: string; } +const VERSION_DOCUMENT_TYPES = new Set(["LOCAL_FOLDER_FILE", "OBSIDIAN_CONNECTOR"]); + +export function isVersionableType(documentType: string) { + return VERSION_DOCUMENT_TYPES.has(documentType); +} + +const DIALOG_CLASSES = + "select-none max-w-[900px] w-[95vw] md:w-[90vw] h-[90vh] md:h-[80vh] max-h-[640px] flex flex-col md:flex-row p-0 gap-0 overflow-hidden [--card:var(--background)] dark:[--card:oklch(0.205_0_0)] dark:[--background:oklch(0.205_0_0)]"; + export function VersionHistoryButton({ documentId, documentType }: VersionHistoryProps) { - const showVersionHistory = documentType === "LOCAL_FOLDER_FILE" || documentType === "OBSIDIAN_CONNECTOR"; - if (!showVersionHistory) return null; + if (!isVersionableType(documentType)) return null; return ( - - + + - - - - Version History - + + + Version History - - + + ); } +export function VersionHistoryDialog({ + open, + onOpenChange, + documentId, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + documentId: number; +}) { + return ( + + + Version History + {open && } + + + ); +} + +function formatRelativeTime(dateStr: string): string { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diffMs = now - then; + const diffMin = Math.floor(diffMs / 60_000); + if (diffMin < 1) return "Just now"; + if (diffMin < 60) return `${diffMin} minute${diffMin !== 1 ? "s" : ""} ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr} hour${diffHr !== 1 ? "s" : ""} ago`; + return new Date(dateStr).toLocaleDateString(undefined, { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + function VersionHistoryPanel({ documentId }: { documentId: number }) { const [versions, setVersions] = useState([]); const [loading, setLoading] = useState(true); @@ -55,6 +100,7 @@ function VersionHistoryPanel({ documentId }: { documentId: number }) { const [versionContent, setVersionContent] = useState(""); const [contentLoading, setContentLoading] = useState(false); const [restoring, setRestoring] = useState(false); + const [copied, setCopied] = useState(false); const loadVersions = useCallback(async () => { setLoading(true); @@ -73,6 +119,7 @@ function VersionHistoryPanel({ documentId }: { documentId: number }) { }, [loadVersions]); const handleSelectVersion = async (versionNumber: number) => { + if (selectedVersion === versionNumber) return; setSelectedVersion(versionNumber); setContentLoading(true); try { @@ -101,9 +148,15 @@ function VersionHistoryPanel({ documentId }: { documentId: number }) { } }; + const handleCopy = () => { + navigator.clipboard.writeText(versionContent); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + if (loading) { return ( -
+
); @@ -111,75 +164,111 @@ function VersionHistoryPanel({ documentId }: { documentId: number }) { if (versions.length === 0) { return ( -
- +

No version history available yet.

Versions are created when file content changes.

); } - return ( -
-
- {versions.map((v) => ( -
handleSelectVersion(v.version_number)} - > -
-
-

Version {v.version_number}

- {v.created_at && ( -

- {new Date(v.created_at).toLocaleString()} -

- )} - {v.title && ( -

- {v.title} -

- )} -
- -
-
- ))} -
+ const selectedVersionData = versions.find((v) => v.version_number === selectedVersion); - {selectedVersion !== null && ( -
-

- Preview — Version {selectedVersion} -

- {contentLoading ? ( -
- -
- ) : ( -
-							{versionContent || "(empty)"}
-						
- )} + return ( + <> + {/* Left panel — version list */} +
+
+
+ {versions.map((v) => ( + + ))} +
+
+ + + {/* Right panel — content preview */} +
+ {selectedVersion !== null && selectedVersionData ? ( + <> +
+

+ {selectedVersionData.title || `Version ${selectedVersion}`} +

+
+ + +
+
+ +
+ {contentLoading ? ( +
+ +
+ ) : ( +
+									{versionContent || "(empty)"}
+								
+ )} +
+ + ) : ( +
+

Select a version to preview

+
+ )} +
+ ); } diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 7aa518361..031390c9e 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -21,6 +21,7 @@ import type { DocumentNodeDoc } from "@/components/documents/DocumentNode"; import type { FolderDisplay } from "@/components/documents/FolderNode"; import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog"; import { FolderTreeView } from "@/components/documents/FolderTreeView"; +import { VersionHistoryDialog } from "@/components/documents/version-history"; import { JsonMetadataViewer } from "@/components/json-metadata-viewer"; import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems"; import { @@ -579,6 +580,7 @@ export function DocumentsSidebar({ const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false); const [isBulkDeleting, setIsBulkDeleting] = useState(false); + const [versionDocId, setVersionDocId] = useState(null); const handleBulkDeleteSelected = useCallback(async () => { if (deletableSelectedIds.length === 0) return; @@ -826,6 +828,7 @@ export function DocumentsSidebar({ onDeleteDocument={(doc) => handleDeleteDocument(doc.id)} onMoveDocument={handleMoveDocument} onExportDocument={handleExportDocument} + onVersionHistory={(doc) => setVersionDocId(doc.id)} activeTypes={activeTypes} onDropIntoFolder={handleDropIntoFolder} onReorderFolder={handleReorderFolder} @@ -850,6 +853,14 @@ export function DocumentsSidebar({ }} /> + {versionDocId !== null && ( + { if (!open) setVersionDocId(null); }} + documentId={versionDocId} + /> + )} +