"use client"; import { useAtomValue, useSetAtom } from "jotai"; import { Check, Copy, Download, FileQuestionMark, FileText, Pencil, RefreshCw, XIcon, } from "lucide-react"; import dynamic from "next/dynamic"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom"; import { VersionHistoryButton } from "@/components/documents/version-history"; import { SourceCodeEditor } from "@/components/editor/source-code-editor"; import { MarkdownViewer } from "@/components/markdown-viewer"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; import { Spinner } from "@/components/ui/spinner"; import { useMediaQuery } from "@/hooks/use-media-query"; import { useElectronAPI } from "@/hooks/use-platform"; import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils"; import { inferMonacoLanguageFromPath } from "@/lib/editor-language"; const PlateEditor = dynamic( () => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })), { ssr: false, loading: () => } ); const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024; // 2MB interface EditorContent { document_id: number; title: string; document_type?: string; source_markdown: string; content_size_bytes?: number; chunk_count?: number; truncated?: boolean; } const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]); type EditorRenderMode = "rich_markdown" | "source_code"; function EditorPanelSkeleton() { return (
); } export function EditorPanelContent({ kind = "document", documentId, localFilePath, searchSpaceId, title, onClose, }: { kind?: "document" | "local_file"; documentId?: number; localFilePath?: string; searchSpaceId?: number; title: string | null; onClose?: () => void; }) { const electronAPI = useElectronAPI(); const [editorDoc, setEditorDoc] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); const [downloading, setDownloading] = useState(false); const [isEditing, setIsEditing] = useState(false); const [editedMarkdown, setEditedMarkdown] = useState(null); const [localFileContent, setLocalFileContent] = useState(""); const [hasCopied, setHasCopied] = useState(false); const markdownRef = useRef(""); const copyResetTimeoutRef = useRef | null>(null); const initialLoadDone = useRef(false); const changeCountRef = useRef(0); const [displayTitle, setDisplayTitle] = useState(title || "Untitled"); const isLocalFileMode = kind === "local_file"; const editorRenderMode: EditorRenderMode = isLocalFileMode ? "source_code" : "rich_markdown"; const isLargeDocument = (editorDoc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD; useEffect(() => { const controller = new AbortController(); setIsLoading(true); setError(null); setEditorDoc(null); setEditedMarkdown(null); setLocalFileContent(""); setHasCopied(false); setIsEditing(false); initialLoadDone.current = false; changeCountRef.current = 0; const doFetch = async () => { try { if (isLocalFileMode) { if (!localFilePath) { throw new Error("Missing local file path"); } if (!electronAPI?.readAgentLocalFileText) { throw new Error("Local file editor is available only in desktop mode."); } const readResult = await electronAPI.readAgentLocalFileText(localFilePath); if (!readResult.ok) { throw new Error(readResult.error || "Failed to read local file"); } const inferredTitle = localFilePath.split("/").pop() || localFilePath; const content: EditorContent = { document_id: -1, title: inferredTitle, document_type: "NOTE", source_markdown: readResult.content, }; markdownRef.current = content.source_markdown; setLocalFileContent(content.source_markdown); setDisplayTitle(title || inferredTitle); setEditorDoc(content); initialLoadDone.current = true; return; } if (!documentId || !searchSpaceId) { throw new Error("Missing document context"); } const token = getBearerToken(); if (!token) { redirectToLogin(); return; } const url = new URL( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content` ); url.searchParams.set("max_length", String(LARGE_DOCUMENT_THRESHOLD)); const response = await authenticatedFetch(url.toString(), { method: "GET" }); if (controller.signal.aborted) return; if (!response.ok) { const errorData = await response .json() .catch(() => ({ detail: "Failed to fetch document" })); throw new Error(errorData.detail || "Failed to fetch document"); } const data = await response.json(); if (data.source_markdown === undefined || data.source_markdown === null) { setError( "This document does not have editable content. Please re-upload to enable editing." ); setIsLoading(false); return; } markdownRef.current = data.source_markdown; setDisplayTitle(data.title || title || "Untitled"); setEditorDoc(data); initialLoadDone.current = true; } catch (err) { if (controller.signal.aborted) return; console.error("Error fetching document:", err); setError(err instanceof Error ? err.message : "Failed to fetch document"); } finally { if (!controller.signal.aborted) setIsLoading(false); } }; doFetch().catch(() => {}); return () => controller.abort(); }, [documentId, electronAPI, isLocalFileMode, localFilePath, searchSpaceId, title]); useEffect(() => { return () => { if (copyResetTimeoutRef.current) { clearTimeout(copyResetTimeoutRef.current); } }; }, []); const handleMarkdownChange = useCallback((md: string) => { markdownRef.current = md; if (!initialLoadDone.current) return; changeCountRef.current += 1; if (changeCountRef.current <= 1) return; setEditedMarkdown(md); }, []); const handleCopy = useCallback(async () => { try { const textToCopy = markdownRef.current ?? editorDoc?.source_markdown ?? ""; await navigator.clipboard.writeText(textToCopy); setHasCopied(true); if (copyResetTimeoutRef.current) { clearTimeout(copyResetTimeoutRef.current); } copyResetTimeoutRef.current = setTimeout(() => { setHasCopied(false); }, 1400); } catch (err) { console.error("Error copying content:", err); } }, [editorDoc?.source_markdown]); const handleSave = useCallback(async (options?: { silent?: boolean }) => { setSaving(true); try { if (isLocalFileMode) { if (!localFilePath) { throw new Error("Missing local file path"); } if (!electronAPI?.writeAgentLocalFileText) { throw new Error("Local file editor is available only in desktop mode."); } const contentToSave = markdownRef.current; const writeResult = await electronAPI.writeAgentLocalFileText( localFilePath, contentToSave ); if (!writeResult.ok) { throw new Error(writeResult.error || "Failed to save local file"); } setEditorDoc((prev) => prev ? { ...prev, source_markdown: contentToSave } : prev ); setEditedMarkdown(markdownRef.current === contentToSave ? null : markdownRef.current); return true; } if (!searchSpaceId || !documentId) { throw new Error("Missing document context"); } const token = getBearerToken(); if (!token) { toast.error("Please login to save"); redirectToLogin(); return; } const response = await authenticatedFetch( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ source_markdown: markdownRef.current }), } ); if (!response.ok) { const errorData = await response .json() .catch(() => ({ detail: "Failed to save document" })); throw new Error(errorData.detail || "Failed to save document"); } setEditorDoc((prev) => (prev ? { ...prev, source_markdown: markdownRef.current } : prev)); setEditedMarkdown(null); toast.success("Document saved! Reindexing in background..."); return true; } catch (err) { console.error("Error saving document:", err); toast.error(err instanceof Error ? err.message : "Failed to save document"); return false; } finally { setSaving(false); } }, [documentId, electronAPI, isLocalFileMode, localFilePath, searchSpaceId]); const isEditableType = editorDoc ? (editorRenderMode === "source_code" || EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "")) && !isLargeDocument : false; const hasUnsavedChanges = editedMarkdown !== null; const showDesktopHeader = !!onClose; const showEditingActions = isEditableType && isEditing; const localFileLanguage = inferMonacoLanguageFromPath(localFilePath); const handleCancelEditing = useCallback(() => { const savedContent = editorDoc?.source_markdown ?? ""; markdownRef.current = savedContent; setLocalFileContent(savedContent); setEditedMarkdown(null); changeCountRef.current = 0; setIsEditing(false); }, [editorDoc?.source_markdown]); return ( <> {showDesktopHeader ? (

File

{displayTitle}

{showEditingActions ? ( <> ) : ( <> {isEditableType && ( )} )} {!showEditingActions && !isLocalFileMode && editorDoc?.document_type && documentId && ( )}
) : (

{displayTitle}

{showEditingActions ? ( <> ) : ( <> {isEditableType && ( )} {!isLocalFileMode && editorDoc?.document_type && documentId && ( )} )}
)}
{isLoading ? ( ) : error || !editorDoc ? (
{error?.toLowerCase().includes("still being processed") ? (
) : (
)}

{error?.toLowerCase().includes("still being processed") ? "Document is processing" : "Document unavailable"}

{error || "An unknown error occurred"}

) : isLargeDocument && !isLocalFileMode ? (
This document is too large for the editor ( {Math.round((editorDoc.content_size_bytes ?? 0) / 1024 / 1024)}MB,{" "} {editorDoc.chunk_count ?? 0} chunks). Showing a preview below.
) : editorRenderMode === "source_code" ? (
{ void handleSave({ silent: true }); }} readOnly={!isEditing} onChange={(next) => { markdownRef.current = next; setLocalFileContent(next); if (!initialLoadDone.current) return; setEditedMarkdown(next === (editorDoc?.source_markdown ?? "") ? null : next); }} />
) : isEditableType ? ( ) : (
)}
); } function DesktopEditorPanel() { const panelState = useAtomValue(editorPanelAtom); const closePanel = useSetAtom(closeEditorPanelAtom); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") closePanel(); }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [closePanel]); const hasTarget = panelState.kind === "document" ? !!panelState.documentId && !!panelState.searchSpaceId : !!panelState.localFilePath; if (!panelState.isOpen || !hasTarget) return null; return (
); } function MobileEditorDrawer() { const panelState = useAtomValue(editorPanelAtom); const closePanel = useSetAtom(closeEditorPanelAtom); if (panelState.kind === "local_file") return null; const hasTarget = panelState.kind === "document" ? !!panelState.documentId && !!panelState.searchSpaceId : !!panelState.localFilePath; if (!hasTarget) return null; return ( { if (!open) closePanel(); }} shouldScaleBackground={false} > {panelState.title || "Editor"}
); } export function EditorPanel() { const panelState = useAtomValue(editorPanelAtom); const isDesktop = useMediaQuery("(min-width: 1024px)"); const hasTarget = panelState.kind === "document" ? !!panelState.documentId && !!panelState.searchSpaceId : !!panelState.localFilePath; if (!panelState.isOpen || !hasTarget) return null; if (!isDesktop && panelState.kind === "local_file") return null; if (isDesktop) { return ; } return ; } export function MobileEditorPanel() { const panelState = useAtomValue(editorPanelAtom); const isDesktop = useMediaQuery("(min-width: 1024px)"); const hasTarget = panelState.kind === "document" ? !!panelState.documentId && !!panelState.searchSpaceId : !!panelState.localFilePath; if (isDesktop || !panelState.isOpen || !hasTarget || panelState.kind === "local_file") return null; return ; }