feat: add LMS web client
This commit is contained in:
@@ -0,0 +1,681 @@
|
||||
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={['min-h-0 border-r transition-opacity', searchActive ? 'flex flex-col' : '', 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={['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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user