682 lines
25 KiB
TypeScript
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>
|
|
)
|
|
}
|