/** * DocumentEditor - Split-view markdown editor with auto-save. * * Features: * - CodeMirror 6 for editing * - Live markdown preview * - Debounced auto-save (2s) * - Keyboard shortcuts (Cmd+S to save) * - Unsaved changes warning */ import { AlertCircleIcon, ArrowLeftIcon, BookOpenIcon, CheckIcon, CloudIcon, HistoryIcon, EyeIcon, EyeOffIcon, HomeIcon, LinkIcon, Loader2Icon, PenIcon, SquareArrowOutUpRightIcon, UnlinkIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { NOTE_PRESETS, resolveNotePreset } from "../../../core/note-presets"; import { Loader } from "../components/ai-elements/loader"; import { CodeMirrorEditor, type CodeMirrorEditorRef, MarkdownPreview, } from "../components/editor"; import { parseFrontmatter } from "../components/FrontmatterDisplay"; import { Badge } from "../components/ui/badge"; import { Button } from "../components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../components/ui/select"; import { Separator } from "../components/ui/separator"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "../components/ui/tooltip"; import { type WikiLinkDoc, WikiLinkAutocomplete, } from "../components/WikiLinkAutocomplete"; import { apiFetch } from "../hooks/use-api"; import { useDocEvents } from "../hooks/use-doc-events"; import { buildEditDeepLink, parseDocumentDeepLink } from "../lib/deep-links"; import { waitForDocumentAvailability } from "../lib/document-availability"; import { appendLocalHistory, loadLocalHistory, loadLatestLocalHistory, type LocalHistoryEntry, } from "../lib/local-history"; import { clearRequestIntent, type RequestIntent, requestIdForIntent, } from "../lib/request-intent"; const SAVE_INTENT_KEY = "gno:save-intent"; import { getActiveWikiLinkQuery } from "../lib/wiki-link"; interface PageProps { navigate: (to: string | number) => void; } interface DocData { docid: string; uri: string; title: string | null; content: string | null; contentAvailable: boolean; collection: string; relPath: 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 UpdateDocResponse { success: boolean; docId: string; uri: string; path: string; jobId: string | null; writeBack?: "applied" | "skipped_unsupported"; version: { sourceHash: string; modifiedAt?: string; }; /** Present when the save carried a request ID. */ request?: { replayed: boolean }; } interface DocsAutocompleteResponse { docs: WikiLinkDoc[]; } type SaveStatus = "saved" | "saving" | "unsaved" | "error"; function useDebouncedCallback( callback: (...args: T) => unknown, delay: number ) { const timeoutRef = useRef | null>(null); const debouncedFn = useCallback( (...args: T) => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } timeoutRef.current = setTimeout(() => { void callback(...args); }, delay); }, [callback, delay] ); const cancel = useCallback(() => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } }, []); const flush = useCallback( (...args: T) => { cancel(); void callback(...args); }, [callback, cancel] ); useEffect(() => { return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, []); return { debouncedFn, cancel, flush }; } function formatTime(date: Date): string { return date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", }); } export default function DocumentEditor({ navigate }: PageProps) { const currentTarget = useMemo( () => parseDocumentDeepLink(window.location.search), [] ); const [doc, setDoc] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [content, setContent] = useState(""); const [originalContent, setOriginalContent] = useState(""); const [saveStatus, setSaveStatus] = useState("saved"); const [lastSaved, setLastSaved] = useState(null); const [saveError, setSaveError] = useState(null); const [creatingCopy, setCreatingCopy] = useState(false); const [copyError, setCopyError] = useState(null); /** * `outside`: the file changed under the editor. `unconfirmed`: a save's * response was lost, so a change on disk may be that save's own commit. */ const [changeNotice, setChangeNotice] = useState< "outside" | "unconfirmed" | null >(null); const [historyEntries, setHistoryEntries] = useState([]); const [historyDialogOpen, setHistoryDialogOpen] = useState(false); const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0); const [insertPresetId, setInsertPresetId] = useState("project-note"); const [showPreview, setShowPreview] = useState(true); const [syncScroll, setSyncScroll] = useState(true); const [showUnsavedDialog, setShowUnsavedDialog] = useState(false); const [wikiLinkDocs, setWikiLinkDocs] = useState([]); const [wikiLinkOpen, setWikiLinkOpen] = useState(false); const [wikiLinkQuery, setWikiLinkQuery] = useState(""); const [wikiLinkRange, setWikiLinkRange] = useState<{ start: number; end: number; } | null>(null); const [wikiLinkPosition, setWikiLinkPosition] = useState({ x: 48, y: 120 }); const [wikiLinkActiveIndex, setWikiLinkActiveIndex] = useState(-1); /** Where to navigate after dialog action (-1 for back, or URL string) */ const [pendingNavigation, setPendingNavigation] = useState< string | number | null >(null); const editorRef = useRef(null); const previewRef = useRef(null); // Event-based suppression: ignore the echo event caused by programmatic scroll const ignoreNextEditorScroll = useRef(false); const ignoreNextPreviewScroll = useRef(false); const ignoreDocEventsUntilRef = useRef(0); const latestDocEvent = useDocEvents(); const hasUnsavedChanges = content !== originalContent; const hasLocalSnapshot = historyEntries.length > 0; const parsedContent = useMemo(() => parseFrontmatter(content), [content]); const refreshHistoryEntries = useCallback((docId: string) => { const next = loadLocalHistory(docId); setHistoryEntries(next); if (next.length === 0) { setSelectedHistoryIndex(0); return; } setSelectedHistoryIndex((current) => Math.min(current, next.length - 1)); }, []); // Reset ignore flags when sync is toggled to prevent stale state useEffect(() => { ignoreNextEditorScroll.current = false; ignoreNextPreviewScroll.current = false; }, [syncScroll, showPreview]); // Scroll sync handlers with event-based loop prevention // Note: Uses percentage-based mapping which provides approximate correspondence. // For very different layouts (headings, code blocks, images), perfect alignment // would require anchor-based mapping between editor lines and rendered elements. const handleEditorScroll = useCallback( (scrollPercent: number) => { // Clear ignore flag first, even if we early-return (prevents lingering) if (ignoreNextEditorScroll.current) { ignoreNextEditorScroll.current = false; return; } if (!syncScroll || !showPreview) return; if (!Number.isFinite(scrollPercent)) return; const clamped = Math.max(0, Math.min(1, scrollPercent)); const preview = previewRef.current; if (!preview) return; const maxScroll = preview.scrollHeight - preview.clientHeight; if (maxScroll <= 0) return; const targetScroll = clamped * maxScroll; // Only set ignore flag if scroll position actually changes (avoids lingering flag) if (Math.abs(preview.scrollTop - targetScroll) > 0.5) { ignoreNextPreviewScroll.current = true; preview.scrollTop = targetScroll; } }, [syncScroll, showPreview] ); const handlePreviewScroll = useCallback(() => { // Clear ignore flag first, even if we early-return (prevents lingering) if (ignoreNextPreviewScroll.current) { ignoreNextPreviewScroll.current = false; return; } if (!syncScroll) return; const preview = previewRef.current; if (!preview) return; const maxScroll = preview.scrollHeight - preview.clientHeight; if (maxScroll <= 0) return; const scrollPercentRaw = preview.scrollTop / maxScroll; if (!Number.isFinite(scrollPercentRaw)) return; const scrollPercent = Math.max(0, Math.min(1, scrollPercentRaw)); // Set ignore flag BEFORE programmatic scroll to prevent race condition ignoreNextEditorScroll.current = true; const didScroll = editorRef.current?.scrollToPercent(scrollPercent) ?? false; // Clear flag if no scroll actually occurred (avoids lingering) if (!didScroll) { ignoreNextEditorScroll.current = false; } }, [syncScroll]); // Autosave timers and Cmd+S run later than the render that scheduled them: // read the current revision and last committed content from refs. const docRef = useRef(doc); const committedContentRef = useRef(originalContent); const draftRef = useRef(content); useEffect(() => { docRef.current = doc; }, [doc]); useEffect(() => { draftRef.current = content; }, [content]); // One request ID per (document revision, content) save intent: retrying a // save whose response was lost replays it instead of reporting a conflict. const saveIntentRef = useRef(null); // Content of a save that is in flight or whose response was lost: its // commit is unknown, so change events are held until a response says whose // change they were. Retry save re-sends exactly this content. const unknownSaveRef = useRef(null); const changedWhileUnknownRef = useRef(false); /** Save `contentToSave`; resolves true once it is committed on disk. */ const persistContent = useCallback( async (contentToSave: string): Promise => { const current = docRef.current; if (!current) return false; if (contentToSave === committedContentRef.current) { setSaveStatus("saved"); return true; } setSaveStatus("saving"); setSaveError(null); unknownSaveRef.current = contentToSave; const { data, error: err, outcomeUnknown, } = await apiFetch( `/api/docs/${encodeURIComponent(current.docid)}`, { method: "PUT", body: JSON.stringify({ content: contentToSave, expectedSourceHash: current.source.sourceHash, expectedModifiedAt: current.source.modifiedAt, uri: current.uri, requestId: requestIdForIntent( saveIntentRef, `${current.uri}\u0000${current.source.sourceHash}\u0000${contentToSave}`, SAVE_INTENT_KEY ), }), } ); if (err) { setSaveStatus("error"); setSaveError(err); if (outcomeUnknown) { // Never downgrade a warning about a change from elsewhere. setChangeNotice((notice) => notice === "outside" ? notice : "unconfirmed" ); return false; } // A definitive rejection: this save did not commit, so a change seen // meanwhile came from elsewhere. unknownSaveRef.current = null; if (changedWhileUnknownRef.current) { changedWhileUnknownRef.current = false; setChangeNotice("outside"); } else { setChangeNotice((notice) => notice === "unconfirmed" ? null : notice ); } return false; } unknownSaveRef.current = null; changedWhileUnknownRef.current = false; if (data?.request?.replayed) { // A replay reports an earlier commit; disk may have moved on since. // Only a read showing a different hash is evidence of another writer. const { data: latest } = await apiFetch( `/api/doc?uri=${encodeURIComponent(current.uri)}` ); setChangeNotice( latest && latest.source.sourceHash !== data.version.sourceHash ? "outside" : null ); } else { // Written now against the loaded revision: the next change event is // this save's own sync, and any earlier notice is stale. ignoreDocEventsUntilRef.current = Date.now() + 5_000; setChangeNotice(null); } const previous = committedContentRef.current; if (previous !== contentToSave) { appendLocalHistory(current.docid, previous); refreshHistoryEntries(current.docid); } clearRequestIntent(saveIntentRef, SAVE_INTENT_KEY); committedContentRef.current = contentToSave; setSaveStatus("saved"); setOriginalContent(contentToSave); setLastSaved(new Date()); if (data) { const next = { ...current, source: { ...current.source, sourceHash: data.version.sourceHash, modifiedAt: data.version.modifiedAt, }, }; docRef.current = next; setDoc(next); } return true; }, [] ); 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) { ignoreDocEventsUntilRef.current = Date.now() + 5_000; 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 handleInsertPreset = useCallback(() => { const resolved = resolveNotePreset({ presetId: insertPresetId, title: doc?.title ?? "Untitled", }); if (!resolved) { return; } const scaffold = hasUnsavedChanges || content.trim().length > 0 ? resolved.body.replace(/^# .*?\n+/u, "").trim() : resolved.content.trim(); if (!scaffold) { return; } const insertion = hasUnsavedChanges || content.trim().length > 0 ? `\n\n${scaffold}\n` : `${scaffold}\n`; editorRef.current?.insertAtCursor(insertion); }, [content, doc?.title, hasUnsavedChanges, insertPresetId]); const insertWikiLink = useCallback( (title: string) => { if (!wikiLinkRange) return; const didReplace = editorRef.current?.replaceRange( wikiLinkRange.start, wikiLinkRange.end, `[[${title}]]` ); if (didReplace) { setWikiLinkOpen(false); setWikiLinkActiveIndex(-1); } }, [wikiLinkRange] ); const handleCreateLinkedNote = useCallback( async (title: string) => { if (!doc) return; const filename = title .toLowerCase() .trim() .replaceAll(/[^\w\s-]/g, "") .replaceAll(/\s+/g, "-") .replaceAll(/-+/g, "-") .replace(/^-|-$/g, ""); const relPath = `${filename || "untitled"}.md`; const { data, error: err } = await apiFetch( "/api/docs", { method: "POST", body: JSON.stringify({ collection: doc.collection, relPath, content: `# ${title}\n`, }), } ); if (err) { setCopyError(err); return; } insertWikiLink(title); if (data) { setWikiLinkDocs((current) => [ ...current, { title, uri: data.uri, docid: data.uri, collection: doc.collection, }, ]); } }, [doc, insertWikiLink] ); // Debounced auto-save const { debouncedFn: debouncedSave, cancel: cancelAutosave } = useDebouncedCallback(persistContent, 2000); // Handle content changes const handleContentChange = useCallback( (newContent: string) => { setContent(newContent); if (newContent === originalContent) { // Back to the committed text: drop a pending autosave of a draft. cancelAutosave(); setSaveStatus("saved"); } else { setSaveStatus("unsaved"); debouncedSave(newContent); } const cursor = editorRef.current?.getCursorInfo(); if (!cursor) { setWikiLinkOpen(false); return; } const activeQuery = getActiveWikiLinkQuery(newContent, cursor.pos); if (!activeQuery) { setWikiLinkOpen(false); setWikiLinkRange(null); return; } setWikiLinkRange({ start: activeQuery.start, end: activeQuery.end }); setWikiLinkQuery(activeQuery.query); setWikiLinkPosition({ x: cursor.x, y: cursor.y + 8 }); setWikiLinkOpen(true); setWikiLinkActiveIndex(0); }, [cancelAutosave, originalContent, debouncedSave] ); useEffect(() => { if (!wikiLinkOpen) return; const params = new URLSearchParams({ limit: "8", query: wikiLinkQuery, }); if (doc?.collection) { params.set("collection", doc.collection); } void apiFetch( `/api/docs/autocomplete?${params.toString()}` ).then(({ data }) => { setWikiLinkDocs(data?.docs ?? []); }); }, [doc?.collection, wikiLinkOpen, wikiLinkQuery]); // Force save (Cmd+S) - supersedes a pending autosave, then triggers embedding const handleForceSave = useCallback(async () => { cancelAutosave(); if (!hasUnsavedChanges) return; if (await persistContent(content)) { // Fire and forget - don't block on result void apiFetch("/api/embed", { method: "POST" }); } }, [cancelAutosave, content, hasUnsavedChanges, persistContent]); // Retry the unconfirmed save itself (same content, same request ID), even // when the draft has since changed or returned to the loaded text; once it // is confirmed, the draft as it is then is saved on top of it. const retryUnconfirmedSave = useCallback(async () => { const pending = unknownSaveRef.current; if (pending === null) return; cancelAutosave(); if (await persistContent(pending)) { await persistContent(draftRef.current); } }, [cancelAutosave, persistContent]); const loadDocument = useCallback(() => { const uri = currentTarget.uri; if (!uri) { setError("No document URI provided"); setLoading(false); return; } void apiFetch(`/api/doc?uri=${encodeURIComponent(uri)}`).then( ({ data, error: err }) => { setLoading(false); if (err) { setError(err); } else if (data) { setDoc(data); const docContent = data.content ?? ""; setContent(docContent); setOriginalContent(docContent); committedContentRef.current = docContent; refreshHistoryEntries(data.docid); // Ensure CodeMirror reflects content after async load requestAnimationFrame(() => { editorRef.current?.setValue(docContent); if (currentTarget.lineStart) { editorRef.current?.revealLine(currentTarget.lineStart); } }); } } ); }, [currentTarget.lineStart, currentTarget.uri]); // Load document useEffect(() => { loadDocument(); }, [loadDocument]); // Judge each change event once; later doc updates must not re-raise it. const handledDocEventRef = useRef(null); useEffect(() => { if (!doc || latestDocEvent?.uri !== doc.uri) { return; } if (handledDocEventRef.current === latestDocEvent.changedAt) { return; } handledDocEventRef.current = latestDocEvent.changedAt; if (unknownSaveRef.current !== null) { changedWhileUnknownRef.current = true; return; } if (Date.now() < ignoreDocEventsUntilRef.current) { return; } setChangeNotice("outside"); }, [doc, latestDocEvent?.changedAt, latestDocEvent?.uri]); const reloadDocument = useCallback(() => { unknownSaveRef.current = null; changedWhileUnknownRef.current = false; setChangeNotice(null); loadDocument(); }, [loadDocument]); const restoreLatestSnapshot = useCallback(() => { if (!doc) return; const latest = loadLatestLocalHistory(doc.docid); if (!latest) return; setContent(latest.content); editorRef.current?.setValue(latest.content); setSaveStatus("unsaved"); }, [doc]); const selectedHistoryEntry = historyEntries[selectedHistoryIndex] ?? null; const restoreSelectedHistory = useCallback(() => { if (!selectedHistoryEntry) { return; } setContent(selectedHistoryEntry.content); editorRef.current?.setValue(selectedHistoryEntry.content); setSaveStatus("unsaved"); setHistoryDialogOpen(false); }, [selectedHistoryEntry]); // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const isMeta = e.metaKey || e.ctrlKey; // Cmd+S / Ctrl+S - Save if (isMeta && e.key === "s") { e.preventDefault(); void handleForceSave(); return; } // Editor-specific shortcuts - only when focus is in CodeMirror const target = e.target as HTMLElement; const inEditor = !!target.closest(".cm-editor"); // Cmd+B - Bold (editor only) if (isMeta && e.key === "b" && inEditor) { e.preventDefault(); editorRef.current?.wrapSelection("**", "**"); return; } // Cmd+I - Italic (editor only) if (isMeta && e.key === "i" && inEditor) { e.preventDefault(); editorRef.current?.wrapSelection("*", "*"); return; } // Cmd+K - Link (editor only) if (isMeta && e.key === "k" && inEditor) { e.preventDefault(); editorRef.current?.wrapSelection("[", "](url)"); return; } // Escape - Close (with warning if unsaved) if (e.key === "Escape") { e.preventDefault(); if (hasUnsavedChanges) { setShowUnsavedDialog(true); } else { navigate(-1); } } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [handleForceSave, hasUnsavedChanges, navigate]); // Warn before leaving with unsaved changes useEffect(() => { const handleBeforeUnload = (e: BeforeUnloadEvent) => { if (hasUnsavedChanges) { e.preventDefault(); e.returnValue = ""; // Required for modern browsers } }; window.addEventListener("beforeunload", handleBeforeUnload); return () => window.removeEventListener("beforeunload", handleBeforeUnload); }, [hasUnsavedChanges]); // Handle close/back - goes to browser history const handleClose = () => { if (hasUnsavedChanges) { setPendingNavigation(-1); setShowUnsavedDialog(true); } else { navigate(-1); } }; // Handle view doc - goes to doc view page const handleViewDoc = () => { const viewUrl = `/doc?uri=${encodeURIComponent(doc?.uri ?? "")}`; if (hasUnsavedChanges) { setPendingNavigation(viewUrl); setShowUnsavedDialog(true); } else { navigate(viewUrl); } }; // Discard and navigate const handleDiscardAndNavigate = () => { setShowUnsavedDialog(false); if (pendingNavigation !== null) { navigate(pendingNavigation); setPendingNavigation(null); } }; // Save and navigate const handleSaveAndNavigate = async () => { cancelAutosave(); // A failed save keeps the editor (and its error) instead of losing the draft. if (!(await persistContent(content))) { setShowUnsavedDialog(false); return; } // Trigger embedding (fire and forget) void apiFetch("/api/embed", { method: "POST" }); setShowUnsavedDialog(false); if (pendingNavigation !== null) { navigate(pendingNavigation); setPendingNavigation(null); } }; // Save status indicator const SaveStatusIndicator = () => { const statusConfig = { saved: { icon: CheckIcon, text: lastSaved ? `Saved at ${formatTime(lastSaved)}` : "Saved", className: "text-green-500", }, saving: { icon: Loader2Icon, text: "Saving...", className: "text-muted-foreground animate-spin", }, unsaved: { icon: CloudIcon, text: "Unsaved changes", className: "text-yellow-500", }, error: { icon: AlertCircleIcon, text: saveError ?? "Save failed", className: "text-destructive", }, }; const { icon: Icon, text, className } = statusConfig[saveStatus]; return (
{saveStatus === "saved" && lastSaved ? formatTime(lastSaved) : text}

{text}

); }; // Loading state if (loading) { return (

