Files
lms-web-fe/src/lms-renderer/features/workspace/WorkspaceFilePanel.tsx
T
2026-08-03 14:06:06 +09:00

682 lines
25 KiB
TypeScript

import { ChevronDown, FilePlus, FileText, Folder, FolderPlus, ListCollapse, RefreshCw, Search, X } from 'lucide-react'
import { Fragment } from 'react'
import type React from 'react'
type WorkspaceTreeItem =
| { kind: 'folder'; name: string; path: string; parent?: string; depth: number }
| { kind: 'file'; name: string; path: string; parent?: string; depth: number }
type NewFileDraft = {
kind: 'file' | 'folder'
parent: string
afterFile: string | null
depth: number
value: string
}
type TreeContextMenu = {
path: string
kind: 'file' | 'folder'
x: number
y: number
}
type TreeClipboard = {
path: string
kind: 'file' | 'folder'
mode: 'cut' | 'copy'
}
type SearchResult = {
file: string
lineNumber: number
lineText: string
}
type WorkspaceFilePanelProps = {
dark: boolean
workspaceActive: boolean
searchActive: boolean
exampleActive: boolean
selectedModuleName?: string
borderSoft: string
textMid: string
textDim: string
workspaceRootPath: string
workspaceRootLabel: string
currentSelectedTreePath: string | null
currentActiveEditorFile: string | null
openProjectFolders: Record<string, boolean>
visibleProjectTreeItems: WorkspaceTreeItem[]
newFileDraft: NewFileDraft | null
moduleFileTreeLoading: boolean
treeContextMenu: TreeContextMenu | null
treeClipboard: TreeClipboard | null
moduleSearchInputRef: React.RefObject<HTMLInputElement | null>
treeContextMenuRef: React.RefObject<HTMLDivElement | null>
moduleSearchQuery: string
activeModuleSearchQuery: string
moduleSearchRunning: boolean
moduleSearchResults: SearchResult[]
onStartCreateFile: (kind: 'file' | 'folder') => void
onRefreshModuleFiles: () => void
onCollapseModuleFolders: () => void
onSearchChange: (value: string) => void
onOpenSearchResult: (file: string, lineNumber: number) => void
onSelectRoot: () => void
onRootContextMenu: (x: number, y: number) => void
onDraftChange: (value: string) => void
onCommitCreateFile: () => void
onCancelCreateFile: () => void
onSelectFolder: (path: string) => void
onFolderContextMenu: (path: string, x: number, y: number) => void
onOpenFile: (path: string) => void
onFileContextMenu: (path: string, x: number, y: number) => void
onOpenTreeContextTarget: () => void
onCutTreeTarget: () => void
onCopyTreeTarget: () => void
onPasteTreeClipboard: () => void
onCopyTreePath: () => void
onRenameTreeTarget: () => void
onDeleteTreeTarget: () => void
}
function toolbarTooltipClass(dark: boolean): string {
return [
'pointer-events-none absolute right-0 top-7 z-50 hidden whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block',
dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'
].join(' ')
}
function fileDraftInputClass(dark: boolean): string {
return [
'h-5 min-w-0 flex-1 rounded border px-1 text-[12px] outline-none',
dark ? 'border-[#2f8dff]/60 bg-[#1f1f1f] text-white' : 'border-[#2f8dff]/60 bg-white text-[#111520]'
].join(' ')
}
function FileDraftInput({
dark,
draft,
onChange,
onCommit,
onCancel
}: {
dark: boolean
draft: NewFileDraft
onChange: (value: string) => void
onCommit: () => void
onCancel: () => void
}): React.JSX.Element {
return (
<input
autoFocus
value={draft.value}
onChange={(event) => onChange(event.target.value)}
onBlur={onCommit}
onKeyDown={(event) => {
if (event.key === 'Enter') onCommit()
if (event.key === 'Escape') onCancel()
}}
className={fileDraftInputClass(dark)}
/>
)
}
function DraftRow({
dark,
draft,
depth,
onChange,
onCommit,
onCancel
}: {
dark: boolean
draft: NewFileDraft
depth: number
onChange: (value: string) => void
onCommit: () => void
onCancel: () => void
}): React.JSX.Element {
return (
<div className="flex h-[24px] items-center pr-2" style={{ paddingLeft: `${depth * 10}px` }}>
{draft.kind === 'folder' ? <ChevronDown size={13} className="mr-[3px] shrink-0 text-transparent" /> : <span className="mr-[3px] w-[13px] shrink-0" />}
{draft.kind === 'folder' ? <Folder size={14} className="mr-1 shrink-0 text-[#e2a93b]" /> : <FileText size={13} className="mr-1 shrink-0" />}
<FileDraftInput dark={dark} draft={draft} onChange={onChange} onCommit={onCommit} onCancel={onCancel} />
</div>
)
}
export default function WorkspaceFilePanel({
dark,
workspaceActive,
searchActive,
exampleActive,
selectedModuleName,
borderSoft,
textMid,
textDim,
workspaceRootPath,
workspaceRootLabel,
currentSelectedTreePath,
currentActiveEditorFile,
openProjectFolders,
visibleProjectTreeItems,
newFileDraft,
moduleFileTreeLoading,
treeContextMenu,
treeClipboard,
moduleSearchInputRef,
treeContextMenuRef,
moduleSearchQuery,
activeModuleSearchQuery,
moduleSearchRunning,
moduleSearchResults,
onStartCreateFile,
onRefreshModuleFiles,
onCollapseModuleFolders,
onSearchChange,
onOpenSearchResult,
onSelectRoot,
onRootContextMenu,
onDraftChange,
onCommitCreateFile,
onCancelCreateFile,
onSelectFolder,
onFolderContextMenu,
onOpenFile,
onFileContextMenu,
onOpenTreeContextTarget,
onCutTreeTarget,
onCopyTreeTarget,
onPasteTreeClipboard,
onCopyTreePath,
onRenameTreeTarget,
onDeleteTreeTarget
}: WorkspaceFilePanelProps): React.JSX.Element {
return (
<aside
className={['flex min-h-0 flex-col border-r transition-opacity', workspaceActive ? '' : 'pointer-events-none overflow-hidden border-r-0 opacity-0', dark ? 'border-white/10 bg-[#242424]' : 'border-[#cfd6e4] bg-[#edf1f7]'].join(' ')}
style={workspaceActive ? { gridColumn: 4, gridRow: 2 } : undefined}
>
<div className={['flex h-11 items-center justify-between border-b px-3', borderSoft].join(' ')}>
<strong className={['text-[11px] font-bold tracking-[0.08em]', textDim].join(' ')}>{searchActive ? 'SEARCH' : 'EXPLORER'}</strong>
{!searchActive && !exampleActive && (
<div className={['flex items-center gap-2', textMid].join(' ')}>
<button type="button" aria-label="파일 만들기" onClick={() => onStartCreateFile('file')} className={['group relative grid h-5 w-4 cursor-pointer place-items-center rounded transition', dark ? 'hover:bg-white/10 hover:text-white' : 'hover:bg-[#e8ebf2] hover:text-[#111520]'].join(' ')}>
<FilePlus size={14} />
<span className={toolbarTooltipClass(dark)}>파일 만들기</span>
</button>
<button type="button" aria-label="폴더 만들기" onClick={() => onStartCreateFile('folder')} className={['group relative grid h-5 w-5 cursor-pointer place-items-center rounded transition', dark ? 'hover:bg-white/10 hover:text-white' : 'hover:bg-[#e8ebf2] hover:text-[#111520]'].join(' ')}>
<FolderPlus size={14} />
<span className={toolbarTooltipClass(dark)}>폴더 만들기</span>
</button>
<button type="button" aria-label="목록 새로고침" onClick={onRefreshModuleFiles} className={['group relative grid h-5 w-5 cursor-pointer place-items-center rounded transition', dark ? 'hover:bg-white/10 hover:text-white' : 'hover:bg-[#e8ebf2] hover:text-[#111520]'].join(' ')}>
<RefreshCw size={14} />
<span className={toolbarTooltipClass(dark)}>목록 새로고침</span>
</button>
<button type="button" aria-label="하위 폴더 모두 닫기" onClick={onCollapseModuleFolders} className={['group relative grid h-5 w-5 cursor-pointer place-items-center rounded transition', dark ? 'hover:bg-white/10 hover:text-white' : 'hover:bg-[#e8ebf2] hover:text-[#111520]'].join(' ')}>
<ListCollapse size={14} />
<span className={toolbarTooltipClass(dark)}>하위 폴더 모두 닫기</span>
</button>
</div>
)}
</div>
{searchActive ? (
<SearchPanel
dark={dark}
textMid={textMid}
textDim={textDim}
selectedModuleName={selectedModuleName}
moduleSearchInputRef={moduleSearchInputRef}
moduleSearchQuery={moduleSearchQuery}
activeModuleSearchQuery={activeModuleSearchQuery}
moduleSearchRunning={moduleSearchRunning}
moduleSearchResults={moduleSearchResults}
onSearchChange={onSearchChange}
onOpenSearchResult={onOpenSearchResult}
/>
) : (
<ExplorerPanel
dark={dark}
textMid={textMid}
textDim={textDim}
exampleActive={exampleActive}
workspaceRootPath={workspaceRootPath}
workspaceRootLabel={workspaceRootLabel}
currentSelectedTreePath={currentSelectedTreePath}
currentActiveEditorFile={currentActiveEditorFile}
openProjectFolders={openProjectFolders}
visibleProjectTreeItems={visibleProjectTreeItems}
newFileDraft={newFileDraft}
moduleFileTreeLoading={moduleFileTreeLoading}
treeContextMenu={treeContextMenu}
treeClipboard={treeClipboard}
treeContextMenuRef={treeContextMenuRef}
onSelectRoot={onSelectRoot}
onRootContextMenu={onRootContextMenu}
onDraftChange={onDraftChange}
onCommitCreateFile={onCommitCreateFile}
onCancelCreateFile={onCancelCreateFile}
onSelectFolder={onSelectFolder}
onFolderContextMenu={onFolderContextMenu}
onOpenFile={onOpenFile}
onFileContextMenu={onFileContextMenu}
onOpenTreeContextTarget={onOpenTreeContextTarget}
onCutTreeTarget={onCutTreeTarget}
onCopyTreeTarget={onCopyTreeTarget}
onPasteTreeClipboard={onPasteTreeClipboard}
onCopyTreePath={onCopyTreePath}
onRenameTreeTarget={onRenameTreeTarget}
onDeleteTreeTarget={onDeleteTreeTarget}
/>
)}
</aside>
)
}
function SearchPanel({
dark,
textMid,
textDim,
selectedModuleName,
moduleSearchInputRef,
moduleSearchQuery,
activeModuleSearchQuery,
moduleSearchRunning,
moduleSearchResults,
onSearchChange,
onOpenSearchResult
}: Pick<WorkspaceFilePanelProps,
'dark' | 'textMid' | 'textDim' | 'selectedModuleName' | 'moduleSearchInputRef' | 'moduleSearchQuery' |
'activeModuleSearchQuery' | 'moduleSearchRunning' | 'moduleSearchResults' | 'onSearchChange' | 'onOpenSearchResult'
>): React.JSX.Element {
return (
<div className={['flex min-h-0 flex-1 flex-col px-2 py-2 text-[13px] font-medium', textMid].join(' ')}>
<div className={['flex h-8 items-center gap-2 rounded-md border px-2', dark ? 'border-white/12 bg-[#1f1f1f]' : 'border-[#cfd6e4] bg-white'].join(' ')}>
<Search size={14} className={dark ? 'text-white/35' : 'text-[#8a94a5]'} />
<input
ref={moduleSearchInputRef}
value={moduleSearchQuery}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="검색"
className={['min-w-0 flex-1 bg-transparent text-[12px] font-medium outline-none', dark ? 'text-white/75 placeholder:text-white/30' : 'text-[#111520] placeholder:text-[#8a94a5]'].join(' ')}
/>
{moduleSearchQuery && (
<button type="button" aria-label="검색어 지우기" onClick={() => onSearchChange('')} className={['grid h-5 w-5 cursor-pointer place-items-center rounded transition', dark ? 'hover:bg-white/10 hover:text-white' : 'hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')}>
<X size={12} />
</button>
)}
</div>
<div className="thin-y-scrollbar mt-3 min-h-0 flex-1 overflow-auto pr-1">
<div className="grid gap-1">
{!moduleSearchQuery.trim() ? (
<p className={['px-1 text-[12px] font-medium', textDim].join(' ')}>검색어를 입력하세요.</p>
) : moduleSearchQuery.trim().length < 2 ? (
<p className={['px-1 text-[12px] font-medium', textDim].join(' ')}>2글자 이상 입력하세요.</p>
) : moduleSearchQuery.trim() !== activeModuleSearchQuery ? (
<p className={['px-1 text-[12px] font-medium', textDim].join(' ')}>검색 중...</p>
) : moduleSearchRunning ? (
<p className={['px-1 text-[12px] font-medium', textDim].join(' ')}>검색 중...</p>
) : moduleSearchResults.length === 0 ? (
<p className={['px-1 text-[12px] font-medium', textDim].join(' ')}>검색 결과가 없습니다.</p>
) : (
moduleSearchResults.map((result) => {
const query = activeModuleSearchQuery
const matchIndex = result.lineText.toLowerCase().indexOf(query.toLowerCase())
const beforeMatch = matchIndex >= 0 ? result.lineText.slice(0, matchIndex) : result.lineText
const matchText = matchIndex >= 0 ? result.lineText.slice(matchIndex, matchIndex + query.length) : ''
const afterMatch = matchIndex >= 0 ? result.lineText.slice(matchIndex + query.length) : ''
return (
<button
key={`${result.file}-${result.lineNumber}`}
type="button"
onClick={() => onOpenSearchResult(result.file, result.lineNumber)}
className={[
'w-full cursor-pointer rounded px-2 py-1.5 text-left transition',
dark ? 'hover:bg-white/7' : 'hover:bg-[#e8ebf2]'
].join(' ')}
>
<span className={['block truncate text-[12px] font-bold', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{result.file.replace(`modules/${selectedModuleName}/`, '')}</span>
<span className="mt-1 flex min-w-0 gap-2 font-mono text-[11px]">
<span className={dark ? 'text-white/28' : 'text-[#9aa3b2]'}>{result.lineNumber}</span>
<span className={['truncate', dark ? 'text-white/48' : 'text-[#687284]'].join(' ')}>
{beforeMatch}
{matchText && <mark className={['rounded px-0.5', dark ? 'bg-[#f59e0b]/35 text-[#ffd89a]' : 'bg-[#fde68a] text-[#78350f]'].join(' ')}>{matchText}</mark>}
{afterMatch}
</span>
</span>
</button>
)
})
)}
</div>
</div>
</div>
)
}
function ExplorerPanel(props: Omit<WorkspaceFilePanelProps,
'workspaceActive' | 'searchActive' | 'selectedModuleName' | 'borderSoft' | 'moduleSearchInputRef' | 'moduleSearchQuery' |
'activeModuleSearchQuery' | 'moduleSearchRunning' | 'moduleSearchResults' | 'onStartCreateFile' | 'onRefreshModuleFiles' |
'onCollapseModuleFolders' | 'onSearchChange' | 'onOpenSearchResult'
>): React.JSX.Element {
const {
dark,
textMid,
exampleActive,
workspaceRootPath,
workspaceRootLabel,
currentSelectedTreePath,
currentActiveEditorFile,
openProjectFolders,
visibleProjectTreeItems,
newFileDraft,
moduleFileTreeLoading,
treeContextMenu,
treeClipboard,
treeContextMenuRef,
onSelectRoot,
onRootContextMenu,
onDraftChange,
onCommitCreateFile,
onCancelCreateFile,
onSelectFolder,
onFolderContextMenu,
onOpenFile,
onFileContextMenu,
onOpenTreeContextTarget,
onCutTreeTarget,
onCopyTreeTarget,
onPasteTreeClipboard,
onCopyTreePath,
onRenameTreeTarget,
onDeleteTreeTarget
} = props
return (
<div className={['thin-y-scrollbar min-h-0 flex-1 overflow-y-auto px-2 py-2 text-[13px] font-medium', textMid].join(' ')}>
<button
type="button"
onClick={onSelectRoot}
onContextMenu={(event) => {
if (exampleActive) return
event.preventDefault()
onRootContextMenu(event.clientX, event.clientY)
}}
className={[
'mb-1 flex h-[22px] w-full cursor-pointer items-center gap-1 px-1 text-left transition',
currentSelectedTreePath === workspaceRootPath
? dark
? 'bg-[#37373d] text-white'
: 'bg-[#dbe5f1] text-[#111520]'
: dark ? 'text-white/62 hover:bg-white/7 hover:text-white' : 'text-[#5f6878] hover:bg-[#e8ebf2] hover:text-[#111520]'
].join(' ')}
>
<ChevronDown size={13} className={['shrink-0 transition-transform', openProjectFolders[workspaceRootPath] ? '' : '-rotate-90'].join(' ')} />
<Folder size={14} className="shrink-0" />
<span className="truncate">{workspaceRootLabel}</span>
</button>
{newFileDraft && !newFileDraft.afterFile && newFileDraft.parent === workspaceRootPath && (
<DraftRow dark={dark} draft={newFileDraft} depth={newFileDraft.depth} onChange={onDraftChange} onCommit={onCommitCreateFile} onCancel={onCancelCreateFile} />
)}
{moduleFileTreeLoading && (
<div className="grid gap-1 py-1 pl-6 pr-2" aria-label="파일 트리 로딩 중">
{[0, 1, 2].map((item) => (
<div key={item} className="flex h-[22px] items-center gap-2">
<span className={['h-3 w-3 shrink-0 animate-pulse rounded-sm', dark ? 'bg-white/10' : 'bg-[#cfd6e4]'].join(' ')} />
<span
className={[
'h-2.5 animate-pulse rounded',
dark ? 'bg-white/10' : 'bg-[#cfd6e4]',
item === 0 ? 'w-[62%]' : item === 1 ? 'w-[44%]' : 'w-[54%]'
].join(' ')}
/>
</div>
))}
</div>
)}
<div className="grid">
{visibleProjectTreeItems.map((item) => (
item.kind === 'folder' ? (
<FolderRow
key={item.path}
dark={dark}
item={item}
active={currentSelectedTreePath === item.path}
open={Boolean(openProjectFolders[item.path])}
exampleActive={exampleActive}
newFileDraft={newFileDraft}
onSelectFolder={onSelectFolder}
onFolderContextMenu={onFolderContextMenu}
onDraftChange={onDraftChange}
onCommitCreateFile={onCommitCreateFile}
onCancelCreateFile={onCancelCreateFile}
/>
) : (
<FileRow
key={item.path}
dark={dark}
item={item}
active={currentSelectedTreePath === item.path || currentActiveEditorFile === item.path}
exampleActive={exampleActive}
newFileDraft={newFileDraft}
onOpenFile={onOpenFile}
onFileContextMenu={onFileContextMenu}
onDraftChange={onDraftChange}
onCommitCreateFile={onCommitCreateFile}
onCancelCreateFile={onCancelCreateFile}
/>
)
))}
</div>
{treeContextMenu && (
<TreeContextMenu
dark={dark}
menu={treeContextMenu}
menuRef={treeContextMenuRef}
treeClipboard={treeClipboard}
workspaceRootPath={workspaceRootPath}
onOpen={onOpenTreeContextTarget}
onCut={onCutTreeTarget}
onCopy={onCopyTreeTarget}
onPaste={onPasteTreeClipboard}
onCopyPath={onCopyTreePath}
onRename={onRenameTreeTarget}
onDelete={onDeleteTreeTarget}
/>
)}
</div>
)
}
function FolderRow({
dark,
item,
active,
open,
exampleActive,
newFileDraft,
onSelectFolder,
onFolderContextMenu,
onDraftChange,
onCommitCreateFile,
onCancelCreateFile
}: {
dark: boolean
item: Extract<WorkspaceTreeItem, { kind: 'folder' }>
active: boolean
open: boolean
exampleActive: boolean
newFileDraft: NewFileDraft | null
onSelectFolder: (path: string) => void
onFolderContextMenu: (path: string, x: number, y: number) => void
onDraftChange: (value: string) => void
onCommitCreateFile: () => void
onCancelCreateFile: () => void
}): React.JSX.Element {
return (
<Fragment>
<button
type="button"
onClick={() => onSelectFolder(item.path)}
onContextMenu={(event) => {
if (exampleActive) return
event.preventDefault()
onFolderContextMenu(item.path, event.clientX, event.clientY)
}}
style={{ paddingLeft: `${item.depth * 10}px` }}
className={[
'flex h-[22px] w-full cursor-pointer items-center pr-2 text-left transition',
active
? dark
? 'bg-[#37373d] text-white'
: 'bg-[#dbe5f1] text-[#111520]'
: dark
? 'text-white/62 hover:bg-white/7 hover:text-white'
: 'text-[#5f6878] hover:bg-[#e8ebf2] hover:text-[#111520]'
].join(' ')}
>
<ChevronDown size={13} className={['mr-[3px] shrink-0 transition-transform', dark ? 'text-white/35' : 'text-[#7a8393]', open ? '' : '-rotate-90'].join(' ')} />
<Folder size={14} className="mr-1 shrink-0 text-[#e2a93b]" />
<span className="truncate">{item.name}</span>
</button>
{newFileDraft && !newFileDraft.afterFile && newFileDraft.parent === item.path && (
<DraftRow dark={dark} draft={newFileDraft} depth={item.depth + 1} onChange={onDraftChange} onCommit={onCommitCreateFile} onCancel={onCancelCreateFile} />
)}
</Fragment>
)
}
function FileRow({
dark,
item,
active,
exampleActive,
newFileDraft,
onOpenFile,
onFileContextMenu,
onDraftChange,
onCommitCreateFile,
onCancelCreateFile
}: {
dark: boolean
item: Extract<WorkspaceTreeItem, { kind: 'file' }>
active: boolean
exampleActive: boolean
newFileDraft: NewFileDraft | null
onOpenFile: (path: string) => void
onFileContextMenu: (path: string, x: number, y: number) => void
onDraftChange: (value: string) => void
onCommitCreateFile: () => void
onCancelCreateFile: () => void
}): React.JSX.Element {
return (
<Fragment>
<button
type="button"
onClick={() => onOpenFile(item.path)}
onContextMenu={(event) => {
if (exampleActive) return
event.preventDefault()
onFileContextMenu(item.path, event.clientX, event.clientY)
}}
style={{ paddingLeft: `${item.depth * 10}px` }}
className={[
'flex h-[22px] w-full cursor-pointer items-center pr-2 text-left transition',
active
? dark
? 'bg-[#37373d] text-white'
: 'bg-[#dbe5f1] text-[#111520]'
: dark
? 'text-white/58 hover:bg-white/7 hover:text-white'
: 'text-[#687284] hover:bg-[#e8ebf2] hover:text-[#111520]'
].join(' ')}
>
<span className="mr-[3px] w-[13px] shrink-0" />
<FileText size={13} className="mr-1 shrink-0" />
<span className="truncate">{item.name}</span>
</button>
{newFileDraft?.afterFile === item.path && (
<DraftRow dark={dark} draft={newFileDraft} depth={newFileDraft.depth} onChange={onDraftChange} onCommit={onCommitCreateFile} onCancel={onCancelCreateFile} />
)}
</Fragment>
)
}
function TreeContextMenu({
dark,
menu,
menuRef,
treeClipboard,
workspaceRootPath,
onOpen,
onCut,
onCopy,
onPaste,
onCopyPath,
onRename,
onDelete
}: {
dark: boolean
menu: TreeContextMenu
menuRef: React.RefObject<HTMLDivElement | null>
treeClipboard: TreeClipboard | null
workspaceRootPath: string
onOpen: () => void
onCut: () => void
onCopy: () => void
onPaste: () => void
onCopyPath: () => void
onRename: () => void
onDelete: () => void
}): React.JSX.Element {
const menuItems = [
{ label: '열기', action: onOpen, disabled: false, separatorAfter: true },
{ label: '자르기', action: onCut, disabled: menu.path === workspaceRootPath },
{ label: '복사', action: onCopy, disabled: menu.path === workspaceRootPath },
{
label: '붙여넣기',
action: onPaste,
disabled: !treeClipboard || (treeClipboard ? menu.path === treeClipboard.path || menu.path.startsWith(`${treeClipboard.path}/`) : false),
separatorAfter: true
},
{ label: '경로 복사', action: onCopyPath, disabled: false, separatorAfter: true },
{ label: '이름 변경', action: onRename, disabled: menu.path === workspaceRootPath },
{ label: '삭제', action: onDelete, disabled: menu.path === workspaceRootPath }
]
return (
<div
ref={menuRef}
className={[
'fixed z-50 w-[174px] overflow-hidden rounded-md border py-1 text-[12px] font-medium shadow-[0_14px_32px_rgba(0,0,0,0.32)]',
dark ? 'border-white/10 bg-[#252526] text-white/80' : 'border-[#d8dee9] bg-white text-[#374151]'
].join(' ')}
style={{ left: menu.x, top: menu.y }}
>
{menuItems.map(({ label, action, disabled, separatorAfter }) => (
<div key={label}>
<button
type="button"
disabled={disabled}
onClick={() => {
if (!disabled) action()
}}
className={[
'flex h-7 w-full items-center px-3 text-left transition',
disabled
? dark ? 'cursor-default text-white/24' : 'cursor-default text-[#b2bac8]'
: dark ? 'cursor-pointer hover:bg-[#37373d] hover:text-white' : 'cursor-pointer hover:bg-[#eef1f7] hover:text-[#111520]'
].join(' ')}
>
{label}
</button>
{separatorAfter && <div className={dark ? 'my-1 h-px bg-white/10' : 'my-1 h-px bg-[#e5e8ef]'} />}
</div>
))}
</div>
)
}