Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565// src/layout/file-tree/CreateItemModal.tsximport { 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-importsimport { 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<void> onCreateFile: (name: string) => Promise<void> /** * 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:<id>`. 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<void> /** Upload a binary image file. Called with (filename, data, mimeType). */ onUploadImage?: (name: string, data: Uint8Array, mimeType: string) => Promise<void> /** 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<void> /** 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<void> onCreateFolder: (name: string) => Promise<void> onCreateFile: (name: string) => Promise<void> onCreateFileFromTemplate?: (name: string, templatePath: string) => Promise<void> expandFilenamePattern?: (title: string) => string selectedTemplate: string}
async function submitCreateItem( parsed: Exclude<ParsedCreateItemInput, { kind: 'invalid' }>, submitters: CreateItemSubmitters,): Promise<string | null> { 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<string | null>(null) const [isSubmitting, setIsSubmitting] = useState(false) const [selectedTemplate, setSelectedTemplate] = useState(defaultTemplate ?? '') const inputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null) const wasOpenRef = useRef(false) const autoFilledValueRef = useRef<string | null>(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<HTMLInputElement>) => { 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 = ( <div className="create-item-modal__backdrop" data-testid="modal-backdrop" onClick={handleBackdropClick} > <div role="dialog" aria-modal="true" aria-labelledby="create-item-title" className="create-item-modal" onClick={e => e.stopPropagation()} > <h2 id="create-item-title" className="create-item-modal__title"> {title} </h2> <form onSubmit={handleSubmit}> <div className="create-item-modal__input-wrapper"> {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. <span className="create-item-modal__path-prefix">{pathPrefix}</span> )} <input ref={inputRef} type="text" value={value} onChange={handleInputChange} placeholder="my-note or folder/my-note" className={`create-item-modal__input ${error ? 'create-item-modal__input--error' : ''}`} disabled={isSubmitting} aria-describedby="create-item-hint create-item-error" aria-invalid={!!error} /> {suffixHint && ( <span className="create-item-modal__suffix-hint" aria-hidden="true">{suffixHint}</span> )} </div> {!patternNeedsTitle && filenamePreview && ( <div className="create-item-modal__filename-preview"> Will create: <code>{filenamePreview}.md</code> </div> )} {templates && templates.length > 0 && ( <div className="create-item-modal__template-select"> <label htmlFor="template-select" className="create-item-modal__template-label"> Template </label> <select id="template-select" role="combobox" aria-label="Template" value={selectedTemplate} onChange={(e) => setSelectedTemplate(e.target.value)} className="create-item-modal__select" disabled={isSubmitting} > <option value="">None</option> {templates.map(t => ( <option key={t.path} value={t.path}>{t.name}</option> ))} </select> </div> )} {onUploadImage && ( <> <button type="button" className="create-item-modal__image-button" onClick={handleImageUpload} disabled={isSubmitting} aria-label="Upload image" > Upload image </button> <input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleImageFileChange} /> </> )} {(error || validationError) && ( <div id="create-item-error" className="create-item-modal__error" role="alert" aria-live="polite" > {error ?? validationError} </div> )} <p id="create-item-hint" className="create-item-modal__hint"> {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' && ( <> <span className="create-item-modal__hint-tip"> Tip: end with <code>/</code> for folder. Use <code>folder/name</code> for nested paths. </span> <br /> <span className="create-item-modal__hint-tip"> Or paste an <code>automerge:...</code> URL to add an existing folder. </span> </> )} </p> <div className="create-item-modal__buttons"> <button type="button" className="create-item-modal__button create-item-modal__button--cancel" onClick={onClose} disabled={isSubmitting} > Cancel </button> <button type="submit" className="create-item-modal__button create-item-modal__button--submit" disabled={!value.trim() || isSubmitting || !!validationError} > {isSubmitting ? submittingLabel : submitLabel} </button> </div> </form> </div> </div> )
return createPortal(content, document.body)}