Loading document...

); } // Error state if (error || !doc) { return (

Failed to load document

{error ?? "Unknown error"}

); } if (!doc.capabilities.editable) { return (

Read-only document

{doc.capabilities.reason ?? "This document cannot be edited in place."}

{copyError && (
{copyError}
)}
{doc.capabilities.canCreateEditableCopy && ( )} {doc.source.absPath && ( )}
); } return (
{/* Toolbar */}
{/* Home button - Scholarly Dusk brass accent */} {/* Back button */} {/* View document (exit edit mode) */}

Exit edit mode

{/* Document info */}

{doc.title || doc.relPath}

{currentTarget.lineStart && ( L{currentTarget.lineStart} {currentTarget.lineEnd && currentTarget.lineEnd !== currentTarget.lineStart ? `-${currentTarget.lineEnd}` : ""} )} {hasUnsavedChanges && ( Unsaved )}
{/* Save status */} {/* Preview toggle */} {/* Preview toggle */}

{showPreview ? "Hide preview" : "Show preview"}

{/* Sync scroll toggle (only show when preview is visible) */} {showPreview && (

{syncScroll ? "Disable scroll sync" : "Enable scroll sync"}

)} {/* Save button */}
{saveStatus === "error" && saveError && (

{saveError}

)} {changeNotice === "unconfirmed" && (

The save may have completed, but its response was lost. Retry to confirm it.

)} {changeNotice === "outside" && (

This document changed on disk. Reload before continuing.

{hasLocalSnapshot && ( )}
)} {/* Editor area */}
{/* Editor pane */}
{/* Preview pane */} {showPreview && (
)}
Local history Restore a recent local snapshot captured before an in-app save.
{historyEntries.length === 0 ? (

No local snapshots yet.

) : ( historyEntries.map((entry, index) => ( )) )}
{selectedHistoryEntry ? (

Snapshot from{" "} {new Date(selectedHistoryEntry.savedAt).toLocaleString( "en-US", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", } )}

                    {selectedHistoryEntry.content || "(empty)"}
                  
) : (

Select a snapshot to preview it.

)}
{ void handleCreateLinkedNote(title); }} onDismiss={() => setWikiLinkOpen(false)} onSelect={(title) => insertWikiLink(title)} position={wikiLinkPosition} searchQuery={wikiLinkQuery} /> {/* Unsaved changes dialog */} Unsaved changes You have unsaved changes. What would you like to do?
); }