// src/layout/file-tree/CreateItemModal.tsx import { useState, useEffect, useRef, useCallback, type FormEvent } from 'react' import { createPortal } from 'react-dom' import type { AutomergeUrl } from '../../storage/document-runtime-contract' import type { AdoptFolderErrorCode } from '../../plugin/vault' // RATIONALE(plugin-api): file-tree create flow still reuses editor image-paste // helpers until upload/filename sanitizing is exposed through a plugin-safe API. // eslint-disable-next-line no-restricted-imports import { readFileAsUint8Array, sanitizeFilename } from '../../editor/image-paste-utils' import { ensureMdExtension, hasSupportedTextExtension, isMarkdownFilename } from '../../utils/filename' import { joinCreateItemPath, parseCreateItemInput, validateCreateItemInput, type ParsedCreateItemInput, } from './create-item-path' import './CreateItemModal.css' export interface CreateItemModalProps { isOpen: boolean onClose: () => void onCreateFolder: (name: string) => Promise onCreateFile: (name: string) => Promise /** * Adopt an existing folder document by URL into the modal's anchor * folder. `intermediateSegments` are folder names relative to the * anchor; any missing ones are created. Called when the user submits * an input whose last segment is `automerge:`. Omit this prop to * disable adoption from the modal — URL inputs will then surface as * an inline parser error and submit stays disabled. */ onAdoptFolder?: (intermediateSegments: string[], url: AutomergeUrl) => Promise /** Upload a binary image file. Called with (filename, data, mimeType). */ onUploadImage?: (name: string, data: Uint8Array, mimeType: string) => Promise /** Available templates for the dropdown */ templates?: Array<{ name: string; path: string }> /** Path of the default template to pre-select */ defaultTemplate?: string /** Callback to create a file from a template */ onCreateFileFromTemplate?: (name: string, templatePath: string) => Promise /** Filename pattern from template plugin (e.g., "{{title}}" or "{{date}}-{{time}}") */ filenamePattern?: string /** Pre-expanded filename preview (when pattern has no {{title}}) */ filenamePreview?: string /** Expand a manually entered leaf through a filename pattern containing {{title}}. */ expandFilenamePattern?: (title: string) => string pathPrefix?: string /** Initial editable leaf value; use empty string to suppress autofill. */ initialValue?: string } type ModalMode = 'file' | 'folder' | 'adopt-folder' interface CreateItemSubmitters { pathPrefix: string | undefined onAdoptFolder?: (intermediateSegments: string[], url: AutomergeUrl) => Promise onCreateFolder: (name: string) => Promise onCreateFile: (name: string) => Promise onCreateFileFromTemplate?: (name: string, templatePath: string) => Promise expandFilenamePattern?: (title: string) => string selectedTemplate: string } async function submitCreateItem( parsed: Exclude, submitters: CreateItemSubmitters, ): Promise { const { pathPrefix, onAdoptFolder, onCreateFolder, onCreateFile, onCreateFileFromTemplate, expandFilenamePattern, selectedTemplate } = submitters if (parsed.kind === 'adopt-folder') { if (!onAdoptFolder) return 'URL adoption not available here' await onAdoptFolder(parsed.intermediateSegments, parsed.url) return null } const slash = parsed.path.lastIndexOf('/') const relativeParent = slash >= 0 ? parsed.path.slice(0, slash) : '' const leaf = slash >= 0 ? parsed.path.slice(slash + 1) : parsed.path const expandedLeaf = parsed.kind === 'create-file' && expandFilenamePattern ? expandFilenamePattern(leaf) : leaf const relativePath = relativeParent ? `${relativeParent}/${expandedLeaf}` : expandedLeaf const fullPath = joinCreateItemPath(pathPrefix, relativePath) const joinedError = validateCreateItemInput(fullPath, parsed.kind === 'create-folder' ? 'folder' : 'file') if (joinedError) return joinedError if (parsed.kind === 'create-folder') { await onCreateFolder(fullPath) return null } const finalName = ensureMdExtension(fullPath) // Templates produce Markdown content; explicit non-Markdown text files are // created directly so a JSON path cannot be paired with a Markdown template. if (selectedTemplate && onCreateFileFromTemplate && isMarkdownFilename(finalName)) { await onCreateFileFromTemplate(finalName, selectedTemplate) } else { await onCreateFile(finalName) } return null } /** * User-facing copy for an {@link AdoptFolderErrorCode}. We map the error * code (which is the API contract from `Vault.adoptFolderByUrl`) to a * localizable string the modal renders verbatim. The orchestrator's * `.message` is developer-facing (used in logs and stack traces); the * modal's `code`-derived copy is what the user sees. * * Returns `null` for codes we don't have specific copy for — the caller * falls back to the error's `.message`. */ function adoptFolderErrorCopy(code: AdoptFolderErrorCode): string | null { switch (code) { case 'unreachable': return 'Could not load folder from sync server. Check that the sync server is reachable and the URL is correct.' case 'wrong-type': return 'This URL is not a folder.' case 'collision': return 'A folder with that name already exists here. Rename it first or pick a different anchor.' case 'anchor-missing': return 'The anchor folder no longer exists. Close and reopen the modal.' case 'already-adopted': return 'This folder is already here.' case 'internal-state': return 'Reams state folders cannot be added to the file tree.' default: { // Exhaustiveness check — if a new code is added we want a // compile-time error here. TypeScript narrows `code` to `never`. const _exhaustive: never = code return _exhaustive } } } /** * Type guard for an AdoptFolderError shape. We accept duck-typed errors * (object with `code` of the right discriminant) to avoid a runtime * dependency on the vault module from the modal — the orchestrator may * throw across a module boundary where instanceof can fail. */ function isAdoptFolderError( err: unknown, ): err is Error & { code: AdoptFolderErrorCode } { if (!(err instanceof Error)) return false if (err.name !== 'AdoptFolderError') return false const code = (err as { code?: unknown }).code return ( code === 'unreachable' || code === 'wrong-type' || code === 'collision' || code === 'anchor-missing' || code === 'already-adopted' || code === 'internal-state' ) } /** * Get the display suffix hint based on the parsed input. Explicit supported * text extensions are preserved, while extensionless and unknown-dotted names * retain the `.md` default hint. */ function getSuffixHint(parsed: ParsedCreateItemInput | null): string | null { if (!parsed) return null if (parsed.kind !== 'create-file') return null return hasSupportedTextExtension(parsed.path) ? null : '.md' } export function CreateItemModal({ isOpen, onClose, onCreateFolder, onCreateFile, onAdoptFolder, onUploadImage, templates, defaultTemplate, onCreateFileFromTemplate, filenamePattern, filenamePreview, expandFilenamePattern, pathPrefix, initialValue, }: CreateItemModalProps) { const [value, setValue] = useState('') const [error, setError] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) const [selectedTemplate, setSelectedTemplate] = useState(defaultTemplate ?? '') const inputRef = useRef(null) const fileInputRef = useRef(null) const wasOpenRef = useRef(false) const autoFilledValueRef = useRef(null) // Mount tracker. Used to narrow `console.error` in handleSubmit's // catch: when the modal is still mounted, the error is rendered // inline and DevTools logging is noise. When the modal has unmounted // mid-submit (the user dismissed it), the inline UI is gone — log to // console so the failure remains observable. const isMountedRef = useRef(true) useEffect(() => { isMountedRef.current = true return () => { isMountedRef.current = false } }, []) // Whether the filename pattern requires manual title input const patternNeedsTitle = !filenamePattern || filenamePattern.includes('{{title}}') const trimmedValue = value.trim() // Parse once for view-side derivation (suffix hint, mode, inline error). // The parser is pure and synchronous; calling it again inside handleSubmit // keeps dispatch obvious and avoids cross-render staleness. const parsedForValidation: ParsedCreateItemInput | null = trimmedValue ? parseCreateItemInput(trimmedValue, { allowUrlAdoption: !!onAdoptFolder }) : null let mode: ModalMode = 'file' if (parsedForValidation?.kind === 'adopt-folder') { mode = 'adopt-folder' } else if (parsedForValidation?.kind === 'create-folder') { mode = 'folder' } // Joined-path validation regression guard. For create-file / // create-folder, run the existing joined-path check the modal has // always done (depth, name characters, etc. applied to the // FULLY-PREFIXED path). For adopt-folder, skip — the anchor comes // from FileTree's onAdoptFolder closure, not from pathPrefix. For // invalid inputs, use the parser's reason directly. let validationError: string | null = null if (parsedForValidation?.kind === 'invalid') { validationError = parsedForValidation.reason } else if ( parsedForValidation?.kind === 'create-file' || parsedForValidation?.kind === 'create-folder' ) { const joinedPath = joinCreateItemPath(pathPrefix, trimmedValue) const joinedKind = parsedForValidation.kind === 'create-folder' ? 'folder' : 'file' validationError = validateCreateItemInput(joinedPath, joinedKind) } const suffixHint = getSuffixHint(parsedForValidation) // Dynamic title and button text based on detected mode const title = 'New' const submitLabel = 'Create' const submittingLabel = 'Creating...' // Focus input when modal opens useEffect(() => { if (isOpen) { const timer = setTimeout(() => { inputRef.current?.focus() }, 0) return () => clearTimeout(timer) } }, [isOpen]) // Reset state when modal opens/closes useEffect(() => { if (!isOpen) { setValue('') setError(null) setIsSubmitting(false) setSelectedTemplate(defaultTemplate ?? '') wasOpenRef.current = false autoFilledValueRef.current = null } else if (!wasOpenRef.current) { // Only pre-fill on the open transition, not on subsequent re-renders wasOpenRef.current = true setSelectedTemplate(defaultTemplate ?? '') if (initialValue !== undefined) { setValue(initialValue) autoFilledValueRef.current = null } else if (!patternNeedsTitle && filenamePreview) { setValue(filenamePreview) autoFilledValueRef.current = filenamePreview } } }, [isOpen, defaultTemplate, patternNeedsTitle, filenamePreview, initialValue]) useEffect(() => { if (!isOpen || !wasOpenRef.current || initialValue !== undefined) return const nextAutoFilledValue = !patternNeedsTitle && filenamePreview ? filenamePreview : null const previousAutoFilledValue = autoFilledValueRef.current if (previousAutoFilledValue === nextAutoFilledValue) return if (value === (previousAutoFilledValue ?? '')) { setValue(nextAutoFilledValue ?? '') } autoFilledValueRef.current = nextAutoFilledValue }, [isOpen, initialValue, patternNeedsTitle, filenamePreview, value]) useEffect(() => { if (!isOpen) return const templatePaths = new Set(templates?.map(template => template.path) ?? []) if (selectedTemplate && !templatePaths.has(selectedTemplate)) { setSelectedTemplate('') } }, [isOpen, templates, selectedTemplate]) // Handle escape key useEffect(() => { if (!isOpen) return function handleKeyDown(e: globalThis.KeyboardEvent) { if (e.key === 'Escape') { onClose() } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) }, [isOpen, onClose]) const handleInputChange = useCallback((e: React.ChangeEvent) => { setValue(e.target.value) setError(null) }, []) const handleSubmit = useCallback(async (e?: FormEvent) => { e?.preventDefault() if (!trimmedValue) return const parsed = parseCreateItemInput(trimmedValue, { allowUrlAdoption: !!onAdoptFolder, }) if (parsed.kind === 'invalid') { setError(parsed.reason) return } setIsSubmitting(true) setError(null) try { const submitError = await submitCreateItem(parsed, { pathPrefix, onAdoptFolder, onCreateFolder, onCreateFile, onCreateFileFromTemplate, expandFilenamePattern, selectedTemplate, }) if (submitError) { setError(submitError) return } onClose() } catch (err) { // R9: prefer code-derived user copy for AdoptFolderError so the // user-facing string is decoupled from the orchestrator's // developer-facing message. Fall back to `.message` for non- // AdoptFolderError throwables. let userMessage: string if (isAdoptFolderError(err)) { userMessage = adoptFolderErrorCopy(err.code) ?? err.message } else if (err instanceof Error) { userMessage = err.message } else { userMessage = 'An error occurred' } // R5: narrow the noisy `console.error`. When the modal is still // mounted, the error is rendered inline — logging duplicates the // signal in DevTools for normal failures. When the modal has // unmounted mid-submit (e.g. user dismissed it), the inline UI // is gone; log so the failure remains observable. if (!isMountedRef.current) { console.error(err) } setError(userMessage) } finally { setIsSubmitting(false) } }, [ trimmedValue, pathPrefix, onAdoptFolder, onCreateFolder, onCreateFile, onCreateFileFromTemplate, expandFilenamePattern, selectedTemplate, onClose, ]) const handleBackdropClick = useCallback((e: React.MouseEvent) => { if (e.target === e.currentTarget) { onClose() } }, [onClose]) const handleImageUpload = useCallback(() => { fileInputRef.current?.click() }, []) const handleImageFileChange = useCallback(async () => { const file = fileInputRef.current?.files?.[0] if (!file || !onUploadImage) return setIsSubmitting(true) setError(null) try { const data = await readFileAsUint8Array(file) await onUploadImage(sanitizeFilename(file.name), data, file.type) onClose() } catch (err) { setError(err instanceof Error ? err.message : 'Failed to upload image') } finally { setIsSubmitting(false) // Reset file input so re-selecting the same file triggers change if (fileInputRef.current) fileInputRef.current.value = '' } }, [onUploadImage, onClose]) if (!isOpen) return null const content = (
e.stopPropagation()} >

{title}

{pathPrefix && mode !== 'adopt-folder' && ( // R4: in adopt-folder mode, the orchestrator uses the // anchor URL closure from FileTree (which agrees with // pathPrefix but is a different code path). Suppress the // prefix display so the input doesn't look like it's // concatenating an unused path. {pathPrefix} )} {suffixHint && ( )}
{!patternNeedsTitle && filenamePreview && (
Will create: {filenamePreview}.md
)} {templates && templates.length > 0 && (
)} {onUploadImage && ( <> )} {(error || validationError) && ( )}

{mode === 'file' && 'Creates a new file. Extensionless names default to Markdown; supported extensions are preserved.'} {mode === 'file' && trimmedValue.toLowerCase().endsWith('.base') && ' Base results can include notes, typed Records, or both.'} {mode === 'folder' && 'Creates a new folder.'} {mode === 'adopt-folder' && 'Adopts an existing folder.'} {' '} {/* R3: hide the trailing-slash/nested-paths tip when adopting — it doesn't apply to URL inputs (which forbid trailing slashes). */} {mode !== 'adopt-folder' && ( <> Tip: end with / for folder. Use folder/name for nested paths.
Or paste an automerge:... URL to add an existing folder. )}

) return createPortal(content, document.body) }