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 (
);
}
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 = () => (
{/* Section: Properties */}
Properties
Collection
{doc?.collection || "Unknown"}
{doc?.source.sizeBytes !== undefined && (
Size
{formatBytes(doc.source.sizeBytes)}
)}
{doc?.source.modifiedAt && (
Modified
{formatDate(doc.source.modifiedAt)}
)}
{/* Divider */}
{/* Section: Path */}
Path
{doc?.uri}
{currentTarget.lineStart && (
L{currentTarget.lineStart}
{currentTarget.lineEnd &&
currentTarget.lineEnd !== currentTarget.lineStart
? `-${currentTarget.lineEnd}`
: ""}
)}
{
if (!doc) return;
void navigator.clipboard.writeText(
`${window.location.origin}${buildDocDeepLink({
uri: doc.uri,
view:
currentTarget.view === "source" || currentTarget.lineStart
? "source"
: "rendered",
lineStart: currentTarget.lineStart,
lineEnd: currentTarget.lineEnd,
})}`
);
}}
type="button"
>
Copy link
);
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 = () => (
{storedMetadata}
{/* Frontmatter + tags */}
{(hasFrontmatter || showStandaloneTags) && (
<>
{hasFrontmatter ? "Metadata" : "Tags"}
{!editingTags && (
Edit
)}
{tagSaveSuccess && (
)}
{hasFrontmatter && (
)}
{editingTags ? (
{tagSaveError && (
{tagSaveError}
)}
{savingTags && (
)}
Save
Cancel
) : (
!hasFrontmatter && (
{doc?.tags.length === 0 ? (
No tags
) : (
doc?.tags.map((tag) => (
{tag}
))
)}
)
)}
>
)}
{sections.length > 0 && (
<>
Outline
{sectionLinkNotice && (
{SECTION_LINK_NOTICE_COPY[sectionLinkNotice]}
)}
{sections.map((section) => (
{
jumpToSection(section.anchor);
}}
type="button"
>
{section.title}
{section.title}
{
copyReadableSectionLink(section.anchor);
}}
type="button"
>
Copy link
{
void copyCitationSectionLink(section.anchor);
}}
type="button"
>
Copy local citation link
))}
>
)}
);
const renderDocumentOverviewCard = () => (
Overview
{tagSaveSuccess && (
Saved
)}
{(hasFrontmatter || doc?.tags.length) && !editingTags && (
Edit tags
)}
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}`
: ""}
)}
{
if (!doc) {
return;
}
void navigator.clipboard.writeText(
`${window.location.origin}${buildDocDeepLink({
uri: doc.uri,
view:
currentTarget.view === "source" || currentTarget.lineStart
? "source"
: "rendered",
lineStart: currentTarget.lineStart,
lineEnd: currentTarget.lineEnd,
})}`
);
}}
size="sm"
variant="outline"
>
Copy link
{storedMetadata}
{(hasFrontmatter || showStandaloneTags) && (
{hasFrontmatter && (
)}
{editingTags ? (
{tagSaveError && (
{tagSaveError}
)}
{savingTags && (
)}
Save
Cancel
) : (
!hasFrontmatter && (
{doc?.tags.length === 0 ? (
No tags
) : (
doc?.tags.map((tag) => (
{tag}
))
)}
)
)}
)}
);
return (
{/* Header */}
{publishExportOpen && doc && (
setPublishExportOpen(false)}
target={doc.uri}
title={doc.title ?? doc.uri}
/>
)}
{/* Left rail — metadata + outline */}
{doc && (
)}
{/* Main content */}
{/* Loading */}
{loading && (
)}
{/* Error */}
{error && (
Failed to load document
{error}
)}
{/* Document */}
{doc && (
{externalChangeNotice && (
{externalChangeNotice}
Reload
)}
{copyError && (
{copyError}
)}
{/* Breadcrumbs */}
{breadcrumbs.length > 0 && (
{breadcrumbs.map((crumb, i) => (
{i > 0 && (
)}
{crumb.path ? (
navigate(crumb.path)}
type="button"
>
{crumb.label}
) : (
{crumb.label}
)}
))}
)}
{renderDocumentOverviewCard()}
{/* Content */}
{/* Markdown Source/Rendered pill — unchanged for non-PDF */}
{isMarkdown && doc.contentAvailable && !isPdf && (
setShowRawView(!showRawView)}
style={{
position: "absolute",
top: "0.75rem",
right: "0.75rem",
left: "auto",
}}
type="button"
>
{showRawView ? (
<>
Rendered
>
) : (
<>
Source
>
)}
)}
{/* PDF Pages/Text pill — DocView sole owner (no PdfToolbar toggle) */}
{isPdf && (
{showRawView ? (
<>
Pages
>
) : (
<>
Text
>
)}
)}
{/* 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 ? (
) : 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 && (
)}
)}
{/* Right rail — properties/path + relationships */}
{doc && (
{renderPropertiesPathRail()}
navigate(`/doc?uri=${encodeURIComponent(uri)}`)
}
/>
navigate(`/doc?uri=${encodeURIComponent(uri)}`)
}
/>
navigate(`/doc?uri=${encodeURIComponent(uri)}`)
}
/>
)}
{/* 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}
)}
setDeleteDialogOpen(false)}
variant="outline"
>
Cancel
{deleting && (
)}
{doc?.capabilities.editable
? "Move to Trash"
: "Remove from index"}
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}
)}
{
if (renameOutcome && renamePlan) {
setRenameDialogOpen(false);
navigate(
`/doc?uri=${encodeURIComponent(renamePlan.target.uri)}`
);
return;
}
setRenameDialogOpen(false);
}}
variant="outline"
>
{renameOutcome ? "Open note" : "Cancel"}
void handleRename()}
>
{renaming && (
)}
Rename
Move document
Move the current note to another folder inside this collection.
Reference rewrites follow the exact plan digest below.
{
if (moveOutcome && movePlan) {
setMoveDialogOpen(false);
navigate(
`/doc?uri=${encodeURIComponent(movePlan.target.uri)}`
);
return;
}
setMoveDialogOpen(false);
}}
variant="outline"
>
{moveOutcome ? "Open note" : "Cancel"}
void handleMove()}
>
{moving && }
Move
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}
))}
)}
setDuplicateDialogOpen(false)}
variant="outline"
>
Cancel
void handleDuplicate()}
>
{duplicating && (
)}
Duplicate
);
}