// src/layout/file-tree/FileTree.tsx import { 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-imports import { 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() 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({ 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 (
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 (
{sortedChildren.length === 0 ? (
Empty folder
) : ( sortedChildren.map(child => renderTAbstractFile(child, depth + 1) ) )}
) })()}
) } const sortedRootChildren = [...fileTree.rootChildren].sort(comparator) return (
{/* 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. */}
{sortedRootChildren.length === 0 ? (
No files yet. Click + to create one.
) : ( sortedRootChildren.map(item => renderTAbstractFile(item, 0) ) )}
setRenameDialog(null)} /> 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. */} 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} />
) }