Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458// src/layout/file-tree/FileTree.tsximport { useState, useCallback, useMemo, type MouseEvent } from 'react'import type { AutomergeUrl } from '../../storage/document-runtime-contract'import type { App } from '../../plugin/app'import { isFolderEntry, type DirectoryEntry } from '../../types/pushwork'import { TFolder, TFile, type TAbstractFile } from '../../plugin/files'import { useLayout } from '../LayoutContext'import { MOBILE_BREAKPOINT } from '../layout-state'import { useReamsActiveFile, useMetadataCacheVersion } from '../../plugin/hooks'import { useFileTree } from './useFileTree'import { FILE_TREE_INDENT_PX, FILE_TREE_BASE_PAD_PX } from './file-tree-utils'import { compareTAbstractFiles } from '../../plugin/sidebar/sort-mode'import { useSortModeForView } from '../../plugin/sidebar/use-sort-mode'import type { FileTreeView } from '../../plugins/file-tree/file-tree-view'import { FileTreeItem } from './FileTreeItem'import { FileTreeContextMenu } from './FileTreeContextMenu'import { CreateItemModal } from './CreateItemModal'import { newBaseYaml } from '../../bases/default-base'// File tree search integration still depends on the// host SearchContext until a plugin-safe search surface exists.// RATIONALE(plugin-api): file-tree uses the editor image-paste workflow.// eslint-disable-next-line no-restricted-importsimport { useSearch } from '../../services/SearchContext'import { PromptDialog, ConfirmDialog } from './PromptDialog'import { Notice } from 'obsidian'import { createPromptlessNote } from '../promptless-note-creation'import { useReactiveTemplatePlugin } from '../useReactiveTemplatePlugin'import './FileTree.css'
interface FileTreeProps { /** * App instance. Used to subscribe to workspace active-file changes for * the active-row highlight. Vault is read from `app.vault`. */ app: App /** * Open a document in a main-area tab. Implementations should activate * any existing leaf showing the file or create a new one. Wired by * FileTreeView to `app.workspace.openFile(file)` (with a vault lookup * to resolve `url` → `TFile`); tests pass a vi.fn() spy. */ openDocument: (url: AutomergeUrl, name: string) => void /** * Optional FileTreeView. When provided, the tree subscribes to its * sort mode via `useSortModeForView`. When omitted, it falls back to * `DEFAULT_SORT_MODE = 'name-asc'`, which preserves the original * `sortTAbstractFiles(...)` ordering for existing call sites and tests. */ view?: FileTreeView}
interface ContextMenuState { isOpen: boolean position: { x: number; y: number } entry: DirectoryEntry | null folderUrl: AutomergeUrl | null}
export function FileTree({ app, openDocument, view }: FileTreeProps) { const vault = app.vault const { setSidebarMode } = useLayout() const { metadataCache } = useSearch() const fileTree = useFileTree(vault, app.storageScope ? `reams:${app.storageScope}:expanded-folders` : undefined)
// Sort mode: subscribe to the view (or fall back to DEFAULT_SORT_MODE // when no view is provided). The hook is called unconditionally to // respect React's Rules of Hooks. const sortMode = useSortModeForView(view ?? null) // Active file from the workspace (source of truth) — drives the active-row highlight. const activeFile = useReamsActiveFile(app) const activeFileUrl = activeFile ? vault.getUrlForFile(activeFile) ?? null : null
// Subscribe for reactivity — re-render when metadata changes const metadataVersion = useMetadataCacheVersion(metadataCache)
// Pre-compute display names for all indexed documents. Keyed by AutomergeUrl. const displayNames = useMemo(() => { const map = new Map<string, string>() for (const url of metadataCache.getIndexedDocuments()) { const file = vault.getFileByUrl(url) if (!file) continue const title = metadataCache.getDisplayTitle(file) if (title && title !== file.basename) { map.set(url, title) } } return map // eslint-disable-next-line react-hooks/exhaustive-deps }, [metadataCache, vault, metadataVersion])
const comparator = useMemo( () => compareTAbstractFiles(sortMode, true, (file) => { if (!(file instanceof TFile)) return file.name const url = vault.getUrlForFile(file) return (url && displayNames.get(url)) || file.name }), [displayNames, sortMode, vault], )
const [contextMenu, setContextMenu] = useState<ContextMenuState>({ isOpen: false, position: { x: 0, y: 0 }, entry: null, folderUrl: null, })
// Dialog state for styled prompts/confirms (replaces native prompt/confirm) const [renameDialog, setRenameDialog] = useState<{ entry: DirectoryEntry } | null>(null) const [deleteDialog, setDeleteDialog] = useState<{ entry: DirectoryEntry } | null>(null) const [createDialog, setCreateDialog] = useState<{ folderUrl: AutomergeUrl; pathPrefix: string; initialValue?: string } | null>(null) const templateUiActive = createDialog !== null
const { plugin: templatePlugin } = useReactiveTemplatePlugin({ active: templateUiActive, app, })
// Derive template data from the live plugin instance const templates = templatePlugin?.getTemplates() ?? [] const defaultTemplate = templatePlugin?.getDefaultTemplate() ?? '' const filenamePattern = templatePlugin?.getFilenamePattern() const filenamePreview = templatePlugin?.expandFilenamePattern() const createDialogInitialFileValue = (!filenamePattern || filenamePattern.includes('{{title}}')) ? undefined : (filenamePreview ?? '')
const handleItemClick = useCallback((entry: DirectoryEntry) => { if (isFolderEntry(entry)) { fileTree.toggleExpanded(entry.url) } else { // Open file in tab openDocument(entry.url, entry.name) // Auto-close sidebar on mobile - set to hidden for maximum screen space if (window.innerWidth < MOBILE_BREAKPOINT) { setSidebarMode('hidden') } } }, [fileTree, openDocument, setSidebarMode])
const handleContextMenu = useCallback((e: MouseEvent, entry: DirectoryEntry, folderUrl?: AutomergeUrl) => { e.preventDefault() setContextMenu({ isOpen: true, position: { x: e.clientX, y: e.clientY }, entry, folderUrl: folderUrl ?? null, }) }, [])
const handleActionClick = useCallback((entry: DirectoryEntry, position: { x: number; y: number }, folderUrl: AutomergeUrl) => { setContextMenu(prev => { // Toggle: if the menu is already open for this same entry, close it if (prev.isOpen && prev.entry?.url === entry.url) { return { ...prev, isOpen: false } } return { isOpen: true, position, entry, folderUrl, } }) }, [])
const handleCloseContextMenu = useCallback(() => { setContextMenu(prev => ({ ...prev, isOpen: false })) }, [])
const handleRename = useCallback((entry: DirectoryEntry) => { setRenameDialog({ entry }) }, [])
const handleRenameSubmit = useCallback(async (newName: string) => { if (!renameDialog) return const { entry } = renameDialog setRenameDialog(null) if (newName === entry.name) return // Reject names containing path separators — rename is not a move if (newName.includes('/') || newName.includes('\\')) { console.warn('FileTree: rename rejected — name cannot contain path separators') return } const file = vault.getAbstractFileByUrl(entry.url as AutomergeUrl) if (!file) { console.warn(`FileTree: cannot rename "${entry.name}" — URL ${entry.url} not found in vault index`) return } // Preserve extension: if the original name had an extension and the new name // doesn't include one, re-append the original extension. Prevents accidentally // stripping .md (or other extensions) during rename. let finalName = newName const origDot = entry.name.lastIndexOf('.') const newDot = newName.lastIndexOf('.') if (origDot > 0 && newDot === -1) { finalName = newName + entry.name.slice(origDot) } const parentPath = file.parent && !file.parent.isRoot() ? file.parent.path : '' const newPath = parentPath ? `${parentPath}/${finalName}` : finalName await vault.rename(file, newPath) }, [vault, renameDialog])
const handleDelete = useCallback((entry: DirectoryEntry) => { setDeleteDialog({ entry }) }, [])
const handleDeleteConfirm = useCallback(async () => { if (!deleteDialog) return const { entry } = deleteDialog setDeleteDialog(null) const file = vault.getAbstractFileByUrl(entry.url as AutomergeUrl) if (!file) { console.warn(`FileTree: cannot delete "${entry.name}" — URL ${entry.url} not found in vault index`) return } // The user already confirmed via the destructive dialog, so recursive // delete is the expected semantic for folders. Without `force=true`, // `vault.delete` throws on a non-empty folder and the rejection would // be silently swallowed by React's event handler — the menu's "Delete" // click would visibly do nothing (the dialog closes, but the folder // remains in the tree). Pass force=true so the destructive confirm // dialog's "Delete" button actually deletes. // // For files, `force` is a no-op (always deleted), so this is safe to // pass unconditionally. try { await vault.delete(file, true) } catch (err) { // Even with force=true, delete can fail (e.g. storage write // failure, attempt to delete the vault root). Surface the error // so the user gets feedback instead of silent no-op behaviour. console.error(`FileTree: failed to delete "${entry.name}":`, err) } }, [vault, deleteDialog])
const expandFolderAfterCreate = useCallback((folderUrl: AutomergeUrl) => { if (!fileTree.isExpanded(folderUrl)) { fileTree.toggleExpanded(folderUrl) } }, [fileTree])
const openExistingFile = useCallback((filePath: string) => { const existing = vault.getFileByPath(filePath) if (!existing) return false const url = vault.getUrlForPath(filePath) if (url) { openDocument(url, existing.name) return true } return false }, [vault, openDocument])
const openCreateDialog = useCallback((folderUrl: AutomergeUrl, initialValue?: string) => { const folderPath = vault.getPathForUrl(folderUrl) if (folderPath === undefined) return setCreateDialog({ pathPrefix: folderPath ? `${folderPath}/` : '', folderUrl, initialValue }) }, [vault])
const handleNewFile = useCallback((folderUrl: AutomergeUrl) => { void createPromptlessNote(app, folderUrl).then(result => { if (result.status === 'fallback') { openCreateDialog(folderUrl, createDialogInitialFileValue) return } const url = vault.getUrlForFile(result.file) if (!url) throw new Error('FileTree: could not resolve the created note.') openDocument(url, result.file.name) expandFolderAfterCreate(folderUrl) }).catch(error => { console.error(`FileTree: failed to create note in folder ${folderUrl}:`, error) new Notice('Could not create the note.') }) }, [app, openCreateDialog, createDialogInitialFileValue, vault, openDocument, expandFolderAfterCreate])
const handleCreateDialogFile = useCallback(async (name: string) => { if (!createDialog) return const { folderUrl } = createDialog try { const file = await vault.create(name, name.toLowerCase().endsWith('.base') ? newBaseYaml() : '') const fileUrl = vault.getUrlForPath(file.path) if (fileUrl) { openDocument(fileUrl, file.name) } } catch (err) { // File already exists — open it instead if (!openExistingFile(name)) { throw err } } expandFolderAfterCreate(folderUrl) }, [vault, openDocument, createDialog, openExistingFile, expandFolderAfterCreate])
const handleCreateDialogFolder = useCallback(async (name: string) => { if (!createDialog) return const { folderUrl } = createDialog await vault.createFolder(name) expandFolderAfterCreate(folderUrl) }, [vault, createDialog, expandFolderAfterCreate])
const handleCreateDialogFileFromTemplate = useCallback(async (name: string, templatePath: string) => { if (!createDialog) return const { folderUrl } = createDialog
try { const file = templatePlugin ? await templatePlugin.createFileFromTemplate(name, { templatePath }) : await vault.create(name, '') const url = vault.getUrlForPath(file.path) if (url) { openDocument(url, file.name) } } catch (err) { if (!openExistingFile(name)) { throw err } }
expandFolderAfterCreate(folderUrl) }, [createDialog, templatePlugin, vault, openDocument, openExistingFile, expandFolderAfterCreate])
// Adoption handler for the ctx-menu modal: closes over the right-clicked // folder URL so the orchestrator anchors under that folder. const handleAdoptFolderUnderDialog = useCallback( async (intermediateSegments: string[], url: AutomergeUrl) => { if (!createDialog) return await vault.adoptFolderByUrl(createDialog.folderUrl, intermediateSegments, url) }, [vault, createDialog], )
const renderTAbstractFile = (item: TAbstractFile, depth: number): React.ReactNode => { const url = vault.getUrlForFile(item) if (!url) return null const isFolder = item instanceof TFolder const isFileItem = item instanceof TFile const isExpanded = isFolder && fileTree.isExpanded(url) const isActive = activeFileUrl !== null && activeFileUrl === url
const entry: DirectoryEntry = { name: item.name, type: isFolder ? 'folder' : 'file', url } let folderUrl = vault.rootUrl if (isFolder) { folderUrl = url } else if (item.parent) { folderUrl = vault.getUrlForFile(item.parent) ?? vault.rootUrl } const displayName = isFileItem ? displayNames.get(url) : undefined
return ( <div key={url}> <FileTreeItem entry={entry} depth={depth} isExpanded={isExpanded} isActive={isActive} onClick={handleItemClick} onContextMenu={(e, ent) => handleContextMenu(e, ent, folderUrl)} onToggleExpand={fileTree.toggleExpanded} onActionClick={(ent, pos) => handleActionClick(ent, pos, folderUrl)} displayName={displayName} /> {isFolder && isExpanded && (() => { const sortedChildren = [...fileTree.getFolderChildren(url)].sort(comparator) return ( <div role="group"> {sortedChildren.length === 0 ? ( <div className="file-tree__empty-folder" style={{ paddingLeft: `${FILE_TREE_BASE_PAD_PX + (depth + 1) * FILE_TREE_INDENT_PX}px` }}> Empty folder </div> ) : ( sortedChildren.map(child => renderTAbstractFile(child, depth + 1) ) )} </div> ) })()} </div> ) }
const sortedRootChildren = [...fileTree.rootChildren].sort(comparator)
return ( <div className="file-tree" role="tree" aria-label="File browser"> {/* Panel header is rendered by the host SidebarViewContainer. The view's getDisplayText() ("Files") appears in the chrome row; the "+" button is provided by FileTreeView.getHeaderActions() so it lands in the same chrome row, aligned with the central TabBar. */} <div className="file-tree__content"> {sortedRootChildren.length === 0 ? ( <div className="file-tree__empty"> No files yet. Click + to create one. </div> ) : ( sortedRootChildren.map(item => renderTAbstractFile(item, 0) ) )} </div>
<FileTreeContextMenu isOpen={contextMenu.isOpen} position={contextMenu.position} entry={contextMenu.entry} folderUrl={contextMenu.folderUrl} onClose={handleCloseContextMenu} onRename={handleRename} onDelete={handleDelete} onNewFile={handleNewFile} />
<PromptDialog isOpen={renameDialog !== null} title="Rename" label="New name" defaultValue={renameDialog?.entry.name ?? ''} placeholder="Enter new name" submitLabel="Rename" onSubmit={handleRenameSubmit} onClose={() => setRenameDialog(null)} />
<ConfirmDialog isOpen={deleteDialog !== null} title="Delete" message={`Are you sure you want to delete "${deleteDialog?.entry.name ?? ''}"?`} confirmLabel="Delete" destructive onConfirm={handleDeleteConfirm} onClose={() => setDeleteDialog(null)} />
{/* Root-level "+ create" modal is rendered globally by AppShell and triggered via the `reams-create-file:create-file` command (the same command FileTreeView.getHeaderActions() dispatches and the ribbon's "New file" icon dispatches). This component only owns the per-folder context-menu modal below. */} <CreateItemModal isOpen={createDialog !== null} pathPrefix={createDialog?.pathPrefix ?? ''} initialValue={createDialog?.initialValue} onClose={() => setCreateDialog(null)} onCreateFolder={handleCreateDialogFolder} onCreateFile={handleCreateDialogFile} onAdoptFolder={handleAdoptFolderUnderDialog} templates={templates} defaultTemplate={defaultTemplate} onCreateFileFromTemplate={handleCreateDialogFileFromTemplate} filenamePattern={filenamePattern} filenamePreview={filenamePreview} expandFilenamePattern={filenamePattern?.includes('{{title}}') ? title => templatePlugin?.expandFilenamePattern(title) ?? title : undefined} /> </div> )}