Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158// src/layout/file-tree/FileTreeItem.tsximport { useCallback, type KeyboardEvent, type MouseEvent } from 'react'import { ChevronRight, MoreHorizontal } from 'lucide-react'import type { AutomergeUrl } from '../../storage/document-runtime-contract'import { isFolderEntry, type DirectoryEntry } from '../../types/pushwork'import { getFileIcon, getFolderIcon, getFileExtension, FILE_TREE_INDENT_PX, FILE_TREE_BASE_PAD_PX } from './file-tree-utils'import './FileTreeItem.css'
export interface FileTreeItemProps { entry: DirectoryEntry depth: number isExpanded: boolean isActive: boolean onClick: (entry: DirectoryEntry) => void onContextMenu: (event: MouseEvent, entry: DirectoryEntry) => void onToggleExpand: (url: AutomergeUrl) => void onActionClick?: (entry: DirectoryEntry, position: { x: number; y: number }) => void /** Optional display name (e.g. from frontmatter title). Shows instead of entry.name when provided. */ displayName?: string}
export function FileTreeItem({ entry, depth, isExpanded, isActive, onClick, onContextMenu, onToggleExpand, onActionClick, displayName,}: FileTreeItemProps) { const isFolder = isFolderEntry(entry) const extension = isFolder ? '' : getFileExtension(entry.name) const Icon = isFolder ? getFolderIcon(isExpanded) : getFileIcon(extension)
const handleClick = useCallback((e: MouseEvent) => { e.stopPropagation() onClick(entry) }, [entry, onClick])
const handleChevronClick = useCallback((e: MouseEvent) => { e.stopPropagation() onToggleExpand(entry.url) }, [entry.url, onToggleExpand])
const handleContextMenu = useCallback((e: MouseEvent) => { e.preventDefault() e.stopPropagation() onContextMenu(e, entry) }, [entry, onContextMenu])
const handleKeyDown = useCallback((e: KeyboardEvent) => { switch (e.key) { case 'Enter': e.preventDefault() onClick(entry) break case 'ArrowRight': if (isFolder && !isExpanded) { e.preventDefault() onToggleExpand(entry.url) } break case 'ArrowLeft': if (isFolder && isExpanded) { e.preventDefault() onToggleExpand(entry.url) } break case 'ContextMenu': { e.preventDefault() if (onActionClick) { // Position relative to the action button if present, otherwise the row const el = e.currentTarget as HTMLElement const btn = el.querySelector('.file-tree-item__action-btn') as HTMLElement | null const rect = (btn ?? el).getBoundingClientRect() onActionClick(entry, { x: rect.right, y: rect.bottom }) } break } default: // Shift+F10 is the standard keyboard shortcut for context menu if (e.key === 'F10' && e.shiftKey) { e.preventDefault() if (onActionClick) { const el = e.currentTarget as HTMLElement const btn = el.querySelector('.file-tree-item__action-btn') as HTMLElement | null const rect = (btn ?? el).getBoundingClientRect() onActionClick(entry, { x: rect.right, y: rect.bottom }) } } break } }, [entry, isFolder, isExpanded, onClick, onToggleExpand, onActionClick])
const handleActionMouseDown = useCallback((e: MouseEvent) => { // Stop mousedown from reaching the document listener in FileTreeContextMenu // which would close the menu before our click handler can toggle it e.stopPropagation() }, [])
const handleActionClick = useCallback((e: MouseEvent) => { e.stopPropagation() if (onActionClick) { const button = e.currentTarget as HTMLElement const rect = button.getBoundingClientRect() onActionClick(entry, { x: rect.right, y: rect.bottom }) } }, [entry, onActionClick])
// Base left padding (FILE_TREE_BASE_PAD_PX, 8px) gives the icon // breathing room from the panel's left edge at depth 0, matching the // right-side gap between icon and filename. Indent for deeper levels // stacks on top of that base. Kept inline (rather than in the CSS // padding shorthand) because depth varies per row. const paddingLeft = FILE_TREE_BASE_PAD_PX + depth * FILE_TREE_INDENT_PX
return ( <div role="treeitem" aria-selected={isActive} aria-expanded={isFolder ? isExpanded : undefined} aria-level={depth + 1} tabIndex={0} className={`file-tree-item ${isActive ? 'file-tree-item--active' : ''}`} style={{ paddingLeft: `${paddingLeft}px` }} onClick={handleClick} onContextMenu={handleContextMenu} onKeyDown={handleKeyDown} > {isFolder && ( <button type="button" className={`file-tree-item__chevron ${isExpanded ? 'file-tree-item__chevron--expanded' : ''}`} onClick={handleChevronClick} aria-label={isExpanded ? `Collapse ${entry.name}` : `Expand ${entry.name}`} > <ChevronRight size={14} /> </button> )} <Icon size={16} className="file-tree-item__icon" /> <span className="file-tree-item__name">{displayName ?? entry.name}</span> {onActionClick && ( <button type="button" className="file-tree-item__action-btn" onMouseDown={handleActionMouseDown} onClick={handleActionClick} aria-label={`Actions for ${entry.name}`} > <MoreHorizontal size={14} /> </button> )} </div> )}