import { AlertTriangleIcon, ArrowLeft, Calendar, CheckIcon, ChevronRightIcon, CodeIcon, CopyIcon, FileText, FolderOpen, HardDrive, HomeIcon, LinkIcon, Loader2Icon, PencilIcon, QuoteIcon, Share2Icon, SquareArrowOutUpRightIcon, TextIcon, TrashIcon, } from "lucide-react"; import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import type { TypedMetadata } from "../../../core/typed-metadata"; import type { PdfFallbackReason } from "../lib/pdf"; import { FILE_REFACTOR_APPLY_CONFIRMATION, type FileRefactorPreviewPlan, } from "../../../core/file-refactor-contract"; import { extractSections } from "../../../core/sections"; import { CodeBlock, CodeBlockCopyButton, } from "../components/ai-elements/code-block"; import { Loader } from "../components/ai-elements/loader"; import { BacklinksPanel } from "../components/BacklinksPanel"; import { MarkdownPreview } from "../components/editor"; import { OutgoingLinksPanel, type OutgoingLink, } from "../components/OutgoingLinksPanel"; import { PublishExportDialog } from "../components/PublishExportDialog"; import { RefactorImpactPreview } from "../components/RefactorImpactPreview"; import { RelatedNotesSidebar } from "../components/RelatedNotesSidebar"; import { TagInput } from "../components/TagInput"; import { Badge } from "../components/ui/badge"; import { Button } from "../components/ui/button"; import { Card, CardContent } from "../components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../components/ui/dialog"; import { Input } from "../components/ui/input"; import { Separator } from "../components/ui/separator"; import { Tooltip, TooltipContent, TooltipTrigger, } from "../components/ui/tooltip"; import { apiFetch } from "../hooks/use-api"; import { useDocEvents } from "../hooks/use-doc-events"; import { buildDocDeepLink, buildEditDeepLink, parseDocumentDeepLink, } from "../lib/deep-links"; import { buildDocAssetUrl, isExtractedTextAvailable, isPdfDocument, } from "../lib/doc-asset-url"; import { waitForDocumentAvailability } from "../lib/document-availability"; import { buildReadableSectionUrl, createCitationSectionUrl, readSectionTargetLinkParam, resolveSectionLinkNavigation, SECTION_LINK_NOTICE_COPY, stripSectionTargetLinkParam, type SectionLinkNoticeKind, } from "../lib/section-links"; import { fetchServerCapabilities, type ServerCapabilities, } from "../lib/server-capabilities"; import { subscribeWorkspaceActionRequest } from "../lib/workspace-events"; import { FrontmatterDisplay, parseFrontmatter, } from "./doc-frontmatter-display"; /** Lazy so pdfjs is never pulled for non-PDF documents. */ const PdfViewer = lazy(() => import("./doc-pdf-viewer")); /** Spec "Canonical fallback-notice copy" — DocView Text branch only. */ const PDF_FALLBACK_NOTICE: Record< PdfFallbackReason, { eyebrow: string; body: string } > = { corrupt: { eyebrow: "CANNOT RENDER", body: "This PDF could not be rendered. View the extracted text or download the original.", }, password: { eyebrow: "PASSWORD PROTECTED", body: "This PDF is password protected. Showing the extracted text instead. Download the original to open it in a PDF reader.", }, network: { eyebrow: "COULD NOT LOAD", body: "The document could not be loaded from this session. Showing the extracted text instead. Switch to Pages to try again, or download the original.", }, bootstrap: { eyebrow: "VIEWER UNAVAILABLE", body: "The PDF viewer could not start in this window. Showing the extracted text instead. Download the original to read it.", }, }; function PdfFallbackNotice({ reason, downloadUrl, }: { reason: PdfFallbackReason; downloadUrl: string; }) { const copy = PDF_FALLBACK_NOTICE[reason]; return (

{copy.eyebrow}

{copy.body}

); } interface PageProps { navigate: (to: string | number) => void; } interface DocData { typedMetadata?: TypedMetadata | null; metadataError?: string | null; docid: string; uri: string; title: string | null; content: string | null; contentAvailable: boolean; collection: string; relPath: string; tags: string[]; source: { absPath?: string; mime: string; ext: string; modifiedAt?: string; sizeBytes?: number; sourceHash?: string; }; capabilities: { editable: boolean; tagsEditable: boolean; tagsWriteback: boolean; canCreateEditableCopy: boolean; mode: "editable" | "read_only"; reason?: string; }; } interface CreateEditableCopyResponse { uri: string; path: string; jobId: string | null; note?: string; } interface RenameDocResponse { success: boolean; uri: string; path: string; relPath: string; planDigest?: string; status?: string; warning?: string; refactorWarnings?: { warnings: string[]; }; } interface MoveDocResponse { success: boolean; uri: string; path: string; relPath: string; planDigest?: string; status?: string; warning?: string; refactorWarnings?: { warnings: string[]; }; } interface DuplicateDocResponse { success: boolean; uri: string; path: string; relPath: string; refactorWarnings?: { warnings: string[]; }; } interface UpdateDocResponse { success: boolean; docId: string; uri: string; path: string; jobId: string | null; writeBack?: "applied" | "skipped_unsupported"; version: { sourceHash: string; modifiedAt?: string; }; } function formatBytes(bytes: number): string { if (bytes === 0) { return "0 B"; } const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`; } function formatDate(dateStr: string): string { try { return new Date(dateStr).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); } catch { return dateStr; } } // Shiki BundledLanguage subset we actually use // Cast is safe - all values are valid BundledLanguage type SupportedLanguage = | "markdown" | "javascript" | "jsx" | "typescript" | "tsx" | "python" | "rust" | "go" | "json" | "yaml" | "html" | "css" | "sql" | "bash" | "text"; // Import BundledLanguage for type assertion import type { BundledLanguage } from "shiki"; function getLanguageFromExt(ext: string): SupportedLanguage { const map: Record = { ".md": "markdown", ".markdown": "markdown", ".js": "javascript", ".jsx": "jsx", ".ts": "typescript", ".tsx": "tsx", ".py": "python", ".rs": "rust", ".go": "go", ".json": "json", ".yaml": "yaml", ".yml": "yaml", ".html": "html", ".css": "css", ".sql": "sql", ".sh": "bash", ".bash": "bash", }; return map[ext.toLowerCase()] || "text"; } /** Parse breadcrumb segments from collection and relPath */ function parseBreadcrumbs( collection: string, relPath: string ): { label: string; path: string }[] { const segments: { label: string; path: string }[] = [ { label: collection, path: `/browse?collection=${encodeURIComponent(collection)}`, }, ]; const parts = relPath.split("/").filter(Boolean); let currentPath = ""; for (let i = 0; i < parts.length; i++) { const part = parts[i]; if (!part) { continue; } currentPath = currentPath ? `${currentPath}/${part}` : part; // Last segment is the file - no link if (i === parts.length - 1) { segments.push({ label: part, path: "" }); } else { segments.push({ label: part, path: `/browse?collection=${encodeURIComponent(collection)}&path=${encodeURIComponent(currentPath)}`, }); } } return segments; } function getParentPath(relPath: string): string { const parts = relPath.split("/").filter(Boolean); parts.pop(); return parts.join("/"); } export default function DocView({ navigate }: PageProps) { const [doc, setDoc] = useState(null); // undefined = not answered yet (offer neither locality-dependent action), // null = fetch failed (fail closed: treat the client as remote). const [serverCapabilities, setServerCapabilities] = useState< ServerCapabilities | null | undefined >(undefined); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleting, setDeleting] = useState(false); const [deleteError, setDeleteError] = useState(null); const [renameDialogOpen, setRenameDialogOpen] = useState(false); const [renaming, setRenaming] = useState(false); const [renameError, setRenameError] = useState(null); const [renameValue, setRenameValue] = useState(""); const [renamePlan, setRenamePlan] = useState( null ); const [renamePlanLoading, setRenamePlanLoading] = useState(false); const [renameConfirmed, setRenameConfirmed] = useState(false); const [renameOutcome, setRenameOutcome] = useState(null); const [moveDialogOpen, setMoveDialogOpen] = useState(false); const [moving, setMoving] = useState(false); const [moveError, setMoveError] = useState(null); const [moveFolderPath, setMoveFolderPath] = useState(""); const [moveName, setMoveName] = useState(""); const [movePlan, setMovePlan] = useState( null ); const [movePlanLoading, setMovePlanLoading] = useState(false); const [moveConfirmed, setMoveConfirmed] = useState(false); const [moveOutcome, setMoveOutcome] = useState(null); const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false); const [duplicating, setDuplicating] = useState(false); const [duplicateError, setDuplicateError] = useState(null); const [duplicateFolderPath, setDuplicateFolderPath] = useState(""); const [duplicateName, setDuplicateName] = useState(""); const [duplicateWarnings, setDuplicateWarnings] = useState([]); const [showRawView, setShowRawView] = useState(false); /** DocView-owned PDF fallback reason (null when Pages or no fallback). */ const [pdfFallbackReason, setPdfFallbackReason] = useState(null); const [creatingCopy, setCreatingCopy] = useState(false); const [copyError, setCopyError] = useState(null); const [externalChangeNotice, setExternalChangeNotice] = useState< string | null >(null); const [publishExportOpen, setPublishExportOpen] = useState(false); // Tag editing state const [editingTags, setEditingTags] = useState(false); const [editedTags, setEditedTags] = useState([]); const [savingTags, setSavingTags] = useState(false); const [tagSaveError, setTagSaveError] = useState(null); const [tagSaveSuccess, setTagSaveSuccess] = useState(false); const [resolvedWikiLinks, setResolvedWikiLinks] = useState( [] ); const [activeSectionAnchor, setActiveSectionAnchor] = useState( null ); const [sectionLinkNotice, setSectionLinkNotice] = useState(null); const [blockHashNavigation, setBlockHashNavigation] = useState(false); // Request sequencing - ignore stale responses on rapid navigation const requestIdRef = useRef(0); const sectionResolveRequestRef = useRef(0); const latestDocEvent = useDocEvents(); // App remounts page on route/query changes, so URI is stable per render. const currentTarget = useMemo( () => parseDocumentDeepLink(window.location.search), [] ); const currentUri = currentTarget.uri; const currentHash = useMemo( () => window.location.hash.replace(/^#/u, ""), [] ); const encodedSectionTarget = useMemo( () => readSectionTargetLinkParam(window.location.search), [] ); const highlightedLines = useMemo(() => { if (!currentTarget.lineStart) return []; const end = currentTarget.lineEnd ?? currentTarget.lineStart; const lines: number[] = []; for (let line = currentTarget.lineStart; line <= end; line += 1) { lines.push(line); } return lines; }, [currentTarget.lineEnd, currentTarget.lineStart]); const loadDocument = useCallback(() => { if (!currentUri) { setError("No document URI provided"); setLoading(false); return; } const currentRequestId = ++requestIdRef.current; setLoading(true); setError(null); setDoc(null); setEditingTags(false); setTagSaveSuccess(false); void apiFetch( `/api/doc?uri=${encodeURIComponent(currentUri)}` ).then(({ data, error: fetchError }) => { // Ignore stale response if newer request was made if (currentRequestId !== requestIdRef.current) { return; } setLoading(false); if (fetchError) { setError(fetchError); } else if (data) { setDoc(data); } }); }, [currentUri]); // Server capabilities decide which host-local actions may render. A failed // fetch leaves them null, which the view reads as a remote client. useEffect(() => { let cancelled = false; void fetchServerCapabilities().then(({ data }) => { if (!cancelled) { setServerCapabilities(data); } }); return () => { cancelled = true; }; }, []); // Fetch document when URI changes useEffect(() => { loadDocument(); }, [loadDocument]); useEffect(() => { if (!doc?.docid) { setResolvedWikiLinks([]); return; } void apiFetch<{ links: OutgoingLink[] }>( `/api/doc/${encodeURIComponent(doc.docid)}/links?type=wiki` ).then(({ data }) => { setResolvedWikiLinks(data?.links ?? []); }); }, [doc?.docid]); useEffect(() => { if (latestDocEvent?.uri !== currentUri) { return; } setExternalChangeNotice( "This document changed on disk. Reload to see the latest content." ); }, [currentUri, latestDocEvent?.changedAt, latestDocEvent?.uri]); const reloadDocument = useCallback(() => { setExternalChangeNotice(null); loadDocument(); }, [loadDocument]); const isMarkdown = doc?.source.ext && [".md", ".markdown"].includes(doc.source.ext.toLowerCase()); const isCodeFile = doc?.source.ext && [ ".md", ".js", ".jsx", ".ts", ".tsx", ".py", ".rs", ".go", ".json", ".yaml", ".yml", ".html", ".css", ".sql", ".sh", ".bash", ].includes(doc.source.ext.toLowerCase()); const isPdf = Boolean(doc && isPdfDocument(doc.source)); const localClient = serverCapabilities?.localClient ?? false; // Until /api/capabilities answers, a same-host user would otherwise see the // remote "Open original" flash in and then swap to Reveal. const localityKnown = serverCapabilities !== undefined; // Spec predicate — evaluated per render, never from mime/ext. const extractedTextAvailable = Boolean(doc && isExtractedTextAvailable(doc)); const pdfAssetUrl = useMemo(() => { if (!doc || !isPdf) { return null; } return buildDocAssetUrl(doc.uri, doc.relPath); }, [doc, isPdf]); // Remote "Open original" target for any document that has a source file: // /api/doc-asset serves any collection file inline, so this keeps the // previous file:// scope (every read-only source) for remote clients. const sourceAssetUrl = useMemo(() => { if (!doc?.source.absPath) { return null; } return buildDocAssetUrl(doc.uri, doc.relPath); }, [doc]); // Parse frontmatter for markdown files const parsedContent = useMemo(() => { if (!doc?.content || !isMarkdown) { return { data: {}, body: doc?.content ?? "" }; } return parseFrontmatter(doc.content); }, [doc?.content, isMarkdown]); const hasFrontmatter = Object.keys(parsedContent.data).length > 0; const showStandaloneTags = !hasFrontmatter || editingTags; useEffect(() => { if (currentTarget.view === "source" || currentTarget.lineStart) { setShowRawView(true); } }, [currentTarget.lineStart, currentTarget.view]); // Clear fallback when the loaded document identity changes. useEffect(() => { setPdfFallbackReason(null); }, [doc?.uri]); /** * DocView defends the extractedTextAvailable boundary itself: a spurious * onFallback while the predicate is false must not switch view or store a * reason (viewer/error surface stays mounted). */ const handlePdfFallback = useCallback( (reason: PdfFallbackReason) => { // Re-evaluate from current doc identity — never trust a stale closure alone. if (!doc || !isExtractedTextAvailable(doc)) { return; } setPdfFallbackReason(reason); setShowRawView(true); }, [doc] ); /** Pages/Text for PDFs — clearing notice when returning to Pages. */ const togglePdfPagesText = useCallback(() => { setShowRawView((prev) => { if (prev) { setPdfFallbackReason(null); return false; } return true; }); }, []); useEffect(() => { if (!sectionLinkNotice) { return; } const timer = window.setTimeout(() => setSectionLinkNotice(null), 3200); return () => { window.clearTimeout(timer); }; }, [sectionLinkNotice]); useEffect(() => { if (!doc?.content || loading || !encodedSectionTarget) { return; } const requestId = ++sectionResolveRequestRef.current; const content = doc.content; void resolveSectionLinkNavigation({ content, uri: doc.uri, encodedTarget: encodedSectionTarget, hashAnchor: currentHash, }).then((result) => { if (requestId !== sectionResolveRequestRef.current) { return; } setBlockHashNavigation(result.blockHashNavigation); if (result.notice) { setSectionLinkNotice(result.notice); } if (result.cleanCitationParam) { const cleanedSearch = stripSectionTargetLinkParam( window.location.search ); const nextHash = result.navigateAnchor ? `#${result.navigateAnchor}` : window.location.hash; window.history.replaceState( {}, "", `${window.location.pathname}${cleanedSearch}${nextHash}` ); } if (result.blockHashNavigation || !result.navigateAnchor) { return; } if (showRawView) { return; } requestAnimationFrame(() => { document .getElementById(result.navigateAnchor ?? "") ?.scrollIntoView({ behavior: "smooth", block: "start" }); setActiveSectionAnchor(result.navigateAnchor); }); }); }, [ currentHash, doc?.content, doc?.uri, encodedSectionTarget, loading, showRawView, ]); useEffect(() => { if ( blockHashNavigation || encodedSectionTarget || !currentHash || showRawView || loading ) { return; } requestAnimationFrame(() => { document .getElementById(currentHash) ?.scrollIntoView({ behavior: "smooth", block: "start" }); setActiveSectionAnchor(currentHash); }); }, [ blockHashNavigation, currentHash, encodedSectionTarget, loading, showRawView, ]); const breadcrumbs = doc ? parseBreadcrumbs(doc.collection, doc.relPath) : []; const sections = useMemo( () => extractSections(parsedContent.body), [parsedContent.body] ); const copyReadableSectionLink = useCallback( (anchor: string) => { if (!doc) { return; } void navigator.clipboard .writeText( buildReadableSectionUrl(window.location.origin, { uri: doc.uri, view: "rendered", anchor, }) ) .then(() => { setSectionLinkNotice("copied_link"); }) .catch(() => { setSectionLinkNotice("clipboard_unavailable"); }); }, [doc] ); const copyCitationSectionLink = useCallback( async (anchor: string) => { const content = doc?.content; if (!doc || !content) { return; } const citationUrl = await createCitationSectionUrl({ origin: window.location.origin, uri: doc.uri, content, anchor, view: "rendered", }); if (!citationUrl) { setSectionLinkNotice("citation_unavailable"); return; } try { await navigator.clipboard.writeText(citationUrl); setSectionLinkNotice("copied_citation"); } catch { setSectionLinkNotice("clipboard_unavailable"); } }, [doc] ); const jumpToSection = useCallback( (anchor: string) => { setShowRawView(false); setBlockHashNavigation(false); requestAnimationFrame(() => { document.getElementById(anchor)?.scrollIntoView({ behavior: "smooth", block: "start", }); window.history.replaceState( {}, "", `${buildDocDeepLink({ uri: doc?.uri ?? "", view: "rendered", })}#${anchor}` ); setActiveSectionAnchor(anchor); }); }, [doc?.uri] ); useEffect(() => { if (showRawView || sections.length === 0) { setActiveSectionAnchor(sections[0]?.anchor ?? null); return; } const updateActiveSection = () => { let current = sections[0]?.anchor ?? null; for (const section of sections) { const element = document.getElementById(section.anchor); if (!element) { continue; } if (element.getBoundingClientRect().top <= 160) { current = section.anchor; } } setActiveSectionAnchor(current); }; updateActiveSection(); window.addEventListener("scroll", updateActiveSection, { passive: true }); return () => { window.removeEventListener("scroll", updateActiveSection); }; }, [sections, showRawView]); const handleEdit = () => { if (doc?.capabilities.editable) { navigate( buildEditDeepLink({ uri: doc.uri, lineStart: currentTarget.lineStart, lineEnd: currentTarget.lineEnd, }) ); } }; const handleCreateEditableCopy = useCallback(async () => { if (!doc?.capabilities.canCreateEditableCopy) return; setCreatingCopy(true); setCopyError(null); const { data, error: err } = await apiFetch( `/api/docs/${encodeURIComponent(doc.docid)}/editable-copy`, { method: "POST", body: JSON.stringify({ uri: doc.uri }), } ); setCreatingCopy(false); if (err) { setCopyError(err); return; } if (data) { const ready = await waitForDocumentAvailability(data.uri); if (!ready) { setCopyError( "Created the markdown copy, but it is still indexing. Try again in a moment." ); return; } navigate(`/edit?uri=${encodeURIComponent(data.uri)}`); } }, [doc, navigate]); const handleDelete = async () => { if (!doc) return; setDeleting(true); setDeleteError(null); const endpoint = doc.capabilities.editable ? `/api/docs/${encodeURIComponent(doc.docid)}/trash?uri=${encodeURIComponent(doc.uri)}` : `/api/docs/${encodeURIComponent(doc.docid)}/deactivate?uri=${encodeURIComponent(doc.uri)}`; const { error: err } = await apiFetch(endpoint, { method: "POST" }); setDeleting(false); if (err) { setDeleteError(err); return; } setDeleteDialogOpen(false); navigate(-1); }; const handleStartRename = useCallback(() => { if (!doc) { return; } const filename = doc.relPath.split("/").pop() ?? doc.relPath; setRenameValue(filename); setRenameError(null); setRenamePlan(null); setRenameConfirmed(false); setRenameOutcome(null); setRenameDialogOpen(true); }, [doc]); useEffect(() => { if (!renameDialogOpen || !doc || !renameValue.trim()) { return; } let cancelled = false; setRenamePlanLoading(true); setRenamePlan(null); setRenameConfirmed(false); void apiFetch( `/api/docs/${encodeURIComponent(doc.docid)}/refactor-plan`, { method: "POST", body: JSON.stringify({ operation: "rename", name: renameValue, uri: doc.uri, }), } ).then(({ data, error: err }) => { if (cancelled) { return; } setRenamePlanLoading(false); if (err) { setRenameError(err); setRenamePlan(null); return; } setRenameError(null); setRenamePlan(data); }); return () => { cancelled = true; }; }, [doc, renameDialogOpen, renameValue]); const handleRename = useCallback(async () => { if (!doc || !renamePlan?.canApply || !renameConfirmed) { return; } setRenaming(true); setRenameError(null); setRenameOutcome(null); const { data, error: err } = await apiFetch( `/api/docs/${encodeURIComponent(doc.docid)}/rename`, { method: "POST", body: JSON.stringify({ name: renameValue, uri: doc.uri, planDigest: renamePlan.planDigest, confirmation: FILE_REFACTOR_APPLY_CONFIRMATION, schemaVersion: renamePlan.schemaVersion, }), } ); setRenaming(false); if (err) { setRenameError(err); return; } if (data?.status === "applied_with_sync_pending" || data?.warning) { setRenameOutcome( data.warning ?? "Files renamed, but index sync is still pending. Run Update All to finish." ); return; } setRenameDialogOpen(false); if (data?.uri) { navigate(`/doc?uri=${encodeURIComponent(data.uri)}`); } }, [doc, navigate, renameConfirmed, renamePlan, renameValue]); const handleStartMove = useCallback(() => { if (!doc) { return; } setMoveFolderPath(getParentPath(doc.relPath)); setMoveName(doc.relPath.split("/").pop() ?? doc.relPath); setMoveError(null); setMovePlan(null); setMoveConfirmed(false); setMoveOutcome(null); setMoveDialogOpen(true); }, [doc]); useEffect(() => { if (!moveDialogOpen || !doc || !moveFolderPath.trim()) { return; } let cancelled = false; setMovePlanLoading(true); setMovePlan(null); setMoveConfirmed(false); void apiFetch( `/api/docs/${encodeURIComponent(doc.docid)}/refactor-plan`, { method: "POST", body: JSON.stringify({ operation: "move", folderPath: moveFolderPath, name: moveName, uri: doc.uri, }), } ).then(({ data, error: err }) => { if (cancelled) { return; } setMovePlanLoading(false); if (err) { setMoveError(err); setMovePlan(null); return; } setMoveError(null); setMovePlan(data); }); return () => { cancelled = true; }; }, [doc, moveDialogOpen, moveFolderPath, moveName]); const handleMove = useCallback(async () => { if (!doc || !movePlan?.canApply || !moveConfirmed) { return; } setMoving(true); setMoveError(null); setMoveOutcome(null); const { data, error: err } = await apiFetch( `/api/docs/${encodeURIComponent(doc.docid)}/move`, { method: "POST", body: JSON.stringify({ folderPath: moveFolderPath, name: moveName, uri: doc.uri, planDigest: movePlan.planDigest, confirmation: FILE_REFACTOR_APPLY_CONFIRMATION, schemaVersion: movePlan.schemaVersion, }), } ); setMoving(false); if (err) { setMoveError(err); return; } if (data?.status === "applied_with_sync_pending" || data?.warning) { setMoveOutcome( data.warning ?? "Files moved, but index sync is still pending. Run Update All to finish." ); return; } setMoveDialogOpen(false); if (data?.uri) { navigate(`/doc?uri=${encodeURIComponent(data.uri)}`); } }, [doc, moveConfirmed, moveFolderPath, moveName, movePlan, navigate]); const handleStartDuplicate = useCallback(() => { if (!doc) { return; } setDuplicateFolderPath(getParentPath(doc.relPath)); setDuplicateName(doc.relPath.split("/").pop() ?? doc.relPath); setDuplicateError(null); setDuplicateWarnings([]); setDuplicateDialogOpen(true); }, [doc]); useEffect(() => { if (!duplicateDialogOpen || !doc) { return; } void apiFetch<{ refactorWarnings?: { warnings: string[] } }>( `/api/docs/${encodeURIComponent(doc.docid)}/refactor-plan`, { method: "POST", body: JSON.stringify({ operation: "duplicate", folderPath: duplicateFolderPath || undefined, name: duplicateName || undefined, uri: doc.uri, }), } ).then(({ data }) => { setDuplicateWarnings(data?.refactorWarnings?.warnings ?? []); }); }, [doc, duplicateDialogOpen, duplicateFolderPath, duplicateName]); const handleDuplicate = useCallback(async () => { if (!doc) { return; } setDuplicating(true); setDuplicateError(null); const { data, error: err } = await apiFetch( `/api/docs/${encodeURIComponent(doc.docid)}/duplicate`, { method: "POST", body: JSON.stringify({ folderPath: duplicateFolderPath || undefined, name: duplicateName || undefined, uri: doc.uri, }), } ); setDuplicating(false); if (err) { setDuplicateError(err); return; } setDuplicateDialogOpen(false); if (data?.uri) { navigate(`/doc?uri=${encodeURIComponent(data.uri)}`); } }, [doc, duplicateFolderPath, duplicateName, navigate]); useEffect(() => { const unsubscribers = [ subscribeWorkspaceActionRequest("rename-current-note", () => { if (doc?.capabilities.editable) { handleStartRename(); } }), subscribeWorkspaceActionRequest("move-current-note", () => { if (doc?.capabilities.editable) { handleStartMove(); } }), subscribeWorkspaceActionRequest("duplicate-current-note", () => { if (doc?.capabilities.editable) { handleStartDuplicate(); } }), ]; return () => { for (const unsubscribe of unsubscribers) { unsubscribe(); } }; }, [ doc?.capabilities.editable, handleStartDuplicate, handleStartMove, handleStartRename, ]); const handleReveal = useCallback(async () => { if (!doc) { return; } const { error: err } = await apiFetch( `/api/docs/${encodeURIComponent(doc.docid)}/reveal?uri=${encodeURIComponent(doc.uri)}`, { method: "POST" } ); if (err) { setDeleteError(err); } }, [doc]); // Start editing tags const handleStartEditTags = useCallback(() => { if (doc) { setEditedTags([...doc.tags]); setEditingTags(true); setTagSaveError(null); setTagSaveSuccess(false); } }, [doc]); // Cancel editing tags const handleCancelEditTags = useCallback(() => { setEditingTags(false); setEditedTags([]); setTagSaveError(null); }, []); // Save tags const handleSaveTags = useCallback(async () => { if (!doc) return; setSavingTags(true); setTagSaveError(null); setTagSaveSuccess(false); const { data, error: err } = await apiFetch( `/api/docs/${encodeURIComponent(doc.docid)}`, { method: "PUT", body: JSON.stringify({ tags: editedTags, expectedSourceHash: doc.source.sourceHash, expectedModifiedAt: doc.source.modifiedAt, uri: doc.uri, }), } ); setSavingTags(false); if (err) { setTagSaveError(err); return; } // Update doc with new tags setDoc({ ...doc, tags: editedTags, source: { ...doc.source, sourceHash: data?.version.sourceHash ?? doc.source.sourceHash, modifiedAt: data?.version.modifiedAt ?? doc.source.modifiedAt, }, }); setEditingTags(false); setTagSaveSuccess(true); // Clear success indicator after 2s setTimeout(() => setTagSaveSuccess(false), 2000); }, [doc, editedTags]); const renderPropertiesPathRail = () => ( ); const storedMetadata = doc?.metadataError || (doc?.typedMetadata && Object.keys(doc.typedMetadata).length > 0) ? (

Indexed custom metadata

{doc.metadataError ? (

{doc.metadataError}. Correct the source and sync again; this document is excluded from custom metadata filters.

) : (
{Object.entries(doc.typedMetadata ?? {}).map(([key, value]) => (
{key}
{JSON.stringify(value)}
))}
)}
) : null; /** Left rail — metadata + outline */ const renderDocumentFactsRail = () => ( ); const renderDocumentOverviewCard = () => (
Overview
{tagSaveSuccess && ( Saved )} {(hasFrontmatter || doc?.tags.length) && !editingTags && ( )}
Collection
{doc?.collection || "Unknown"}
{doc?.source.sizeBytes !== undefined && (
Size
{formatBytes(doc.source.sizeBytes)}
)} {doc?.source.modifiedAt && (
Modified
{formatDate(doc.source.modifiedAt)}
)}
Path
{doc?.uri}
{currentTarget.lineStart && ( L{currentTarget.lineStart} {currentTarget.lineEnd && currentTarget.lineEnd !== currentTarget.lineStart ? `-${currentTarget.lineEnd}` : ""} )}
{storedMetadata} {(hasFrontmatter || showStandaloneTags) && (
{hasFrontmatter && ( )} {editingTags ? (
{tagSaveError && (

{tagSaveError}

)}
) : ( !hasFrontmatter && (
{doc?.tags.length === 0 ? ( No tags ) : ( doc?.tags.map((tag) => ( {tag} )) )}
) )}
)}
); return (
{/* Header */}
{/* Home button - Scholarly Dusk brass accent */}

{doc?.title || "Document"}

{doc?.capabilities.mode === "read_only" && ( Read-only )} {doc?.source.ext && ( {doc.source.ext} )} {doc && ( <>
{doc.capabilities.editable ? ( <> ) : ( <> {doc.capabilities.canCreateEditableCopy && ( )} {localClient && doc.source.absPath && ( <> )} {localityKnown && !localClient && sourceAssetUrl && ( )} )} {localClient && doc.capabilities.editable && doc.source.absPath && ( )} {isPdf && pdfAssetUrl ? ( ) : null}
)}
{publishExportOpen && doc && ( setPublishExportOpen(false)} target={doc.uri} title={doc.title ?? doc.uri} /> )}
{/* Left rail — metadata + outline */} {doc && ( )} {/* Main content */}
{/* Loading */} {loading && (

Loading document...

)} {/* Error */} {error && (

Failed to load document

{error}

)} {/* Document */} {doc && (
{externalChangeNotice && (

{externalChangeNotice}

)} {copyError && ( {copyError} )} {/* Breadcrumbs */} {breadcrumbs.length > 0 && ( )}
{renderDocumentOverviewCard()}
{/* Content */}
{/* Markdown Source/Rendered pill — unchanged for non-PDF */} {isMarkdown && doc.contentAvailable && !isPdf && ( )} {/* PDF Pages/Text pill — DocView sole owner (no PdfToolbar toggle) */} {isPdf && ( )} {/* PDF branch: Pages = lazy PdfViewer; Text = extracted + optional notice */} {isPdf && !showRawView && pdfAssetUrl ? ( // Reserve the band the absolutely-positioned Pages/Text pill // occupies (top 0.75rem + ~1.75rem tall). Without it the // sticky PdfToolbar (z-10) renders after the pill (also // z-10) and covers it, leaving the toggle invisible and // un-clickable on every PDF that renders. Inline (like the // pill's own positioning above) so the exact clearance is // explicit: the pill occupies 0.75rem + ~1.75rem, and a // `pt-10` utility (2.5rem) would leave it 0.14px short.
Loading viewer…
} >
) : null} {isPdf && showRawView ? (
{pdfFallbackReason && extractedTextAvailable ? ( ) : null} {!doc.contentAvailable ? (

Content not available (document may need re-indexing)

) : null} {doc.contentAvailable && !extractedTextAvailable ? (

No extracted text for this document.

{pdfAssetUrl ? ( ) : null}
) : null} {extractedTextAvailable ? (
                          {doc.content}
                        
) : null}
) : null} {/* Non-PDF branches (byte-identical behavior) */} {!isPdf && !doc.contentAvailable && (

Content not available (document may need re-indexing)

)} {!isPdf && doc.contentAvailable && isMarkdown && !showRawView && (
)} {!isPdf && doc.contentAvailable && isMarkdown && showRawView && ( )} {!isPdf && doc.contentAvailable && isCodeFile && !isMarkdown && ( )} {!isPdf && doc.contentAvailable && !isCodeFile && (
                      {doc.content}
                    
)}
)} {/* Right rail — properties/path + relationships */} {doc && ( )}
{/* Delete confirmation dialog */} {doc?.capabilities.editable ? "Move to Trash?" : "Remove from index?"} {doc?.capabilities.editable ? ( <> This will move{" "} "{doc?.title || doc?.relPath}" to your system Trash and remove it from the current index. This is reversible through Trash. GNO will stop showing the file after the current collection refresh. ) : ( <> This will remove{" "} "{doc?.title || doc?.relPath}" from the GNO search index. The source file stays on disk. It may be re-indexed on the next sync unless you exclude it. )} {deleteError && (
{deleteError}
)}
Rename document Rename the file on disk inside its current folder. Reference rewrites follow the exact plan digest below. setRenameValue(event.target.value)} value={renameValue} /> {renameError && (
{renameError}
)}
Move document Move the current note to another folder inside this collection. Reference rewrites follow the exact plan digest below.
setMoveFolderPath(event.target.value)} placeholder="projects/research" value={moveFolderPath} /> setMoveName(event.target.value)} placeholder="note.md" value={moveName} /> {moveError && (
{moveError}
)}
Duplicate document Create a copy of this note in the current or another folder.
setDuplicateFolderPath(event.target.value)} placeholder="projects/research" value={duplicateFolderPath} /> setDuplicateName(event.target.value)} placeholder="note-copy.md" value={duplicateName} /> {duplicateError && (
{duplicateError}
)} {duplicateWarnings.length > 0 && (
{duplicateWarnings.map((warning) => (
{warning}
))}
)}
); }