feat: add LMS web client
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import type React from 'react'
|
||||
|
||||
type DeletableModule = {
|
||||
name: string
|
||||
}
|
||||
|
||||
type ModuleDeleteConfirmProps<Module extends DeletableModule> = {
|
||||
dark: boolean
|
||||
module: Module
|
||||
onCancel: () => void
|
||||
onConfirm: (module: Module, options: { deleteRepository: boolean }) => void
|
||||
}
|
||||
|
||||
export default function ModuleDeleteConfirm<Module extends DeletableModule>({
|
||||
dark,
|
||||
module,
|
||||
onCancel,
|
||||
onConfirm
|
||||
}: ModuleDeleteConfirmProps<Module>): React.JSX.Element {
|
||||
const [deleteRepository, setDeleteRepository] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 grid place-items-center bg-black/36">
|
||||
<section
|
||||
className={[
|
||||
'w-[360px] overflow-hidden rounded-lg border shadow-[0_24px_70px_rgba(0,0,0,0.42)]',
|
||||
dark ? 'border-white/10 bg-[#252525] text-white' : 'border-[#d8dee9] bg-white text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['flex items-center gap-2 border-b px-4 py-3', dark ? 'border-white/10' : 'border-[#d8dde8]'].join(' ')}>
|
||||
<Trash2 size={15} className="text-[#e87979]" />
|
||||
<strong className="text-[13px] font-bold">삭제</strong>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className={['text-[13px] font-semibold', dark ? 'text-white' : 'text-[#111520]'].join(' ')}>
|
||||
{module.name} 삭제할까요?
|
||||
</p>
|
||||
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dark ? 'text-white/45' : 'text-[#727b8c]'].join(' ')}>
|
||||
워크스페이스에 설치된 모듈 폴더가 삭제됩니다.
|
||||
</p>
|
||||
<label
|
||||
className={[
|
||||
'mt-4 flex cursor-pointer items-start gap-2 rounded-md border p-3',
|
||||
dark ? 'border-white/10 bg-white/[0.03]' : 'border-[#d8dee9] bg-[#f8fafc]'
|
||||
].join(' ')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteRepository}
|
||||
onChange={(event) => setDeleteRepository(event.target.checked)}
|
||||
className="mt-[2px] h-4 w-4 cursor-pointer accent-[#d14b4b]"
|
||||
/>
|
||||
<span>
|
||||
<span className={['block text-[12px] font-bold', dark ? 'text-white/82' : 'text-[#1f2937]'].join(' ')}>
|
||||
학생 저장소도 함께 삭제
|
||||
</span>
|
||||
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/42' : 'text-[#7a8494]'].join(' ')}>
|
||||
제출 커밋과 작업 이력이 삭제되고, 다음 설치 시 템플릿 저장소 기준으로 새로 생성됩니다.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className={[
|
||||
'h-8 cursor-pointer rounded-md border px-3 text-[12px] font-bold transition',
|
||||
dark ? 'border-white/12 text-white/62 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfirm(module, { deleteRepository })}
|
||||
className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838]"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
|
||||
type ModuleListEmptyStateProps = {
|
||||
dark: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export default function ModuleListEmptyState({
|
||||
dark,
|
||||
loading = false
|
||||
}: ModuleListEmptyStateProps): React.JSX.Element {
|
||||
return (
|
||||
<div className={['grid min-h-0 flex-1 place-items-center text-[12px] font-semibold', dark ? 'text-white/42' : 'text-[#7b8494]'].join(' ')}>
|
||||
{loading ? (
|
||||
<div className="grid justify-items-center gap-3">
|
||||
<RefreshCw size={22} className={['animate-spin', dark ? 'text-white/55' : 'text-[#64748b]'].join(' ')} />
|
||||
<span>모듈 목록 불러오는 중</span>
|
||||
</div>
|
||||
) : (
|
||||
'표시할 모듈 없음'
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type React from 'react'
|
||||
|
||||
type ModuleListHeaderProps = {
|
||||
dark: boolean
|
||||
}
|
||||
|
||||
export default function ModuleListHeader({ dark }: ModuleListHeaderProps): React.JSX.Element {
|
||||
return (
|
||||
<div className={['grid h-9 grid-cols-[78px_100px_minmax(150px,1fr)_minmax(104px,0.45fr)_minmax(104px,0.45fr)_84px_104px_62px] items-center border-b px-3 text-[11px] font-bold', dark ? 'border-white/10 bg-[#2b2b2b] text-white/45' : 'border-[#e1e6ef] bg-[#f3f5f9] text-[#727b8c]'].join(' ')}>
|
||||
<span className="text-center">상태</span>
|
||||
<span className="text-center">분류</span>
|
||||
<span>모듈명</span>
|
||||
<span className="text-center">CPU</span>
|
||||
<span className="text-center">메모리</span>
|
||||
<span className="text-center">가동시간</span>
|
||||
<span className="col-span-2 text-center">제어</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { Download, FolderOpen, Hammer, Play, RefreshCw, Square, Trash2 } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
|
||||
export type ModuleListRowItem = {
|
||||
state: 'running' | 'stopped' | 'uninstalled'
|
||||
category: string
|
||||
name: string
|
||||
parameters: string[]
|
||||
cpu: number | null
|
||||
memory: number | null
|
||||
runtime: string
|
||||
}
|
||||
|
||||
type ModuleActionTone = 'install' | 'start' | 'warning' | 'delete' | 'neutral'
|
||||
|
||||
type ModuleListRowProps<Module extends ModuleListRowItem> = {
|
||||
dark: boolean
|
||||
module: Module
|
||||
installing: boolean
|
||||
controlling: boolean
|
||||
onAddModuleTab: (module: Module) => void
|
||||
onOpenSource: (module: Module) => void
|
||||
onOpenParameters: (module: Module) => void
|
||||
onInstallModule: (module: Module) => void
|
||||
onDeleteModule: (module: Module) => void
|
||||
onStartModule: (module: Module) => void
|
||||
onBuildModule: (module: Module) => void
|
||||
onStopModule: (module: Module) => void
|
||||
onRestartModule: (module: Module) => void
|
||||
}
|
||||
|
||||
const stateLabel: Record<ModuleListRowItem['state'], string> = {
|
||||
running: '실행 중',
|
||||
stopped: '정지됨',
|
||||
uninstalled: '미설치'
|
||||
}
|
||||
|
||||
function actionButtonClass(dark: boolean, tone: ModuleActionTone, busy: boolean): string {
|
||||
return [
|
||||
'group/action relative grid h-7 w-7 cursor-pointer place-items-center rounded-md border transition',
|
||||
busy ? 'opacity-55' : '',
|
||||
tone === 'install'
|
||||
? dark
|
||||
? 'border-[#2f8dff]/45 bg-[#2f8dff]/18 text-[#9cc8ff] hover:bg-[#2f8dff]/26'
|
||||
: 'border-[#2f8dff]/35 bg-[#edf6ff] text-[#1d5fae] hover:bg-[#dfefff]'
|
||||
: tone === 'start'
|
||||
? dark
|
||||
? 'border-[#2fbda0]/35 bg-[#2fbda0]/10 text-[#80ddc7] hover:bg-[#2fbda0]/18'
|
||||
: 'border-[#2fbda0]/28 bg-[#effbf7] text-[#177553] hover:bg-[#e1f7ee]'
|
||||
: tone === 'warning'
|
||||
? dark
|
||||
? 'border-[#f59e0b]/25 bg-[#f59e0b]/8 text-[#e8b76b] hover:bg-[#f59e0b]/14'
|
||||
: 'border-[#f59e0b]/22 bg-[#fff8e8] text-[#9a6814] hover:bg-[#fff1cf]'
|
||||
: tone === 'delete'
|
||||
? dark
|
||||
? 'border-[#ef4444]/20 bg-white/[0.03] text-[#e87979] hover:border-[#ef4444]/40 hover:bg-[#ef4444]/10 hover:text-[#ffb4b4]'
|
||||
: 'border-[#ef4444]/18 bg-white text-[#c25a5a] hover:border-[#ef4444]/32 hover:bg-[#fff4f4] hover:text-[#b42323]'
|
||||
: dark
|
||||
? 'border-white/15 bg-white/[0.04] text-white/60 hover:bg-white/10 hover:text-white'
|
||||
: 'border-[#cfd6e4] bg-white text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
function TooltipLabel({ dark, children }: { dark: boolean; children: React.ReactNode }): React.JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
'pointer-events-none absolute -top-8 left-1/2 z-20 -translate-x-1/2 whitespace-nowrap rounded px-2 py-1 text-[11px] font-bold opacity-0 shadow-[0_8px_18px_rgba(0,0,0,0.22)] transition group-hover/action:opacity-100',
|
||||
dark ? 'bg-[#111315] text-white' : 'bg-[#111520] text-white'
|
||||
].join(' ')}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
dark,
|
||||
module,
|
||||
installing,
|
||||
controlling,
|
||||
onAddModuleTab,
|
||||
onOpenSource,
|
||||
onOpenParameters,
|
||||
onInstallModule,
|
||||
onDeleteModule,
|
||||
onStartModule,
|
||||
onBuildModule,
|
||||
onStopModule,
|
||||
onRestartModule
|
||||
}: ModuleListRowProps<Module>): React.JSX.Element {
|
||||
const selectable = module.state !== 'uninstalled'
|
||||
const busy = installing || controlling
|
||||
const stateStyle: Record<ModuleListRowItem['state'], string> = {
|
||||
running: dark ? 'bg-[#12372d] text-[#72e2b5]' : 'bg-[#dff8ed] text-[#177553]',
|
||||
stopped: dark ? 'bg-white/8 text-white/45' : 'bg-[#edf0f5] text-[#687284]',
|
||||
uninstalled: dark ? 'bg-[#2d2d2d] text-white/45' : 'bg-[#edf0f5] text-[#687284]'
|
||||
}
|
||||
const controlActions: Array<{
|
||||
label: string
|
||||
Icon: LucideIcon
|
||||
tone: ModuleActionTone
|
||||
action: (module: Module) => void
|
||||
}> = module.state === 'running'
|
||||
? [
|
||||
{ label: '재시작', Icon: RefreshCw, tone: 'neutral', action: onRestartModule },
|
||||
{ label: '중지', Icon: Square, tone: 'warning', action: onStopModule }
|
||||
]
|
||||
: module.state === 'stopped' && controlling
|
||||
? [
|
||||
{ label: '빌드', Icon: Hammer, tone: 'neutral', action: onBuildModule },
|
||||
{ label: '중지', Icon: Square, tone: 'warning', action: onStopModule }
|
||||
]
|
||||
: module.state === 'stopped'
|
||||
? [
|
||||
{ label: '빌드', Icon: Hammer, tone: 'neutral', action: onBuildModule },
|
||||
{ label: '실행', Icon: Play, tone: 'start', action: onStartModule }
|
||||
]
|
||||
: []
|
||||
const installAction = module.state === 'uninstalled'
|
||||
? { label: '설치', Icon: Download, tone: 'install' as ModuleActionTone }
|
||||
: { label: '삭제', Icon: Trash2, tone: 'delete' as ModuleActionTone }
|
||||
|
||||
return (
|
||||
<div
|
||||
onDoubleClick={() => {
|
||||
if (selectable) onOpenSource(module)
|
||||
}}
|
||||
className={[
|
||||
'grid h-[58px] grid-cols-[78px_100px_minmax(150px,1fr)_minmax(104px,0.45fr)_minmax(104px,0.45fr)_84px_104px_62px] items-center px-3 text-[12px] transition',
|
||||
'cursor-default',
|
||||
selectable
|
||||
? dark ? 'text-white/70 hover:bg-white/[0.045]' : 'text-[#4f596b] hover:bg-[#f5f7fb]'
|
||||
: dark ? 'text-white/42' : 'text-[#8a94a5]'
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-center">
|
||||
<span className={['inline-flex h-6 items-center rounded px-2 text-[11px] font-bold', stateStyle[module.state]].join(' ')}>
|
||||
{stateLabel[module.state]}
|
||||
</span>
|
||||
</span>
|
||||
<span className={['text-center', dark ? 'text-white/52' : 'text-[#687284]'].join(' ')}>{module.category}</span>
|
||||
<span className="min-w-0">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className={['truncate text-[12px] font-semibold', dark ? 'text-white/86' : 'text-[#111520]'].join(' ')}>{module.name}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
title="파라미터 설정"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onOpenParameters(module)
|
||||
}}
|
||||
className={[
|
||||
'mt-1 block h-[14px] max-w-full truncate text-left text-[10px] transition',
|
||||
'cursor-pointer hover:underline',
|
||||
dark ? 'text-white/32 hover:text-white/55' : 'text-[#8a94a5] hover:text-[#4f596b]'
|
||||
].join(' ')}
|
||||
>
|
||||
{module.parameters.length ? module.parameters.join(' ') : '파라미터 설정'}
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
{module.cpu === null ? (
|
||||
<span className={dark ? 'text-white/28' : 'text-[#a0a7b4]'}>-</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={['w-10 text-right font-mono text-[10px]', dark ? 'text-white/58' : 'text-[#687284]'].join(' ')}>
|
||||
{module.cpu.toFixed(module.cpu >= 10 ? 0 : 1)}%
|
||||
</span>
|
||||
<span className={['h-1.5 w-[42px] overflow-hidden rounded-full', dark ? 'bg-white/10' : 'bg-[#e2e7f0]'].join(' ')}>
|
||||
<span className="block h-full rounded-full bg-[#2f8dff]" style={{ width: `${Math.min(100, module.cpu)}%` }} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
{module.memory === null ? (
|
||||
<span className={dark ? 'text-white/28' : 'text-[#a0a7b4]'}>-</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={['w-10 text-right font-mono text-[10px]', dark ? 'text-white/58' : 'text-[#687284]'].join(' ')}>
|
||||
{module.memory.toFixed(module.memory >= 10 ? 0 : 1)}%
|
||||
</span>
|
||||
<span className={['h-1.5 w-[42px] overflow-hidden rounded-full', dark ? 'bg-white/10' : 'bg-[#e2e7f0]'].join(' ')}>
|
||||
<span className="block h-full rounded-full bg-[#2fbda0]" style={{ width: `${Math.min(100, module.memory)}%` }} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className={['text-center', module.runtime === '-' ? (dark ? 'text-white/28' : 'text-[#a0a7b4]') : (dark ? 'font-mono text-white/58' : 'font-mono text-[#687284]')].join(' ')}>
|
||||
{module.runtime}
|
||||
</span>
|
||||
<span className="flex justify-center gap-1.5">
|
||||
{selectable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onAddModuleTab(module)
|
||||
}}
|
||||
aria-label="열기"
|
||||
className={[
|
||||
'group/action relative grid h-7 w-7 cursor-pointer place-items-center rounded-md border transition',
|
||||
dark
|
||||
? 'border-[#2f8dff]/35 bg-[#2f8dff]/12 text-[#9cc8ff] hover:bg-[#2f8dff]/22'
|
||||
: 'border-[#2f8dff]/28 bg-[#edf6ff] text-[#1d5fae] hover:bg-[#dfefff]'
|
||||
].join(' ')}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
<TooltipLabel dark={dark}>열기</TooltipLabel>
|
||||
</button>
|
||||
)}
|
||||
{controlActions.map(({ label, Icon, tone, action }) => (
|
||||
(() => {
|
||||
const canCancelBuild = module.state === 'stopped' && controlling && label === '중지'
|
||||
const disabled = busy && !canCancelBuild
|
||||
return (
|
||||
<button
|
||||
key={`${module.name}-${label}`}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
action(module)
|
||||
}}
|
||||
aria-label={label}
|
||||
className={actionButtonClass(dark, tone, disabled)}
|
||||
>
|
||||
{busy && !canCancelBuild ? <RefreshCw size={14} className="animate-spin" /> : <Icon size={14} />}
|
||||
<TooltipLabel dark={dark}>{label}</TooltipLabel>
|
||||
</button>
|
||||
)
|
||||
})()
|
||||
))}
|
||||
</span>
|
||||
<span className="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (module.state === 'uninstalled') {
|
||||
onInstallModule(module)
|
||||
return
|
||||
}
|
||||
onDeleteModule(module)
|
||||
}}
|
||||
aria-label={installAction.label}
|
||||
className={actionButtonClass(dark, installAction.tone, busy)}
|
||||
>
|
||||
{busy ? <RefreshCw size={14} className="animate-spin" /> : <installAction.Icon size={14} />}
|
||||
<TooltipLabel dark={dark}>{installAction.label}</TooltipLabel>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Download, MapPinned, Play, RefreshCw, Square } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
|
||||
export type ModuleFilter = 'all' | 'running' | 'stopped' | 'uninstalled'
|
||||
|
||||
type ModuleListToolbarProps = {
|
||||
dark: boolean
|
||||
filter: ModuleFilter
|
||||
loading: boolean
|
||||
installingModuleName: string | null
|
||||
uninstalledCount: number
|
||||
runningCount: number
|
||||
stoppedCount: number
|
||||
onFilterChange: (filter: ModuleFilter) => void
|
||||
onInstallAll: () => void
|
||||
onStartAll: () => void
|
||||
onStopAll: () => void
|
||||
}
|
||||
|
||||
const FILTER_TABS: Array<{ id: ModuleFilter; label: string }> = [
|
||||
{ id: 'all', label: '전체' },
|
||||
{ id: 'running', label: '실행중' },
|
||||
{ id: 'stopped', label: '정지됨' },
|
||||
{ id: 'uninstalled', label: '미설치' }
|
||||
]
|
||||
|
||||
export default function ModuleListToolbar({
|
||||
dark,
|
||||
filter,
|
||||
loading,
|
||||
installingModuleName,
|
||||
uninstalledCount,
|
||||
runningCount,
|
||||
stoppedCount,
|
||||
onFilterChange,
|
||||
onInstallAll,
|
||||
onStartAll,
|
||||
onStopAll
|
||||
}: ModuleListToolbarProps): React.JSX.Element {
|
||||
const installingAll = installingModuleName === '__all__'
|
||||
const startingAll = installingModuleName === '__start_all__'
|
||||
const stoppingAll = installingModuleName === '__stop_all__'
|
||||
|
||||
const bulkActions = [
|
||||
{ label: '맵설정', Icon: MapPinned, busy: false, disabled: false, onClick: undefined },
|
||||
{
|
||||
label: '전체 설치',
|
||||
Icon: Download,
|
||||
busy: installingAll,
|
||||
disabled: loading || installingModuleName !== null || uninstalledCount === 0,
|
||||
onClick: onInstallAll
|
||||
},
|
||||
{
|
||||
label: '전체 실행',
|
||||
Icon: Play,
|
||||
busy: startingAll,
|
||||
disabled: loading || installingModuleName !== null || stoppedCount === 0,
|
||||
onClick: onStartAll
|
||||
},
|
||||
{
|
||||
label: '전체 중지',
|
||||
Icon: Square,
|
||||
busy: stoppingAll,
|
||||
disabled: loading || installingModuleName !== null || runningCount === 0,
|
||||
onClick: onStopAll
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={['flex h-10 items-end justify-between px-3', dark ? 'bg-[#252525]' : 'bg-[#f7f8fb]'].join(' ')}>
|
||||
<div className="flex items-end">
|
||||
{FILTER_TABS.map(({ id, label }) => {
|
||||
const active = filter === id
|
||||
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => onFilterChange(id)}
|
||||
className={[
|
||||
'h-8 min-w-[76px] cursor-pointer border-x border-t px-3 text-[12px] font-bold transition first:rounded-tl-md last:rounded-tr-md',
|
||||
active
|
||||
? dark
|
||||
? 'border-white/10 bg-[#2b2b2b] text-white'
|
||||
: 'border-[#e1e6ef] bg-white text-[#111520]'
|
||||
: dark
|
||||
? 'border-white/8 bg-[#202020] text-white/45 hover:text-white/70'
|
||||
: 'border-[#e1e6ef] bg-[#eef1f6] text-[#687284] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pb-2">
|
||||
{bulkActions.map(({ label, Icon, busy, disabled, onClick }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (!onClick) return
|
||||
onClick()
|
||||
}}
|
||||
className={[
|
||||
'flex h-7 items-center gap-1.5 rounded-md border px-3 text-[12px] font-bold transition',
|
||||
disabled ? 'cursor-default opacity-45' : 'cursor-pointer',
|
||||
dark
|
||||
? 'border-white/15 bg-white/[0.04] text-white/70 hover:border-white/25 hover:bg-white/10 hover:text-white'
|
||||
: 'border-[#cfd6e4] bg-white text-[#4f596b] hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
{busy ? <RefreshCw size={14} className="animate-spin" /> : <Icon size={14} />}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type React from 'react'
|
||||
import ModuleDeleteConfirm from './ModuleDeleteConfirm'
|
||||
import ModuleListEmptyState from './ModuleListEmptyState'
|
||||
import ModuleListHeader from './ModuleListHeader'
|
||||
import ModuleListRow from './ModuleListRow'
|
||||
import ModuleListToolbar, { type ModuleFilter } from './ModuleListToolbar'
|
||||
import ModuleParameterDialog from './ModuleParameterDialog'
|
||||
import { createParameterId, parseModuleParameters } from './moduleParameters'
|
||||
import type { ModuleParameterPair, ModuleRow } from './types'
|
||||
|
||||
type ModuleListViewProps = {
|
||||
dark: boolean
|
||||
modules: ModuleRow[]
|
||||
loading: boolean
|
||||
installingModuleName: string | null
|
||||
controllingModuleNames: string[]
|
||||
listError: string | null
|
||||
installError: string | null
|
||||
onAddModuleTab: (module: ModuleRow) => void
|
||||
onOpenSource: (module: ModuleRow) => void
|
||||
onInstallModule: (module: ModuleRow) => void
|
||||
onDeleteModule: (module: ModuleRow, options: { deleteRepository: boolean }) => void
|
||||
onUpdateModuleParameters: (module: ModuleRow, parameters: ModuleParameterPair[]) => void
|
||||
onStartModule: (module: ModuleRow) => void
|
||||
onBuildModule: (module: ModuleRow) => void
|
||||
onStopModule: (module: ModuleRow) => void
|
||||
onRestartModule: (module: ModuleRow) => void
|
||||
onInstallAll: () => void
|
||||
onStartAll: () => void
|
||||
onStopAll: () => void
|
||||
}
|
||||
|
||||
export default function ModuleListView({
|
||||
dark,
|
||||
modules,
|
||||
loading,
|
||||
installingModuleName,
|
||||
controllingModuleNames,
|
||||
listError,
|
||||
installError,
|
||||
onAddModuleTab,
|
||||
onOpenSource,
|
||||
onInstallModule,
|
||||
onDeleteModule,
|
||||
onUpdateModuleParameters,
|
||||
onStartModule,
|
||||
onBuildModule,
|
||||
onStopModule,
|
||||
onRestartModule,
|
||||
onInstallAll,
|
||||
onStartAll,
|
||||
onStopAll
|
||||
}: ModuleListViewProps): React.JSX.Element {
|
||||
const [moduleFilter, setModuleFilter] = useState<ModuleFilter>('all')
|
||||
const [deleteConfirmModule, setDeleteConfirmModule] = useState<ModuleRow | null>(null)
|
||||
const [parameterDialog, setParameterDialog] = useState<{ module: ModuleRow; parameters: ModuleParameterPair[] } | null>(null)
|
||||
const filteredModuleRows = moduleFilter === 'all' ? modules : modules.filter((row) => row.state === moduleFilter)
|
||||
const uninstalledCount = modules.filter((row) => row.state === 'uninstalled').length
|
||||
const runningCount = modules.filter((row) => row.state === 'running').length
|
||||
const stoppedCount = modules.filter((row) => row.state === 'stopped').length
|
||||
const installingAll = installingModuleName === '__all__'
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent): void => {
|
||||
if (event.key !== 'Escape') return
|
||||
if (parameterDialog) {
|
||||
event.preventDefault()
|
||||
setParameterDialog(null)
|
||||
return
|
||||
}
|
||||
if (deleteConfirmModule) {
|
||||
event.preventDefault()
|
||||
setDeleteConfirmModule(null)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [deleteConfirmModule, parameterDialog])
|
||||
|
||||
return (
|
||||
<section className={['relative flex h-full min-h-0 flex-col', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
|
||||
<div className={['flex h-[69px] items-center justify-between p-3', dark ? 'bg-[#252525]' : 'bg-[#eef1f6]'].join(' ')}>
|
||||
<div>
|
||||
<strong className={['block text-[13px] font-bold', dark ? 'text-white' : 'text-[#111520]'].join(' ')}>모듈 리스트</strong>
|
||||
<span className={['mt-1 block text-[11px] font-medium', dark ? 'text-white/40' : 'text-[#727b8c]'].join(' ')}>자율주행 알고리즘 모듈 관리</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="-mt-2 flex min-h-0 flex-1 overflow-auto">
|
||||
<div className={['flex min-h-full min-w-[760px] flex-1 flex-col overflow-hidden border-b', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}>
|
||||
<ModuleListToolbar
|
||||
dark={dark}
|
||||
filter={moduleFilter}
|
||||
loading={loading}
|
||||
installingModuleName={installingModuleName}
|
||||
uninstalledCount={uninstalledCount}
|
||||
runningCount={runningCount}
|
||||
stoppedCount={stoppedCount}
|
||||
onFilterChange={setModuleFilter}
|
||||
onInstallAll={onInstallAll}
|
||||
onStartAll={onStartAll}
|
||||
onStopAll={onStopAll}
|
||||
/>
|
||||
<ModuleListHeader dark={dark} />
|
||||
{(listError || installError) && (
|
||||
<div className={['border-b px-3 py-2 text-[12px] font-semibold', dark ? 'border-white/10 bg-[#3a2525] text-[#ffb4b4]' : 'border-[#ffd4d4] bg-[#fff4f4] text-[#b42323]'].join(' ')}>
|
||||
{listError || installError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={[dark ? 'divide-y divide-white/7' : 'divide-y divide-[#eef1f6]', 'flex min-h-0 flex-1 flex-col'].join(' ')}>
|
||||
{loading ? (
|
||||
<ModuleListEmptyState dark={dark} loading />
|
||||
) : filteredModuleRows.length === 0 ? (
|
||||
<ModuleListEmptyState dark={dark} />
|
||||
) : filteredModuleRows.map((row) => (
|
||||
<ModuleListRow
|
||||
key={row.name}
|
||||
dark={dark}
|
||||
module={row}
|
||||
installing={installingModuleName === row.name || (installingAll && row.state === 'uninstalled')}
|
||||
controlling={controllingModuleNames.includes(row.name)}
|
||||
onAddModuleTab={onAddModuleTab}
|
||||
onOpenSource={onOpenSource}
|
||||
onOpenParameters={(module) => {
|
||||
const parsedParameters = parseModuleParameters(module.parameters)
|
||||
setParameterDialog({
|
||||
module,
|
||||
parameters: parsedParameters.length
|
||||
? parsedParameters
|
||||
: [{ id: createParameterId(), name: '', value: '' }]
|
||||
})
|
||||
}}
|
||||
onInstallModule={onInstallModule}
|
||||
onDeleteModule={setDeleteConfirmModule}
|
||||
onStartModule={onStartModule}
|
||||
onBuildModule={onBuildModule}
|
||||
onStopModule={onStopModule}
|
||||
onRestartModule={onRestartModule}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{deleteConfirmModule && (
|
||||
<ModuleDeleteConfirm
|
||||
dark={dark}
|
||||
module={deleteConfirmModule}
|
||||
onCancel={() => setDeleteConfirmModule(null)}
|
||||
onConfirm={(module, options) => {
|
||||
onDeleteModule(module, options)
|
||||
setDeleteConfirmModule(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{parameterDialog && (
|
||||
<ModuleParameterDialog
|
||||
dark={dark}
|
||||
module={parameterDialog.module}
|
||||
initialParameters={parameterDialog.parameters}
|
||||
createParameterId={createParameterId}
|
||||
onCancel={() => setParameterDialog(null)}
|
||||
onSave={(module, parameters) => {
|
||||
onUpdateModuleParameters(module, parameters)
|
||||
setParameterDialog(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { FilePlus, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import type React from 'react'
|
||||
import type { ModuleParameterPair } from './types'
|
||||
|
||||
type ParameterModule = {
|
||||
name: string
|
||||
}
|
||||
|
||||
type ModuleParameterDialogProps<Module extends ParameterModule> = {
|
||||
dark: boolean
|
||||
module: Module
|
||||
initialParameters: ModuleParameterPair[]
|
||||
onCancel: () => void
|
||||
onSave: (module: Module, parameters: ModuleParameterPair[]) => void
|
||||
createParameterId: () => string
|
||||
}
|
||||
|
||||
export default function ModuleParameterDialog<Module extends ParameterModule>({
|
||||
dark,
|
||||
module,
|
||||
initialParameters,
|
||||
onCancel,
|
||||
onSave,
|
||||
createParameterId
|
||||
}: ModuleParameterDialogProps<Module>): React.JSX.Element {
|
||||
const [parameters, setParameters] = useState(initialParameters)
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 grid place-items-center bg-black/36">
|
||||
<section
|
||||
className={[
|
||||
'w-[460px] overflow-hidden rounded-lg border shadow-[0_24px_70px_rgba(0,0,0,0.42)]',
|
||||
dark ? 'border-white/10 bg-[#252525] text-white' : 'border-[#d8dee9] bg-white text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['border-b px-4 py-3', dark ? 'border-white/10' : 'border-[#d8dde8]'].join(' ')}>
|
||||
<strong className="text-[13px] font-bold">파라미터 설정</strong>
|
||||
<span className={['mt-1 block text-[11px] font-semibold', dark ? 'text-white/42' : 'text-[#727b8c]'].join(' ')}>
|
||||
{module.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="grid gap-2">
|
||||
<div className={['grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_32px] gap-2 text-[11px] font-bold', dark ? 'text-white/42' : 'text-[#727b8c]'].join(' ')}>
|
||||
<span>파라미터명</span>
|
||||
<span>값</span>
|
||||
<span />
|
||||
</div>
|
||||
{parameters.map((parameter) => (
|
||||
<div key={parameter.id} className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_32px] gap-2">
|
||||
<input
|
||||
value={parameter.name}
|
||||
onChange={(event) => setParameters((items) => items.map((item) => item.id === parameter.id ? { ...item, name: event.target.value } : item))}
|
||||
className={[
|
||||
'h-8 min-w-0 rounded-md border px-2 font-mono text-[12px] outline-none transition',
|
||||
dark ? 'border-white/12 bg-[#1f1f1f] text-white/78 focus:border-[#2f8dff]/55' : 'border-[#cfd6e4] bg-[#f9fafc] text-[#111520] focus:border-[#2f8dff]/55'
|
||||
].join(' ')}
|
||||
placeholder=""
|
||||
/>
|
||||
<input
|
||||
value={parameter.value}
|
||||
onChange={(event) => setParameters((items) => items.map((item) => item.id === parameter.id ? { ...item, value: event.target.value } : item))}
|
||||
className={[
|
||||
'h-8 min-w-0 rounded-md border px-2 font-mono text-[12px] outline-none transition',
|
||||
dark ? 'border-white/12 bg-[#1f1f1f] text-white/78 focus:border-[#2f8dff]/55' : 'border-[#cfd6e4] bg-[#f9fafc] text-[#111520] focus:border-[#2f8dff]/55'
|
||||
].join(' ')}
|
||||
placeholder=""
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setParameters((items) => items.filter((item) => item.id !== parameter.id))}
|
||||
className={[
|
||||
'grid h-8 w-8 cursor-pointer place-items-center rounded-md border transition',
|
||||
dark ? 'border-[#ef4444]/20 text-[#e87979] hover:bg-[#ef4444]/10' : 'border-[#ef4444]/18 text-[#c25a5a] hover:bg-[#fff4f4]'
|
||||
].join(' ')}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setParameters((items) => [...items, { id: createParameterId(), name: '', value: '' }])}
|
||||
className={[
|
||||
'mt-1 flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-md border text-[12px] font-bold transition',
|
||||
dark ? 'border-white/12 text-white/62 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
<FilePlus size={13} />
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className={[
|
||||
'h-8 cursor-pointer rounded-md border px-3 text-[12px] font-bold transition',
|
||||
dark ? 'border-white/12 text-white/62 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSave(module, parameters)}
|
||||
className="h-8 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8]"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type React from 'react'
|
||||
|
||||
type StudioSettingsFieldProps = {
|
||||
label: string
|
||||
description?: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
dark: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function StudioSettingsField({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
dark,
|
||||
disabled = false
|
||||
}: StudioSettingsFieldProps): React.JSX.Element {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{label}</span>
|
||||
{description && (
|
||||
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>{description}</span>
|
||||
)}
|
||||
<input
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className={[
|
||||
[description ? 'mt-2' : 'mt-1.5', 'h-10 w-full rounded-md border px-3 text-[13px] font-semibold outline-none transition'].join(' '),
|
||||
disabled
|
||||
? dark
|
||||
? 'cursor-not-allowed border-white/10 bg-white/[0.04] text-white/42'
|
||||
: 'cursor-not-allowed border-[#d7dde8] bg-[#f1f5f9] text-[#64748b]'
|
||||
: dark
|
||||
? 'border-white/12 bg-[#1f1f1f] text-white/78 placeholder:text-white/25 focus:border-[#2563eb]'
|
||||
: 'border-[#d7dde8] bg-white text-[#111520] placeholder:text-[#9aa3b2] focus:border-[#2563eb]'
|
||||
].join(' ')}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
type StudioSettingsSwitchProps = {
|
||||
label: string
|
||||
description?: string
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
dark: boolean
|
||||
}
|
||||
|
||||
export function StudioSettingsSwitch({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
dark
|
||||
}: StudioSettingsSwitchProps): React.JSX.Element {
|
||||
return (
|
||||
<div className={['flex items-center justify-between gap-4 rounded-lg border px-4 py-3', dark ? 'border-white/10 bg-[#1f1f1f]' : 'border-[#e1e6ef] bg-white'].join(' ')}>
|
||||
<div className="min-w-0">
|
||||
<p className={['text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{label}</p>
|
||||
{description && (
|
||||
<p className={['mt-1 text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={[
|
||||
'relative h-7 w-12 shrink-0 cursor-pointer rounded-full transition-colors',
|
||||
checked ? 'bg-[#2563eb]' : dark ? 'bg-white/16' : 'bg-[#cbd5e1]'
|
||||
].join(' ')}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
'absolute left-1 top-1 h-5 w-5 rounded-full bg-white shadow transition-transform',
|
||||
checked ? 'translate-x-5' : 'translate-x-0'
|
||||
].join(' ')}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type React from 'react'
|
||||
|
||||
type WorkspaceActivityHeaderProps = {
|
||||
dark: boolean
|
||||
textStrong: string
|
||||
mode: 'settings' | 'examples' | 'git' | 'empty' | 'module'
|
||||
gitPanelTab: 'source' | 'history'
|
||||
gitSubmissionCount: number
|
||||
gitChangedFileCount: number
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
function getHeaderCopy(
|
||||
mode: WorkspaceActivityHeaderProps['mode'],
|
||||
gitPanelTab: WorkspaceActivityHeaderProps['gitPanelTab'],
|
||||
gitSubmissionCount: number,
|
||||
gitChangedFileCount: number
|
||||
): { title: string; subtitle: string } | null {
|
||||
if (mode === 'settings') return { title: '개발스튜디오 설정', subtitle: '워크스페이스 경로와 템플릿 저장소 관리' }
|
||||
if (mode === 'examples') return { title: '예제 코드', subtitle: '파일 조회' }
|
||||
if (mode === 'git') {
|
||||
return {
|
||||
title: '소스 제어',
|
||||
subtitle: gitPanelTab === 'history' ? `제출 내역 ${gitSubmissionCount}개` : `모듈별 변경 파일 조회 · 변경 ${gitChangedFileCount}개`
|
||||
}
|
||||
}
|
||||
if (mode === 'empty') return { title: '코드 편집', subtitle: '모듈 소스 편집' }
|
||||
return null
|
||||
}
|
||||
|
||||
export default function WorkspaceActivityHeader({
|
||||
dark,
|
||||
textStrong,
|
||||
mode,
|
||||
gitPanelTab,
|
||||
gitSubmissionCount,
|
||||
gitChangedFileCount,
|
||||
children
|
||||
}: WorkspaceActivityHeaderProps): React.JSX.Element {
|
||||
const copy = getHeaderCopy(mode, gitPanelTab, gitSubmissionCount, gitChangedFileCount)
|
||||
|
||||
if (!copy) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center px-3">
|
||||
<div>
|
||||
<strong className={['block text-[13px] font-bold', textStrong].join(' ')}>{copy.title}</strong>
|
||||
<span className={['mt-0.5 block text-[11px] font-medium', dark ? 'text-white/40' : 'text-[#727b8c]'].join(' ')}>
|
||||
{copy.subtitle}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import Editor, { type BeforeMount, type OnMount } from '@monaco-editor/react'
|
||||
import { FileText } from 'lucide-react'
|
||||
|
||||
type Props = {
|
||||
dark: boolean
|
||||
textDim: string
|
||||
editorPaneRef: React.RefObject<HTMLDivElement | null>
|
||||
currentActiveEditorFile: string | null
|
||||
activeFileIsImage: boolean
|
||||
activeImagePreviewSource?: string
|
||||
editorLanguage: string
|
||||
editorValue: string | undefined
|
||||
exampleActive: boolean
|
||||
beforeMount: BeforeMount
|
||||
onMount: OnMount
|
||||
onChange: (value: string | undefined) => void
|
||||
}
|
||||
|
||||
function WorkspaceEditorPane({
|
||||
dark,
|
||||
textDim,
|
||||
editorPaneRef,
|
||||
currentActiveEditorFile,
|
||||
activeFileIsImage,
|
||||
activeImagePreviewSource,
|
||||
editorLanguage,
|
||||
editorValue,
|
||||
exampleActive,
|
||||
beforeMount,
|
||||
onMount,
|
||||
onChange
|
||||
}: Props): React.JSX.Element {
|
||||
return (
|
||||
<div ref={editorPaneRef} className="min-h-0 min-w-0 overflow-hidden">
|
||||
{!currentActiveEditorFile ? (
|
||||
<div className={['grid h-full place-items-center', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
|
||||
<div className="px-6 py-5 text-center">
|
||||
<FileText size={24} className={['mx-auto mb-3', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')} />
|
||||
<strong className={['block text-[13px] font-bold', textDim].join(' ')}>파일을 선택하세요</strong>
|
||||
</div>
|
||||
</div>
|
||||
) : activeFileIsImage ? (
|
||||
<div className={['h-full', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
|
||||
<div
|
||||
className={[
|
||||
'grid h-full min-h-0 place-items-center overflow-auto',
|
||||
dark
|
||||
? 'bg-[linear-gradient(45deg,#202020_25%,transparent_25%),linear-gradient(-45deg,#202020_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#202020_75%),linear-gradient(-45deg,transparent_75%,#202020_75%)] bg-[length:24px_24px] bg-[position:0_0,0_12px,12px_-12px,-12px_0] bg-[#181818]'
|
||||
: 'bg-[linear-gradient(45deg,#e7ebf2_25%,transparent_25%),linear-gradient(-45deg,#e7ebf2_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#e7ebf2_75%),linear-gradient(-45deg,transparent_75%,#e7ebf2_75%)] bg-[length:24px_24px] bg-[position:0_0,0_12px,12px_-12px,-12px_0] bg-[#f8fafc]'
|
||||
].join(' ')}
|
||||
>
|
||||
{activeImagePreviewSource ? (
|
||||
<img
|
||||
src={activeImagePreviewSource}
|
||||
alt={currentActiveEditorFile}
|
||||
className="max-h-[82%] max-w-[82%] object-contain shadow-[0_18px_46px_rgba(0,0,0,0.28)]"
|
||||
/>
|
||||
) : (
|
||||
<div className={['rounded-lg border px-5 py-4 text-[13px] font-bold', dark ? 'border-white/10 bg-[#252525] text-white/60' : 'border-[#d8dee9] bg-white text-[#687284]'].join(' ')}>
|
||||
이미지 미리보기를 사용할 수 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Editor
|
||||
height="100%"
|
||||
width="100%"
|
||||
path={currentActiveEditorFile}
|
||||
language={editorLanguage}
|
||||
theme={dark ? 'ads-ide-dark' : 'ads-ide-light'}
|
||||
wrapperProps={{ className: 'h-full min-w-0 overflow-hidden' }}
|
||||
beforeMount={beforeMount}
|
||||
onMount={onMount}
|
||||
value={editorValue}
|
||||
onChange={onChange}
|
||||
options={{
|
||||
automaticLayout: true,
|
||||
fontFamily: 'JetBrains Mono, Consolas, Monaco, monospace',
|
||||
fontSize: 13,
|
||||
minimap: { enabled: true },
|
||||
renderLineHighlight: 'all',
|
||||
roundedSelection: false,
|
||||
scrollBeyondLastLine: false,
|
||||
tabSize: 4,
|
||||
wordWrap: 'on',
|
||||
readOnly: exampleActive
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkspaceEditorPane
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Save } from 'lucide-react'
|
||||
import Tooltip from '../../components/Tooltip'
|
||||
|
||||
type Props = {
|
||||
dark: boolean
|
||||
currentActiveEditorFile: string | null
|
||||
exampleActive: boolean
|
||||
isDirtyFile: (file: string) => boolean
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
function WorkspaceEditorPathBar({
|
||||
dark,
|
||||
currentActiveEditorFile,
|
||||
exampleActive,
|
||||
isDirtyFile,
|
||||
onSave
|
||||
}: Props): React.JSX.Element | null {
|
||||
if (!currentActiveEditorFile) return null
|
||||
|
||||
return (
|
||||
<div className={['flex min-w-0 items-center gap-1 border-b px-4 text-[12px] font-medium', dark ? 'border-white/10 bg-[#1f1f1f] text-white/45' : 'border-[#d8dee9] bg-[#f7f8fb] text-[#687284]'].join(' ')}>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
|
||||
{currentActiveEditorFile.split('/').map((part, index, parts) => (
|
||||
<span key={`${part}-${index}`} className="flex min-w-0 items-center gap-1">
|
||||
{index > 0 && <span className={dark ? 'text-white/25' : 'text-[#9aa3b2]'}>/</span>}
|
||||
<span className={index === parts.length - 1 ? (dark ? 'truncate text-white/70' : 'truncate text-[#111520]') : 'truncate'}>
|
||||
{part}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{!exampleActive && (
|
||||
<Tooltip label="저장" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={!isDirtyFile(currentActiveEditorFile)}
|
||||
className={[
|
||||
'ml-auto grid h-6 w-6 shrink-0 cursor-pointer place-items-center rounded transition',
|
||||
!isDirtyFile(currentActiveEditorFile) ? 'opacity-40' : '',
|
||||
dark ? 'hover:bg-white/10 hover:text-white' : 'hover:bg-[#e1e7f0] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
<Save size={13} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkspaceEditorPathBar
|
||||
@@ -0,0 +1,67 @@
|
||||
type EditorTabContextMenuState = {
|
||||
file: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
type Props = {
|
||||
dark: boolean
|
||||
menu: EditorTabContextMenuState | null
|
||||
menuRef: React.RefObject<HTMLDivElement | null>
|
||||
onCloseTab: (file: string) => void
|
||||
onCloseOtherTabs: (file: string) => void
|
||||
onCloseTabsToRight: (file: string) => void
|
||||
onCloseAllTabs: () => void
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
function WorkspaceEditorTabContextMenu({
|
||||
dark,
|
||||
menu,
|
||||
menuRef,
|
||||
onCloseTab,
|
||||
onCloseOtherTabs,
|
||||
onCloseTabsToRight,
|
||||
onCloseAllTabs,
|
||||
onDismiss
|
||||
}: Props): React.JSX.Element | null {
|
||||
if (!menu) return null
|
||||
|
||||
const items = [
|
||||
{ label: '닫기', action: () => onCloseTab(menu.file) },
|
||||
{ label: '다른 탭 닫기', action: () => onCloseOtherTabs(menu.file) },
|
||||
{ label: '오른쪽 탭 닫기', action: () => onCloseTabsToRight(menu.file) },
|
||||
{ label: '모든 탭 닫기', action: onCloseAllTabs }
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={[
|
||||
'fixed z-50 w-[180px] 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 }}
|
||||
>
|
||||
{items.map(({ label, action }, index) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
action()
|
||||
onDismiss()
|
||||
}}
|
||||
className={[
|
||||
'flex h-7 w-full cursor-pointer items-center px-3 text-left transition',
|
||||
index === 3 ? (dark ? 'border-t border-white/10' : 'border-t border-[#e5e8ef]') : '',
|
||||
dark ? 'hover:bg-[#37373d] hover:text-white' : 'hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkspaceEditorTabContextMenu
|
||||
@@ -0,0 +1,114 @@
|
||||
import { FileText, X } from 'lucide-react'
|
||||
|
||||
type EditorFilePath = string
|
||||
|
||||
type Props = {
|
||||
dark: boolean
|
||||
files: EditorFilePath[]
|
||||
activeFile: EditorFilePath | null
|
||||
draggingFile: EditorFilePath | null
|
||||
dropIndex: number | null
|
||||
isDirtyFile: (file: EditorFilePath) => boolean
|
||||
onOpenFile: (file: EditorFilePath) => void
|
||||
onCloseFile: (file: EditorFilePath) => void
|
||||
onContextMenu: (file: EditorFilePath, x: number, y: number) => void
|
||||
onDragStart: (file: EditorFilePath, index: number, event: React.DragEvent<HTMLButtonElement>) => void
|
||||
onDragOver: (index: number, event: React.DragEvent<HTMLButtonElement>) => void
|
||||
onDrop: (event: React.DragEvent<HTMLButtonElement>) => void
|
||||
onDragEnd: () => void
|
||||
}
|
||||
|
||||
function WorkspaceEditorTabs({
|
||||
dark,
|
||||
files,
|
||||
activeFile,
|
||||
draggingFile,
|
||||
dropIndex,
|
||||
isDirtyFile,
|
||||
onOpenFile,
|
||||
onCloseFile,
|
||||
onContextMenu,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragEnd
|
||||
}: Props): React.JSX.Element | null {
|
||||
if (files.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className={['min-w-0 overflow-hidden border-b pl-1 pr-2', dark ? 'border-white/10 bg-[#252525]' : 'border-[#d8dee9] bg-[#eef1f6]'].join(' ')}>
|
||||
<div className="thin-x-scrollbar flex h-full min-w-0 items-end gap-0.5 overflow-x-auto overflow-y-hidden [scrollbar-gutter:stable]">
|
||||
{files.map((file, index) => (
|
||||
<button
|
||||
key={file}
|
||||
type="button"
|
||||
draggable
|
||||
onClick={() => onOpenFile(file)}
|
||||
onAuxClick={(event) => {
|
||||
if (event.button !== 1) return
|
||||
event.preventDefault()
|
||||
onCloseFile(file)
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
onContextMenu(file, event.clientX, event.clientY)
|
||||
}}
|
||||
onDragStart={(event) => onDragStart(file, index, event)}
|
||||
onDragOver={(event) => onDragOver(index, event)}
|
||||
onDrop={onDrop}
|
||||
onDragEnd={onDragEnd}
|
||||
className={[
|
||||
'group relative flex h-8 min-w-[118px] max-w-[180px] shrink-0 cursor-pointer items-center gap-1.5 rounded-t-md border-x border-t px-3 text-[12px] font-bold transition',
|
||||
draggingFile === file ? 'opacity-60' : '',
|
||||
activeFile === file
|
||||
? dark
|
||||
? 'border-white/10 bg-[#1f1f1f] text-white'
|
||||
: 'border-[#d8dee9] bg-[#f7f8fb] text-[#111520]'
|
||||
: dark
|
||||
? 'border-white/10 bg-[#2b2b2b] text-white/45 hover:text-white/70'
|
||||
: 'border-[#d8dee9] bg-[#e8ebf2] text-[#687284] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
{draggingFile && dropIndex === index && (
|
||||
<span className="absolute -left-[3px] bottom-0 top-0 w-[2px] rounded-full bg-[#d1d5db]" />
|
||||
)}
|
||||
{draggingFile && dropIndex === index + 1 && index === files.length - 1 && (
|
||||
<span className="absolute -right-[3px] bottom-0 top-0 w-[2px] rounded-full bg-[#d1d5db]" />
|
||||
)}
|
||||
<FileText size={13} />
|
||||
<span className="truncate">{file.split('/').pop()}</span>
|
||||
{isDirtyFile(file) && (
|
||||
<span className={['ml-auto h-2 w-2 shrink-0 rounded-full', dark ? 'bg-[#f1f5f9]' : 'bg-[#2563eb]'].join(' ')} />
|
||||
)}
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${file} 닫기`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onCloseFile(file)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onCloseFile(file)
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
isDirtyFile(file) ? 'ml-0' : 'ml-auto',
|
||||
'grid h-4 w-4 shrink-0 place-items-center rounded transition',
|
||||
activeFile === file ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
|
||||
dark ? 'hover:bg-white/12 hover:text-white' : 'hover:bg-[#cfd8e6] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
<X size={12} />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkspaceEditorTabs
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Code2 } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
|
||||
type WorkspaceEmptyModuleStateProps = {
|
||||
dark: boolean
|
||||
textStrong: string
|
||||
textDim: string
|
||||
onOpenModuleList: () => void
|
||||
}
|
||||
|
||||
export default function WorkspaceEmptyModuleState({
|
||||
dark,
|
||||
textStrong,
|
||||
textDim,
|
||||
onOpenModuleList
|
||||
}: WorkspaceEmptyModuleStateProps): React.JSX.Element {
|
||||
return (
|
||||
<section
|
||||
className={['grid min-w-0 place-items-center', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}
|
||||
style={{ gridColumn: '4 / span 3', gridRow: 2 }}
|
||||
>
|
||||
<div className="px-6 py-5 text-center">
|
||||
<Code2 size={24} className={['mx-auto mb-3', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')} />
|
||||
<strong className={['block text-[13px] font-bold', textStrong].join(' ')}>모듈을 선택하세요</strong>
|
||||
<p className={['mt-1 text-[12px] font-medium', textDim].join(' ')}>모듈 리스트에서 소스를 볼 모듈을 선택하면 알고리즘 코드를 수정할 수 있습니다.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenModuleList}
|
||||
className="mt-3 cursor-pointer text-[12px] font-bold text-[#2563eb] underline underline-offset-4 transition hover:text-[#1d4ed8]"
|
||||
>
|
||||
리스트로 이동
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { BookOpen, ChevronDown, Download, FileText, Minus, Play } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
|
||||
export type WorkspaceGuideResourceMode = 'material' | 'reference'
|
||||
|
||||
export type WorkspaceGuideMaterialItem = {
|
||||
title: string
|
||||
description: string
|
||||
videoUrl: string
|
||||
concepts?: string[]
|
||||
}
|
||||
|
||||
type WorkspaceGuideResource = {
|
||||
id: string
|
||||
title: string
|
||||
fileName?: string | null
|
||||
}
|
||||
|
||||
type WorkspaceGuidePanelProps<Resource extends WorkspaceGuideResource> = {
|
||||
dark: boolean
|
||||
workspaceActive: boolean
|
||||
minimized: boolean
|
||||
panelClassName: string
|
||||
borderSoft: string
|
||||
textStrong: string
|
||||
textMid: string
|
||||
textDim: string
|
||||
iconButtonClass: string
|
||||
resourceMode: WorkspaceGuideResourceMode
|
||||
materialItems: WorkspaceGuideMaterialItem[]
|
||||
activeMaterialIndex: number
|
||||
expandedMaterialIndexes: number[]
|
||||
allowFutureMaterialSteps?: boolean
|
||||
topics: string[]
|
||||
overview: string
|
||||
topicDetails: string[]
|
||||
resources: Resource[]
|
||||
downloadingResourceIds: string[]
|
||||
getResourceSizeLabel: (resource: Resource) => string
|
||||
onModeChange: (mode: WorkspaceGuideResourceMode) => void
|
||||
onMinimize: () => void
|
||||
onToggleMaterial: (index: number) => void
|
||||
onDownloadResource: (resource: Resource) => void
|
||||
}
|
||||
|
||||
function getYoutubeEmbedUrl(url: string | undefined): string | null {
|
||||
if (!url) return null
|
||||
const trimmed = url.trim()
|
||||
if (!trimmed) return null
|
||||
try {
|
||||
const parsed = new URL(trimmed)
|
||||
if (parsed.hostname.includes('youtu.be')) {
|
||||
const id = parsed.pathname.replace(/^\/+/, '').split('/')[0]
|
||||
return id ? `https://www.youtube.com/embed/${id}` : null
|
||||
}
|
||||
if (parsed.hostname.includes('youtube.com')) {
|
||||
const id = parsed.searchParams.get('v') || parsed.pathname.split('/').filter(Boolean).pop()
|
||||
return id ? `https://www.youtube.com/embed/${id}` : null
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getYoutubeVideoId(url: string | undefined): string | null {
|
||||
const embedUrl = getYoutubeEmbedUrl(url)
|
||||
if (!embedUrl) return null
|
||||
return embedUrl.split('/').pop() ?? null
|
||||
}
|
||||
|
||||
export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResource>({
|
||||
dark,
|
||||
workspaceActive,
|
||||
minimized,
|
||||
panelClassName,
|
||||
borderSoft,
|
||||
textStrong,
|
||||
textMid,
|
||||
textDim,
|
||||
iconButtonClass,
|
||||
resourceMode,
|
||||
materialItems,
|
||||
activeMaterialIndex,
|
||||
expandedMaterialIndexes,
|
||||
allowFutureMaterialSteps = false,
|
||||
topics,
|
||||
overview,
|
||||
topicDetails,
|
||||
resources,
|
||||
downloadingResourceIds,
|
||||
getResourceSizeLabel,
|
||||
onModeChange,
|
||||
onMinimize,
|
||||
onToggleMaterial,
|
||||
onDownloadResource
|
||||
}: WorkspaceGuidePanelProps<Resource>): React.JSX.Element {
|
||||
return (
|
||||
<aside
|
||||
data-help="workspace-guide"
|
||||
className={['flex min-w-0 flex-col border-r transition-opacity', minimized ? 'pointer-events-none overflow-hidden border-r-0 opacity-0' : '', panelClassName].join(' ')}
|
||||
style={workspaceActive ? { gridColumn: 1, gridRow: '1 / span 2' } : { gridColumn: 1, gridRow: 1 }}
|
||||
>
|
||||
<div className={['border-b p-3', borderSoft].join(' ')}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className={['flex items-center gap-2 text-[13px] font-bold', textStrong].join(' ')}>
|
||||
<BookOpen size={14} className={textDim} />
|
||||
학습가이드
|
||||
</h3>
|
||||
<button type="button" aria-label="학습가이드 최소화" onClick={onMinimize} className={[iconButtonClass, 'group relative'].join(' ')}>
|
||||
<Minus size={15} />
|
||||
<span className={['pointer-events-none absolute right-0 top-8 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(' ')}>
|
||||
최소화
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className={['mt-3 grid grid-cols-2 overflow-hidden rounded-md border', borderSoft].join(' ')}>
|
||||
{[
|
||||
{ id: 'material', label: '강의내용', Icon: BookOpen },
|
||||
{ id: 'reference', label: '참고자료', Icon: FileText }
|
||||
].map(({ id, label, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => onModeChange(id as WorkspaceGuideResourceMode)}
|
||||
className={[
|
||||
'flex h-8 cursor-pointer items-center justify-center gap-1.5 text-[12px] font-bold transition',
|
||||
resourceMode === id
|
||||
? 'bg-[#2563eb] text-white'
|
||||
: dark
|
||||
? 'bg-[#202020] text-white/55 hover:bg-white/10 hover:text-white'
|
||||
: 'bg-white text-[#4f596b] hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto overscroll-contain p-3">
|
||||
{resourceMode === 'material' ? (
|
||||
<div className="grid gap-3">
|
||||
{materialItems.length ? materialItems.map((item, index) => {
|
||||
const active = index === activeMaterialIndex
|
||||
const previous = index < activeMaterialIndex
|
||||
const expanded = expandedMaterialIndexes.includes(index)
|
||||
const canToggle = allowFutureMaterialSteps || previous || active
|
||||
const youtubeEmbedUrl = getYoutubeEmbedUrl(item.videoUrl)
|
||||
const youtubeVideoId = getYoutubeVideoId(item.videoUrl)
|
||||
|
||||
return (
|
||||
<section
|
||||
key={`${item.title}-${index}`}
|
||||
role={canToggle ? 'button' : undefined}
|
||||
tabIndex={canToggle ? 0 : undefined}
|
||||
onClick={canToggle ? () => onToggleMaterial(index) : undefined}
|
||||
onKeyDown={canToggle ? (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
onToggleMaterial(index)
|
||||
} : undefined}
|
||||
className={[
|
||||
'rounded-lg border transition',
|
||||
expanded ? 'px-2.5 pb-4 pt-2.5' : 'p-3',
|
||||
canToggle ? 'cursor-pointer' : '',
|
||||
active
|
||||
? dark ? 'border-[#60a5fa]/42 bg-[#303030]' : 'border-[#2563eb]/55 bg-white'
|
||||
: canToggle
|
||||
? dark ? 'border-white/12 bg-[#292929] opacity-92 hover:border-white/28 hover:bg-[#303030] hover:opacity-100' : 'border-[#dbe2ee] bg-[#f4f6fa] opacity-95 hover:border-[#bfc8d9] hover:bg-white hover:opacity-100'
|
||||
: dark ? 'border-white/9 bg-[#292929] opacity-55' : 'border-[#e1e6ef] bg-[#f4f6fa] opacity-60'
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['flex gap-2.5', expanded ? 'items-start' : 'items-center'].join(' ')}>
|
||||
<span className={[
|
||||
'grid h-6 w-6 shrink-0 place-items-center rounded-md text-[11px] font-bold',
|
||||
active ? 'bg-[#2563eb]/18 text-[#9dbdff]' : (previous || allowFutureMaterialSteps) ? dark ? 'bg-white/12 text-white/70' : 'bg-white text-[#4f596b]' : dark ? 'bg-white/8 text-white/38' : 'bg-white text-[#9aa3b2]'
|
||||
].join(' ')}>{index + 1}</span>
|
||||
<div className={['min-w-0 flex-1', expanded ? 'pt-0.5' : ''].join(' ')}>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<strong className={['block min-w-0 truncate text-[13px] font-bold', active || previous || allowFutureMaterialSteps ? textStrong : textDim].join(' ')}>{item.title}</strong>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
{active && (
|
||||
<span className="rounded border border-[#60a5fa]/24 bg-[#2563eb]/16 px-2 py-0.5 text-[10px] font-bold text-[#bfdbfe]">
|
||||
진행중
|
||||
</span>
|
||||
)}
|
||||
{canToggle && (
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={[
|
||||
'shrink-0 transition-transform',
|
||||
expanded ? 'rotate-180' : '',
|
||||
textDim
|
||||
].join(' ')}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="mt-2.5 grid gap-2.5">
|
||||
<div className={['h-px', dark ? 'bg-white/10' : 'bg-[#e5e8ef]'].join(' ')} />
|
||||
{youtubeEmbedUrl && youtubeVideoId && (
|
||||
<button
|
||||
type="button"
|
||||
className={['group relative aspect-video w-full cursor-pointer overflow-hidden rounded-md border bg-black text-left', dark ? 'border-white/10' : 'border-[#d8dee9]'].join(' ')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void window.learningVideo.open(youtubeEmbedUrl, `${item.title} 동영상강의`)
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`https://img.youtube.com/vi/${youtubeVideoId}/hqdefault.jpg`}
|
||||
alt=""
|
||||
className="h-full w-full object-cover opacity-90 transition group-hover:scale-[1.02] group-hover:opacity-100"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="absolute inset-0 bg-black/28 transition group-hover:bg-black/18" />
|
||||
<span className="absolute inset-0 grid place-items-center">
|
||||
<span className="grid h-11 w-11 place-items-center rounded-full bg-white/92 text-[#2563eb] shadow-[0_14px_34px_rgba(0,0,0,0.32)] transition group-hover:scale-105">
|
||||
<Play size={22} fill="currentColor" />
|
||||
</span>
|
||||
</span>
|
||||
<span className="absolute bottom-2 left-2 rounded bg-black/65 px-2 py-1 text-[11px] font-bold text-white/88">
|
||||
동영상강의 보기
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<section>
|
||||
<span className={['block text-[11px] font-bold', textMid].join(' ')}>학습 내용</span>
|
||||
<p className={['mt-1 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>
|
||||
{item.description || topicDetails[index] || overview}
|
||||
</p>
|
||||
</section>
|
||||
<section className={['border-b pb-2.5', borderSoft].join(' ')}>
|
||||
<span className={['block text-[11px] font-bold', textMid].join(' ')}>핵심 개념</span>
|
||||
<div className="mt-2 grid gap-1.5">
|
||||
{(item.concepts?.length ? item.concepts : [topics[index]].filter(Boolean)).map((concept) => (
|
||||
<div key={concept} className="flex items-center gap-2">
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[#2563eb]" />
|
||||
<span className={['text-[12px] font-medium leading-snug', textDim].join(' ')}>{concept}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}) : (
|
||||
<div className={['rounded-lg border px-3 py-6 text-center text-[12px] font-semibold', dark ? 'border-white/10 bg-white/[0.03] text-white/42' : 'border-[#e1e6ef] bg-[#f7f8fb] text-[#7b8494]'].join(' ')}>
|
||||
등록된 강의자료가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{resources.length > 0 ? resources.map((resource) => {
|
||||
const downloading = downloadingResourceIds.includes(resource.id)
|
||||
return (
|
||||
<button
|
||||
key={resource.id}
|
||||
type="button"
|
||||
disabled={downloading}
|
||||
onClick={() => onDownloadResource(resource)}
|
||||
className={[
|
||||
'flex min-h-10 cursor-pointer items-center justify-between gap-2 rounded-md border px-3 py-2 text-left transition disabled:cursor-default',
|
||||
downloading ? 'opacity-60' : '',
|
||||
dark ? 'border-white/10 bg-[#292929] text-white/70 hover:bg-white/8 hover:text-white' : 'border-[#e1e6ef] bg-[#f7f8fb] text-[#4f596b] hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[12px] font-bold">{downloading ? '다운로드 중...' : resource.title}</span>
|
||||
{resource.fileName ? (
|
||||
<span className={['mt-0.5 block truncate text-[10px] font-medium', textDim].join(' ')}>
|
||||
{resource.fileName} · {getResourceSizeLabel(resource)}
|
||||
</span>
|
||||
) : (
|
||||
<span className={['mt-0.5 block truncate text-[10px] font-medium', textDim].join(' ')}>
|
||||
{getResourceSizeLabel(resource)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Download size={14} className={textDim} />
|
||||
</button>
|
||||
)
|
||||
}) : (
|
||||
<div className={['rounded-lg border px-3 py-6 text-center text-[12px] font-semibold', dark ? 'border-white/10 bg-white/[0.03] text-white/42' : 'border-[#e1e6ef] bg-[#f7f8fb] text-[#7b8494]'].join(' ')}>
|
||||
등록된 참고자료가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Terminal } from 'lucide-react'
|
||||
|
||||
type ModuleLogEntry = {
|
||||
time: string
|
||||
level: 'INFO' | 'WARN' | 'ERROR'
|
||||
source: string
|
||||
message: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
dark: boolean
|
||||
borderSoft: string
|
||||
textMid: string
|
||||
selectedModuleState: 'running' | 'stopped' | 'uninstalled' | null
|
||||
moduleLogsLoading: boolean
|
||||
moduleLogMessage: string | null
|
||||
visibleModuleLogEntries: ModuleLogEntry[]
|
||||
moduleLogScrollRef: React.RefObject<HTMLDivElement | null>
|
||||
onScroll: () => void
|
||||
}
|
||||
|
||||
function WorkspaceLogView({
|
||||
dark,
|
||||
borderSoft,
|
||||
textMid,
|
||||
selectedModuleState,
|
||||
moduleLogsLoading,
|
||||
moduleLogMessage,
|
||||
visibleModuleLogEntries,
|
||||
moduleLogScrollRef,
|
||||
onScroll
|
||||
}: Props): React.JSX.Element {
|
||||
return (
|
||||
<section
|
||||
className={['min-w-0 overflow-hidden', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}
|
||||
style={{ gridColumn: '4 / span 3', gridRow: 2 }}
|
||||
>
|
||||
<div className={['flex h-full min-h-0 flex-col', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
|
||||
<div className={['flex h-11 items-center justify-between border-b px-4', borderSoft].join(' ')}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal size={15} className={dark ? 'text-white/58' : 'text-[#687284]'} />
|
||||
<span className={['text-[12px] font-bold', textMid].join(' ')}>실시간 로그</span>
|
||||
</div>
|
||||
<span className={['rounded px-2 py-1 font-mono text-[10px]', dark ? 'bg-white/7 text-white/45' : 'bg-[#e8ebf2] text-[#687284]'].join(' ')}>
|
||||
{selectedModuleState === 'running' ? 'STREAMING' : 'IDLE'}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
ref={moduleLogScrollRef}
|
||||
onScroll={onScroll}
|
||||
className={['thin-y-scrollbar min-h-0 flex-1 overflow-auto p-4 font-mono text-[12px] leading-5', dark ? 'bg-[#171717] text-white/68' : 'bg-[#f9fafc] text-[#374151]'].join(' ')}
|
||||
>
|
||||
{moduleLogsLoading ? (
|
||||
<div className={['grid h-full place-items-center font-sans text-[12px] font-semibold', dark ? 'text-white/42' : 'text-[#7b8494]'].join(' ')}>
|
||||
로그 불러오는 중
|
||||
</div>
|
||||
) : visibleModuleLogEntries.length === 0 ? (
|
||||
<div className={['grid h-full place-items-center font-sans text-[12px] font-semibold', dark ? 'text-white/42' : 'text-[#7b8494]'].join(' ')}>
|
||||
{moduleLogMessage ?? '표시할 로그 없음'}
|
||||
</div>
|
||||
) : visibleModuleLogEntries.map((entry, index) => (
|
||||
<div key={`${entry.time}-${entry.source}-${index}`} className="flex min-w-0 gap-1.5">
|
||||
<span className={dark ? 'w-[52px] shrink-0 text-white/28' : 'w-[52px] shrink-0 text-[#9aa3b2]'}>{entry.time}</span>
|
||||
<span
|
||||
className={[
|
||||
'w-[44px] shrink-0',
|
||||
entry.level === 'ERROR'
|
||||
? 'text-[#ef4444]'
|
||||
: entry.level === 'WARN'
|
||||
? 'text-[#f59e0b]'
|
||||
: dark ? 'text-[#5eead4]' : 'text-[#0f766e]'
|
||||
].join(' ')}
|
||||
>
|
||||
[{entry.level}]
|
||||
</span>
|
||||
<span className={['min-w-0 flex-1 whitespace-pre-wrap break-words', dark ? 'text-white/72' : 'text-[#2f3747]'].join(' ')}>
|
||||
{entry.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkspaceLogView
|
||||
@@ -0,0 +1,447 @@
|
||||
import { FileText, LogOut, Power, Trash2, Upload, X } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
|
||||
type WorkspaceModalTheme = {
|
||||
dark: boolean
|
||||
borderSoft: string
|
||||
textStrong: string
|
||||
textDim: string
|
||||
}
|
||||
|
||||
type TreeTargetDialogState =
|
||||
| { type: 'rename'; path: string; kind: 'file' | 'folder'; value: string }
|
||||
| { type: 'delete'; path: string; kind: 'file' | 'folder' }
|
||||
|
||||
type TreeTargetDialogProps = WorkspaceModalTheme & {
|
||||
dialog: TreeTargetDialogState
|
||||
getPathName: (path: string) => string
|
||||
getDisplayPath: (path: string) => string
|
||||
onRenameValueChange: (value: string) => void
|
||||
onRename: (path: string, value: string) => void
|
||||
onDelete: (path: string) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function TreeTargetDialog({
|
||||
dark,
|
||||
borderSoft,
|
||||
textStrong,
|
||||
textDim,
|
||||
dialog,
|
||||
getPathName,
|
||||
getDisplayPath,
|
||||
onRenameValueChange,
|
||||
onRename,
|
||||
onDelete,
|
||||
onCancel
|
||||
}: TreeTargetDialogProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 grid place-items-center bg-black/36">
|
||||
<section
|
||||
className={[
|
||||
'w-[360px] overflow-hidden rounded-lg border shadow-[0_24px_70px_rgba(0,0,0,0.42)]',
|
||||
dark ? 'border-white/10 bg-[#252525] text-white' : 'border-[#d8dee9] bg-white text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['flex items-center gap-2 border-b px-4 py-3', borderSoft].join(' ')}>
|
||||
{dialog.type === 'delete' ? (
|
||||
<Trash2 size={15} className="text-[#e87979]" />
|
||||
) : (
|
||||
<FileText size={15} className={textDim} />
|
||||
)}
|
||||
<strong className="text-[13px] font-bold">
|
||||
{dialog.type === 'delete' ? '삭제' : '이름 변경'}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
{dialog.type === 'rename' ? (
|
||||
<form
|
||||
className="p-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
onRename(dialog.path, dialog.value)
|
||||
}}
|
||||
>
|
||||
<label className={['block text-[12px] font-semibold', textDim].join(' ')} htmlFor="tree-rename-input">
|
||||
새 이름
|
||||
</label>
|
||||
<input
|
||||
id="tree-rename-input"
|
||||
autoFocus
|
||||
value={dialog.value}
|
||||
onChange={(event) => onRenameValueChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') onCancel()
|
||||
}}
|
||||
className={[
|
||||
'mt-2 h-9 w-full rounded-md border px-3 text-[13px] font-medium outline-none transition',
|
||||
dark
|
||||
? 'border-white/12 bg-[#1f1f1f] text-white focus:border-[#2f8dff]/60'
|
||||
: 'border-[#cfd6e4] bg-[#f9fafc] text-[#111520] focus:border-[#2f8dff]/60'
|
||||
].join(' ')}
|
||||
/>
|
||||
<p className={['mt-2 truncate text-[11px] font-medium', textDim].join(' ')}>
|
||||
{getDisplayPath(dialog.path)}
|
||||
</p>
|
||||
<ModalButtonRow
|
||||
dark={dark}
|
||||
confirmLabel="변경"
|
||||
confirmClassName="bg-[#2563eb] hover:bg-[#1d5ed8]"
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<div className="p-4">
|
||||
<p className={['text-[13px] font-semibold', textStrong].join(' ')}>
|
||||
{getPathName(dialog.path)} 삭제할까요?
|
||||
</p>
|
||||
<p className={['mt-2 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>
|
||||
선택한 {dialog.kind === 'folder' ? '폴더와 하위 항목' : '파일'}이 현재 작업 트리에서 제거됩니다.
|
||||
</p>
|
||||
<p className={['mt-2 truncate text-[11px] font-medium', textDim].join(' ')}>
|
||||
{getDisplayPath(dialog.path)}
|
||||
</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className={cancelButtonClass(dark)}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(dialog.path)}
|
||||
className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838]"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type SourceSettingsSaveConfirmModalProps = WorkspaceModalTheme & {
|
||||
saving: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export function SourceSettingsSaveConfirmModal({
|
||||
dark,
|
||||
borderSoft,
|
||||
textStrong,
|
||||
textDim,
|
||||
saving,
|
||||
onCancel,
|
||||
onConfirm
|
||||
}: SourceSettingsSaveConfirmModalProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 grid place-items-center bg-black/36">
|
||||
<section
|
||||
className={[
|
||||
'w-[380px] overflow-hidden rounded-lg border shadow-[0_24px_70px_rgba(0,0,0,0.42)]',
|
||||
dark ? 'border-white/10 bg-[#252525] text-white' : 'border-[#d8dee9] bg-white text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['flex items-center gap-2 border-b px-4 py-3', borderSoft].join(' ')}>
|
||||
<Power size={15} className="text-[#e87979]" />
|
||||
<strong className="text-[13px] font-bold">모듈 종료</strong>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className={['text-[13px] font-semibold', textStrong].join(' ')}>
|
||||
실행 중인 모듈을 종료하고 설정을 저장할까요?
|
||||
</p>
|
||||
<p className={['mt-2 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>
|
||||
워크스페이스 경로가 변경되면 현재 경로에서 실행 중인 모듈을 먼저 종료해야 합니다.
|
||||
</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button type="button" onClick={onCancel} className={cancelButtonClass(dark)}>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={onConfirm}
|
||||
className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838] disabled:cursor-default disabled:opacity-60"
|
||||
>
|
||||
{saving ? '종료 중' : '종료하고 저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type GitFileContextMenuProps = {
|
||||
dark: boolean
|
||||
menuRef: React.RefObject<HTMLDivElement | null>
|
||||
x: number
|
||||
y: number
|
||||
onRestore: () => void
|
||||
}
|
||||
|
||||
export function GitFileContextMenu({
|
||||
dark,
|
||||
menuRef,
|
||||
x,
|
||||
y,
|
||||
onRestore
|
||||
}: GitFileContextMenuProps): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={[
|
||||
'fixed z-[90] w-[160px] 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: x, top: y }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestore}
|
||||
className={[
|
||||
'flex h-8 w-full cursor-pointer items-center px-3 text-left transition',
|
||||
dark ? 'hover:bg-[#37373d] hover:text-white' : 'hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
되돌리기
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function cancelButtonClass(dark: boolean): string {
|
||||
return [
|
||||
'h-8 cursor-pointer rounded-md border px-3 text-[12px] font-bold transition',
|
||||
dark ? 'border-white/12 text-white/62 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
function ModalButtonRow({
|
||||
dark,
|
||||
confirmLabel,
|
||||
confirmClassName,
|
||||
onCancel
|
||||
}: {
|
||||
dark: boolean
|
||||
confirmLabel: string
|
||||
confirmClassName: string
|
||||
onCancel: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button type="button" onClick={onCancel} className={cancelButtonClass(dark)}>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={['h-8 cursor-pointer rounded-md px-3 text-[12px] font-bold text-white transition', confirmClassName].join(' ')}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type SubmitConfirmModalProps = WorkspaceModalTheme & {
|
||||
requiresSourceSubmission: boolean
|
||||
submitting: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export function SubmitConfirmModal({
|
||||
dark,
|
||||
borderSoft,
|
||||
textStrong,
|
||||
textDim,
|
||||
requiresSourceSubmission,
|
||||
submitting,
|
||||
onCancel,
|
||||
onConfirm
|
||||
}: SubmitConfirmModalProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 grid place-items-center bg-black/36">
|
||||
<section
|
||||
className={[
|
||||
'w-[360px] overflow-hidden rounded-lg border shadow-[0_24px_70px_rgba(0,0,0,0.42)]',
|
||||
dark ? 'border-white/10 bg-[#252525] text-white' : 'border-[#d8dee9] bg-white text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['flex items-center gap-2 border-b px-4 py-3', borderSoft].join(' ')}>
|
||||
<Upload size={15} className="text-[#7aa7ff]" />
|
||||
<strong className="text-[13px] font-bold">{requiresSourceSubmission ? '제출' : '학습 완료'}</strong>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className={['text-[13px] font-semibold', textStrong].join(' ')}>
|
||||
{requiresSourceSubmission ? '변경사항을 제출할까요?' : '학습을 완료하시겠습니까?'}
|
||||
</p>
|
||||
<p className={['mt-2 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>
|
||||
{requiresSourceSubmission ? '변경된 모듈만 원격 저장소에 제출합니다.' : '현재 주차의 학습 완료 상태로 기록합니다.'}
|
||||
</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className={[
|
||||
'h-8 cursor-pointer rounded-md border px-3 text-[12px] font-bold transition',
|
||||
dark ? 'border-white/12 text-white/62 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={onConfirm}
|
||||
className="h-8 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8] disabled:cursor-default disabled:opacity-60"
|
||||
>
|
||||
{requiresSourceSubmission ? '제출' : '완료'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type PowerActionModalProps = WorkspaceModalTheme & {
|
||||
platformName: string
|
||||
canReleaseControl: boolean
|
||||
onClose: () => void
|
||||
onDisconnectSession: () => void
|
||||
onReleaseControl: () => void
|
||||
}
|
||||
|
||||
export function PowerActionModal({
|
||||
dark,
|
||||
borderSoft,
|
||||
textDim,
|
||||
platformName,
|
||||
canReleaseControl,
|
||||
onClose,
|
||||
onDisconnectSession,
|
||||
onReleaseControl
|
||||
}: PowerActionModalProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 grid place-items-center bg-black/36">
|
||||
<section
|
||||
className={[
|
||||
'w-[360px] overflow-hidden rounded-lg border shadow-[0_24px_70px_rgba(0,0,0,0.42)]',
|
||||
dark ? 'border-white/10 bg-[#252525] text-white' : 'border-[#d8dee9] bg-white text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['flex items-center justify-between gap-2 border-b px-4 py-3', borderSoft].join(' ')}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Power size={15} className={dark ? 'text-white/62' : 'text-[#687284]'} />
|
||||
<strong className="text-[13px] font-bold">종료</strong>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="닫기"
|
||||
onClick={onClose}
|
||||
className={[
|
||||
'grid h-7 w-7 cursor-pointer place-items-center rounded-md transition',
|
||||
dark ? 'text-white/45 hover:bg-white/10 hover:text-white' : 'text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className={['mb-5 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>
|
||||
{platformName} 세션에 적용할 작업을 선택하세요.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDisconnectSession}
|
||||
className={[
|
||||
'flex h-20 w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-md border px-4 py-3 text-[12px] font-bold transition',
|
||||
dark ? 'border-white/10 bg-[#1f1f1f] text-white/76 hover:bg-white/8 hover:text-white' : 'border-[#d8dee9] bg-[#f8f9fc] text-[#4f596b] hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
<LogOut size={30} />
|
||||
<span>연결종료</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canReleaseControl}
|
||||
onClick={onReleaseControl}
|
||||
className={[
|
||||
'flex h-20 w-full flex-col items-center justify-center gap-2 rounded-md border px-4 py-3 text-[12px] font-bold transition disabled:cursor-default disabled:opacity-45',
|
||||
canReleaseControl ? 'cursor-pointer' : 'cursor-default',
|
||||
dark ? 'border-[#ef4444]/24 bg-[#ef4444]/10 text-[#fca5a5] hover:bg-[#ef4444]/16 hover:text-[#fecaca] disabled:hover:bg-[#ef4444]/10 disabled:hover:text-[#fca5a5]' : 'border-[#fecaca] bg-[#fff1f2] text-[#dc2626] hover:bg-[#fee2e2] disabled:hover:bg-[#fff1f2]'
|
||||
].join(' ')}
|
||||
>
|
||||
<Trash2 size={30} />
|
||||
<span>반납</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type UnsavedEditorCloseModalProps = WorkspaceModalTheme & {
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export function UnsavedEditorCloseModal({
|
||||
dark,
|
||||
borderSoft,
|
||||
textStrong,
|
||||
textDim,
|
||||
onCancel,
|
||||
onConfirm
|
||||
}: UnsavedEditorCloseModalProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="absolute inset-0 z-[90] grid place-items-center bg-black/36">
|
||||
<section
|
||||
className={[
|
||||
'w-[360px] overflow-hidden rounded-lg border shadow-[0_24px_70px_rgba(0,0,0,0.42)]',
|
||||
dark ? 'border-white/10 bg-[#252525] text-white' : 'border-[#d8dee9] bg-white text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['flex items-center gap-2 border-b px-4 py-3', borderSoft].join(' ')}>
|
||||
<Trash2 size={15} className="text-[#e87979]" />
|
||||
<strong className="text-[13px] font-bold">나가기</strong>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className={['text-[13px] font-semibold', textStrong].join(' ')}>
|
||||
저장하지 않고 나갈까요?
|
||||
</p>
|
||||
<p className={['mt-2 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>
|
||||
저장하지 않고 닫으면 현재 편집 내용이 사라집니다.
|
||||
</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className={[
|
||||
'h-8 cursor-pointer rounded-md border px-3 text-[12px] font-bold transition',
|
||||
dark ? 'border-white/12 text-white/62 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838]"
|
||||
>
|
||||
나가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { ModuleRow } from './types'
|
||||
|
||||
type ModuleControlAction = 'start' | 'stop' | 'restart'
|
||||
|
||||
type ModuleTabContextMenuState = {
|
||||
moduleName: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
type Props = {
|
||||
dark: boolean
|
||||
menu: ModuleTabContextMenuState | null
|
||||
menuRef: React.RefObject<HTMLDivElement | null>
|
||||
moduleTabs: ModuleRow[]
|
||||
controllingModuleNames: string[]
|
||||
onRunModuleControl: (module: ModuleRow, action: ModuleControlAction) => void
|
||||
onCloseModuleTab: (moduleName: string) => void
|
||||
onCloseOtherModuleTabs: (moduleName: string) => void
|
||||
onCloseModuleTabsToRight: (moduleName: string) => void
|
||||
onCloseAllModuleTabs: () => void
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
function WorkspaceModuleTabContextMenu({
|
||||
dark,
|
||||
menu,
|
||||
menuRef,
|
||||
moduleTabs,
|
||||
controllingModuleNames,
|
||||
onRunModuleControl,
|
||||
onCloseModuleTab,
|
||||
onCloseOtherModuleTabs,
|
||||
onCloseModuleTabsToRight,
|
||||
onCloseAllModuleTabs,
|
||||
onDismiss
|
||||
}: Props): React.JSX.Element | null {
|
||||
if (!menu) return null
|
||||
|
||||
const contextModule = moduleTabs.find((module) => module.name === menu.moduleName)
|
||||
const controlling = contextModule ? controllingModuleNames.includes(contextModule.name) : false
|
||||
const controlItems = contextModule?.state === 'running'
|
||||
? [
|
||||
{ label: '중지', action: () => onRunModuleControl(contextModule, 'stop') },
|
||||
{ label: '재실행', action: () => onRunModuleControl(contextModule, 'restart') }
|
||||
]
|
||||
: contextModule?.state === 'stopped'
|
||||
? [{ label: '실행', action: () => onRunModuleControl(contextModule, 'start') }]
|
||||
: []
|
||||
|
||||
const tabItems = [
|
||||
{ label: '닫기', action: () => onCloseModuleTab(menu.moduleName) },
|
||||
{ label: '다른 탭 닫기', action: () => onCloseOtherModuleTabs(menu.moduleName) },
|
||||
{ label: '오른쪽 탭 닫기', action: () => onCloseModuleTabsToRight(menu.moduleName) },
|
||||
{ label: '모든 탭 닫기', action: onCloseAllModuleTabs }
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={[
|
||||
'fixed z-50 w-[190px] 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 }}
|
||||
>
|
||||
<div className={['mb-1 border-b px-3 py-2', dark ? 'border-white/10 bg-[#313138]' : 'border-[#e5e8ef] bg-[#f3f5f9]'].join(' ')}>
|
||||
<span className={['block truncate text-[12px] font-black', dark ? 'text-white' : 'text-[#111520]'].join(' ')}>
|
||||
{menu.moduleName}
|
||||
</span>
|
||||
</div>
|
||||
{controlItems.map(({ label, action }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
disabled={controlling}
|
||||
onClick={() => {
|
||||
if (controlling) return
|
||||
action()
|
||||
onDismiss()
|
||||
}}
|
||||
className={[
|
||||
'flex h-7 w-full items-center px-3 text-left transition',
|
||||
controlling ? 'cursor-default opacity-45' : 'cursor-pointer',
|
||||
dark ? 'hover:bg-[#37373d] hover:text-white' : 'hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{controlItems.length > 0 && <div className={dark ? 'my-1 h-px bg-white/10' : 'my-1 h-px bg-[#e5e8ef]'} />}
|
||||
{tabItems.map(({ label, action }, index) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
action()
|
||||
onDismiss()
|
||||
}}
|
||||
className={[
|
||||
'flex h-7 w-full cursor-pointer items-center px-3 text-left transition',
|
||||
index === 3 ? (dark ? 'border-t border-white/10' : 'border-t border-[#e5e8ef]') : '',
|
||||
dark ? 'hover:bg-[#37373d] hover:text-white' : 'hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkspaceModuleTabContextMenu
|
||||
@@ -0,0 +1,115 @@
|
||||
import { RefreshCw, X } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
import type { WorkspaceActivity } from './WorkspaceNav'
|
||||
|
||||
type WorkspaceModuleTab = {
|
||||
name: string
|
||||
state: 'running' | 'stopped' | 'uninstalled'
|
||||
}
|
||||
|
||||
type WorkspaceModuleTabActivity = Extract<WorkspaceActivity, 'workspace' | 'search' | 'logs'>
|
||||
|
||||
type WorkspaceModuleTabsProps = {
|
||||
dark: boolean
|
||||
modules: WorkspaceModuleTab[]
|
||||
selectedModuleName?: string | null
|
||||
activeActivity: WorkspaceActivity
|
||||
controllingModuleNames: string[]
|
||||
onActivate: (module: WorkspaceModuleTab, activity: WorkspaceModuleTabActivity) => void
|
||||
onClose: (moduleName: string) => void
|
||||
onOpenContextMenu: (moduleName: string, x: number, y: number) => void
|
||||
}
|
||||
|
||||
export default function WorkspaceModuleTabs({
|
||||
dark,
|
||||
modules,
|
||||
selectedModuleName,
|
||||
activeActivity,
|
||||
controllingModuleNames,
|
||||
onActivate,
|
||||
onClose,
|
||||
onOpenContextMenu
|
||||
}: WorkspaceModuleTabsProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="thin-x-scrollbar flex h-full min-w-0 items-stretch overflow-x-auto overflow-y-hidden">
|
||||
{modules.length > 0 ? (
|
||||
modules.map((module) => {
|
||||
const active = selectedModuleName === module.name
|
||||
const moduleControlling = controllingModuleNames.includes(module.name)
|
||||
const targetActivity = activeActivity === 'logs'
|
||||
? 'logs'
|
||||
: activeActivity === 'search'
|
||||
? 'search'
|
||||
: 'workspace'
|
||||
|
||||
return (
|
||||
<button
|
||||
key={module.name}
|
||||
type="button"
|
||||
onClick={() => onActivate(module, targetActivity)}
|
||||
onAuxClick={(event) => {
|
||||
if (event.button !== 1) return
|
||||
event.preventDefault()
|
||||
onClose(module.name)
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
onOpenContextMenu(module.name, event.clientX, event.clientY)
|
||||
}}
|
||||
className={[
|
||||
'group flex h-full min-w-[170px] max-w-[210px] shrink-0 cursor-pointer items-center gap-2 rounded-none border-r px-3 text-[12px] font-bold transition',
|
||||
active
|
||||
? dark ? 'border-white/10 bg-[#1f1f1f] text-white/90' : 'border-[#d8dee9] bg-[#f9fafc] text-[#111520]'
|
||||
: dark ? 'border-white/10 bg-[#252525] text-white/45 hover:bg-[#2b2b2b] hover:text-white/75' : 'border-[#d8dee9] bg-[#eef1f6] text-[#687284] hover:bg-[#e7ebf2] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
{moduleControlling ? (
|
||||
<RefreshCw size={10} className={['shrink-0 animate-spin', dark ? 'text-white/68' : 'text-[#4f596b]'].join(' ')} />
|
||||
) : (
|
||||
<span
|
||||
className={[
|
||||
'h-1.5 w-1.5 shrink-0 rounded-full',
|
||||
module.state === 'running'
|
||||
? 'bg-[#22c55e] shadow-[0_0_6px_rgba(34,197,94,0.55)]'
|
||||
: module.state === 'stopped'
|
||||
? dark ? 'bg-white/32' : 'bg-[#9aa3b2]'
|
||||
: dark ? 'bg-white/18' : 'bg-[#c4cbd7]'
|
||||
].join(' ')}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 truncate">{module.name}</span>
|
||||
<span className="min-w-0 flex-1" />
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${module.name} 닫기`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onClose(module.name)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onClose(module.name)
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
'grid h-4 w-4 shrink-0 place-items-center rounded opacity-0 transition group-hover:opacity-100',
|
||||
active ? 'opacity-100' : '',
|
||||
dark ? 'hover:bg-white/12 hover:text-white' : 'hover:bg-[#cfd8e6] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
<X size={12} />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className={['flex h-full items-center px-3 text-[11px] font-medium', dark ? 'text-white/40' : 'text-[#7b8494]'].join(' ')}>
|
||||
열린 모듈 없음
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Code2, FileCode2, GitBranch, Network, RefreshCw, Search, Settings, Terminal } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
|
||||
export type WorkspaceActivity = 'modules' | 'workspace' | 'search' | 'logs' | 'examples' | 'git' | 'settings'
|
||||
|
||||
type WorkspaceNavProps = {
|
||||
dark: boolean
|
||||
workspaceActive: boolean
|
||||
activeActivity: WorkspaceActivity
|
||||
selectedModuleExists: boolean
|
||||
installedGitModuleCount: number
|
||||
moduleTabCount: number
|
||||
gitChangedFileCount: number
|
||||
workspaceNavFlash: boolean
|
||||
onSelect: (activity: WorkspaceActivity) => void
|
||||
}
|
||||
|
||||
const WORKSPACE_NAV_ITEMS: Array<{
|
||||
id: WorkspaceActivity
|
||||
Icon: typeof Network
|
||||
label: string
|
||||
}> = [
|
||||
{ id: 'modules', Icon: Network, label: '모듈 리스트' },
|
||||
{ id: 'workspace', Icon: Code2, label: '코드 편집' },
|
||||
{ id: 'search', Icon: Search, label: '검색' },
|
||||
{ id: 'logs', Icon: Terminal, label: '로그' },
|
||||
{ id: 'examples', Icon: FileCode2, label: '예제 코드' },
|
||||
{ id: 'git', Icon: GitBranch, label: '소스 제어' },
|
||||
{ id: 'settings', Icon: Settings, label: '설정' }
|
||||
]
|
||||
|
||||
export default function WorkspaceNav({
|
||||
dark,
|
||||
workspaceActive,
|
||||
activeActivity,
|
||||
selectedModuleExists,
|
||||
installedGitModuleCount,
|
||||
moduleTabCount,
|
||||
gitChangedFileCount,
|
||||
workspaceNavFlash,
|
||||
onSelect
|
||||
}: WorkspaceNavProps): React.JSX.Element {
|
||||
const tooltipClassName = [
|
||||
'pointer-events-none absolute left-11 top-1/2 z-50 hidden -translate-y-1/2 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(' ')
|
||||
|
||||
return (
|
||||
<nav
|
||||
data-help="workspace-nav"
|
||||
className={['relative z-30 flex flex-col items-center gap-3 overflow-visible border-r py-3', dark ? 'border-white/10 bg-[#303030] text-white/65' : 'border-[#cfd6e4] bg-[#f3f5f9] text-[#687284]'].join(' ')}
|
||||
style={workspaceActive ? { gridColumn: 3, gridRow: '1 / span 2' } : { gridColumn: 3, gridRow: 1 }}
|
||||
>
|
||||
{WORKSPACE_NAV_ITEMS.map(({ id, Icon, label }) => {
|
||||
const active = activeActivity === id
|
||||
const selectable = id === 'modules' ||
|
||||
id === 'workspace' ||
|
||||
id === 'examples' ||
|
||||
id === 'settings' ||
|
||||
(id === 'git' && installedGitModuleCount > 0) ||
|
||||
((id === 'search' || id === 'logs') && selectedModuleExists)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={() => {
|
||||
if (!selectable) return
|
||||
onSelect(id)
|
||||
}}
|
||||
className={[
|
||||
'group relative z-0 grid h-8 w-8 place-items-center overflow-visible rounded-lg transition',
|
||||
selectable ? 'cursor-pointer' : 'cursor-default opacity-55',
|
||||
active
|
||||
? dark
|
||||
? 'bg-[#2563eb]/32 text-white'
|
||||
: 'bg-[#edf3ff] text-[#1d4ed8]'
|
||||
: dark
|
||||
? selectable ? 'hover:bg-white/10 hover:text-white' : ''
|
||||
: selectable ? 'hover:bg-[#eef1f7] hover:text-[#111520]' : ''
|
||||
].join(' ')}
|
||||
>
|
||||
{id === 'workspace' && workspaceNavFlash && (
|
||||
<>
|
||||
<span className="pointer-events-none absolute inset-[-3px] rounded-xl border border-white/70 shadow-[0_0_18px_rgba(255,255,255,0.28)] animate-ping" />
|
||||
<span className="pointer-events-none absolute inset-0 rounded-lg bg-[#60a5fa]/18 shadow-[0_0_18px_rgba(96,165,250,0.38)] animate-pulse" />
|
||||
</>
|
||||
)}
|
||||
<Icon
|
||||
size={17}
|
||||
className={[
|
||||
'relative z-10',
|
||||
id === 'modules' ? 'translate-x-[0.5px]' : '',
|
||||
id === 'workspace' && workspaceNavFlash ? 'text-[#b8d7ff]' : ''
|
||||
].join(' ')}
|
||||
/>
|
||||
{id === 'workspace' && moduleTabCount > 0 && (
|
||||
<span className="absolute -right-1 -top-1 z-20 flex h-4 min-w-4 items-center justify-center rounded-full bg-[#2563eb] px-1 pt-px text-center text-[10px] font-bold leading-4 text-white shadow-[0_0_10px_rgba(37,99,235,0.42)]">
|
||||
<span className="translate-x-[0.5px]">{moduleTabCount}</span>
|
||||
</span>
|
||||
)}
|
||||
{id === 'git' && gitChangedFileCount > 0 && (
|
||||
<span className="absolute -right-1 -top-1 z-20 flex h-4 min-w-4 items-center justify-center rounded-full bg-[#2563eb] px-1 pt-px text-center text-[10px] font-bold leading-4 text-white shadow-[0_0_10px_rgba(37,99,235,0.42)]">
|
||||
<span className="translate-x-[0.5px]">{gitChangedFileCount > 9 ? '9+' : gitChangedFileCount}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className={tooltipClassName}>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import { Bot, MessageCircle, Minus, User } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ChatAvatar, ExpandableMarkdownMessage, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets'
|
||||
|
||||
type QaMode = 'ai' | 'teacher'
|
||||
|
||||
type WorkspaceQaMessage = {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
senderAvatarUrl?: string | null
|
||||
isTyping?: boolean
|
||||
keepExpanded?: boolean
|
||||
}
|
||||
|
||||
type WorkspaceQaPanelProps = {
|
||||
dark: boolean
|
||||
workspaceActive: boolean
|
||||
minimized: boolean
|
||||
panelClassName: string
|
||||
borderSoft: string
|
||||
textStrong: string
|
||||
textDim: string
|
||||
iconButtonClass: string
|
||||
accessToken: string
|
||||
qaMode: QaMode
|
||||
teacherChatFlash: boolean
|
||||
qaMessagesLoading: boolean
|
||||
visibleAiMessages: WorkspaceQaMessage[]
|
||||
visibleTeacherMessages: WorkspaceQaMessage[]
|
||||
aiPending: boolean
|
||||
teacherThreadStatus: string | null
|
||||
qaCollapseSignal: number
|
||||
inputBusy: boolean
|
||||
inputDisabled: boolean
|
||||
workspaceChatScrollRef: React.RefObject<HTMLDivElement | null>
|
||||
onMinimize: () => void
|
||||
onModeChange: (mode: QaMode) => void
|
||||
onStop: () => void
|
||||
onSubmit: (content: string) => void
|
||||
onClearConversation: () => void
|
||||
}
|
||||
|
||||
export default function WorkspaceQaPanel({
|
||||
dark,
|
||||
workspaceActive,
|
||||
minimized,
|
||||
panelClassName,
|
||||
borderSoft,
|
||||
textStrong,
|
||||
textDim,
|
||||
iconButtonClass,
|
||||
accessToken,
|
||||
qaMode,
|
||||
teacherChatFlash,
|
||||
qaMessagesLoading,
|
||||
visibleAiMessages,
|
||||
visibleTeacherMessages,
|
||||
aiPending,
|
||||
teacherThreadStatus,
|
||||
qaCollapseSignal,
|
||||
inputBusy,
|
||||
inputDisabled,
|
||||
workspaceChatScrollRef,
|
||||
onMinimize,
|
||||
onModeChange,
|
||||
onStop,
|
||||
onSubmit,
|
||||
onClearConversation
|
||||
}: WorkspaceQaPanelProps): React.JSX.Element {
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
|
||||
const activeMessages = qaMode === 'ai' ? visibleAiMessages : visibleTeacherMessages
|
||||
const lastMessage = activeMessages[activeMessages.length - 1]
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return
|
||||
const closeContextMenu = (): void => setContextMenu(null)
|
||||
window.addEventListener('click', closeContextMenu)
|
||||
window.addEventListener('contextmenu', closeContextMenu)
|
||||
return () => {
|
||||
window.removeEventListener('click', closeContextMenu)
|
||||
window.removeEventListener('contextmenu', closeContextMenu)
|
||||
}
|
||||
}, [contextMenu])
|
||||
|
||||
useEffect(() => {
|
||||
if (minimized || qaMessagesLoading) return
|
||||
const target = workspaceChatScrollRef.current
|
||||
if (!target) return
|
||||
|
||||
let disposed = false
|
||||
const scrollToBottom = (): void => {
|
||||
if (disposed) return
|
||||
target.scrollTop = target.scrollHeight
|
||||
}
|
||||
|
||||
const animationFrames: number[] = []
|
||||
animationFrames.push(window.requestAnimationFrame(scrollToBottom))
|
||||
animationFrames.push(window.requestAnimationFrame(() => {
|
||||
animationFrames.push(window.requestAnimationFrame(scrollToBottom))
|
||||
}))
|
||||
|
||||
const timeoutIds = [40, 120, 260, 520, 900].map((delay) => window.setTimeout(scrollToBottom, delay))
|
||||
const observer = typeof ResizeObserver !== 'undefined'
|
||||
? new ResizeObserver(scrollToBottom)
|
||||
: null
|
||||
|
||||
observer?.observe(target)
|
||||
if (target.firstElementChild) observer?.observe(target.firstElementChild)
|
||||
|
||||
scrollToBottom()
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
animationFrames.forEach((frame) => window.cancelAnimationFrame(frame))
|
||||
timeoutIds.forEach((timeoutId) => window.clearTimeout(timeoutId))
|
||||
observer?.disconnect()
|
||||
}
|
||||
}, [
|
||||
aiPending,
|
||||
lastMessage?.content,
|
||||
lastMessage?.id,
|
||||
minimized,
|
||||
qaMessagesLoading,
|
||||
qaMode,
|
||||
teacherThreadStatus,
|
||||
visibleAiMessages.length,
|
||||
visibleTeacherMessages.length,
|
||||
workspaceChatScrollRef
|
||||
])
|
||||
|
||||
const openContextMenu = (event: React.MouseEvent<HTMLDivElement>): void => {
|
||||
const target = event.target as HTMLElement | null
|
||||
if (target?.closest('[data-qa-message="true"]')) return
|
||||
event.preventDefault()
|
||||
setContextMenu({ x: event.clientX, y: event.clientY })
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-help="workspace-chat"
|
||||
className={['flex min-w-0 flex-col border-l transition-opacity', minimized ? 'pointer-events-none overflow-hidden border-l-0 opacity-0' : '', panelClassName].join(' ')}
|
||||
style={workspaceActive ? { gridColumn: 8, gridRow: '1 / span 2' } : { gridColumn: 8, gridRow: 1 }}
|
||||
>
|
||||
<div className={['border-b p-3', borderSoft].join(' ')}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className={['flex items-center gap-2 text-[13px] font-bold', textStrong].join(' ')}>
|
||||
<MessageCircle size={14} className={textDim} />
|
||||
질의응답
|
||||
</h3>
|
||||
<button type="button" aria-label="질의응답 최소화" onClick={onMinimize} className={[iconButtonClass, 'group relative'].join(' ')}>
|
||||
<Minus size={15} />
|
||||
<span className={['pointer-events-none absolute right-0 top-8 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(' ')}>
|
||||
최소화
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className={['mt-3 grid grid-cols-2 overflow-hidden rounded-md border', borderSoft].join(' ')}>
|
||||
{[
|
||||
{ id: 'ai' as const, label: 'AI', Icon: Bot },
|
||||
{ id: 'teacher' as const, label: '강사', Icon: User }
|
||||
].map(({ id, label, Icon }) => {
|
||||
const teacherTabFlash = id === 'teacher' && teacherChatFlash && !minimized && qaMode === 'ai'
|
||||
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => onModeChange(id)}
|
||||
className={[
|
||||
'relative flex h-8 cursor-pointer items-center justify-center gap-1.5 text-[12px] font-bold transition',
|
||||
teacherTabFlash
|
||||
? dark
|
||||
? 'animate-pulse bg-[#2563eb]/28 text-[#9ec2ff] shadow-[inset_0_0_0_1px_rgba(96,165,250,0.28)]'
|
||||
: 'animate-pulse bg-[#dbeafe] text-[#1d4ed8] shadow-[inset_0_0_0_1px_rgba(37,99,235,0.18)]'
|
||||
: qaMode === id
|
||||
? 'bg-[#2563eb] text-white'
|
||||
: dark
|
||||
? 'bg-[#202020] text-white/55 hover:bg-white/10 hover:text-white'
|
||||
: 'bg-white text-[#4f596b] hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon size={14} className={['relative z-10', teacherTabFlash ? 'animate-pulse text-[#7db3ff]' : ''].join(' ')} />
|
||||
<span className={['relative z-10', teacherTabFlash ? 'animate-pulse text-[#7db3ff]' : ''].join(' ')}>{label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div ref={workspaceChatScrollRef} className="min-h-0 flex-1 overflow-auto p-3" onContextMenu={openContextMenu}>
|
||||
{qaMessagesLoading ? (
|
||||
<div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold', dark ? 'text-white/48' : 'text-[#687284]'].join(' ')}>
|
||||
<div>
|
||||
<span className={['mx-auto mb-3 block h-6 w-6 animate-spin rounded-full border-2 border-t-transparent', dark ? 'border-white/45' : 'border-[#94a3b8]'].join(' ')} />
|
||||
<span>채팅 내역을 불러오는 중입니다</span>
|
||||
</div>
|
||||
</div>
|
||||
) : qaMode === 'ai' ? (
|
||||
<AiMessages
|
||||
dark={dark}
|
||||
messages={visibleAiMessages}
|
||||
pending={aiPending}
|
||||
collapseSignal={qaCollapseSignal}
|
||||
/>
|
||||
) : (
|
||||
<TeacherMessages
|
||||
dark={dark}
|
||||
accessToken={accessToken}
|
||||
messages={visibleTeacherMessages}
|
||||
threadStatus={teacherThreadStatus}
|
||||
collapseSignal={qaCollapseSignal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{contextMenu && (
|
||||
<div
|
||||
className={['fixed z-[140] min-w-[150px] overflow-hidden rounded-md border py-1 shadow-[0_18px_42px_rgba(0,0,0,0.28)]', dark ? 'border-white/10 bg-[#2b2b2b]' : 'border-[#d5dbe7] bg-white'].join(' ')}
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={['block w-full px-3 py-2 text-left text-[12px] font-semibold transition', dark ? 'text-white/78 hover:bg-white/10' : 'text-[#334155] hover:bg-[#f1f5f9]'].join(' ')}
|
||||
onClick={() => {
|
||||
setContextMenu(null)
|
||||
onClearConversation()
|
||||
}}
|
||||
>
|
||||
대화내역 삭제
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<QaInputBox
|
||||
dark={dark}
|
||||
busy={inputBusy}
|
||||
disabled={inputDisabled}
|
||||
onStop={onStop}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function AiMessages({
|
||||
dark,
|
||||
messages,
|
||||
pending,
|
||||
collapseSignal
|
||||
}: {
|
||||
dark: boolean
|
||||
messages: WorkspaceQaMessage[]
|
||||
pending: boolean
|
||||
collapseSignal: number
|
||||
}): React.JSX.Element {
|
||||
if (!messages.length && !pending) {
|
||||
return (
|
||||
<div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
|
||||
<span>
|
||||
AI에게 질문할
|
||||
<br />
|
||||
내용을 입력하세요
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{messages.map((message, index) => (
|
||||
message.role === 'user' ? (
|
||||
<div key={message.id} data-qa-message="true" className="flex justify-end">
|
||||
<div className="max-w-[230px] overflow-hidden rounded-lg bg-[#2563eb] px-3 py-2 text-[12px] font-bold leading-relaxed text-white">
|
||||
<ExpandablePlainMessage
|
||||
collapseOnSignal={index < messages.length - 1}
|
||||
collapseSignal={collapseSignal}
|
||||
content={message.content}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={message.id} data-qa-message="true" className="flex min-w-0 gap-2.5">
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#2563eb] text-white">
|
||||
<Bot size={15} />
|
||||
</span>
|
||||
<div className={['min-w-0 max-w-[calc(100%-38px)] overflow-hidden rounded-lg px-3 py-2 text-[12px] font-medium leading-relaxed', dark ? 'bg-[#303030] text-white/75' : 'bg-white text-[#4f596b]'].join(' ')}>
|
||||
<ExpandableMarkdownMessage
|
||||
collapseOnSignal={index < messages.length - 1}
|
||||
collapseSignal={collapseSignal}
|
||||
content={message.content}
|
||||
dark={dark}
|
||||
defaultCollapsed={!message.isTyping && !message.keepExpanded}
|
||||
/>
|
||||
{message.isTyping && (
|
||||
<span className={['mt-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full', dark ? 'bg-white/65' : 'bg-[#2563eb]'].join(' ')} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
{pending && (
|
||||
<div data-qa-message="true" className="flex gap-2.5">
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#2563eb] text-white">
|
||||
<Bot size={15} />
|
||||
</span>
|
||||
<div className={['inline-flex items-center gap-2 rounded-lg px-3 py-2 text-[12px] font-medium', dark ? 'bg-[#303030] text-white/55' : 'bg-white text-[#727b8c]'].join(' ')}>
|
||||
<span>답변을 작성 중입니다...</span>
|
||||
<span className={['h-3 w-3 shrink-0 animate-spin rounded-full border-2 border-t-transparent', dark ? 'border-white/45' : 'border-[#94a3b8]'].join(' ')} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TeacherMessages({
|
||||
dark,
|
||||
accessToken,
|
||||
messages,
|
||||
threadStatus,
|
||||
collapseSignal
|
||||
}: {
|
||||
dark: boolean
|
||||
accessToken: string
|
||||
messages: WorkspaceQaMessage[]
|
||||
threadStatus: string | null
|
||||
collapseSignal: number
|
||||
}): React.JSX.Element {
|
||||
if (!messages.length) {
|
||||
return (
|
||||
<div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
|
||||
<span>
|
||||
강사에게 문의할
|
||||
<br />
|
||||
내용을 입력하세요
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{messages.map((message, index) => (
|
||||
message.role === 'user' ? (
|
||||
<div key={message.id} data-qa-message="true" className="flex justify-end">
|
||||
<div className="max-w-[245px] overflow-hidden rounded-lg bg-[#2563eb] px-3 py-2 text-[12px] font-bold leading-relaxed text-white">
|
||||
<ExpandablePlainMessage
|
||||
collapseOnSignal={index < messages.length - 1}
|
||||
collapseSignal={collapseSignal}
|
||||
content={message.content}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={message.id} data-qa-message="true" className="flex min-w-0 gap-2.5">
|
||||
<ChatAvatar
|
||||
accessToken={accessToken}
|
||||
avatarUrl={message.senderAvatarUrl}
|
||||
className="grid h-7 w-7 shrink-0 place-items-center overflow-hidden rounded-full bg-[#2fbda0] text-white"
|
||||
fallback={<User size={15} />}
|
||||
/>
|
||||
<div className={['min-w-0 max-w-[calc(100%-38px)] overflow-hidden rounded-lg px-3 py-2 text-[12px] font-medium leading-relaxed', dark ? 'bg-[#303030] text-white/75' : 'bg-white text-[#4f596b]'].join(' ')}>
|
||||
<strong className={['mb-1 block text-[11px]', dark ? 'text-white' : 'text-[#111520]'].join(' ')}>강사</strong>
|
||||
<MarkdownMessage content={message.content} dark={dark} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
{threadStatus === 'WAITING_INSTRUCTOR' && (
|
||||
<div data-qa-message="true" className={['rounded-lg border px-3 py-2', dark ? 'border-[#b78b28]/35 bg-[#2b261b]' : 'border-[#ead7a3] bg-[#fff8e8]'].join(' ')}>
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-bold text-[#c58a1d]">
|
||||
<MessageCircle size={13} />
|
||||
답변 대기 중
|
||||
</div>
|
||||
<p className={['mt-1 text-[12px] font-medium', dark ? 'text-white/55' : 'text-[#6d6470]'].join(' ')}>강사가 확인하는 대로 답변을 드립니다</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { StudioSourceSettings } from '../platforms/types'
|
||||
import { StudioSettingsField, StudioSettingsSwitch } from './StudioSettingsControls'
|
||||
|
||||
type Props = {
|
||||
dark: boolean
|
||||
textStrong: string
|
||||
textMid: string
|
||||
remoteWorkspace: boolean
|
||||
platformName: string
|
||||
configuredDraftWorkspaceRoot: string
|
||||
sourceSettingsDraft: StudioSourceSettings
|
||||
platformSourceSettingsDefaults: StudioSourceSettings
|
||||
onUpdateSourceSetting: <Key extends keyof StudioSourceSettings>(key: Key, value: StudioSourceSettings[Key]) => void
|
||||
onResetSourceSettings: (settings: StudioSourceSettings) => void
|
||||
onSaveSourceSettings: () => void
|
||||
}
|
||||
|
||||
function WorkspaceSettingsView({
|
||||
dark,
|
||||
textStrong,
|
||||
textMid,
|
||||
remoteWorkspace,
|
||||
platformName,
|
||||
configuredDraftWorkspaceRoot,
|
||||
sourceSettingsDraft,
|
||||
platformSourceSettingsDefaults,
|
||||
onUpdateSourceSetting,
|
||||
onResetSourceSettings,
|
||||
onSaveSourceSettings
|
||||
}: Props): React.JSX.Element {
|
||||
return (
|
||||
<section
|
||||
className={['min-w-0 overflow-hidden', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}
|
||||
style={{ gridColumn: '4 / span 3', gridRow: 2 }}
|
||||
>
|
||||
<div className="thin-y-scrollbar h-full overflow-auto p-6">
|
||||
<div className="mx-auto max-w-[760px]">
|
||||
<div className={['rounded-lg border p-5', dark ? 'border-white/10 bg-[#252525]' : 'border-[#d8dee9] bg-white'].join(' ')}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className={['text-[16px] font-black', textStrong].join(' ')}>개발환경</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{remoteWorkspace ? (
|
||||
<div className={['mt-5 rounded-lg border p-4', dark ? 'border-white/10 bg-[#1f1f1f]' : 'border-[#e1e6ef] bg-[#f8fafc]'].join(' ')}>
|
||||
<p className={['text-[13px] font-black', textStrong].join(' ')}>공용 플랫폼 환경</p>
|
||||
<p className={['mt-2 text-[12px] font-semibold leading-relaxed', textMid].join(' ')}>
|
||||
원격 플랫폼의 워크스페이스 설정은 강사/관리자 설정을 사용합니다.
|
||||
</p>
|
||||
<div className="mt-4 grid gap-2 text-[12px] font-semibold">
|
||||
<div className={['rounded-md px-3 py-2', dark ? 'bg-white/[0.05] text-white/62' : 'bg-white text-[#4f596b]'].join(' ')}>
|
||||
플랫폼: {platformName}
|
||||
</div>
|
||||
<div className={['rounded-md px-3 py-2', dark ? 'bg-white/[0.05] text-white/62' : 'bg-white text-[#4f596b]'].join(' ')}>
|
||||
실행 방식: 세션 단위 임시 런타임
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-5 grid gap-4">
|
||||
<StudioSettingsField
|
||||
label="사용자 워크스페이스 루트"
|
||||
value={configuredDraftWorkspaceRoot}
|
||||
onChange={(value) => onUpdateSourceSetting('workspaceRoot', value)}
|
||||
dark={dark}
|
||||
/>
|
||||
<StudioSettingsField
|
||||
label="ROS Domain ID"
|
||||
value={sourceSettingsDraft.rosDomainId}
|
||||
onChange={(value) => onUpdateSourceSetting('rosDomainId', value.replace(/[^\d]/g, '').slice(0, 3))}
|
||||
dark={dark}
|
||||
/>
|
||||
<StudioSettingsSwitch
|
||||
label="GPU 가속"
|
||||
description="ON이면 Docker 실행 시 NVIDIA GPU를 사용합니다."
|
||||
checked={sourceSettingsDraft.gpuEnabled}
|
||||
onChange={(checked) => onUpdateSourceSetting('gpuEnabled', checked)}
|
||||
dark={dark}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onResetSourceSettings(platformSourceSettingsDefaults)}
|
||||
className={['h-9 cursor-pointer rounded-md border px-4 text-[12px] font-bold transition', dark ? 'border-white/12 text-white/58 hover:bg-white/7 hover:text-white' : 'border-[#d7dde8] bg-white text-[#4f596b] hover:bg-[#f8f9fc] hover:text-[#111520]'].join(' ')}
|
||||
>
|
||||
기본값
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSaveSourceSettings}
|
||||
className="h-9 cursor-pointer rounded-md border border-[#2563eb] bg-[#2563eb] px-4 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8]"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkspaceSettingsView
|
||||
@@ -0,0 +1,382 @@
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import type { ModuleRow } from './types'
|
||||
|
||||
type GitChangedFile = {
|
||||
path: string
|
||||
status: 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked'
|
||||
staged: boolean
|
||||
unstaged: boolean
|
||||
}
|
||||
|
||||
type GitCommitInfo = {
|
||||
hash: string
|
||||
shortHash: string
|
||||
author: string
|
||||
date: string
|
||||
message: string
|
||||
}
|
||||
|
||||
type GitStatusInfo = {
|
||||
ok: boolean
|
||||
branch: string | null
|
||||
ahead: number
|
||||
behind: number
|
||||
files: GitChangedFile[]
|
||||
selectedFilePath: string | null
|
||||
diff: string
|
||||
commits: GitCommitInfo[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
type GitSubmissionInfo = {
|
||||
id: string
|
||||
submittedAt: string
|
||||
moduleName: string
|
||||
commit: GitCommitInfo
|
||||
}
|
||||
|
||||
type DiffViewLine = {
|
||||
key: string
|
||||
type: 'file' | 'hunk' | 'context' | 'add' | 'delete' | 'note'
|
||||
oldLine: number | null
|
||||
newLine: number | null
|
||||
content: string
|
||||
}
|
||||
|
||||
type GitTarget = {
|
||||
moduleName: string
|
||||
filePath: string
|
||||
}
|
||||
|
||||
type GitSubmissionGroup = {
|
||||
id: string
|
||||
submittedAt: string
|
||||
submissions: GitSubmissionInfo[]
|
||||
}
|
||||
|
||||
type Props = {
|
||||
dark: boolean
|
||||
borderSoft: string
|
||||
textStrong: string
|
||||
textMid: string
|
||||
textDim: string
|
||||
gitPanelTab: 'source' | 'history'
|
||||
gitChangedFileCount: number
|
||||
gitChangedModules: ModuleRow[]
|
||||
gitModuleStatuses: Record<string, GitStatusInfo>
|
||||
gitError: string | null
|
||||
gitLoading: boolean
|
||||
gitSubmissionsLoading: boolean
|
||||
gitSubmissionDiffLoading: boolean
|
||||
gitSubmissions: GitSubmissionInfo[]
|
||||
gitSubmissionGroups: GitSubmissionGroup[]
|
||||
selectedGitTarget: GitTarget | null
|
||||
selectedGitFile: GitChangedFile | null
|
||||
selectedGitStatus: GitStatusInfo | null
|
||||
selectedGitSubmission: GitSubmissionInfo | null
|
||||
selectedGitSubmissionDiff: string
|
||||
onGitPanelTabChange: (tab: 'source' | 'history') => void
|
||||
onRefresh: () => void
|
||||
onSelectGitFile: (moduleName: string, filePath: string) => void
|
||||
onOpenGitChangedFile: (moduleName: string, filePath: string) => void
|
||||
onOpenGitFileContextMenu: (module: ModuleRow, file: GitChangedFile, x: number, y: number) => void
|
||||
onSelectGitSubmission: (submission: GitSubmissionInfo) => void
|
||||
parseDiffViewLines: (diff: string) => DiffViewLine[]
|
||||
renderDiffLine: (line: DiffViewLine) => React.JSX.Element
|
||||
getGitStatusLabel: (status: GitChangedFile['status']) => string
|
||||
getGitStatusTone: (status: GitChangedFile['status']) => string
|
||||
formatGitCommitDate: (date: string) => string
|
||||
}
|
||||
|
||||
function WorkspaceSourceControlView({
|
||||
dark,
|
||||
borderSoft,
|
||||
textStrong,
|
||||
textMid,
|
||||
textDim,
|
||||
gitPanelTab,
|
||||
gitChangedFileCount,
|
||||
gitChangedModules,
|
||||
gitModuleStatuses,
|
||||
gitError,
|
||||
gitLoading,
|
||||
gitSubmissionsLoading,
|
||||
gitSubmissionDiffLoading,
|
||||
gitSubmissions,
|
||||
gitSubmissionGroups,
|
||||
selectedGitTarget,
|
||||
selectedGitFile,
|
||||
selectedGitStatus,
|
||||
selectedGitSubmission,
|
||||
selectedGitSubmissionDiff,
|
||||
onGitPanelTabChange,
|
||||
onRefresh,
|
||||
onSelectGitFile,
|
||||
onOpenGitChangedFile,
|
||||
onOpenGitFileContextMenu,
|
||||
onSelectGitSubmission,
|
||||
parseDiffViewLines,
|
||||
renderDiffLine,
|
||||
getGitStatusLabel,
|
||||
getGitStatusTone,
|
||||
formatGitCommitDate
|
||||
}: Props): React.JSX.Element {
|
||||
const refreshing = gitPanelTab === 'history' ? gitSubmissionsLoading : gitLoading
|
||||
|
||||
return (
|
||||
<section
|
||||
className={['min-w-0 overflow-hidden', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}
|
||||
style={{ gridColumn: '4 / span 3', gridRow: 2 }}
|
||||
>
|
||||
<div className={['flex h-full min-h-0 flex-col', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
|
||||
<div className={['flex h-10 shrink-0 items-end justify-between px-3', dark ? 'bg-[#252525]' : 'bg-[#f7f8fb]'].join(' ')}>
|
||||
<div className="flex items-end">
|
||||
{[
|
||||
{ key: 'source' as const, label: '소스 제어' },
|
||||
{ key: 'history' as const, label: '제출 내역' }
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => onGitPanelTabChange(tab.key)}
|
||||
className={[
|
||||
'h-8 min-w-[92px] cursor-pointer border-x border-t px-3 text-[12px] font-bold transition first:rounded-tl-md last:rounded-tr-md',
|
||||
gitPanelTab === tab.key
|
||||
? dark
|
||||
? 'border-white/10 bg-[#2b2b2b] text-white'
|
||||
: 'border-[#e1e6ef] bg-white text-[#111520]'
|
||||
: dark
|
||||
? 'border-white/8 bg-[#202020] text-white/45 hover:text-white/70'
|
||||
: 'border-[#e1e6ef] bg-[#eef1f6] text-[#687284] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing}
|
||||
className={[
|
||||
'flex h-7 items-center gap-1.5 rounded-md border px-3 text-[12px] font-bold transition',
|
||||
refreshing ? 'cursor-default opacity-45' : 'cursor-pointer',
|
||||
dark
|
||||
? 'border-white/15 bg-white/[0.04] text-white/70 hover:border-white/25 hover:bg-white/10 hover:text-white'
|
||||
: 'border-[#cfd6e4] bg-white text-[#4f596b] hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
aria-label="새로고침"
|
||||
>
|
||||
<RefreshCw size={14} className={refreshing ? 'animate-spin' : ''} />
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{gitPanelTab === 'source' ? (
|
||||
<div className={['grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)]', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
|
||||
<aside className={['flex min-h-0 flex-col border-r', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}>
|
||||
<div className={['flex h-11 items-center justify-between border-b px-4', borderSoft, dark ? 'bg-[#2b2b2b]' : 'bg-[#f3f5f9]'].join(' ')}>
|
||||
<span className={['truncate text-[12px] font-bold', textMid].join(' ')}>변경 파일</span>
|
||||
<span className={['text-[11px] font-bold', textDim].join(' ')}>{gitChangedFileCount}개</span>
|
||||
</div>
|
||||
|
||||
<div className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{gitError && (
|
||||
<div className={['mb-3 rounded-lg border px-3 py-2 text-[12px] font-semibold leading-relaxed', dark ? 'border-[#ef4444]/24 bg-[#ef4444]/10 text-[#fca5a5]' : 'border-[#fecaca] bg-[#fff1f2] text-[#dc2626]'].join(' ')}>
|
||||
{gitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gitLoading && !Object.keys(gitModuleStatuses).length ? (
|
||||
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
|
||||
Git 상태 불러오는 중
|
||||
</div>
|
||||
) : gitChangedFileCount === 0 ? (
|
||||
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
|
||||
변경된 파일 없음
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{gitChangedModules.map((module) => {
|
||||
const status = gitModuleStatuses[module.name]
|
||||
return (
|
||||
<div key={module.name} className={['overflow-hidden rounded-lg border', dark ? 'border-white/10' : 'border-[#e1e6ef]'].join(' ')}>
|
||||
<div className={['flex items-center justify-between px-3 py-2', dark ? 'bg-[#1f1f1f]' : 'bg-[#f8fafc]'].join(' ')}>
|
||||
<div className="min-w-0">
|
||||
<p className={['truncate text-[12px] font-black', textStrong].join(' ')}>{module.name}</p>
|
||||
<p className={['mt-0.5 truncate font-mono text-[10px]', textDim].join(' ')}>
|
||||
{status?.branch ?? 'main'}
|
||||
</p>
|
||||
</div>
|
||||
<span className={['shrink-0 rounded px-2 py-0.5 text-[10px] font-black', dark ? 'bg-white/8 text-white/58' : 'bg-[#edf0f5] text-[#687284]'].join(' ')}>
|
||||
{status?.files.length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-1 p-1.5">
|
||||
{(status?.files ?? []).map((file) => {
|
||||
const active = selectedGitTarget?.moduleName === module.name && selectedGitTarget.filePath === file.path
|
||||
return (
|
||||
<button
|
||||
key={`${module.name}-${file.path}`}
|
||||
type="button"
|
||||
onClick={() => onSelectGitFile(module.name, file.path)}
|
||||
onDoubleClick={() => onOpenGitChangedFile(module.name, file.path)}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
onOpenGitFileContextMenu(module, file, event.clientX, event.clientY)
|
||||
}}
|
||||
className={[
|
||||
'min-w-0 rounded-md border px-3 py-2 text-left transition',
|
||||
active
|
||||
? dark ? 'border-[#2563eb]/50 bg-[#2563eb]/16' : 'border-[#2563eb]/35 bg-[#edf6ff]'
|
||||
: dark ? 'border-transparent hover:bg-white/[0.045]' : 'border-transparent hover:bg-[#f3f5f9]'
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={['shrink-0 rounded px-1.5 py-0.5 text-[10px] font-black', getGitStatusTone(file.status)].join(' ')}>
|
||||
{getGitStatusLabel(file.status)}
|
||||
</span>
|
||||
<span className={['truncate text-[12px] font-bold', dark ? 'text-white/78' : 'text-[#111520]'].join(' ')}>
|
||||
{file.path.split('/').pop()}
|
||||
</span>
|
||||
</div>
|
||||
<p className={['mt-1 truncate font-mono text-[10px]', textDim].join(' ')}>{file.path}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className={['min-h-0 border-t', borderSoft].join(' ')}>
|
||||
<div className={['flex h-11 items-center justify-between px-4', dark ? 'bg-[#171717]' : 'bg-[#f9fafc]'].join(' ')}>
|
||||
<span className={['block min-w-0 truncate text-[12px] font-bold', textMid].join(' ')}>
|
||||
{selectedGitTarget ? `${selectedGitTarget.moduleName} / ${selectedGitTarget.filePath}` : ''}
|
||||
</span>
|
||||
{selectedGitTarget && selectedGitFile?.status !== 'deleted' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenGitChangedFile(selectedGitTarget.moduleName, selectedGitTarget.filePath)}
|
||||
className={['h-7 cursor-pointer rounded-md border px-3 text-[11px] font-bold transition', dark ? 'border-white/12 text-white/58 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')}
|
||||
>
|
||||
파일 열기
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={['thin-y-scrollbar h-[calc(100%-44px)] overflow-auto p-4 font-mono text-[11px] leading-5', dark ? 'bg-[#171717] text-white/68' : 'bg-[#f9fafc] text-[#374151]'].join(' ')}>
|
||||
{gitLoading && !selectedGitStatus ? (
|
||||
<div className={['grid h-full place-items-center font-sans text-[12px] font-semibold', textDim].join(' ')}>
|
||||
변경 내용 불러오는 중
|
||||
</div>
|
||||
) : selectedGitStatus?.diff ? (
|
||||
<div className={['overflow-hidden rounded-md border text-[11px]', dark ? 'border-white/10 bg-[#111111]' : 'border-[#e1e6ef] bg-white'].join(' ')}>
|
||||
{parseDiffViewLines(selectedGitStatus.diff).map(renderDiffLine)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full" />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div className={['grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)]', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
|
||||
<aside className={['flex min-h-0 flex-col border-r', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}>
|
||||
<div className={['flex h-11 items-center justify-between border-b px-4', borderSoft, dark ? 'bg-[#2b2b2b]' : 'bg-[#f3f5f9]'].join(' ')}>
|
||||
<span className={['text-[12px] font-bold', textMid].join(' ')}>제출 목록</span>
|
||||
<span className={['text-[11px] font-bold', textDim].join(' ')}>{gitSubmissionGroups.length}개</span>
|
||||
</div>
|
||||
<div className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{gitError && (
|
||||
<div className={['mb-3 rounded-lg border px-3 py-2 text-[12px] font-semibold leading-relaxed', dark ? 'border-[#ef4444]/24 bg-[#ef4444]/10 text-[#fca5a5]' : 'border-[#fecaca] bg-[#fff1f2] text-[#dc2626]'].join(' ')}>
|
||||
{gitError}
|
||||
</div>
|
||||
)}
|
||||
{gitSubmissionsLoading && !gitSubmissions.length ? (
|
||||
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
|
||||
제출 내역 불러오는 중
|
||||
</div>
|
||||
) : !gitSubmissionGroups.length ? (
|
||||
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
|
||||
제출 내역 없음
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{gitSubmissionGroups.map((group, groupIndex) => (
|
||||
<div key={group.id} className={['overflow-hidden rounded-lg border', dark ? 'border-white/10' : 'border-[#e1e6ef]'].join(' ')}>
|
||||
<div className={['px-3 py-2', dark ? 'bg-[#1f1f1f]' : 'bg-[#f8fafc]'].join(' ')}>
|
||||
<p className={['text-[12px] font-black', textStrong].join(' ')}>제출 {gitSubmissionGroups.length - groupIndex}</p>
|
||||
<p className={['mt-0.5 text-[10px] font-bold', textDim].join(' ')}>
|
||||
{formatGitCommitDate(group.submittedAt)} · {group.submissions.length}개 모듈
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-1 p-1.5">
|
||||
{group.submissions.map((submission) => {
|
||||
const active = selectedGitSubmission?.moduleName === submission.moduleName && selectedGitSubmission.commit.hash === submission.commit.hash
|
||||
return (
|
||||
<button
|
||||
key={`${submission.moduleName}-${submission.commit.hash}`}
|
||||
type="button"
|
||||
onClick={() => onSelectGitSubmission(submission)}
|
||||
className={[
|
||||
'min-w-0 rounded-md border px-3 py-2 text-left transition',
|
||||
active
|
||||
? dark ? 'border-[#2563eb]/50 bg-[#2563eb]/16' : 'border-[#2563eb]/35 bg-[#edf6ff]'
|
||||
: dark ? 'border-transparent hover:bg-white/[0.045]' : 'border-transparent hover:bg-[#f3f5f9]'
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<span className={['min-w-0 truncate text-[12px] font-black', textStrong].join(' ')}>{submission.moduleName}</span>
|
||||
<span className={['shrink-0 font-mono text-[10px]', dark ? 'text-[#9dbdff]' : 'text-[#1d5fae]'].join(' ')}>{submission.commit.shortHash}</span>
|
||||
</div>
|
||||
<p className={['mt-1 truncate text-[11px] font-bold', textDim].join(' ')}>{submission.commit.message}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className={['min-h-0 border-t', borderSoft].join(' ')}>
|
||||
<div className={['flex h-11 items-center justify-between px-4', dark ? 'bg-[#171717]' : 'bg-[#f9fafc]'].join(' ')}>
|
||||
<span className={['block min-w-0 truncate text-[12px] font-bold', textMid].join(' ')}>
|
||||
{selectedGitSubmission ? `${selectedGitSubmission.moduleName} / ${selectedGitSubmission.commit.shortHash}` : ''}
|
||||
</span>
|
||||
{selectedGitSubmission && (
|
||||
<span className={['shrink-0 text-[11px] font-bold', textDim].join(' ')}>
|
||||
{formatGitCommitDate(selectedGitSubmission.submittedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={['thin-y-scrollbar h-[calc(100%-44px)] overflow-auto p-4 font-mono text-[11px] leading-5', dark ? 'bg-[#171717] text-white/68' : 'bg-[#f9fafc] text-[#374151]'].join(' ')}>
|
||||
{gitSubmissionDiffLoading ? (
|
||||
<div className={['grid h-full place-items-center font-sans text-[12px] font-semibold', textDim].join(' ')}>
|
||||
변경 내용 불러오는 중
|
||||
</div>
|
||||
) : selectedGitSubmissionDiff ? (
|
||||
<div className={['overflow-hidden rounded-md border text-[11px]', dark ? 'border-white/10 bg-[#111111]' : 'border-[#e1e6ef] bg-white'].join(' ')}>
|
||||
{parseDiffViewLines(selectedGitSubmissionDiff).map(renderDiffLine)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full" />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkspaceSourceControlView
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ClipboardPaste, Copy } from 'lucide-react'
|
||||
|
||||
type TerminalContextMenuState = {
|
||||
text: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
type Props = {
|
||||
dark: boolean
|
||||
menu: TerminalContextMenuState | null
|
||||
menuRef: React.RefObject<HTMLDivElement | null>
|
||||
onCopy: () => void
|
||||
onPaste: () => void
|
||||
}
|
||||
|
||||
function WorkspaceTerminalContextMenu({
|
||||
dark,
|
||||
menu,
|
||||
menuRef,
|
||||
onCopy,
|
||||
onPaste
|
||||
}: Props): React.JSX.Element | null {
|
||||
if (!menu) return null
|
||||
const hasSelection = Boolean(menu.text.trim())
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={[
|
||||
'fixed z-50 w-[132px] 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 }}
|
||||
>
|
||||
{hasSelection && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className={[
|
||||
'flex h-8 w-full cursor-pointer items-center gap-2 px-3 text-left transition',
|
||||
dark ? 'hover:bg-[#37373d] hover:text-white' : 'hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
<Copy size={13} />
|
||||
복사
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPaste}
|
||||
className={[
|
||||
'flex h-8 w-full cursor-pointer items-center gap-2 px-3 text-left transition',
|
||||
dark ? 'hover:bg-[#37373d] hover:text-white' : 'hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
<ClipboardPaste size={13} />
|
||||
붙여넣기
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkspaceTerminalContextMenu
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Terminal } from 'lucide-react'
|
||||
|
||||
type Props = {
|
||||
dark: boolean
|
||||
borderSoft: string
|
||||
textDim: string
|
||||
textMid: string
|
||||
terminalPanelHeight: number
|
||||
terminalHostRef: React.RefObject<HTMLDivElement | null>
|
||||
onStartResize: (event: React.MouseEvent<HTMLDivElement>) => void
|
||||
}
|
||||
|
||||
function WorkspaceTerminalPanel({
|
||||
dark,
|
||||
borderSoft,
|
||||
textDim,
|
||||
textMid,
|
||||
terminalPanelHeight,
|
||||
terminalHostRef,
|
||||
onStartResize
|
||||
}: Props): React.JSX.Element {
|
||||
return (
|
||||
<footer className={['relative border-t', dark ? 'border-white/10 bg-[#242424]' : 'border-[#cfd6e4] bg-[#e9edf4]'].join(' ')}>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
onMouseDown={onStartResize}
|
||||
className="group absolute -top-[3px] left-0 right-0 z-10 grid h-[6px] cursor-row-resize place-items-center"
|
||||
>
|
||||
<span className={['h-px w-full transition', dark ? 'bg-white/10 group-hover:bg-white/45' : 'bg-[#c5cedb] group-hover:bg-[#8d98a8]'].join(' ')} />
|
||||
</div>
|
||||
<div className={['flex h-10 items-center gap-2 border-b px-3', borderSoft].join(' ')}>
|
||||
<Terminal size={14} className={textDim} />
|
||||
<span className={['text-[12px] font-bold', textMid].join(' ')}>터미널</span>
|
||||
</div>
|
||||
<div
|
||||
ref={terminalHostRef}
|
||||
className={['box-border overflow-hidden px-2 py-1', dark ? 'bg-[#171717]' : 'bg-[#f9fafc]'].join(' ')}
|
||||
style={{ height: Math.max(110, terminalPanelHeight - 41) }}
|
||||
/>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkspaceTerminalPanel
|
||||
@@ -0,0 +1,141 @@
|
||||
import { BookOpen, Clock3, MessageCircle, PanelLeft, Power, RefreshCw, Upload, X } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
import Tooltip from '../../components/Tooltip'
|
||||
|
||||
type WorkspaceTopBarProps = {
|
||||
dark: boolean
|
||||
textStrong: string
|
||||
textMid: string
|
||||
hoverSoft: string
|
||||
platformName: string
|
||||
weekNumber: number
|
||||
title: string
|
||||
leftPanelMinimized: boolean
|
||||
rightPanelMinimized: boolean
|
||||
teacherChatFlash: boolean
|
||||
previewOpen: boolean
|
||||
showControlCountdown: boolean
|
||||
controlCountdownDanger: boolean
|
||||
controlEndsAt: string | null
|
||||
submitVisible: boolean
|
||||
submitDisabled: boolean
|
||||
submitting: boolean
|
||||
topbarClassName: string
|
||||
topToggleButtonClass: (active: boolean) => string
|
||||
formatControlRemainingTime: (endsAt: string | null) => string
|
||||
onToggleLeftPanel: () => void
|
||||
onToggleRightPanel: () => void
|
||||
onTogglePreview: () => void
|
||||
onSubmit: () => void
|
||||
onOpenPowerActions: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function WorkspaceTopBar({
|
||||
dark,
|
||||
textStrong,
|
||||
textMid,
|
||||
hoverSoft,
|
||||
platformName,
|
||||
weekNumber,
|
||||
title,
|
||||
leftPanelMinimized,
|
||||
rightPanelMinimized,
|
||||
teacherChatFlash,
|
||||
previewOpen,
|
||||
showControlCountdown,
|
||||
controlCountdownDanger,
|
||||
controlEndsAt,
|
||||
submitVisible,
|
||||
submitDisabled,
|
||||
submitting,
|
||||
topbarClassName,
|
||||
topToggleButtonClass,
|
||||
formatControlRemainingTime,
|
||||
onToggleLeftPanel,
|
||||
onToggleRightPanel,
|
||||
onTogglePreview,
|
||||
onSubmit,
|
||||
onOpenPowerActions,
|
||||
onClose
|
||||
}: WorkspaceTopBarProps): React.JSX.Element {
|
||||
return (
|
||||
<header className={['flex h-10 items-center justify-between border-b px-3', topbarClassName].join(' ')}>
|
||||
<div className="flex items-center gap-2 pl-4">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[#22c55e] shadow-[0_0_6px_rgba(34,197,94,0.55)]" />
|
||||
<strong className={['text-[13px] font-bold', textStrong].join(' ')}>{platformName}</strong>
|
||||
<span className="rounded-md bg-[#2563eb]/18 px-2 py-1 text-[11px] font-bold text-[#9dbdff]">{weekNumber}주차 · {title}</span>
|
||||
</div>
|
||||
<div className={['flex items-center gap-2 text-[12px] font-bold', textMid].join(' ')}>
|
||||
<button type="button" onClick={onToggleLeftPanel} className={topToggleButtonClass(!leftPanelMinimized)}>
|
||||
<BookOpen size={14} />
|
||||
학습가이드
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleRightPanel}
|
||||
className={[topToggleButtonClass(!rightPanelMinimized), 'relative'].join(' ')}
|
||||
>
|
||||
{rightPanelMinimized && teacherChatFlash && (
|
||||
<>
|
||||
<span className="pointer-events-none absolute inset-[-3px] rounded-lg border border-white/70 shadow-[0_0_18px_rgba(255,255,255,0.28)] animate-ping" />
|
||||
<span className="pointer-events-none absolute inset-0 rounded-md bg-[#60a5fa]/18 shadow-[0_0_18px_rgba(96,165,250,0.38)] animate-pulse" />
|
||||
</>
|
||||
)}
|
||||
<MessageCircle size={14} className="relative z-10" />
|
||||
<span className="relative z-10">질의응답</span>
|
||||
</button>
|
||||
<button type="button" onClick={onTogglePreview} className={topToggleButtonClass(previewOpen)}>
|
||||
<PanelLeft size={14} />
|
||||
시각화
|
||||
</button>
|
||||
{showControlCountdown && (
|
||||
<>
|
||||
<span className={['ml-2 h-4 w-px', dark ? 'bg-white/20' : 'bg-[#cfd6e4]'].join(' ')} />
|
||||
<span
|
||||
className={[
|
||||
'inline-flex items-center gap-1 whitespace-nowrap pl-1 text-[12px] font-black tabular-nums',
|
||||
controlCountdownDanger ? 'text-[#ef4444]' : dark ? 'text-white/72' : 'text-[#475569]'
|
||||
].join(' ')}
|
||||
>
|
||||
<Clock3 size={13} />
|
||||
{formatControlRemainingTime(controlEndsAt)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className={['ml-1 mr-1 h-4 w-px', dark ? 'bg-white/16' : 'bg-[#cfd6e4]'].join(' ')} />
|
||||
{submitVisible && (
|
||||
<Tooltip label="제출" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="제출"
|
||||
disabled={submitDisabled}
|
||||
onClick={onSubmit}
|
||||
className={[
|
||||
'grid h-7 w-7 place-items-center rounded transition',
|
||||
!submitDisabled ? ['cursor-pointer', hoverSoft].join(' ') : 'cursor-default opacity-45'
|
||||
].join(' ')}
|
||||
>
|
||||
{submitting ? <RefreshCw size={15} className="animate-spin" /> : <Upload size={15} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="종료" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenPowerActions}
|
||||
className={['grid h-7 w-7 cursor-pointer place-items-center rounded transition', hoverSoft].join(' ')}
|
||||
aria-label="종료"
|
||||
>
|
||||
<Power size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="닫기" side="bottom">
|
||||
<button type="button" aria-label="닫기" onClick={onClose} className={['grid h-7 w-7 cursor-pointer place-items-center rounded transition', hoverSoft].join(' ')}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Maximize2, Minimize2, PanelLeft, X } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
|
||||
export type VisualizationStack = 'autoware' | 'nav2' | 'slam'
|
||||
|
||||
type WorkspaceVisualizationPanelProps = {
|
||||
dark: boolean
|
||||
textStrong: string
|
||||
textDim: string
|
||||
iconButtonClass: string
|
||||
large: boolean
|
||||
position: { x: number; y: number }
|
||||
size: { width: number; height: number }
|
||||
stacks: VisualizationStack[]
|
||||
activeStack: VisualizationStack
|
||||
running: boolean
|
||||
busy: boolean
|
||||
viewerUrl: string
|
||||
onDragStart: (event: React.MouseEvent<HTMLDivElement>) => void
|
||||
onToggleLarge: () => void
|
||||
onClose: () => void
|
||||
onStackChange: (stack: VisualizationStack) => void
|
||||
onStart: () => void
|
||||
onStop: () => void
|
||||
onResizeStart: (event: React.MouseEvent<HTMLDivElement>, direction: 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw') => void
|
||||
}
|
||||
|
||||
export default function WorkspaceVisualizationPanel({
|
||||
dark,
|
||||
textStrong,
|
||||
textDim,
|
||||
iconButtonClass,
|
||||
large,
|
||||
position,
|
||||
size,
|
||||
stacks,
|
||||
activeStack,
|
||||
running,
|
||||
busy,
|
||||
viewerUrl,
|
||||
onDragStart,
|
||||
onToggleLarge,
|
||||
onClose,
|
||||
onStackChange,
|
||||
onStart,
|
||||
onStop,
|
||||
onResizeStart
|
||||
}: WorkspaceVisualizationPanelProps): React.JSX.Element {
|
||||
return (
|
||||
<section
|
||||
className={[
|
||||
'absolute z-40 overflow-hidden rounded-lg border shadow-[0_18px_48px_rgba(0,0,0,0.32)]',
|
||||
dark ? 'border-white/12 bg-[#1c1c1c]' : 'border-[#cfd6e4] bg-white'
|
||||
].join(' ')}
|
||||
style={large
|
||||
? {
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 40,
|
||||
bottom: 0
|
||||
}
|
||||
: {
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
width: size.width
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onMouseDown={onDragStart}
|
||||
className={[
|
||||
'flex h-9 items-center justify-between border-b px-3',
|
||||
large ? 'cursor-default' : 'cursor-move',
|
||||
dark ? 'border-white/10 bg-[#252525]' : 'border-[#d8dee9] bg-[#f3f5f9]'
|
||||
].join(' ')}
|
||||
>
|
||||
<strong className={['flex items-center gap-2 text-[12px] font-bold', textStrong].join(' ')}>
|
||||
<PanelLeft size={14} className={textDim} />
|
||||
시각화
|
||||
</strong>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="크게보기"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={onToggleLarge}
|
||||
className={[iconButtonClass, 'group relative'].join(' ')}
|
||||
>
|
||||
{large ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||
<span className={['pointer-events-none absolute right-0 top-8 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(' ')}>
|
||||
{large ? '작게보기' : '크게보기'}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="닫기"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={onClose}
|
||||
className={iconButtonClass}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={['flex flex-col bg-black', large ? 'h-[calc(100%-36px)]' : ''].join(' ')}
|
||||
style={large ? undefined : { height: size.height }}
|
||||
>
|
||||
<div className="flex h-10 shrink-0 items-center justify-between border-b border-white/10 bg-[#101624] px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{stacks.map((stack) => (
|
||||
<button
|
||||
key={stack}
|
||||
type="button"
|
||||
disabled={running || busy}
|
||||
onClick={() => onStackChange(stack)}
|
||||
className={[
|
||||
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black uppercase transition',
|
||||
activeStack === stack ? 'bg-[#2563eb] text-white' : 'bg-white/8 text-white/58 hover:bg-white/12 hover:text-white',
|
||||
running || busy ? 'cursor-default opacity-70' : ''
|
||||
].join(' ')}
|
||||
>
|
||||
{stack === 'nav2' ? 'Nav2' : stack === 'slam' ? 'SLAM' : 'Autoware'}
|
||||
</button>
|
||||
))}
|
||||
{!stacks.length && (
|
||||
<span className="text-[11px] font-bold text-white/48">지원되는 시각화 없음</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !stacks.length}
|
||||
onClick={() => {
|
||||
if (running) onStop()
|
||||
else onStart()
|
||||
}}
|
||||
className={[
|
||||
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition',
|
||||
running ? 'bg-[#7f1d1d] text-[#fecaca] hover:bg-[#991b1b]' : 'bg-[#14532d] text-[#bbf7d0] hover:bg-[#166534]',
|
||||
busy || !stacks.length ? 'cursor-default opacity-60' : ''
|
||||
].join(' ')}
|
||||
>
|
||||
{busy ? '처리 중' : running ? '종료' : '시작'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<iframe
|
||||
key={`${activeStack}-${running ? 'running' : 'offline'}`}
|
||||
title="Autonomy Studio Visualization"
|
||||
src={running ? viewerUrl : 'about:blank'}
|
||||
className="h-full w-full border-0"
|
||||
allow="fullscreen"
|
||||
/>
|
||||
{!running && (
|
||||
<div className="pointer-events-none absolute inset-0 grid place-items-center bg-black/34">
|
||||
<div className="rounded-lg border border-white/10 bg-[#101624]/88 px-5 py-4 text-center shadow-[0_18px_48px_rgba(0,0,0,0.35)]">
|
||||
<p className="text-[13px] font-black text-white">{stacks.length ? '시작하세요' : '해당 주차는 시각화를 지원하지 않습니다'}</p>
|
||||
<p className="mt-2 text-[11px] font-semibold text-white/50">{stacks.length ? '지도와 차량 상태가 표시됩니다.' : '강사 커리큘럼의 모듈 관리 탭에서 시각화 플랫폼을 선택해야 합니다.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!large && (
|
||||
<>
|
||||
<div onMouseDown={(event) => onResizeStart(event, 'n')} className="absolute left-2 right-2 top-0 h-1.5 cursor-ns-resize" />
|
||||
<div onMouseDown={(event) => onResizeStart(event, 's')} className="absolute bottom-0 left-2 right-2 h-1.5 cursor-ns-resize" />
|
||||
<div onMouseDown={(event) => onResizeStart(event, 'w')} className="absolute bottom-2 left-0 top-2 w-1.5 cursor-ew-resize" />
|
||||
<div onMouseDown={(event) => onResizeStart(event, 'e')} className="absolute bottom-2 right-0 top-2 w-1.5 cursor-ew-resize" />
|
||||
<div onMouseDown={(event) => onResizeStart(event, 'nw')} className="absolute left-0 top-0 h-3 w-3 cursor-nwse-resize" />
|
||||
<div onMouseDown={(event) => onResizeStart(event, 'ne')} className="absolute right-0 top-0 h-3 w-3 cursor-nesw-resize" />
|
||||
<div onMouseDown={(event) => onResizeStart(event, 'sw')} className="absolute bottom-0 left-0 h-3 w-3 cursor-nesw-resize" />
|
||||
<div onMouseDown={(event) => onResizeStart(event, 'se')} className="absolute bottom-0 right-0 h-3 w-3 cursor-nwse-resize" />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
export type GitChangedFile = {
|
||||
path: string
|
||||
status: 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked'
|
||||
staged: boolean
|
||||
unstaged: boolean
|
||||
}
|
||||
|
||||
export type GitCommitInfo = {
|
||||
hash: string
|
||||
shortHash: string
|
||||
author: string
|
||||
date: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type GitStatusInfo = {
|
||||
ok: boolean
|
||||
branch: string | null
|
||||
ahead: number
|
||||
behind: number
|
||||
files: GitChangedFile[]
|
||||
selectedFilePath: string | null
|
||||
diff: string
|
||||
commits: GitCommitInfo[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
export type GitSubmissionInfo = {
|
||||
id: string
|
||||
submittedAt: string
|
||||
moduleName: string
|
||||
commit: GitCommitInfo
|
||||
}
|
||||
|
||||
export type DiffViewLine = {
|
||||
key: string
|
||||
type: 'file' | 'hunk' | 'context' | 'add' | 'delete' | 'note'
|
||||
oldLine: number | null
|
||||
newLine: number | null
|
||||
content: string
|
||||
}
|
||||
|
||||
export function parseDiffViewLines(diff: string): DiffViewLine[] {
|
||||
const viewLines: DiffViewLine[] = []
|
||||
let oldLine = 0
|
||||
let newLine = 0
|
||||
|
||||
diff.split('\n').forEach((line, index) => {
|
||||
if (line.startsWith('diff --git ')) {
|
||||
const match = line.match(/^diff --git a\/(.+) b\/(.+)$/)
|
||||
viewLines.push({
|
||||
key: `file-${index}`,
|
||||
type: 'file',
|
||||
oldLine: null,
|
||||
newLine: null,
|
||||
content: match?.[2] ?? line.replace(/^diff --git\s+/, '')
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
line.startsWith('index ') ||
|
||||
line.startsWith('--- ') ||
|
||||
line.startsWith('+++ ') ||
|
||||
line.startsWith('new file mode ') ||
|
||||
line.startsWith('deleted file mode ')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (line.startsWith('\\ No newline')) {
|
||||
viewLines.push({
|
||||
key: `note-${index}`,
|
||||
type: 'note',
|
||||
oldLine: null,
|
||||
newLine: null,
|
||||
content: '파일 끝 줄바꿈 없음'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (line.startsWith('@@')) {
|
||||
const match = line.match(/@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@(.*)$/)
|
||||
oldLine = Number(match?.[1] ?? 0)
|
||||
newLine = Number(match?.[2] ?? 0)
|
||||
viewLines.push({
|
||||
key: `hunk-${index}`,
|
||||
type: 'hunk',
|
||||
oldLine: null,
|
||||
newLine: null,
|
||||
content: match?.[3]?.trim() || line
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!viewLines.length) return
|
||||
|
||||
if (line.startsWith('+')) {
|
||||
viewLines.push({
|
||||
key: `add-${index}`,
|
||||
type: 'add',
|
||||
oldLine: null,
|
||||
newLine,
|
||||
content: line.slice(1)
|
||||
})
|
||||
newLine += 1
|
||||
return
|
||||
}
|
||||
|
||||
if (line.startsWith('-')) {
|
||||
viewLines.push({
|
||||
key: `delete-${index}`,
|
||||
type: 'delete',
|
||||
oldLine,
|
||||
newLine: null,
|
||||
content: line.slice(1)
|
||||
})
|
||||
oldLine += 1
|
||||
return
|
||||
}
|
||||
|
||||
viewLines.push({
|
||||
key: `context-${index}`,
|
||||
type: 'context',
|
||||
oldLine,
|
||||
newLine,
|
||||
content: line.startsWith(' ') ? line.slice(1) : line
|
||||
})
|
||||
oldLine += 1
|
||||
newLine += 1
|
||||
})
|
||||
|
||||
return viewLines
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { ModuleParameterPair } from './types'
|
||||
import { STUDIO_MODULE_PARAMETERS_STORAGE_KEY } from '../../config/appConfig'
|
||||
|
||||
export type StoredModuleParameters = Record<string, ModuleParameterPair[]>
|
||||
|
||||
export function createParameterId(): string {
|
||||
return typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`
|
||||
}
|
||||
|
||||
export function parseModuleParameters(tokens: string[]): ModuleParameterPair[] {
|
||||
return tokens.map((token) => {
|
||||
const separatorIndex = token.indexOf(':=')
|
||||
return {
|
||||
id: createParameterId(),
|
||||
name: separatorIndex >= 0 ? token.slice(0, separatorIndex) : token,
|
||||
value: separatorIndex >= 0 ? token.slice(separatorIndex + 2) : ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function serializeModuleParameters(parameters: ModuleParameterPair[]): string {
|
||||
return parameters
|
||||
.map((parameter) => {
|
||||
const name = parameter.name.trim()
|
||||
if (!name) return ''
|
||||
return `${name}:=${parameter.value.trim()}`
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export function readStoredModuleParameters(): StoredModuleParameters {
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(STUDIO_MODULE_PARAMETERS_STORAGE_KEY)
|
||||
if (!rawValue) return {}
|
||||
const parsed = JSON.parse(rawValue) as StoredModuleParameters
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function storeModuleParameters(key: string, parameters: ModuleParameterPair[]): void {
|
||||
const current = readStoredModuleParameters()
|
||||
try {
|
||||
window.localStorage.setItem(STUDIO_MODULE_PARAMETERS_STORAGE_KEY, JSON.stringify({
|
||||
...current,
|
||||
[key]: parameters
|
||||
}))
|
||||
} catch {
|
||||
// Module parameters fall back to run.sh defaults if local persistence is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
export function makeModuleParameterKey(courseKey: string, week: number, folderName?: string): string {
|
||||
return `${courseKey || 'default-course'}:week-${week}:${folderName || 'module'}`
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
const extractStringRecord = (value: unknown): Record<string, unknown> | null => (
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null
|
||||
)
|
||||
|
||||
export const readNestedString = (value: unknown, keys: string[]): string | null => {
|
||||
let current: unknown = value
|
||||
for (const key of keys) {
|
||||
const record = extractStringRecord(current)
|
||||
if (!record) return null
|
||||
current = record[key]
|
||||
}
|
||||
return typeof current === 'string' && current.trim() ? current.trim() : null
|
||||
}
|
||||
|
||||
export const parseHostFromUrl = (value?: string | null): string | null => {
|
||||
if (!value?.trim()) return null
|
||||
try {
|
||||
return new URL(value).hostname
|
||||
} catch {
|
||||
return value.trim()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { STUDIO_SOURCE_SETTINGS_STORAGE_KEY } from '../../config/appConfig'
|
||||
import type { StudioSourceSettings } from '../platforms/types'
|
||||
|
||||
export const defaultStudioSourceSettings: StudioSourceSettings = {
|
||||
workspaceRoot: '~/autonomy-studio/workspaces',
|
||||
giteaBaseUrl: 'http://localhost:3000',
|
||||
giteaOwner: 'student',
|
||||
gpuEnabled: false,
|
||||
rosDomainId: '77'
|
||||
}
|
||||
|
||||
function normalizeCoursePathSegment(courseKey: string): string {
|
||||
return courseKey.trim().replace(/[^a-zA-Z0-9_-]+/g, '-') || 'default-course'
|
||||
}
|
||||
|
||||
export function makeWeekWorkspaceRoot(workspaceRoot: string, studentSlug: string, courseKey: string, weekNumber: number): string {
|
||||
const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '')
|
||||
const suffix = `${studentSlug}/${normalizeCoursePathSegment(courseKey)}/week-${weekNumber}`
|
||||
if (normalizedRoot === suffix || normalizedRoot.endsWith(`/${suffix}`)) {
|
||||
return normalizedRoot
|
||||
}
|
||||
return `${normalizedRoot}/${suffix}`
|
||||
}
|
||||
|
||||
export function makePlatformWorkspaceRoot(workspaceRoot: string, studentSlug: string, courseKey: string, weekNumber: number, platformKey: string): string {
|
||||
const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '')
|
||||
if (platformKey === 'futuredrive-local-pc') return makeWeekWorkspaceRoot(normalizedRoot, studentSlug, courseKey, weekNumber)
|
||||
const safePlatformKey = platformKey
|
||||
.replace(/^futuredrive[-_]/i, '')
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, '-')
|
||||
.toLowerCase()
|
||||
const suffix = `${studentSlug}/${safePlatformKey}/week-${weekNumber}`
|
||||
if (normalizedRoot === suffix || normalizedRoot.endsWith(`/${suffix}`)) return normalizedRoot
|
||||
return `${normalizedRoot}/${suffix}`
|
||||
}
|
||||
|
||||
export function normalizeStudioSourceSettings(settings?: Partial<StudioSourceSettings> | null): StudioSourceSettings {
|
||||
const giteaOwner = settings?.giteaOwner?.trim() || defaultStudioSourceSettings.giteaOwner
|
||||
return {
|
||||
workspaceRoot: settings?.workspaceRoot || defaultStudioSourceSettings.workspaceRoot,
|
||||
giteaBaseUrl: (settings?.giteaBaseUrl || defaultStudioSourceSettings.giteaBaseUrl).replace(/\/+$/, ''),
|
||||
giteaOwner: giteaOwner === 'students' ? defaultStudioSourceSettings.giteaOwner : giteaOwner,
|
||||
gpuEnabled: Boolean(settings?.gpuEnabled),
|
||||
rosDomainId: String(settings?.rosDomainId ?? defaultStudioSourceSettings.rosDomainId).trim() || defaultStudioSourceSettings.rosDomainId,
|
||||
agentUrl: settings?.agentUrl ?? null,
|
||||
sshUser: settings?.sshUser ?? null,
|
||||
sshPort: settings?.sshPort ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function studioSourceSettingsStorageKey(platformId: string): string {
|
||||
return `${STUDIO_SOURCE_SETTINGS_STORAGE_KEY}:platform:${platformId}`
|
||||
}
|
||||
|
||||
export function readStudioSourceSettings(platformId: string, defaults: StudioSourceSettings = defaultStudioSourceSettings): StudioSourceSettings {
|
||||
try {
|
||||
const rawSettings = window.localStorage.getItem(studioSourceSettingsStorageKey(platformId))
|
||||
?? window.localStorage.getItem(STUDIO_SOURCE_SETTINGS_STORAGE_KEY)
|
||||
if (!rawSettings) return defaults
|
||||
const settings = JSON.parse(rawSettings) as Partial<StudioSourceSettings>
|
||||
return normalizeStudioSourceSettings({ ...defaults, ...settings })
|
||||
} catch {
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
|
||||
export function storeStudioSourceSettings(platformId: string, settings: StudioSourceSettings): void {
|
||||
try {
|
||||
window.localStorage.setItem(studioSourceSettingsStorageKey(platformId), JSON.stringify(settings))
|
||||
} catch {
|
||||
// Local source settings can be re-entered if persistence is unavailable.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { STUDIO_RUNTIME_SESSION_STORAGE_KEY } from '../../config/appConfig'
|
||||
|
||||
export type StudioRuntimeSessionState = {
|
||||
localPcLayoutOpen: boolean
|
||||
studioEntered: boolean
|
||||
sessionStatus: 'idle' | 'running' | 'ended'
|
||||
sessionStartedAt: string | null
|
||||
sessionEndedAt: string | null
|
||||
connectedPlatformId: string | null
|
||||
runningPlatformIds: string[]
|
||||
workspaceWeekNumber: number | null
|
||||
}
|
||||
|
||||
export function readStudioRuntimeSessionState(): StudioRuntimeSessionState | null {
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(STUDIO_RUNTIME_SESSION_STORAGE_KEY)
|
||||
?? window.sessionStorage.getItem(STUDIO_RUNTIME_SESSION_STORAGE_KEY)
|
||||
if (!rawValue) return null
|
||||
const parsed = JSON.parse(rawValue) as Partial<StudioRuntimeSessionState>
|
||||
if (!Array.isArray(parsed.runningPlatformIds)) return null
|
||||
|
||||
return {
|
||||
localPcLayoutOpen: Boolean(parsed.localPcLayoutOpen),
|
||||
studioEntered: Boolean(parsed.studioEntered),
|
||||
sessionStatus: parsed.sessionStatus === 'running' || parsed.sessionStatus === 'ended' ? parsed.sessionStatus : 'idle',
|
||||
sessionStartedAt: typeof parsed.sessionStartedAt === 'string' ? parsed.sessionStartedAt : null,
|
||||
sessionEndedAt: typeof parsed.sessionEndedAt === 'string' ? parsed.sessionEndedAt : null,
|
||||
connectedPlatformId: typeof parsed.connectedPlatformId === 'string' ? parsed.connectedPlatformId : null,
|
||||
runningPlatformIds: parsed.runningPlatformIds.filter((id): id is string => typeof id === 'string'),
|
||||
workspaceWeekNumber: typeof parsed.workspaceWeekNumber === 'number' && parsed.workspaceWeekNumber > 0 ? parsed.workspaceWeekNumber : null
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function storeStudioRuntimeSessionState(state: StudioRuntimeSessionState): void {
|
||||
const hasRunningSession = state.runningPlatformIds.length > 0 || state.sessionStatus === 'running' || state.localPcLayoutOpen
|
||||
if (!hasRunningSession) {
|
||||
window.localStorage.removeItem(STUDIO_RUNTIME_SESSION_STORAGE_KEY)
|
||||
window.sessionStorage.removeItem(STUDIO_RUNTIME_SESSION_STORAGE_KEY)
|
||||
return
|
||||
}
|
||||
window.localStorage.setItem(STUDIO_RUNTIME_SESSION_STORAGE_KEY, JSON.stringify(state))
|
||||
}
|
||||
|
||||
export function makeWorkspaceUiSessionKey(studentSlug: string, weekNumber: number, platformId: string): string {
|
||||
return `${STUDIO_RUNTIME_SESSION_STORAGE_KEY}:workspace:${studentSlug}:week-${weekNumber}:platform-${platformId}`
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export type ModuleParameterPair = {
|
||||
id: string
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type ModuleRow = {
|
||||
state: 'running' | 'stopped' | 'uninstalled'
|
||||
sortOrder: number
|
||||
category: string
|
||||
name: string
|
||||
folderName?: string
|
||||
sourcePath?: string
|
||||
workspacePath?: string | null
|
||||
templateRepositoryUrl?: string | null
|
||||
sourceType?: string | null
|
||||
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
|
||||
parameters: string[]
|
||||
cpu: number | null
|
||||
memory: number | null
|
||||
runtime: string
|
||||
}
|
||||
|
||||
export type ModulePrebuiltArtifact = {
|
||||
type?: string | null
|
||||
url?: string | null
|
||||
fileName?: string | null
|
||||
checksumSha256?: string | null
|
||||
extractPath?: string | null
|
||||
required?: boolean | null
|
||||
}
|
||||
|
||||
export type RuntimeImageArtifact = {
|
||||
image?: string | null
|
||||
url?: string | null
|
||||
fileName?: string | null
|
||||
checksumSha256?: string | null
|
||||
required?: boolean | null
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { io } from 'socket.io-client'
|
||||
import type { Socket } from 'socket.io-client'
|
||||
|
||||
export type QaMode = 'ai' | 'teacher'
|
||||
|
||||
export type QaChatMessage = {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
createdAt?: string
|
||||
senderAvatarUrl?: string | null
|
||||
isTyping?: boolean
|
||||
keepExpanded?: boolean
|
||||
status?: 'ACTIVE' | 'CANCELED'
|
||||
}
|
||||
|
||||
export type QaChatThread = {
|
||||
id: string
|
||||
title: string
|
||||
status: 'OPEN' | 'WAITING_INSTRUCTOR' | 'RESOLVED' | 'ARCHIVED'
|
||||
lastMessageAt?: string
|
||||
messages: QaChatMessage[]
|
||||
}
|
||||
|
||||
type ChatThreadResponse = {
|
||||
id: string
|
||||
type: 'AI' | 'INSTRUCTOR' | 'MIXED'
|
||||
status: QaChatThread['status']
|
||||
title: string
|
||||
lastMessageAt?: string
|
||||
messages: Array<{
|
||||
id: string
|
||||
senderType: 'STUDENT' | 'AI' | 'INSTRUCTOR' | 'SYSTEM'
|
||||
senderName?: string | null
|
||||
senderAvatarUrl?: string | null
|
||||
content: string
|
||||
status?: 'ACTIVE' | 'CANCELED'
|
||||
canceledAt?: string | null
|
||||
createdAt: string
|
||||
}>
|
||||
}
|
||||
|
||||
type ChatNotificationPayload = {
|
||||
type: 'chat.message.created'
|
||||
threadId: string
|
||||
messageId: string
|
||||
senderType: 'STUDENT' | 'AI' | 'INSTRUCTOR' | 'SYSTEM'
|
||||
preview: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type UseQaChatOptions = {
|
||||
accessToken: string
|
||||
weekNumber: number
|
||||
globalScope?: boolean
|
||||
apiBaseUrl: string
|
||||
socketBaseUrl: string
|
||||
socketPath: string
|
||||
getFreshAccessToken: () => Promise<string>
|
||||
onTitlebarNotice: (title: string, tone?: 'info' | 'warning' | 'error', addToList?: boolean) => void
|
||||
}
|
||||
|
||||
const initialAiChatMessages: QaChatMessage[] = [
|
||||
{
|
||||
id: 'welcome',
|
||||
role: 'assistant',
|
||||
content: '안녕하세요! SLAM 실습을 도와드리겠습니다. 궁금한 점을 물어보세요.'
|
||||
}
|
||||
]
|
||||
|
||||
const qaVisibleMessageLimit = 20
|
||||
|
||||
export function useQaChat({
|
||||
accessToken,
|
||||
weekNumber,
|
||||
globalScope = false,
|
||||
apiBaseUrl,
|
||||
socketBaseUrl,
|
||||
socketPath,
|
||||
getFreshAccessToken,
|
||||
onTitlebarNotice
|
||||
}: UseQaChatOptions) {
|
||||
const [qaMode, setQaMode] = useState<QaMode>('ai')
|
||||
const [aiMessages, setAiMessages] = useState<QaChatMessage[]>(initialAiChatMessages)
|
||||
const [aiConversations, setAiConversations] = useState<QaChatThread[]>([])
|
||||
const [activeAiConversationId, setActiveAiConversationId] = useState<string | null>(null)
|
||||
const [selectedAiConversationId, setSelectedAiConversationId] = useState<string | null>(null)
|
||||
const [teacherMessages, setTeacherMessages] = useState<QaChatMessage[]>([])
|
||||
const [teacherThreadStatus, setTeacherThreadStatus] = useState<ChatThreadResponse['status'] | null>(null)
|
||||
const [chatRefreshSignal, setChatRefreshSignal] = useState(0)
|
||||
const [teacherChatFlash, setTeacherChatFlash] = useState(false)
|
||||
const [aiMessagesLoading, setAiMessagesLoading] = useState(Boolean(accessToken && (globalScope || weekNumber >= 1)))
|
||||
const [teacherMessagesLoading, setTeacherMessagesLoading] = useState(Boolean(accessToken && (globalScope || weekNumber >= 1)))
|
||||
const [aiPending, setAiPending] = useState(false)
|
||||
const [aiTypingMessageId, setAiTypingMessageId] = useState<string | null>(null)
|
||||
const [qaCollapseSignal, setQaCollapseSignal] = useState(0)
|
||||
const workspaceChatScrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const aiAbortControllerRef = useRef<AbortController | null>(null)
|
||||
const aiRevealCancelRef = useRef(false)
|
||||
const expandedAiMessageIdsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
const validChatWeek = globalScope ? null : Number.isInteger(weekNumber) && weekNumber >= 1 ? weekNumber : null
|
||||
const chatAvailable = globalScope || validChatWeek !== null
|
||||
const chatWeekQuery = validChatWeek ? `&week=${validChatWeek}` : ''
|
||||
|
||||
const requestChatApi = async <T,>(path: string, options?: RequestInit): Promise<T> => {
|
||||
const requestAccessToken = accessToken ? await getFreshAccessToken() : null
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options?.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(requestAccessToken ? { Authorization: `Bearer ${requestAccessToken}` } : {}),
|
||||
...options?.headers
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null) as { message?: string } | null
|
||||
throw new Error(payload?.message ?? '질의응답 요청을 처리하지 못했습니다.')
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
const toQaMessages = (messages: ChatThreadResponse['messages']): QaChatMessage[] => messages.map((message) => ({
|
||||
id: message.id,
|
||||
role: message.senderType === 'STUDENT' ? 'user' : 'assistant',
|
||||
content: message.content,
|
||||
createdAt: message.createdAt,
|
||||
senderAvatarUrl: message.senderAvatarUrl ?? null,
|
||||
keepExpanded: message.senderType === 'AI' && expandedAiMessageIdsRef.current.has(message.id),
|
||||
status: message.status ?? 'ACTIVE'
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) return
|
||||
const socket: Socket = io(socketBaseUrl, {
|
||||
auth: { token: accessToken },
|
||||
path: socketPath,
|
||||
transports: ['websocket', 'polling']
|
||||
})
|
||||
|
||||
const onMessageCreated = (payload: ChatNotificationPayload): void => {
|
||||
if (payload.senderType === 'INSTRUCTOR') {
|
||||
setChatRefreshSignal((value) => value + 1)
|
||||
onTitlebarNotice('강사 답변이 도착했습니다', 'info', true)
|
||||
setTeacherChatFlash(true)
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('chat.message.created', onMessageCreated)
|
||||
|
||||
return () => {
|
||||
socket.off('chat.message.created', onMessageCreated)
|
||||
socket.disconnect()
|
||||
}
|
||||
}, [accessToken, onTitlebarNotice, socketBaseUrl, socketPath])
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || !chatAvailable) {
|
||||
setAiMessagesLoading(false)
|
||||
setTeacherMessagesLoading(false)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
|
||||
setAiMessagesLoading(true)
|
||||
requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?mode=ai${chatWeekQuery}&includeArchived=true`)
|
||||
.then((payload) => {
|
||||
if (cancelled) return
|
||||
const conversations = payload.threads.map((thread) => ({
|
||||
id: thread.id,
|
||||
title: thread.title,
|
||||
status: thread.status,
|
||||
lastMessageAt: thread.lastMessageAt,
|
||||
messages: toQaMessages(thread.messages)
|
||||
}))
|
||||
const activeConversation = conversations.find((thread) => thread.status !== 'ARCHIVED')
|
||||
setAiConversations(conversations)
|
||||
setActiveAiConversationId(activeConversation?.id ?? null)
|
||||
setSelectedAiConversationId(null)
|
||||
const messages = activeConversation?.messages ?? []
|
||||
setAiMessages(messages.length ? messages : initialAiChatMessages)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAiMessages(initialAiChatMessages)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setAiMessagesLoading(false)
|
||||
})
|
||||
|
||||
setTeacherMessagesLoading(true)
|
||||
requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?mode=teacher${chatWeekQuery}`)
|
||||
.then((payload) => {
|
||||
if (cancelled) return
|
||||
const thread = payload.threads[0]
|
||||
setTeacherThreadStatus(thread?.status ?? null)
|
||||
setTeacherMessages(thread ? toQaMessages(thread.messages) : [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setTeacherThreadStatus(null)
|
||||
setTeacherMessages([])
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setTeacherMessagesLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [accessToken, chatAvailable, chatRefreshSignal, chatWeekQuery])
|
||||
|
||||
useEffect(() => {
|
||||
const target = workspaceChatScrollRef.current
|
||||
if (!target) return
|
||||
const currentLoading = qaMode === 'ai' ? aiMessagesLoading : teacherMessagesLoading
|
||||
if (currentLoading) return
|
||||
|
||||
const scrollToBottom = (): void => {
|
||||
target.scrollTo({
|
||||
top: target.scrollHeight,
|
||||
behavior: 'auto'
|
||||
})
|
||||
}
|
||||
|
||||
scrollToBottom()
|
||||
const frame = window.requestAnimationFrame(scrollToBottom)
|
||||
const secondFrame = window.requestAnimationFrame(() => window.requestAnimationFrame(scrollToBottom))
|
||||
const timeoutId = window.setTimeout(scrollToBottom, 120)
|
||||
const lateTimeoutId = window.setTimeout(scrollToBottom, 260)
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame)
|
||||
window.cancelAnimationFrame(secondFrame)
|
||||
window.clearTimeout(timeoutId)
|
||||
window.clearTimeout(lateTimeoutId)
|
||||
}
|
||||
}, [aiMessages, aiMessagesLoading, aiPending, qaMode, teacherMessages.length, teacherMessagesLoading, teacherThreadStatus, weekNumber])
|
||||
|
||||
const stopAiResponse = (): void => {
|
||||
aiRevealCancelRef.current = true
|
||||
aiAbortControllerRef.current?.abort()
|
||||
aiAbortControllerRef.current = null
|
||||
|
||||
if (aiTypingMessageId) {
|
||||
const currentMessage = aiMessages.find((message) => message.id === aiTypingMessageId)
|
||||
setAiMessages((messages) => messages.map((message) => (
|
||||
message.id === aiTypingMessageId
|
||||
? { ...message, isTyping: false, status: 'CANCELED' }
|
||||
: message
|
||||
)))
|
||||
setAiTypingMessageId(null)
|
||||
void requestChatApi<{ message: ChatThreadResponse['messages'][number] }>(`/chat/ai/messages/${aiTypingMessageId}/cancel`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content: currentMessage?.content ?? '' })
|
||||
}).catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
if (aiPending) {
|
||||
setAiPending(false)
|
||||
void requestChatApi<{ message: ChatThreadResponse['messages'][number] | null }>('/chat/ai/cancel-active', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(validChatWeek ? { week: validChatWeek } : {})
|
||||
}).catch(() => {})
|
||||
setAiMessages((messages) => [
|
||||
...messages,
|
||||
{
|
||||
id: `assistant-canceled-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: '응답이 중단되었습니다.',
|
||||
status: 'CANCELED'
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
const sendQaMessage = async (rawContent: string, options?: { contextWeek?: number }): Promise<void> => {
|
||||
const content = rawContent.trim()
|
||||
if (!content || aiPending || aiTypingMessageId || !chatAvailable) return
|
||||
setQaCollapseSignal((value) => value + 1)
|
||||
|
||||
if (qaMode !== 'ai') {
|
||||
const userMessage: QaChatMessage = {
|
||||
id: `teacher-user-${Date.now()}`,
|
||||
role: 'user',
|
||||
content
|
||||
}
|
||||
setTeacherMessages((messages) => [...messages, userMessage])
|
||||
setTeacherThreadStatus('WAITING_INSTRUCTOR')
|
||||
try {
|
||||
const response = await requestChatApi<{ message: { id: string; content: string; createdAt: string } }>('/chat/messages', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode: 'teacher', content, ...(validChatWeek ? { week: validChatWeek } : {}) })
|
||||
})
|
||||
setTeacherMessages((messages) => messages.map((message) => (
|
||||
message.id === userMessage.id
|
||||
? { ...message, id: response.message.id, createdAt: response.message.createdAt }
|
||||
: message
|
||||
)))
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '강사 문의를 저장하지 못했습니다.'
|
||||
setTeacherMessages((messages) => [
|
||||
...messages,
|
||||
{
|
||||
id: `teacher-error-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: message
|
||||
}
|
||||
])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const userMessage: QaChatMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: 'user',
|
||||
content,
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
const nextMessages = [...aiMessages, userMessage]
|
||||
|
||||
setAiMessages(nextMessages)
|
||||
setAiPending(true)
|
||||
aiRevealCancelRef.current = false
|
||||
const abortController = new AbortController()
|
||||
aiAbortControllerRef.current = abortController
|
||||
|
||||
try {
|
||||
const response = await requestChatApi<{ threadId: string; threadTitle?: string | null; message: { id: string; content: string; createdAt: string }; content: string; canceled?: boolean }>('/chat/ai/respond', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
content,
|
||||
...(validChatWeek ? { week: validChatWeek } : {}),
|
||||
...(options?.contextWeek ? { contextWeek: options.contextWeek } : {})
|
||||
}),
|
||||
signal: abortController.signal
|
||||
})
|
||||
aiAbortControllerRef.current = null
|
||||
if (aiRevealCancelRef.current || response.canceled) return
|
||||
const fullContent = response.content ?? ''
|
||||
setActiveAiConversationId(response.threadId)
|
||||
setAiConversations((threads) => {
|
||||
const assistantMessage: QaChatMessage = {
|
||||
id: response.message.id,
|
||||
role: 'assistant',
|
||||
content: fullContent,
|
||||
createdAt: response.message.createdAt,
|
||||
keepExpanded: true
|
||||
}
|
||||
const nextThread: QaChatThread = {
|
||||
id: response.threadId,
|
||||
title: response.threadTitle ?? threads.find((thread) => thread.id === response.threadId)?.title ?? content.slice(0, 42),
|
||||
status: 'OPEN',
|
||||
lastMessageAt: response.message.createdAt,
|
||||
messages: [...nextMessages, assistantMessage]
|
||||
}
|
||||
return [nextThread, ...threads.filter((thread) => thread.id !== response.threadId)]
|
||||
})
|
||||
expandedAiMessageIdsRef.current.add(response.message.id)
|
||||
setAiPending(false)
|
||||
setAiTypingMessageId(response.message.id)
|
||||
setAiMessages((messages) => [
|
||||
...messages,
|
||||
{
|
||||
id: response.message.id,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
createdAt: response.message.createdAt,
|
||||
isTyping: true,
|
||||
keepExpanded: true
|
||||
}
|
||||
])
|
||||
|
||||
const contentLines = fullContent.split('\n')
|
||||
const revealChunks = contentLines.length > 2
|
||||
? contentLines.reduce<string[]>((chunks, _line, index) => {
|
||||
if ((index + 1) % 3 === 0 || index === contentLines.length - 1) {
|
||||
chunks.push(contentLines.slice(0, index + 1).join('\n'))
|
||||
}
|
||||
return chunks
|
||||
}, [])
|
||||
: fullContent.match(/.{1,260}(?:\s|$)/g)?.map((_chunk, index, chunks) => chunks.slice(0, index + 1).join('').trimEnd()) ?? [fullContent]
|
||||
|
||||
for (const nextContent of revealChunks) {
|
||||
if (aiRevealCancelRef.current) break
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 90))
|
||||
if (aiRevealCancelRef.current) break
|
||||
setAiMessages((messages) => messages.map((message) => (
|
||||
message.id === response.message.id
|
||||
? { ...message, content: nextContent, isTyping: nextContent.length < fullContent.length }
|
||||
: message
|
||||
)))
|
||||
}
|
||||
if (!aiRevealCancelRef.current) {
|
||||
setAiTypingMessageId(null)
|
||||
}
|
||||
} catch (error) {
|
||||
aiAbortControllerRef.current = null
|
||||
if (aiRevealCancelRef.current || abortController.signal.aborted || (error instanceof DOMException && error.name === 'AbortError')) {
|
||||
setAiTypingMessageId(null)
|
||||
return
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'AI 응답을 가져오지 못했습니다.'
|
||||
setAiMessages((messages) => [
|
||||
...messages,
|
||||
{
|
||||
id: `assistant-error-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: message
|
||||
}
|
||||
])
|
||||
setAiTypingMessageId(null)
|
||||
} finally {
|
||||
setAiPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const clearQaConversation = async (): Promise<void> => {
|
||||
if (!chatAvailable) return
|
||||
if (qaMode === 'ai') {
|
||||
aiRevealCancelRef.current = true
|
||||
aiAbortControllerRef.current?.abort()
|
||||
aiAbortControllerRef.current = null
|
||||
setAiPending(false)
|
||||
setAiTypingMessageId(null)
|
||||
}
|
||||
|
||||
await requestChatApi<{ deletedCount: number }>(`/chat/threads?mode=${qaMode}${chatWeekQuery}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
if (qaMode === 'ai') {
|
||||
if (activeAiConversationId) {
|
||||
setAiConversations((threads) => threads.map((thread) => (
|
||||
thread.id === activeAiConversationId
|
||||
? { ...thread, status: 'ARCHIVED', messages: aiMessages }
|
||||
: thread
|
||||
)))
|
||||
}
|
||||
setActiveAiConversationId(null)
|
||||
setSelectedAiConversationId(null)
|
||||
setAiMessages(initialAiChatMessages)
|
||||
return
|
||||
}
|
||||
setTeacherMessages([])
|
||||
setTeacherThreadStatus(null)
|
||||
setTeacherChatFlash(false)
|
||||
}
|
||||
|
||||
const setMode = (mode: QaMode): void => {
|
||||
setQaMode(mode)
|
||||
if (mode === 'teacher') setTeacherChatFlash(false)
|
||||
}
|
||||
|
||||
const selectedAiConversation = selectedAiConversationId
|
||||
? aiConversations.find((thread) => thread.id === selectedAiConversationId) ?? null
|
||||
: null
|
||||
|
||||
const selectAiConversation = (threadId: string | null): void => {
|
||||
setSelectedAiConversationId(threadId)
|
||||
}
|
||||
|
||||
const startNewAiConversation = async (): Promise<void> => {
|
||||
if (activeAiConversationId) {
|
||||
await clearQaConversation()
|
||||
return
|
||||
}
|
||||
setSelectedAiConversationId(null)
|
||||
}
|
||||
|
||||
const deleteAiConversation = async (threadId: string): Promise<void> => {
|
||||
await requestChatApi<{ id: string }>(`/chat/threads/${threadId}`, { method: 'DELETE' })
|
||||
setAiConversations((threads) => threads.filter((thread) => thread.id !== threadId))
|
||||
if (selectedAiConversationId === threadId) {
|
||||
setSelectedAiConversationId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
qaMode,
|
||||
setQaMode: setMode,
|
||||
teacherChatFlash,
|
||||
setTeacherChatFlash,
|
||||
aiPending,
|
||||
aiTypingMessageId,
|
||||
teacherThreadStatus,
|
||||
qaCollapseSignal,
|
||||
qaMessagesLoading: qaMode === 'ai' ? aiMessagesLoading : teacherMessagesLoading,
|
||||
visibleAiMessages: aiMessages.filter((message) => message.id !== 'welcome').slice(-qaVisibleMessageLimit),
|
||||
displayedAiMessages: selectedAiConversation
|
||||
? selectedAiConversation.messages
|
||||
: aiMessages.filter((message) => message.id !== 'welcome').slice(-qaVisibleMessageLimit),
|
||||
aiConversationHistory: aiConversations.filter((thread) => thread.status === 'ARCHIVED'),
|
||||
activeAiConversation: activeAiConversationId
|
||||
? aiConversations.find((thread) => thread.id === activeAiConversationId) ?? null
|
||||
: null,
|
||||
selectedAiConversationId,
|
||||
viewingAiHistory: Boolean(selectedAiConversation),
|
||||
selectAiConversation,
|
||||
startNewAiConversation,
|
||||
deleteAiConversation,
|
||||
visibleTeacherMessages: teacherMessages.slice(-qaVisibleMessageLimit),
|
||||
validChatWeek,
|
||||
chatAvailable,
|
||||
workspaceChatScrollRef,
|
||||
stopAiResponse,
|
||||
sendQaMessage,
|
||||
clearQaConversation
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { LMS_API_BASE_URL } from '../../config/appConfig'
|
||||
import type { CurriculumWeek } from '../curriculum/types'
|
||||
import type { ModulePrebuiltArtifact, ModuleRow, RuntimeImageArtifact } from './types'
|
||||
|
||||
type UseRemoteWorkspaceRuntimeOptions = {
|
||||
availableModules: ModuleRow[]
|
||||
curriculumModules: CurriculumWeek['modules']
|
||||
courseKey: string
|
||||
weekNumber: number
|
||||
clonePathPreview: string
|
||||
remoteWorkspace: boolean
|
||||
remoteWorkspaceTargetKey: string
|
||||
activeRuntimeImages: RuntimeImageArtifact[]
|
||||
getFreshAccessToken: () => Promise<string>
|
||||
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
|
||||
saveDirtyFilesForModule: (module: ModuleRow) => Promise<void>
|
||||
onRegisterStopRunningModules?: (handler: (() => Promise<void>) | null) => void
|
||||
}
|
||||
|
||||
type UseRemoteWorkspaceRuntimeResult = {
|
||||
prepareRemoteRuntimeModule: (module: ModuleRow) => Promise<void>
|
||||
}
|
||||
|
||||
export function useRemoteWorkspaceRuntime({
|
||||
availableModules,
|
||||
curriculumModules,
|
||||
courseKey,
|
||||
weekNumber,
|
||||
clonePathPreview,
|
||||
remoteWorkspace,
|
||||
remoteWorkspaceTargetKey,
|
||||
activeRuntimeImages,
|
||||
getFreshAccessToken,
|
||||
getRemoteWorkspaceTarget,
|
||||
saveDirtyFilesForModule,
|
||||
onRegisterStopRunningModules
|
||||
}: UseRemoteWorkspaceRuntimeOptions): UseRemoteWorkspaceRuntimeResult {
|
||||
const prepareRemoteRuntimeModule = useCallback(async (module: ModuleRow): Promise<void> => {
|
||||
const requestAccessToken = await getFreshAccessToken()
|
||||
await saveDirtyFilesForModule(module)
|
||||
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/studio/repositories/prepare`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${requestAccessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
courseId: courseKey,
|
||||
week: weekNumber,
|
||||
moduleName: module.name,
|
||||
folderName: module.folderName,
|
||||
templateRepositoryUrl: module.templateRepositoryUrl
|
||||
})
|
||||
})
|
||||
if (!response.ok) throw new Error(await response.text())
|
||||
const prepared = await response.json() as {
|
||||
repositoryUrlWithToken?: string
|
||||
templateRepositoryUrlWithToken?: string
|
||||
templateRepositoryUrl?: string
|
||||
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
|
||||
}
|
||||
|
||||
const submitResult = await window.localWorkspace.git.submit(
|
||||
module.name,
|
||||
clonePathPreview,
|
||||
module.folderName,
|
||||
`Run: ${weekNumber}주차 ${module.name}`,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
if (!submitResult.ok && !submitResult.message.includes('제출할 변경사항이 없습니다')) {
|
||||
throw new Error(`[${module.name}] ${submitResult.message}`)
|
||||
}
|
||||
|
||||
const templateRepositoryUrl = prepared.templateRepositoryUrlWithToken ?? prepared.templateRepositoryUrl ?? module.templateRepositoryUrl
|
||||
await window.remoteWorkspace.modules.install(
|
||||
getRemoteWorkspaceTarget(),
|
||||
module.name,
|
||||
module.folderName,
|
||||
templateRepositoryUrl,
|
||||
prepared.repositoryUrlWithToken,
|
||||
{
|
||||
prebuiltArtifacts: prepared.prebuiltArtifacts ?? module.prebuiltArtifacts ?? null,
|
||||
runtimeImages: activeRuntimeImages
|
||||
}
|
||||
)
|
||||
}, [
|
||||
activeRuntimeImages,
|
||||
clonePathPreview,
|
||||
courseKey,
|
||||
getFreshAccessToken,
|
||||
getRemoteWorkspaceTarget,
|
||||
saveDirtyFilesForModule,
|
||||
weekNumber
|
||||
])
|
||||
|
||||
const stopRunningWorkspaceModules = useCallback(async (): Promise<void> => {
|
||||
if (remoteWorkspace) {
|
||||
const moduleMap = new Map<string, { moduleName: string; folderName?: string | null; sortOrder?: number | null }>()
|
||||
for (const module of availableModules) {
|
||||
moduleMap.set(module.name, {
|
||||
moduleName: module.name,
|
||||
folderName: module.folderName,
|
||||
sortOrder: module.sortOrder
|
||||
})
|
||||
}
|
||||
for (const module of curriculumModules ?? []) {
|
||||
if (!moduleMap.has(module.name)) {
|
||||
moduleMap.set(module.name, {
|
||||
moduleName: module.name,
|
||||
folderName: module.folderName,
|
||||
sortOrder: module.sortOrder
|
||||
})
|
||||
}
|
||||
}
|
||||
const modules = Array.from(moduleMap.values())
|
||||
.sort((left, right) => (right.sortOrder ?? 0) - (left.sortOrder ?? 0))
|
||||
.map(({ moduleName, folderName }) => ({ moduleName, folderName }))
|
||||
if (!modules.length) return
|
||||
await window.remoteWorkspace.modules.resetSession(getRemoteWorkspaceTarget(), modules).catch(() => ({ ok: false, message: '플랫폼 초기화 실패' }))
|
||||
return
|
||||
}
|
||||
await window.localWorkspace.modules.stopRunning().catch(() => ({ ok: false, message: '모듈 중지 실패' }))
|
||||
}, [availableModules, curriculumModules, getRemoteWorkspaceTarget, remoteWorkspace, remoteWorkspaceTargetKey])
|
||||
|
||||
useEffect(() => {
|
||||
onRegisterStopRunningModules?.(stopRunningWorkspaceModules)
|
||||
return () => onRegisterStopRunningModules?.(null)
|
||||
}, [onRegisterStopRunningModules, stopRunningWorkspaceModules])
|
||||
|
||||
return {
|
||||
prepareRemoteRuntimeModule
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { ModuleRow } from './types'
|
||||
import type { GitStatusInfo, GitSubmissionInfo } from './gitUtils'
|
||||
|
||||
export type GitTarget = {
|
||||
moduleName: string
|
||||
filePath: string
|
||||
}
|
||||
|
||||
type GitModuleRef = {
|
||||
moduleName: string
|
||||
folderName?: string | null
|
||||
}
|
||||
|
||||
type UseSourceControlOptions = {
|
||||
modules: ModuleRow[]
|
||||
readGitStatus: (module: ModuleRow, selectedPath: string | null) => Promise<GitStatusInfo>
|
||||
readGitSubmissions: (modules: GitModuleRef[]) => Promise<{ ok: boolean; submissions: GitSubmissionInfo[]; message?: string }>
|
||||
readGitCommitDiff: (moduleName: string, folderName: string | null | undefined, commitHash: string) => Promise<{ ok: boolean; diff: string; message?: string }>
|
||||
}
|
||||
|
||||
export function useSourceControl({
|
||||
modules,
|
||||
readGitStatus,
|
||||
readGitSubmissions,
|
||||
readGitCommitDiff
|
||||
}: UseSourceControlOptions) {
|
||||
const [gitModuleStatuses, setGitModuleStatuses] = useState<Record<string, GitStatusInfo>>({})
|
||||
const [gitLoading, setGitLoading] = useState(false)
|
||||
const [gitError, setGitError] = useState<string | null>(null)
|
||||
const [selectedGitTarget, setSelectedGitTarget] = useState<GitTarget | null>(null)
|
||||
const [gitPanelTab, setGitPanelTab] = useState<'source' | 'history'>('source')
|
||||
const [gitSubmissions, setGitSubmissions] = useState<GitSubmissionInfo[]>([])
|
||||
const [gitSubmissionsLoading, setGitSubmissionsLoading] = useState(false)
|
||||
const [selectedGitSubmission, setSelectedGitSubmission] = useState<GitSubmissionInfo | null>(null)
|
||||
const [selectedGitSubmissionDiff, setSelectedGitSubmissionDiff] = useState('')
|
||||
const [gitSubmissionDiffLoading, setGitSubmissionDiffLoading] = useState(false)
|
||||
const [gitSubmitting, setGitSubmitting] = useState(false)
|
||||
|
||||
const moduleIdentityKey = modules.map((module) => `${module.name}:${module.folderName ?? ''}:${module.state}`).join('|')
|
||||
const moduleFolderKey = modules.map((module) => `${module.name}:${module.folderName ?? ''}`).join('|')
|
||||
const installedGitModules = useMemo(
|
||||
() => modules.filter((module) => module.state !== 'uninstalled'),
|
||||
[moduleIdentityKey]
|
||||
)
|
||||
const moduleFolderByName = useMemo(
|
||||
() => new Map(modules.map((module) => [module.name, module.folderName ?? null])),
|
||||
[moduleFolderKey]
|
||||
)
|
||||
|
||||
const refreshGitStatuses = useCallback(async (target = selectedGitTarget): Promise<void> => {
|
||||
if (!installedGitModules.length) {
|
||||
setGitModuleStatuses({})
|
||||
setGitError(null)
|
||||
setSelectedGitTarget(null)
|
||||
return
|
||||
}
|
||||
|
||||
setGitLoading(true)
|
||||
setGitError(null)
|
||||
try {
|
||||
const entries = await Promise.all(installedGitModules.map(async (module) => {
|
||||
const selectedPath = target?.moduleName === module.name ? target.filePath : null
|
||||
const status = await readGitStatus(module, selectedPath)
|
||||
return [module.name, status] as const
|
||||
}))
|
||||
const statusMap = Object.fromEntries(entries)
|
||||
setGitModuleStatuses(statusMap)
|
||||
|
||||
const currentTargetStatus = target ? statusMap[target.moduleName] : null
|
||||
const nextTarget = currentTargetStatus?.files.some((file) => file.path === target?.filePath)
|
||||
? target
|
||||
: entries.flatMap(([moduleName, status]) => status.files.map((file) => ({ moduleName, filePath: file.path })))[0] ?? null
|
||||
setSelectedGitTarget(nextTarget)
|
||||
|
||||
const firstError = entries.find(([, status]) => !status.ok)?.[1]
|
||||
if (firstError) setGitError(firstError.message ?? '일부 모듈의 Git 상태를 불러오지 못했습니다.')
|
||||
} catch (error) {
|
||||
console.error('[git] status failed', error)
|
||||
setGitModuleStatuses({})
|
||||
setGitError(error instanceof Error ? error.message : 'Git 상태를 불러오지 못했습니다.')
|
||||
} finally {
|
||||
setGitLoading(false)
|
||||
}
|
||||
}, [installedGitModules, readGitStatus, selectedGitTarget])
|
||||
|
||||
const selectGitFile = useCallback(async (moduleName: string, filePath: string): Promise<void> => {
|
||||
const target = { moduleName, filePath }
|
||||
setSelectedGitTarget(target)
|
||||
await refreshGitStatuses(target)
|
||||
}, [refreshGitStatuses])
|
||||
|
||||
const refreshGitSubmissions = useCallback(async (): Promise<void> => {
|
||||
if (!installedGitModules.length) {
|
||||
setGitSubmissions([])
|
||||
setSelectedGitSubmission(null)
|
||||
setSelectedGitSubmissionDiff('')
|
||||
return
|
||||
}
|
||||
|
||||
setGitSubmissionsLoading(true)
|
||||
setGitError(null)
|
||||
try {
|
||||
const submissionModules = installedGitModules.map((module) => ({ moduleName: module.name, folderName: module.folderName }))
|
||||
const result = await readGitSubmissions(submissionModules)
|
||||
if (!result.ok) throw new Error(result.message ?? '제출 내역을 불러오지 못했습니다.')
|
||||
|
||||
setGitSubmissions(result.submissions)
|
||||
setSelectedGitSubmission((current) => {
|
||||
if (current && result.submissions.some((submission) => submission.moduleName === current.moduleName && submission.commit.hash === current.commit.hash)) {
|
||||
return current
|
||||
}
|
||||
return result.submissions[0] ?? null
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[git] submissions failed', error)
|
||||
setGitSubmissions([])
|
||||
setSelectedGitSubmission(null)
|
||||
setSelectedGitSubmissionDiff('')
|
||||
setGitError(error instanceof Error ? error.message : '제출 내역을 불러오지 못했습니다.')
|
||||
} finally {
|
||||
setGitSubmissionsLoading(false)
|
||||
}
|
||||
}, [installedGitModules, readGitSubmissions])
|
||||
|
||||
const loadGitSubmissionDiff = useCallback(async (submission: GitSubmissionInfo | null): Promise<void> => {
|
||||
if (!submission) {
|
||||
setSelectedGitSubmissionDiff('')
|
||||
return
|
||||
}
|
||||
|
||||
const folderName = moduleFolderByName.get(submission.moduleName)
|
||||
setGitSubmissionDiffLoading(true)
|
||||
try {
|
||||
const result = await readGitCommitDiff(submission.moduleName, folderName, submission.commit.hash)
|
||||
if (!result.ok) throw new Error(result.message ?? '제출 diff를 불러오지 못했습니다.')
|
||||
setSelectedGitSubmissionDiff(result.diff)
|
||||
} catch (error) {
|
||||
console.error('[git] submission diff failed', error)
|
||||
setSelectedGitSubmissionDiff('')
|
||||
setGitError(error instanceof Error ? error.message : '제출 diff를 불러오지 못했습니다.')
|
||||
} finally {
|
||||
setGitSubmissionDiffLoading(false)
|
||||
}
|
||||
}, [moduleFolderByName, readGitCommitDiff])
|
||||
|
||||
useEffect(() => {
|
||||
void loadGitSubmissionDiff(selectedGitSubmission)
|
||||
}, [loadGitSubmissionDiff, selectedGitSubmission])
|
||||
|
||||
const gitChangedModules = useMemo(
|
||||
() => installedGitModules.filter((module) => gitModuleStatuses[module.name]?.files.length),
|
||||
[gitModuleStatuses, installedGitModules]
|
||||
)
|
||||
const gitChangedFileCount = useMemo(
|
||||
() => gitChangedModules.reduce((count, module) => count + (gitModuleStatuses[module.name]?.files.length ?? 0), 0),
|
||||
[gitChangedModules, gitModuleStatuses]
|
||||
)
|
||||
const selectedGitStatus = selectedGitTarget ? gitModuleStatuses[selectedGitTarget.moduleName] : null
|
||||
const selectedGitFile = selectedGitTarget
|
||||
? selectedGitStatus?.files.find((file) => file.path === selectedGitTarget.filePath) ?? null
|
||||
: null
|
||||
const gitSubmissionGroups = useMemo(() => (
|
||||
gitSubmissions.reduce<Array<{ id: string; submittedAt: string; submissions: GitSubmissionInfo[] }>>((groups, submission) => {
|
||||
const existing = groups.find((group) => group.id === submission.id)
|
||||
if (existing) {
|
||||
existing.submissions.push(submission)
|
||||
if (new Date(submission.submittedAt).getTime() > new Date(existing.submittedAt).getTime()) {
|
||||
existing.submittedAt = submission.submittedAt
|
||||
}
|
||||
return groups
|
||||
}
|
||||
groups.push({ id: submission.id, submittedAt: submission.submittedAt, submissions: [submission] })
|
||||
return groups
|
||||
}, []).sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
|
||||
), [gitSubmissions])
|
||||
|
||||
return {
|
||||
gitPanelTab,
|
||||
setGitPanelTab,
|
||||
gitModuleStatuses,
|
||||
setGitModuleStatuses,
|
||||
gitLoading,
|
||||
gitError,
|
||||
setGitError,
|
||||
selectedGitTarget,
|
||||
setSelectedGitTarget,
|
||||
gitSubmissions,
|
||||
setGitSubmissions,
|
||||
gitSubmissionsLoading,
|
||||
selectedGitSubmission,
|
||||
setSelectedGitSubmission,
|
||||
selectedGitSubmissionDiff,
|
||||
setSelectedGitSubmissionDiff,
|
||||
gitSubmissionDiffLoading,
|
||||
gitSubmitting,
|
||||
setGitSubmitting,
|
||||
installedGitModules,
|
||||
gitChangedModules,
|
||||
gitChangedFileCount,
|
||||
selectedGitStatus,
|
||||
selectedGitFile,
|
||||
gitSubmissionGroups,
|
||||
refreshGitStatuses,
|
||||
selectGitFile,
|
||||
refreshGitSubmissions,
|
||||
loadGitSubmissionDiff
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, type RefObject } from 'react'
|
||||
|
||||
type MenuRef = RefObject<HTMLElement | null>
|
||||
|
||||
type UseWorkspaceContextMenusOptions = {
|
||||
tabOpen: boolean
|
||||
tabRef: MenuRef
|
||||
closeTab: () => void
|
||||
moduleTabOpen: boolean
|
||||
moduleTabRef: MenuRef
|
||||
closeModuleTab: () => void
|
||||
treeOpen: boolean
|
||||
treeRef: MenuRef
|
||||
closeTree: () => void
|
||||
terminalOpen: boolean
|
||||
terminalRef: MenuRef
|
||||
closeTerminal: () => void
|
||||
gitFileOpen: boolean
|
||||
gitFileRef: MenuRef
|
||||
closeGitFile: () => void
|
||||
}
|
||||
|
||||
function useCloseOnOutsideClick(open: boolean, ref: MenuRef, close: () => void): void {
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const closeOnOutsideClick = (event: MouseEvent): void => {
|
||||
if (!ref.current?.contains(event.target as Node)) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', closeOnOutsideClick)
|
||||
return () => document.removeEventListener('mousedown', closeOnOutsideClick)
|
||||
}, [open, ref, close])
|
||||
}
|
||||
|
||||
export function useWorkspaceContextMenus({
|
||||
tabOpen,
|
||||
tabRef,
|
||||
closeTab,
|
||||
moduleTabOpen,
|
||||
moduleTabRef,
|
||||
closeModuleTab,
|
||||
treeOpen,
|
||||
treeRef,
|
||||
closeTree,
|
||||
terminalOpen,
|
||||
terminalRef,
|
||||
closeTerminal,
|
||||
gitFileOpen,
|
||||
gitFileRef,
|
||||
closeGitFile
|
||||
}: UseWorkspaceContextMenusOptions): void {
|
||||
useCloseOnOutsideClick(tabOpen, tabRef, closeTab)
|
||||
useCloseOnOutsideClick(moduleTabOpen, moduleTabRef, closeModuleTab)
|
||||
useCloseOnOutsideClick(treeOpen, treeRef, closeTree)
|
||||
useCloseOnOutsideClick(terminalOpen, terminalRef, closeTerminal)
|
||||
useCloseOnOutsideClick(gitFileOpen, gitFileRef, closeGitFile)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
export type EditorFilePath = string
|
||||
|
||||
export type EditorCloseRequest =
|
||||
| { kind: 'tab'; file: EditorFilePath }
|
||||
| { kind: 'others'; file: EditorFilePath }
|
||||
| { kind: 'right'; file: EditorFilePath }
|
||||
| { kind: 'all'; example: boolean }
|
||||
| { kind: 'module'; moduleName: string }
|
||||
| { kind: 'modules'; moduleNames: string[] }
|
||||
| { kind: 'workspace' }
|
||||
|
||||
export function useWorkspaceEditorFiles(initialFiles: Record<string, string>, exampleActive: boolean) {
|
||||
const [activeEditorFile, setActiveEditorFile] = useState<EditorFilePath | null>(null)
|
||||
const [editorFiles, setEditorFiles] = useState<Record<string, string>>(initialFiles)
|
||||
const [savedEditorFiles, setSavedEditorFiles] = useState<Record<string, string>>(initialFiles)
|
||||
const [dirtyEditorFiles, setDirtyEditorFiles] = useState<Record<string, boolean>>({})
|
||||
const [openEditorTabs, setOpenEditorTabs] = useState<EditorFilePath[]>([])
|
||||
const [activeExampleEditorFile, setActiveExampleEditorFile] = useState<EditorFilePath | null>(null)
|
||||
const [openExampleEditorTabs, setOpenExampleEditorTabs] = useState<EditorFilePath[]>([])
|
||||
const [draggingEditorTab, setDraggingEditorTab] = useState<EditorFilePath | null>(null)
|
||||
const [tabDropIndex, setTabDropIndex] = useState<number | null>(null)
|
||||
const [pendingEditorCloseRequest, setPendingEditorCloseRequest] = useState<EditorCloseRequest | null>(null)
|
||||
|
||||
const currentActiveEditorFile = exampleActive ? activeExampleEditorFile : activeEditorFile
|
||||
const currentOpenEditorTabs = exampleActive ? openExampleEditorTabs : openEditorTabs
|
||||
|
||||
const isExampleFile = (file: EditorFilePath): boolean => file.startsWith('examples/example-code/')
|
||||
const isDirtyEditorFile = (file: EditorFilePath): boolean => Boolean(dirtyEditorFiles[file])
|
||||
const hasDirtyFiles = (files: EditorFilePath[]): boolean => files.some((file) => isDirtyEditorFile(file))
|
||||
|
||||
const markEditorFileSaved = (file: EditorFilePath, content: string): void => {
|
||||
setDirtyEditorFiles((dirtyFiles) => {
|
||||
const nextDirtyFiles = { ...dirtyFiles }
|
||||
delete nextDirtyFiles[file]
|
||||
return nextDirtyFiles
|
||||
})
|
||||
setSavedEditorFiles((files) => ({ ...files, [file]: content }))
|
||||
}
|
||||
|
||||
const updateEditorFileContent = (file: EditorFilePath, value: string, readOnly = false): void => {
|
||||
setEditorFiles((files) => ({ ...files, [file]: value }))
|
||||
if (readOnly || isExampleFile(file)) return
|
||||
setDirtyEditorFiles((dirtyFiles) => {
|
||||
const savedValue = savedEditorFiles[file] ?? ''
|
||||
if (value === savedValue) {
|
||||
const nextDirtyFiles = { ...dirtyFiles }
|
||||
delete nextDirtyFiles[file]
|
||||
return nextDirtyFiles
|
||||
}
|
||||
return { ...dirtyFiles, [file]: true }
|
||||
})
|
||||
}
|
||||
|
||||
const discardDirtyFiles = (files: EditorFilePath[]): void => {
|
||||
setEditorFiles((editorFileMap) => {
|
||||
const nextEditorFiles = { ...editorFileMap }
|
||||
files.forEach((file) => {
|
||||
if (savedEditorFiles[file] === undefined) {
|
||||
delete nextEditorFiles[file]
|
||||
return
|
||||
}
|
||||
nextEditorFiles[file] = savedEditorFiles[file]
|
||||
})
|
||||
return nextEditorFiles
|
||||
})
|
||||
setDirtyEditorFiles((dirtyFiles) => {
|
||||
const nextDirtyFiles = { ...dirtyFiles }
|
||||
files.forEach((file) => delete nextDirtyFiles[file])
|
||||
return nextDirtyFiles
|
||||
})
|
||||
}
|
||||
|
||||
const performCloseEditorTab = (file: EditorFilePath): void => {
|
||||
const exampleFile = isExampleFile(file)
|
||||
const setTabs = exampleFile ? setOpenExampleEditorTabs : setOpenEditorTabs
|
||||
const activeFile = exampleFile ? activeExampleEditorFile : activeEditorFile
|
||||
const setActiveFile = exampleFile ? setActiveExampleEditorFile : setActiveEditorFile
|
||||
|
||||
setTabs((tabs) => {
|
||||
const nextTabs = tabs.filter((tab) => tab !== file)
|
||||
if (activeFile === file) {
|
||||
setActiveFile(nextTabs.at(-1) ?? null)
|
||||
}
|
||||
return nextTabs
|
||||
})
|
||||
}
|
||||
|
||||
const closeEditorTab = (file: EditorFilePath): void => {
|
||||
if (isDirtyEditorFile(file)) {
|
||||
setPendingEditorCloseRequest({ kind: 'tab', file })
|
||||
return
|
||||
}
|
||||
performCloseEditorTab(file)
|
||||
}
|
||||
|
||||
const performCloseOtherEditorTabs = (file: EditorFilePath): void => {
|
||||
if (isExampleFile(file)) {
|
||||
setOpenExampleEditorTabs([file])
|
||||
setActiveExampleEditorFile(file)
|
||||
return
|
||||
}
|
||||
setOpenEditorTabs([file])
|
||||
setActiveEditorFile(file)
|
||||
}
|
||||
|
||||
const closeOtherEditorTabs = (file: EditorFilePath): void => {
|
||||
const tabsToClose = (isExampleFile(file) ? openExampleEditorTabs : openEditorTabs).filter((tab) => tab !== file)
|
||||
if (hasDirtyFiles(tabsToClose)) {
|
||||
setPendingEditorCloseRequest({ kind: 'others', file })
|
||||
return
|
||||
}
|
||||
performCloseOtherEditorTabs(file)
|
||||
}
|
||||
|
||||
const performCloseTabsToRight = (file: EditorFilePath): void => {
|
||||
const exampleFile = isExampleFile(file)
|
||||
const setTabs = exampleFile ? setOpenExampleEditorTabs : setOpenEditorTabs
|
||||
const activeFile = exampleFile ? activeExampleEditorFile : activeEditorFile
|
||||
const setActiveFile = exampleFile ? setActiveExampleEditorFile : setActiveEditorFile
|
||||
|
||||
setTabs((tabs) => {
|
||||
const fileIndex = tabs.indexOf(file)
|
||||
if (fileIndex < 0) return tabs
|
||||
const nextTabs = tabs.slice(0, fileIndex + 1)
|
||||
if (!activeFile || !nextTabs.includes(activeFile)) setActiveFile(file)
|
||||
return nextTabs
|
||||
})
|
||||
}
|
||||
|
||||
const closeTabsToRight = (file: EditorFilePath): void => {
|
||||
const tabs = isExampleFile(file) ? openExampleEditorTabs : openEditorTabs
|
||||
const fileIndex = tabs.indexOf(file)
|
||||
const tabsToClose = fileIndex < 0 ? [] : tabs.slice(fileIndex + 1)
|
||||
if (hasDirtyFiles(tabsToClose)) {
|
||||
setPendingEditorCloseRequest({ kind: 'right', file })
|
||||
return
|
||||
}
|
||||
performCloseTabsToRight(file)
|
||||
}
|
||||
|
||||
const performCloseAllEditorTabs = (example: boolean): void => {
|
||||
if (example) {
|
||||
setOpenExampleEditorTabs([])
|
||||
setActiveExampleEditorFile(null)
|
||||
return
|
||||
}
|
||||
setOpenEditorTabs([])
|
||||
setActiveEditorFile(null)
|
||||
}
|
||||
|
||||
const closeAllEditorTabs = (): void => {
|
||||
const tabsToClose = exampleActive ? openExampleEditorTabs : openEditorTabs
|
||||
if (hasDirtyFiles(tabsToClose)) {
|
||||
setPendingEditorCloseRequest({ kind: 'all', example: exampleActive })
|
||||
return
|
||||
}
|
||||
performCloseAllEditorTabs(exampleActive)
|
||||
}
|
||||
|
||||
const updateTabDropIndex = (event: React.DragEvent<HTMLButtonElement>, index: number): void => {
|
||||
if (!draggingEditorTab) return
|
||||
event.preventDefault()
|
||||
const bounds = event.currentTarget.getBoundingClientRect()
|
||||
const nextIndex = event.clientX > bounds.left + bounds.width / 2 ? index + 1 : index
|
||||
setTabDropIndex(nextIndex)
|
||||
}
|
||||
|
||||
const finishTabDrop = (): void => {
|
||||
if (!draggingEditorTab || tabDropIndex === null) return
|
||||
const setTabs = exampleActive ? setOpenExampleEditorTabs : setOpenEditorTabs
|
||||
setTabs((tabs) => {
|
||||
const fromIndex = tabs.indexOf(draggingEditorTab)
|
||||
if (fromIndex < 0) return tabs
|
||||
|
||||
const withoutDraggedTab = tabs.filter((tab) => tab !== draggingEditorTab)
|
||||
const normalizedIndex = Math.min(withoutDraggedTab.length, Math.max(0, tabDropIndex > fromIndex ? tabDropIndex - 1 : tabDropIndex))
|
||||
if (normalizedIndex === fromIndex) return tabs
|
||||
|
||||
return [
|
||||
...withoutDraggedTab.slice(0, normalizedIndex),
|
||||
draggingEditorTab,
|
||||
...withoutDraggedTab.slice(normalizedIndex)
|
||||
]
|
||||
})
|
||||
setDraggingEditorTab(null)
|
||||
setTabDropIndex(null)
|
||||
}
|
||||
|
||||
const resetTabDrag = (): void => {
|
||||
setDraggingEditorTab(null)
|
||||
setTabDropIndex(null)
|
||||
}
|
||||
|
||||
return {
|
||||
activeEditorFile,
|
||||
setActiveEditorFile,
|
||||
editorFiles,
|
||||
setEditorFiles,
|
||||
savedEditorFiles,
|
||||
setSavedEditorFiles,
|
||||
dirtyEditorFiles,
|
||||
setDirtyEditorFiles,
|
||||
openEditorTabs,
|
||||
setOpenEditorTabs,
|
||||
activeExampleEditorFile,
|
||||
setActiveExampleEditorFile,
|
||||
openExampleEditorTabs,
|
||||
setOpenExampleEditorTabs,
|
||||
draggingEditorTab,
|
||||
setDraggingEditorTab,
|
||||
tabDropIndex,
|
||||
setTabDropIndex,
|
||||
pendingEditorCloseRequest,
|
||||
setPendingEditorCloseRequest,
|
||||
currentActiveEditorFile,
|
||||
currentOpenEditorTabs,
|
||||
isDirtyEditorFile,
|
||||
hasDirtyFiles,
|
||||
markEditorFileSaved,
|
||||
updateEditorFileContent,
|
||||
discardDirtyFiles,
|
||||
performCloseEditorTab,
|
||||
closeEditorTab,
|
||||
performCloseOtherEditorTabs,
|
||||
closeOtherEditorTabs,
|
||||
performCloseTabsToRight,
|
||||
closeTabsToRight,
|
||||
performCloseAllEditorTabs,
|
||||
closeAllEditorTabs,
|
||||
updateTabDropIndex,
|
||||
finishTabDrop,
|
||||
resetTabDrag
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import type { ModuleRow } from './types'
|
||||
import type { EditorFilePath } from './workspaceFiles'
|
||||
import type { NewFileDraftState, TreeClipboardState, TreeContextMenuState } from './useWorkspaceTreeState'
|
||||
import type { WorkspaceActivity } from './WorkspaceNav'
|
||||
import { getPathName, getPathParent, makeUniquePath, replacePathPrefix } from './workspaceTree'
|
||||
|
||||
type UseWorkspaceFileActionsOptions = {
|
||||
selectedModule: ModuleRow | null
|
||||
clonePathPreview: string
|
||||
selectedTreePath: string | null
|
||||
setSelectedTreePath: Dispatch<SetStateAction<string | null>>
|
||||
setSelectedExampleTreePath: Dispatch<SetStateAction<string | null>>
|
||||
setActiveIdeActivity: Dispatch<SetStateAction<WorkspaceActivity>>
|
||||
editorFiles: Record<string, string>
|
||||
setEditorFiles: Dispatch<SetStateAction<Record<string, string>>>
|
||||
dirtyEditorFiles: Record<string, boolean>
|
||||
setDirtyEditorFiles: Dispatch<SetStateAction<Record<string, boolean>>>
|
||||
setSavedEditorFiles: Dispatch<SetStateAction<Record<string, string>>>
|
||||
currentActiveEditorFile: EditorFilePath | null
|
||||
isDirtyEditorFile: (file: EditorFilePath) => boolean
|
||||
markEditorFileSaved: (file: EditorFilePath, content: string) => void
|
||||
setActiveEditorFile: Dispatch<SetStateAction<EditorFilePath | null>>
|
||||
setOpenEditorTabs: Dispatch<SetStateAction<EditorFilePath[]>>
|
||||
setActiveExampleEditorFile: Dispatch<SetStateAction<EditorFilePath | null>>
|
||||
setOpenExampleEditorTabs: Dispatch<SetStateAction<EditorFilePath[]>>
|
||||
loadedModuleFolders: Record<string, string[]>
|
||||
setLoadedModuleFolders: Dispatch<SetStateAction<Record<string, string[]>>>
|
||||
setLoadedModuleFilePaths: Dispatch<SetStateAction<Record<string, string[]>>>
|
||||
setModuleFileLoadingByName: Dispatch<SetStateAction<Record<string, boolean>>>
|
||||
loadedModuleRoots: Record<string, string | null>
|
||||
setLoadedModuleRoots: Dispatch<SetStateAction<Record<string, string | null>>>
|
||||
createdModuleFolders: string[]
|
||||
setCreatedModuleFolders: Dispatch<SetStateAction<string[]>>
|
||||
openProjectFolders: Record<string, boolean>
|
||||
setOpenProjectFolders: Dispatch<SetStateAction<Record<string, boolean>>>
|
||||
treeContextMenu: TreeContextMenuState | null
|
||||
setTreeContextMenu: Dispatch<SetStateAction<TreeContextMenuState | null>>
|
||||
treeClipboard: TreeClipboardState | null
|
||||
setTreeClipboard: Dispatch<SetStateAction<TreeClipboardState | null>>
|
||||
setTreeDialog: Dispatch<SetStateAction<
|
||||
| { type: 'rename'; path: string; kind: 'file' | 'folder'; value: string }
|
||||
| { type: 'delete'; path: string; kind: 'file' | 'folder' }
|
||||
| null
|
||||
>>
|
||||
newFileDraft: NewFileDraftState | null
|
||||
setNewFileDraft: Dispatch<SetStateAction<NewFileDraftState | null>>
|
||||
installedGitModules: ModuleRow[]
|
||||
selectedGitTarget: { moduleName: string; filePath: string } | null
|
||||
refreshGitStatuses: (target?: { moduleName: string; filePath: string } | null) => Promise<void>
|
||||
}
|
||||
|
||||
type UseWorkspaceFileActionsResult = {
|
||||
loadModuleFiles: (module: ModuleRow) => Promise<void>
|
||||
startCreateFile: (kind: 'file' | 'folder') => void
|
||||
commitCreateFile: () => Promise<void>
|
||||
getTreeDisplayPath: (path: string) => string
|
||||
getModuleFilePrefix: (moduleName: string) => string
|
||||
getDirtyModuleFiles: (moduleName: string) => EditorFilePath[]
|
||||
saveEditorFile: (file: EditorFilePath) => Promise<void>
|
||||
saveCurrentEditorFile: () => void
|
||||
saveAllEditorFiles: () => void
|
||||
updateModulePaths: (updater: (paths: string[]) => string[]) => void
|
||||
updateOpenPathReferences: (fromPath: string, toPath: string) => void
|
||||
openTreeContextTarget: (target?: TreeContextMenuState | null) => void
|
||||
copyTreePathToClipboard: () => Promise<void>
|
||||
performRenameTreeTarget: (currentPath: string, nextNameInput: string) => Promise<void>
|
||||
requestRenameTreeTarget: () => void
|
||||
performDeleteTreeTarget: (targetPath: string) => Promise<void>
|
||||
requestDeleteTreeTarget: () => void
|
||||
pasteTreeClipboard: () => Promise<void>
|
||||
refreshModuleFiles: () => void
|
||||
}
|
||||
|
||||
export function useWorkspaceFileActions({
|
||||
selectedModule,
|
||||
clonePathPreview,
|
||||
selectedTreePath,
|
||||
setSelectedTreePath,
|
||||
setSelectedExampleTreePath,
|
||||
setActiveIdeActivity,
|
||||
editorFiles,
|
||||
setEditorFiles,
|
||||
dirtyEditorFiles,
|
||||
setDirtyEditorFiles,
|
||||
setSavedEditorFiles,
|
||||
currentActiveEditorFile,
|
||||
isDirtyEditorFile,
|
||||
markEditorFileSaved,
|
||||
setActiveEditorFile,
|
||||
setOpenEditorTabs,
|
||||
setActiveExampleEditorFile,
|
||||
setOpenExampleEditorTabs,
|
||||
loadedModuleFolders,
|
||||
setLoadedModuleFolders,
|
||||
setLoadedModuleFilePaths,
|
||||
setModuleFileLoadingByName,
|
||||
loadedModuleRoots,
|
||||
setLoadedModuleRoots,
|
||||
createdModuleFolders,
|
||||
setCreatedModuleFolders,
|
||||
openProjectFolders,
|
||||
setOpenProjectFolders,
|
||||
treeContextMenu,
|
||||
setTreeContextMenu,
|
||||
treeClipboard,
|
||||
setTreeClipboard,
|
||||
setTreeDialog,
|
||||
newFileDraft,
|
||||
setNewFileDraft,
|
||||
installedGitModules,
|
||||
selectedGitTarget,
|
||||
refreshGitStatuses
|
||||
}: UseWorkspaceFileActionsOptions): UseWorkspaceFileActionsResult {
|
||||
const loadModuleFiles = async (module: ModuleRow): Promise<void> => {
|
||||
setModuleFileLoadingByName((loading) => ({ ...loading, [module.name]: true }))
|
||||
try {
|
||||
const result = await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
|
||||
const nextFiles = result.files
|
||||
const nextFolders = result.folders.length ? result.folders : []
|
||||
|
||||
setEditorFiles((files) => {
|
||||
const merged = { ...files }
|
||||
Object.entries(nextFiles).forEach(([path, content]) => {
|
||||
if (!dirtyEditorFiles[path]) merged[path] = content
|
||||
})
|
||||
return merged
|
||||
})
|
||||
setSavedEditorFiles((files) => ({ ...files, ...nextFiles }))
|
||||
setLoadedModuleFilePaths((paths) => ({ ...paths, [module.name]: Object.keys(nextFiles) }))
|
||||
setLoadedModuleFolders((folders) => ({ ...folders, [module.name]: nextFolders }))
|
||||
setLoadedModuleRoots((roots) => ({ ...roots, [module.name]: result.absoluteRoot }))
|
||||
} catch (error) {
|
||||
console.error('[module-files] load failed', error)
|
||||
setLoadedModuleFilePaths((paths) => ({ ...paths, [module.name]: [] }))
|
||||
setLoadedModuleFolders((folders) => ({ ...folders, [module.name]: [] }))
|
||||
setLoadedModuleRoots((roots) => ({ ...roots, [module.name]: null }))
|
||||
} finally {
|
||||
setModuleFileLoadingByName((loading) => ({ ...loading, [module.name]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const startCreateFile = (kind: 'file' | 'folder'): void => {
|
||||
if (!selectedModule) return
|
||||
const rootPath = `modules/${selectedModule.name}`
|
||||
const selectedPathInModule = selectedTreePath?.startsWith(`${rootPath}/`) || selectedTreePath === rootPath ? selectedTreePath : null
|
||||
const selectedIsFile = selectedPathInModule ? editorFiles[selectedPathInModule] !== undefined : false
|
||||
const parent = selectedPathInModule
|
||||
? selectedIsFile
|
||||
? selectedPathInModule.split('/').slice(0, -1).join('/')
|
||||
: selectedPathInModule
|
||||
: rootPath
|
||||
const depth = Math.max(1, parent.split('/').length - 1)
|
||||
|
||||
setOpenProjectFolders((folders) => ({ ...folders, [rootPath]: true, [parent]: true }))
|
||||
setNewFileDraft({ kind, parent, afterFile: selectedIsFile ? selectedPathInModule : null, depth, value: '' })
|
||||
setActiveIdeActivity('workspace')
|
||||
}
|
||||
|
||||
const commitCreateFile = async (): Promise<void> => {
|
||||
if (!newFileDraft || !selectedModule) return
|
||||
const fileName = newFileDraft.value.trim()
|
||||
if (!fileName) {
|
||||
setNewFileDraft(null)
|
||||
return
|
||||
}
|
||||
const normalizedName = fileName.replace(/^\/+/, '')
|
||||
if (newFileDraft.kind === 'folder') {
|
||||
const nextFolder = `${newFileDraft.parent}/${normalizedName}`
|
||||
try {
|
||||
await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFolder, 'folder', clonePathPreview, selectedModule.folderName)
|
||||
} catch (error) {
|
||||
console.error('[module-files] create folder failed', error)
|
||||
return
|
||||
}
|
||||
setCreatedModuleFolders((folders) => (folders.includes(nextFolder) ? folders : [...folders, nextFolder]))
|
||||
setLoadedModuleFolders((folders) => ({ ...folders, [selectedModule.name]: Array.from(new Set([...(folders[selectedModule.name] ?? []), nextFolder])) }))
|
||||
setOpenProjectFolders((folders) => ({ ...folders, [newFileDraft.parent]: true, [nextFolder]: true }))
|
||||
setSelectedTreePath(nextFolder)
|
||||
setNewFileDraft(null)
|
||||
return
|
||||
}
|
||||
|
||||
const nextFile = `${newFileDraft.parent}/${normalizedName}`
|
||||
try {
|
||||
await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFile, 'file', clonePathPreview, selectedModule.folderName)
|
||||
} catch (error) {
|
||||
console.error('[module-files] create file failed', error)
|
||||
return
|
||||
}
|
||||
|
||||
setEditorFiles((files) => (files[nextFile] === undefined ? { ...files, [nextFile]: '' } : files))
|
||||
setSavedEditorFiles((files) => (files[nextFile] === undefined ? { ...files, [nextFile]: '' } : files))
|
||||
setLoadedModuleFilePaths((paths) => ({ ...paths, [selectedModule.name]: Array.from(new Set([...(paths[selectedModule.name] ?? []), nextFile])) }))
|
||||
setSelectedTreePath(nextFile)
|
||||
setActiveEditorFile(nextFile)
|
||||
setOpenEditorTabs((tabs) => (tabs.includes(nextFile) ? tabs : [...tabs, nextFile]))
|
||||
setNewFileDraft(null)
|
||||
}
|
||||
|
||||
const getTreeDisplayPath = (path: string): string => {
|
||||
const moduleRoot = selectedModule ? loadedModuleRoots[selectedModule.name] : null
|
||||
const logicalRoot = selectedModule ? `modules/${selectedModule.name}` : ''
|
||||
|
||||
if (!moduleRoot || !path.startsWith(logicalRoot)) return path
|
||||
return path === logicalRoot ? moduleRoot : `${moduleRoot}/${path.slice(logicalRoot.length + 1)}`
|
||||
}
|
||||
|
||||
const getModuleFilePrefix = (moduleName: string): string => `modules/${moduleName}`
|
||||
|
||||
const getDirtyModuleFiles = (moduleName: string): EditorFilePath[] => {
|
||||
const moduleRoot = getModuleFilePrefix(moduleName)
|
||||
return Object.keys(dirtyEditorFiles).filter((file) => dirtyEditorFiles[file] && (file === moduleRoot || file.startsWith(`${moduleRoot}/`)))
|
||||
}
|
||||
|
||||
const saveEditorFile = async (file: EditorFilePath): Promise<void> => {
|
||||
if (!selectedModule || file.startsWith('examples/example-code/')) return
|
||||
await window.localWorkspace.moduleFiles.write(selectedModule.name, file, editorFiles[file] ?? '', clonePathPreview, selectedModule.folderName)
|
||||
markEditorFileSaved(file, editorFiles[file] ?? '')
|
||||
if (installedGitModules.length) void refreshGitStatuses(selectedGitTarget)
|
||||
}
|
||||
|
||||
const saveCurrentEditorFile = (): void => {
|
||||
if (!currentActiveEditorFile || currentActiveEditorFile.startsWith('examples/example-code/') || !isDirtyEditorFile(currentActiveEditorFile)) return
|
||||
void saveEditorFile(currentActiveEditorFile).catch((error) => console.error('[module-files] save failed', error))
|
||||
}
|
||||
|
||||
const saveAllEditorFiles = (): void => {
|
||||
if (!selectedModule) return
|
||||
const dirtyFiles = getDirtyModuleFiles(selectedModule.name)
|
||||
if (!dirtyFiles.length) return
|
||||
|
||||
void Promise.all(dirtyFiles.map((file) => saveEditorFile(file))).catch((error) => console.error('[module-files] save all failed', error))
|
||||
}
|
||||
|
||||
const updateModulePaths = (updater: (paths: string[]) => string[]): void => {
|
||||
if (!selectedModule) return
|
||||
setLoadedModuleFilePaths((paths) => ({ ...paths, [selectedModule.name]: updater(paths[selectedModule.name] ?? []) }))
|
||||
setLoadedModuleFolders((folders) => ({ ...folders, [selectedModule.name]: updater(folders[selectedModule.name] ?? []) }))
|
||||
setCreatedModuleFolders((folders) => updater(folders))
|
||||
}
|
||||
|
||||
const updateOpenPathReferences = (fromPath: string, toPath: string): void => {
|
||||
setSelectedTreePath((path) => path ? replacePathPrefix(path, fromPath, toPath) : path)
|
||||
setActiveEditorFile((path) => path ? replacePathPrefix(path, fromPath, toPath) : path)
|
||||
setOpenEditorTabs((tabs) => tabs.map((tab) => replacePathPrefix(tab, fromPath, toPath)))
|
||||
setOpenProjectFolders((folders) => {
|
||||
const nextFolders: Record<string, boolean> = {}
|
||||
Object.entries(folders).forEach(([path, open]) => {
|
||||
nextFolders[replacePathPrefix(path, fromPath, toPath)] = open
|
||||
})
|
||||
return nextFolders
|
||||
})
|
||||
}
|
||||
|
||||
const openTreeContextTarget = (target = treeContextMenu): void => {
|
||||
if (!target) return
|
||||
if (target.kind === 'file') {
|
||||
if (target.path.startsWith('examples/example-code/')) {
|
||||
setSelectedExampleTreePath(target.path)
|
||||
setActiveExampleEditorFile(target.path)
|
||||
setOpenExampleEditorTabs((tabs) => (tabs.includes(target.path) ? tabs : [...tabs, target.path]))
|
||||
} else {
|
||||
setSelectedTreePath(target.path)
|
||||
setActiveEditorFile(target.path)
|
||||
setOpenEditorTabs((tabs) => (tabs.includes(target.path) ? tabs : [...tabs, target.path]))
|
||||
}
|
||||
} else {
|
||||
if (target.path.startsWith('examples/example-code')) setSelectedExampleTreePath(target.path)
|
||||
else setSelectedTreePath(target.path)
|
||||
setOpenProjectFolders((folders) => ({ ...folders, [target.path]: true }))
|
||||
}
|
||||
setTreeContextMenu(null)
|
||||
}
|
||||
|
||||
const copyTreePathToClipboard = async (): Promise<void> => {
|
||||
if (!treeContextMenu) return
|
||||
const clipboardPath = getTreeDisplayPath(treeContextMenu.path)
|
||||
try {
|
||||
await navigator.clipboard.writeText(clipboardPath)
|
||||
} catch {
|
||||
console.warn('[tree] clipboard write failed')
|
||||
}
|
||||
setTreeContextMenu(null)
|
||||
}
|
||||
|
||||
const performRenameTreeTarget = async (currentPath: string, nextNameInput: string): Promise<void> => {
|
||||
if (!selectedModule) return
|
||||
const nextName = nextNameInput.trim()
|
||||
if (!nextName || nextName === getPathName(currentPath)) {
|
||||
setTreeDialog(null)
|
||||
return
|
||||
}
|
||||
const nextPath = `${getPathParent(currentPath)}/${nextName.replace(/^\/+/, '')}`
|
||||
try {
|
||||
await window.localWorkspace.moduleFiles.rename(selectedModule.name, currentPath, nextPath, clonePathPreview, selectedModule.folderName)
|
||||
} catch (error) {
|
||||
console.error('[module-files] rename failed', error)
|
||||
return
|
||||
}
|
||||
|
||||
setEditorFiles((files) => {
|
||||
const nextFiles: Record<string, string> = {}
|
||||
Object.entries(files).forEach(([path, content]) => {
|
||||
nextFiles[replacePathPrefix(path, currentPath, nextPath)] = content
|
||||
})
|
||||
return nextFiles
|
||||
})
|
||||
setSavedEditorFiles((files) => {
|
||||
const nextFiles: Record<string, string> = {}
|
||||
Object.entries(files).forEach(([path, content]) => {
|
||||
nextFiles[replacePathPrefix(path, currentPath, nextPath)] = content
|
||||
})
|
||||
return nextFiles
|
||||
})
|
||||
setDirtyEditorFiles((dirtyFiles) => {
|
||||
const nextDirtyFiles: Record<string, boolean> = {}
|
||||
Object.entries(dirtyFiles).forEach(([path, dirty]) => {
|
||||
if (dirty) nextDirtyFiles[replacePathPrefix(path, currentPath, nextPath)] = true
|
||||
})
|
||||
return nextDirtyFiles
|
||||
})
|
||||
updateModulePaths((paths) => Array.from(new Set(paths.map((path) => replacePathPrefix(path, currentPath, nextPath)))))
|
||||
updateOpenPathReferences(currentPath, nextPath)
|
||||
setTreeDialog(null)
|
||||
}
|
||||
|
||||
const requestRenameTreeTarget = (): void => {
|
||||
if (!treeContextMenu) return
|
||||
setTreeDialog({ type: 'rename', path: treeContextMenu.path, kind: treeContextMenu.kind, value: getPathName(treeContextMenu.path) })
|
||||
setTreeContextMenu(null)
|
||||
}
|
||||
|
||||
const performDeleteTreeTarget = async (targetPath: string): Promise<void> => {
|
||||
if (!selectedModule) return
|
||||
try {
|
||||
await window.localWorkspace.moduleFiles.delete(selectedModule.name, targetPath, clonePathPreview, selectedModule.folderName)
|
||||
} catch (error) {
|
||||
console.error('[module-files] delete failed', error)
|
||||
return
|
||||
}
|
||||
setEditorFiles((files) => Object.fromEntries(Object.entries(files).filter(([path]) => path !== targetPath && !path.startsWith(`${targetPath}/`))))
|
||||
setSavedEditorFiles((files) => Object.fromEntries(Object.entries(files).filter(([path]) => path !== targetPath && !path.startsWith(`${targetPath}/`))))
|
||||
setDirtyEditorFiles((dirtyFiles) => Object.fromEntries(Object.entries(dirtyFiles).filter(([path]) => path !== targetPath && !path.startsWith(`${targetPath}/`))))
|
||||
updateModulePaths((paths) => paths.filter((path) => path !== targetPath && !path.startsWith(`${targetPath}/`)))
|
||||
setOpenEditorTabs((tabs) => tabs.filter((tab) => tab !== targetPath && !tab.startsWith(`${targetPath}/`)))
|
||||
setActiveEditorFile((path) => path && (path === targetPath || path.startsWith(`${targetPath}/`)) ? null : path)
|
||||
setSelectedTreePath((path) => path && (path === targetPath || path.startsWith(`${targetPath}/`)) ? null : path)
|
||||
setTreeDialog(null)
|
||||
}
|
||||
|
||||
const requestDeleteTreeTarget = (): void => {
|
||||
if (!treeContextMenu) return
|
||||
setTreeDialog({ type: 'delete', path: treeContextMenu.path, kind: treeContextMenu.kind })
|
||||
setTreeContextMenu(null)
|
||||
}
|
||||
|
||||
const pasteTreeClipboard = async (): Promise<void> => {
|
||||
if (!treeContextMenu || !treeClipboard || !selectedModule) return
|
||||
const sourcePath = treeClipboard.path
|
||||
const pasteParent = treeContextMenu.kind === 'folder' ? treeContextMenu.path : getPathParent(treeContextMenu.path)
|
||||
if (pasteParent === sourcePath || pasteParent.startsWith(`${sourcePath}/`)) return
|
||||
const rootPath = `modules/${selectedModule.name}`
|
||||
const allFolders = Array.from(new Set([...(loadedModuleFolders[selectedModule.name] ?? []), ...createdModuleFolders.filter((folder) => folder.startsWith(rootPath))]))
|
||||
const targetBasePath = makeUniquePath(`${pasteParent}/${getPathName(sourcePath)}`, editorFiles, allFolders)
|
||||
try {
|
||||
await window.localWorkspace.moduleFiles.copy(selectedModule.name, sourcePath, targetBasePath, treeClipboard.mode, clonePathPreview, selectedModule.folderName)
|
||||
} catch (error) {
|
||||
console.error('[module-files] copy failed', error)
|
||||
return
|
||||
}
|
||||
|
||||
setEditorFiles((files) => {
|
||||
const nextFiles = { ...files }
|
||||
Object.entries(files).forEach(([path, content]) => {
|
||||
if (path === sourcePath || path.startsWith(`${sourcePath}/`)) {
|
||||
nextFiles[replacePathPrefix(path, sourcePath, targetBasePath)] = content
|
||||
}
|
||||
})
|
||||
if (treeClipboard.mode === 'cut') {
|
||||
Object.keys(nextFiles).forEach((path) => {
|
||||
if (path === sourcePath || path.startsWith(`${sourcePath}/`)) delete nextFiles[path]
|
||||
})
|
||||
}
|
||||
return nextFiles
|
||||
})
|
||||
updateModulePaths((paths) => {
|
||||
const copiedPaths = paths
|
||||
.filter((path) => path === sourcePath || path.startsWith(`${sourcePath}/`))
|
||||
.map((path) => replacePathPrefix(path, sourcePath, targetBasePath))
|
||||
const movedPaths = treeClipboard.mode === 'cut' ? paths.filter((path) => path !== sourcePath && !path.startsWith(`${sourcePath}/`)) : paths
|
||||
return Array.from(new Set([...movedPaths, ...copiedPaths]))
|
||||
})
|
||||
setOpenProjectFolders((folders) => ({ ...folders, [pasteParent]: true, [targetBasePath]: treeClipboard.kind === 'folder' }))
|
||||
if (treeClipboard.mode === 'cut') {
|
||||
updateOpenPathReferences(sourcePath, targetBasePath)
|
||||
setTreeClipboard(null)
|
||||
}
|
||||
setTreeContextMenu(null)
|
||||
}
|
||||
|
||||
const refreshModuleFiles = (): void => {
|
||||
if (!selectedModule) return
|
||||
setNewFileDraft(null)
|
||||
void loadModuleFiles(selectedModule)
|
||||
}
|
||||
|
||||
return {
|
||||
loadModuleFiles,
|
||||
startCreateFile,
|
||||
commitCreateFile,
|
||||
getTreeDisplayPath,
|
||||
getModuleFilePrefix,
|
||||
getDirtyModuleFiles,
|
||||
saveEditorFile,
|
||||
saveCurrentEditorFile,
|
||||
saveAllEditorFiles,
|
||||
updateModulePaths,
|
||||
updateOpenPathReferences,
|
||||
openTreeContextTarget,
|
||||
copyTreePathToClipboard,
|
||||
performRenameTreeTarget,
|
||||
requestRenameTreeTarget,
|
||||
performDeleteTreeTarget,
|
||||
requestDeleteTreeTarget,
|
||||
pasteTreeClipboard,
|
||||
refreshModuleFiles
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { LMS_API_BASE_URL } from '../../config/appConfig'
|
||||
import type { StudioSourceSettings } from '../platforms/types'
|
||||
import { serializeModuleParameters, storeModuleParameters, makeModuleParameterKey } from './moduleParameters'
|
||||
import type { ModuleParameterPair, ModulePrebuiltArtifact, ModuleRow, RuntimeImageArtifact } from './types'
|
||||
import { compareModulePriority, compareModuleShutdownPriority } from './workspaceModules'
|
||||
|
||||
type NoticeTone = 'info' | 'warning' | 'error'
|
||||
|
||||
type ModuleActionResult = {
|
||||
ok: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
type UseWorkspaceModuleControlOptions = {
|
||||
availableModules: ModuleRow[]
|
||||
setAvailableModules: React.Dispatch<React.SetStateAction<ModuleRow[]>>
|
||||
loadAvailableModules: (options?: { silent?: boolean }) => Promise<void>
|
||||
courseKey: string
|
||||
weekNumber: number
|
||||
clonePathPreview: string
|
||||
remoteWorkspace: boolean
|
||||
sourceSettings: StudioSourceSettings
|
||||
activeRuntimeImages: RuntimeImageArtifact[]
|
||||
getFreshAccessToken: () => Promise<string>
|
||||
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
|
||||
prepareRemoteRuntimeModule: (module: ModuleRow) => Promise<void>
|
||||
onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void
|
||||
}
|
||||
|
||||
type UseWorkspaceModuleControlResult = {
|
||||
installingModuleName: string | null
|
||||
setInstallingModuleName: React.Dispatch<React.SetStateAction<string | null>>
|
||||
controllingModuleNames: string[]
|
||||
moduleInstallError: string | null
|
||||
setModuleInstallError: React.Dispatch<React.SetStateAction<string | null>>
|
||||
installModule: (module: ModuleRow) => Promise<void>
|
||||
installAllModules: () => Promise<void>
|
||||
startAllModules: () => Promise<void>
|
||||
stopAllModules: () => Promise<void>
|
||||
runModuleControl: (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build') => Promise<void>
|
||||
updateModuleParameters: (module: ModuleRow, parameters: ModuleParameterPair[]) => Promise<void>
|
||||
}
|
||||
|
||||
export function useWorkspaceModuleControl({
|
||||
availableModules,
|
||||
setAvailableModules,
|
||||
loadAvailableModules,
|
||||
courseKey,
|
||||
weekNumber,
|
||||
clonePathPreview,
|
||||
remoteWorkspace,
|
||||
sourceSettings,
|
||||
activeRuntimeImages,
|
||||
getFreshAccessToken,
|
||||
getRemoteWorkspaceTarget,
|
||||
prepareRemoteRuntimeModule,
|
||||
onTitlebarNotice
|
||||
}: UseWorkspaceModuleControlOptions): UseWorkspaceModuleControlResult {
|
||||
const [installingModuleName, setInstallingModuleName] = useState<string | null>(null)
|
||||
const [controllingModuleNames, setControllingModuleNames] = useState<string[]>([])
|
||||
const [moduleInstallError, setModuleInstallError] = useState<string | null>(null)
|
||||
|
||||
const getModuleRosParams = useCallback((module: ModuleRow): string => module.parameters.join(' '), [])
|
||||
|
||||
const installModuleOnce = useCallback(async (module: ModuleRow, requestAccessToken: string): Promise<void> => {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/studio/repositories/prepare`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${requestAccessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
courseId: courseKey,
|
||||
week: weekNumber,
|
||||
moduleName: module.name,
|
||||
folderName: module.folderName,
|
||||
templateRepositoryUrl: module.templateRepositoryUrl
|
||||
})
|
||||
})
|
||||
if (!response.ok) throw new Error(await response.text())
|
||||
const prepared = await response.json() as {
|
||||
repositoryUrlWithToken?: string
|
||||
templateRepositoryUrlWithToken?: string
|
||||
templateRepositoryUrl?: string
|
||||
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
|
||||
}
|
||||
|
||||
const templateRepositoryUrl = prepared.templateRepositoryUrlWithToken ?? prepared.templateRepositoryUrl ?? module.templateRepositoryUrl
|
||||
await window.localWorkspace.modules.install(module.name, clonePathPreview, module.folderName, templateRepositoryUrl, prepared.repositoryUrlWithToken, {
|
||||
prebuiltArtifacts: prepared.prebuiltArtifacts ?? module.prebuiltArtifacts ?? null,
|
||||
runtimeImages: activeRuntimeImages
|
||||
})
|
||||
}, [activeRuntimeImages, clonePathPreview, courseKey, weekNumber])
|
||||
|
||||
const remoteModuleStart = useCallback(async (module: ModuleRow): Promise<ModuleActionResult> => {
|
||||
await prepareRemoteRuntimeModule(module)
|
||||
return window.remoteWorkspace.modules.start(
|
||||
getRemoteWorkspaceTarget(),
|
||||
module.name,
|
||||
module.folderName,
|
||||
getModuleRosParams(module),
|
||||
sourceSettings.gpuEnabled,
|
||||
sourceSettings.rosDomainId,
|
||||
activeRuntimeImages
|
||||
)
|
||||
}, [activeRuntimeImages, getModuleRosParams, getRemoteWorkspaceTarget, prepareRemoteRuntimeModule, sourceSettings.gpuEnabled, sourceSettings.rosDomainId])
|
||||
|
||||
const remoteModuleStop = useCallback(async (module: ModuleRow): Promise<ModuleActionResult> => (
|
||||
window.remoteWorkspace.modules.stop(getRemoteWorkspaceTarget(), module.name, module.folderName)
|
||||
), [getRemoteWorkspaceTarget])
|
||||
|
||||
const remoteModuleRestart = useCallback(async (module: ModuleRow): Promise<ModuleActionResult> => {
|
||||
await remoteModuleStop(module).catch(() => ({ ok: false, message: '모듈 중지 실패' }))
|
||||
return remoteModuleStart(module)
|
||||
}, [remoteModuleStart, remoteModuleStop])
|
||||
|
||||
const installModule = useCallback(async (module: ModuleRow): Promise<void> => {
|
||||
if (installingModuleName) return
|
||||
setInstallingModuleName(module.name)
|
||||
setModuleInstallError(null)
|
||||
try {
|
||||
const requestAccessToken = await getFreshAccessToken()
|
||||
await installModuleOnce(module, requestAccessToken)
|
||||
await loadAvailableModules({ silent: true })
|
||||
} catch (error) {
|
||||
console.error('[modules] install failed', error)
|
||||
setModuleInstallError(error instanceof Error ? error.message : '모듈 설치 실패')
|
||||
} finally {
|
||||
setInstallingModuleName(null)
|
||||
}
|
||||
}, [getFreshAccessToken, installModuleOnce, installingModuleName, loadAvailableModules])
|
||||
|
||||
const installAllModules = useCallback(async (): Promise<void> => {
|
||||
if (installingModuleName) return
|
||||
const targets = availableModules.filter((module) => module.state === 'uninstalled').sort(compareModulePriority)
|
||||
if (!targets.length) return
|
||||
|
||||
setInstallingModuleName('__all__')
|
||||
setModuleInstallError(null)
|
||||
try {
|
||||
const requestAccessToken = await getFreshAccessToken()
|
||||
for (const module of targets) {
|
||||
await installModuleOnce(module, requestAccessToken)
|
||||
}
|
||||
await loadAvailableModules({ silent: true })
|
||||
} catch (error) {
|
||||
console.error('[modules] install all failed', error)
|
||||
setModuleInstallError(error instanceof Error ? error.message : '전체 설치 실패')
|
||||
await loadAvailableModules({ silent: true })
|
||||
} finally {
|
||||
setInstallingModuleName(null)
|
||||
}
|
||||
}, [availableModules, getFreshAccessToken, installModuleOnce, installingModuleName, loadAvailableModules])
|
||||
|
||||
const startAllModules = useCallback(async (): Promise<void> => {
|
||||
if (installingModuleName) return
|
||||
const targets = availableModules.filter((module) => module.state === 'stopped').sort(compareModulePriority)
|
||||
if (!targets.length) {
|
||||
onTitlebarNotice('시작할 모듈이 없습니다', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
setInstallingModuleName('__start_all__')
|
||||
setModuleInstallError(null)
|
||||
try {
|
||||
for (const module of targets) {
|
||||
const result = remoteWorkspace
|
||||
? await remoteModuleStart(module)
|
||||
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
|
||||
if (!result.ok) throw new Error(result.message)
|
||||
}
|
||||
await loadAvailableModules({ silent: true })
|
||||
onTitlebarNotice('모듈이 전체 시작되었습니다', 'info')
|
||||
} catch (error) {
|
||||
console.error('[modules] start all failed', error)
|
||||
setModuleInstallError(error instanceof Error ? error.message : '전체 실행 실패')
|
||||
onTitlebarNotice('모듈 전체 시작에 실패했습니다', 'error')
|
||||
await loadAvailableModules({ silent: true })
|
||||
} finally {
|
||||
setInstallingModuleName(null)
|
||||
}
|
||||
}, [
|
||||
activeRuntimeImages,
|
||||
availableModules,
|
||||
clonePathPreview,
|
||||
getModuleRosParams,
|
||||
installingModuleName,
|
||||
loadAvailableModules,
|
||||
onTitlebarNotice,
|
||||
remoteModuleStart,
|
||||
remoteWorkspace,
|
||||
sourceSettings.gpuEnabled,
|
||||
sourceSettings.rosDomainId
|
||||
])
|
||||
|
||||
const stopAllModules = useCallback(async (): Promise<void> => {
|
||||
if (installingModuleName) return
|
||||
const targets = availableModules.filter((module) => module.state === 'running').sort(compareModuleShutdownPriority)
|
||||
if (!targets.length) {
|
||||
onTitlebarNotice('종료할 모듈이 없습니다', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
setInstallingModuleName('__stop_all__')
|
||||
setModuleInstallError(null)
|
||||
try {
|
||||
if (remoteWorkspace) {
|
||||
for (const module of targets) {
|
||||
const result = await remoteModuleStop(module)
|
||||
if (!result.ok) throw new Error(result.message)
|
||||
}
|
||||
} else {
|
||||
const result = await window.localWorkspace.modules.stopMany(
|
||||
targets.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
|
||||
clonePathPreview
|
||||
)
|
||||
if (!result.ok) throw new Error(result.message)
|
||||
}
|
||||
await loadAvailableModules({ silent: true })
|
||||
onTitlebarNotice('모듈이 전체 종료되었습니다', 'info')
|
||||
} catch (error) {
|
||||
console.error('[modules] stop all failed', error)
|
||||
setModuleInstallError(error instanceof Error ? error.message : '전체 중지 실패')
|
||||
onTitlebarNotice('모듈 전체 종료에 실패했습니다', 'error')
|
||||
await loadAvailableModules({ silent: true })
|
||||
} finally {
|
||||
setInstallingModuleName(null)
|
||||
}
|
||||
}, [availableModules, clonePathPreview, installingModuleName, loadAvailableModules, onTitlebarNotice, remoteModuleStop, remoteWorkspace])
|
||||
|
||||
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<void> => {
|
||||
if (installingModuleName || controllingModuleNames.includes(module.name)) return
|
||||
|
||||
setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name]))
|
||||
setModuleInstallError(null)
|
||||
try {
|
||||
const result = action === 'stop'
|
||||
? remoteWorkspace
|
||||
? await remoteModuleStop(module)
|
||||
: await window.localWorkspace.modules.stop(module.name, clonePathPreview, module.folderName)
|
||||
: action === 'build'
|
||||
? remoteWorkspace
|
||||
? await window.remoteWorkspace.modules.build(getRemoteWorkspaceTarget(), module.name, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
|
||||
: await window.localWorkspace.modules.build(module.name, clonePathPreview, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
|
||||
: action === 'restart'
|
||||
? remoteWorkspace
|
||||
? await remoteModuleRestart(module)
|
||||
: await window.localWorkspace.modules.restart(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
|
||||
: remoteWorkspace
|
||||
? await remoteModuleStart(module)
|
||||
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
|
||||
if (!result.ok) throw new Error(result.message)
|
||||
await loadAvailableModules({ silent: true })
|
||||
const actionLabel = action === 'stop'
|
||||
? '종료되었습니다'
|
||||
: action === 'build'
|
||||
? '빌드되었습니다'
|
||||
: action === 'restart'
|
||||
? '재시작되었습니다'
|
||||
: '실행되었습니다'
|
||||
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'info')
|
||||
} catch (error) {
|
||||
console.error(`[modules] ${action} failed`, error)
|
||||
setModuleInstallError(error instanceof Error ? error.message : '모듈 제어 실패')
|
||||
const actionLabel = action === 'stop'
|
||||
? '종료 실패'
|
||||
: action === 'build'
|
||||
? '빌드 실패'
|
||||
: action === 'restart'
|
||||
? '재시작 실패'
|
||||
: '실행 실패'
|
||||
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'error')
|
||||
await loadAvailableModules({ silent: true })
|
||||
} finally {
|
||||
setControllingModuleNames((names) => names.filter((name) => name !== module.name))
|
||||
}
|
||||
}, [
|
||||
activeRuntimeImages,
|
||||
clonePathPreview,
|
||||
controllingModuleNames,
|
||||
getModuleRosParams,
|
||||
installingModuleName,
|
||||
loadAvailableModules,
|
||||
onTitlebarNotice,
|
||||
remoteModuleRestart,
|
||||
remoteModuleStart,
|
||||
remoteModuleStop,
|
||||
remoteWorkspace,
|
||||
sourceSettings.gpuEnabled,
|
||||
sourceSettings.rosDomainId
|
||||
])
|
||||
|
||||
const updateModuleParameters = useCallback(async (module: ModuleRow, parameters: ModuleParameterPair[]): Promise<void> => {
|
||||
setModuleInstallError(null)
|
||||
try {
|
||||
const normalizedParameters = parameters
|
||||
.map((parameter) => ({ ...parameter, name: parameter.name.trim(), value: parameter.value.trim() }))
|
||||
.filter((parameter) => parameter.name)
|
||||
storeModuleParameters(makeModuleParameterKey(courseKey, weekNumber, module.folderName ?? module.name), normalizedParameters)
|
||||
const nextTokens = serializeModuleParameters(normalizedParameters).split(/\s+/).filter(Boolean)
|
||||
setAvailableModules((modules) => modules.map((item) => item.name === module.name ? { ...item, parameters: nextTokens } : item))
|
||||
} catch (error) {
|
||||
console.error('[modules] update parameters failed', error)
|
||||
setModuleInstallError(error instanceof Error ? error.message : '파라미터 저장 실패')
|
||||
}
|
||||
}, [courseKey, setAvailableModules, weekNumber])
|
||||
|
||||
return {
|
||||
installingModuleName,
|
||||
setInstallingModuleName,
|
||||
controllingModuleNames,
|
||||
moduleInstallError,
|
||||
setModuleInstallError,
|
||||
installModule,
|
||||
installAllModules,
|
||||
startAllModules,
|
||||
stopAllModules,
|
||||
runModuleControl,
|
||||
updateModuleParameters
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import type { CurriculumWeek } from '../curriculum/types'
|
||||
import type { ModuleRow } from './types'
|
||||
import { compareModulePriority, makeUninstalledModuleRows } from './workspaceModules'
|
||||
|
||||
type LoadAvailableModulesOptions = {
|
||||
silent?: boolean
|
||||
}
|
||||
|
||||
type UseWorkspaceModuleListOptions = {
|
||||
clonePathPreview: string
|
||||
curriculumModules: CurriculumWeek['modules']
|
||||
curriculumModuleKey: string
|
||||
remoteWorkspace: boolean
|
||||
remoteWorkspaceTargetKey: string
|
||||
pollingActive: boolean
|
||||
applyModuleParameterOverrides: (modules: ModuleRow[]) => ModuleRow[]
|
||||
}
|
||||
|
||||
type UseWorkspaceModuleListResult = {
|
||||
availableModules: ModuleRow[]
|
||||
setAvailableModules: Dispatch<SetStateAction<ModuleRow[]>>
|
||||
modulesLoading: boolean
|
||||
moduleListError: string | null
|
||||
loadAvailableModules: (options?: LoadAvailableModulesOptions) => Promise<void>
|
||||
loadRemoteModules: () => Promise<{ modules: ModuleRow[] }>
|
||||
}
|
||||
|
||||
function parseRemoteWorkspaceTarget(targetKey: string): RemoteWorkspaceTarget | null {
|
||||
try {
|
||||
const parsed = JSON.parse(targetKey) as RemoteWorkspaceTarget | null
|
||||
return parsed && typeof parsed.host === 'string' ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function useWorkspaceModuleList({
|
||||
clonePathPreview,
|
||||
curriculumModules,
|
||||
curriculumModuleKey,
|
||||
remoteWorkspace,
|
||||
remoteWorkspaceTargetKey,
|
||||
pollingActive,
|
||||
applyModuleParameterOverrides
|
||||
}: UseWorkspaceModuleListOptions): UseWorkspaceModuleListResult {
|
||||
const [availableModules, setAvailableModules] = useState<ModuleRow[]>([])
|
||||
const [modulesLoading, setModulesLoading] = useState(false)
|
||||
const [moduleListError, setModuleListError] = useState<string | null>(null)
|
||||
const remoteTarget = useMemo(() => parseRemoteWorkspaceTarget(remoteWorkspaceTargetKey), [remoteWorkspaceTargetKey])
|
||||
|
||||
const loadRemoteModules = useCallback(async (): Promise<{ modules: ModuleRow[] }> => {
|
||||
if (!remoteTarget) return { modules: [] }
|
||||
return window.remoteWorkspace.modules.list(remoteTarget, curriculumModules ?? [])
|
||||
}, [curriculumModuleKey, remoteTarget])
|
||||
|
||||
const loadAvailableModules = useCallback(async ({ silent = false }: LoadAvailableModulesOptions = {}): Promise<void> => {
|
||||
if (!silent) setModulesLoading(true)
|
||||
try {
|
||||
const result = await window.localWorkspace.modules.list(clonePathPreview, curriculumModules ?? [])
|
||||
if (remoteWorkspace) {
|
||||
const remoteResult = await loadRemoteModules().catch(() => ({ modules: [] as ModuleRow[] }))
|
||||
const remoteStateByName = new Map(remoteResult.modules.map((module) => [module.name, module]))
|
||||
result.modules = result.modules.map((module) => {
|
||||
const remoteModule = remoteStateByName.get(module.name)
|
||||
return remoteModule?.state === 'running'
|
||||
? { ...module, state: 'running', cpu: remoteModule.cpu, memory: remoteModule.memory, runtime: remoteModule.runtime }
|
||||
: module
|
||||
})
|
||||
}
|
||||
setModuleListError(null)
|
||||
setAvailableModules(applyModuleParameterOverrides(result.modules).sort(compareModulePriority))
|
||||
} catch (error) {
|
||||
console.error('[modules] list failed', error)
|
||||
const message = error instanceof Error ? error.message : '모듈 목록을 불러오지 못했습니다.'
|
||||
if (!silent) {
|
||||
setModuleListError(message)
|
||||
setAvailableModules(makeUninstalledModuleRows(curriculumModules))
|
||||
}
|
||||
} finally {
|
||||
if (!silent) setModulesLoading(false)
|
||||
}
|
||||
}, [
|
||||
applyModuleParameterOverrides,
|
||||
clonePathPreview,
|
||||
curriculumModuleKey,
|
||||
curriculumModules,
|
||||
loadRemoteModules,
|
||||
remoteWorkspace
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
void loadAvailableModules()
|
||||
}, [loadAvailableModules, remoteWorkspaceTargetKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pollingActive) return
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
void loadAvailableModules({ silent: true })
|
||||
}, 5000)
|
||||
|
||||
return () => window.clearInterval(intervalId)
|
||||
}, [loadAvailableModules, pollingActive])
|
||||
|
||||
return {
|
||||
availableModules,
|
||||
setAvailableModules,
|
||||
modulesLoading,
|
||||
moduleListError,
|
||||
loadAvailableModules,
|
||||
loadRemoteModules
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ModuleRow } from './types'
|
||||
import { maxVisibleModuleLogEntries, parseModuleLogEntries } from './workspaceLogs'
|
||||
|
||||
type UseWorkspaceModuleLogsOptions = {
|
||||
selectedModule: ModuleRow | null
|
||||
logActive: boolean
|
||||
clonePathPreview: string
|
||||
remoteWorkspace: boolean
|
||||
remoteWorkspaceTargetKey: string
|
||||
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
|
||||
}
|
||||
|
||||
type UseWorkspaceModuleLogsResult = {
|
||||
moduleLogsLoading: boolean
|
||||
moduleLogMessage: string | null
|
||||
visibleModuleLogEntries: ReturnType<typeof parseModuleLogEntries>
|
||||
moduleLogScrollRef: React.RefObject<HTMLDivElement | null>
|
||||
updateModuleLogStickToBottom: () => void
|
||||
}
|
||||
|
||||
export function useWorkspaceModuleLogs({
|
||||
selectedModule,
|
||||
logActive,
|
||||
clonePathPreview,
|
||||
remoteWorkspace,
|
||||
remoteWorkspaceTargetKey,
|
||||
getRemoteWorkspaceTarget
|
||||
}: UseWorkspaceModuleLogsOptions): UseWorkspaceModuleLogsResult {
|
||||
const [moduleLogText, setModuleLogText] = useState('')
|
||||
const [moduleLogMessage, setModuleLogMessage] = useState<string | null>(null)
|
||||
const [moduleLogsLoading, setModuleLogsLoading] = useState(false)
|
||||
const moduleLogScrollRef = useRef<HTMLDivElement>(null)
|
||||
const moduleLogStickToBottomRef = useRef(true)
|
||||
const getRemoteWorkspaceTargetRef = useRef(getRemoteWorkspaceTarget)
|
||||
|
||||
useEffect(() => {
|
||||
getRemoteWorkspaceTargetRef.current = getRemoteWorkspaceTarget
|
||||
}, [getRemoteWorkspaceTarget])
|
||||
|
||||
const moduleLogEntries = selectedModule ? parseModuleLogEntries(selectedModule, moduleLogText) : []
|
||||
const visibleModuleLogEntries = moduleLogEntries.slice(-maxVisibleModuleLogEntries)
|
||||
|
||||
const updateModuleLogStickToBottom = (): void => {
|
||||
const container = moduleLogScrollRef.current
|
||||
if (!container) return
|
||||
const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight
|
||||
moduleLogStickToBottomRef.current = distanceToBottom < 24
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!logActive) return
|
||||
moduleLogStickToBottomRef.current = true
|
||||
}, [logActive, selectedModule?.name])
|
||||
|
||||
useEffect(() => {
|
||||
if (!logActive || !moduleLogStickToBottomRef.current) return
|
||||
const container = moduleLogScrollRef.current
|
||||
if (!container) return
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
container.scrollTop = container.scrollHeight
|
||||
})
|
||||
}, [logActive, moduleLogText, moduleLogsLoading])
|
||||
|
||||
useEffect(() => {
|
||||
if (!logActive || !selectedModule) {
|
||||
setModuleLogText('')
|
||||
setModuleLogMessage(null)
|
||||
setModuleLogsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const loadLogs = async (quiet = false): Promise<void> => {
|
||||
if (!quiet) setModuleLogsLoading(true)
|
||||
try {
|
||||
const result = remoteWorkspace
|
||||
? await window.remoteWorkspace.modules.logs(getRemoteWorkspaceTargetRef.current(), selectedModule.name, selectedModule.folderName)
|
||||
: await window.localWorkspace.modules.logs(selectedModule.name, clonePathPreview, selectedModule.folderName)
|
||||
if (cancelled) return
|
||||
setModuleLogText(result.logs)
|
||||
setModuleLogMessage(result.ok ? (result.message ?? null) : (result.message ?? '로그를 불러오지 못했습니다.'))
|
||||
} catch (error) {
|
||||
if (cancelled) return
|
||||
setModuleLogText('')
|
||||
setModuleLogMessage(error instanceof Error ? error.message : '로그를 불러오지 못했습니다.')
|
||||
} finally {
|
||||
if (!cancelled) setModuleLogsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void loadLogs(false)
|
||||
const intervalId = selectedModule.state === 'running'
|
||||
? window.setInterval(() => {
|
||||
void loadLogs(true)
|
||||
}, 5000)
|
||||
: null
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (intervalId !== null) window.clearInterval(intervalId)
|
||||
}
|
||||
}, [clonePathPreview, logActive, remoteWorkspace, remoteWorkspaceTargetKey, selectedModule?.folderName, selectedModule?.name, selectedModule?.state])
|
||||
|
||||
return {
|
||||
moduleLogsLoading,
|
||||
moduleLogMessage,
|
||||
visibleModuleLogEntries,
|
||||
moduleLogScrollRef,
|
||||
updateModuleLogStickToBottom
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { EditorFilePath } from './workspaceFiles'
|
||||
import type { ModuleRow } from './types'
|
||||
|
||||
export type ModuleSearchResult = {
|
||||
file: string
|
||||
lineNumber: number
|
||||
lineText: string
|
||||
}
|
||||
|
||||
type PendingReveal = {
|
||||
lineNumber: number
|
||||
query: string
|
||||
}
|
||||
|
||||
type UseWorkspaceSearchOptions = {
|
||||
searchActive: boolean
|
||||
selectedModule: ModuleRow | null
|
||||
editorFiles: Record<string, string>
|
||||
moduleFilePaths: string[]
|
||||
activeEditorFile: EditorFilePath | null
|
||||
onOpenEditorFileAtLine: (file: EditorFilePath) => void
|
||||
onRevealSearchMatch: (lineNumber: number, query: string) => boolean
|
||||
}
|
||||
|
||||
type UseWorkspaceSearchResult = {
|
||||
moduleSearchInputRef: React.RefObject<HTMLInputElement | null>
|
||||
moduleSearchQuery: string
|
||||
setModuleSearchQuery: React.Dispatch<React.SetStateAction<string>>
|
||||
activeModuleSearchQuery: string
|
||||
moduleSearchRunning: boolean
|
||||
moduleSearchResults: ModuleSearchResult[]
|
||||
requestSearchReveal: (lineNumber: number) => void
|
||||
openSearchResult: (file: EditorFilePath, lineNumber: number) => void
|
||||
}
|
||||
|
||||
export function useWorkspaceSearch({
|
||||
searchActive,
|
||||
selectedModule,
|
||||
editorFiles,
|
||||
moduleFilePaths,
|
||||
activeEditorFile,
|
||||
onOpenEditorFileAtLine,
|
||||
onRevealSearchMatch
|
||||
}: UseWorkspaceSearchOptions): UseWorkspaceSearchResult {
|
||||
const [moduleSearchQuery, setModuleSearchQuery] = useState('')
|
||||
const [debouncedModuleSearchQuery, setDebouncedModuleSearchQuery] = useState('')
|
||||
const [moduleSearchResults, setModuleSearchResults] = useState<ModuleSearchResult[]>([])
|
||||
const [moduleSearchRunning, setModuleSearchRunning] = useState(false)
|
||||
const [pendingReveal, setPendingReveal] = useState<PendingReveal | null>(null)
|
||||
const moduleSearchInputRef = useRef<HTMLInputElement>(null)
|
||||
const searchRunIdRef = useRef(0)
|
||||
const moduleFilePathKey = moduleFilePaths.join('\n')
|
||||
const activeModuleSearchQuery = debouncedModuleSearchQuery.trim()
|
||||
|
||||
const requestSearchReveal = (lineNumber: number): void => {
|
||||
setPendingReveal({ lineNumber, query: moduleSearchQuery.trim() })
|
||||
}
|
||||
|
||||
const openSearchResult = (file: EditorFilePath, lineNumber: number): void => {
|
||||
onOpenEditorFileAtLine(file)
|
||||
requestSearchReveal(lineNumber)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingReveal) return
|
||||
const revealed = onRevealSearchMatch(pendingReveal.lineNumber, pendingReveal.query)
|
||||
if (revealed) setPendingReveal(null)
|
||||
}, [activeEditorFile, onRevealSearchMatch, pendingReveal])
|
||||
|
||||
useEffect(() => {
|
||||
if (searchActive) {
|
||||
window.setTimeout(() => moduleSearchInputRef.current?.focus(), 0)
|
||||
}
|
||||
}, [searchActive])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setDebouncedModuleSearchQuery(moduleSearchQuery)
|
||||
}, 350)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [moduleSearchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
const query = debouncedModuleSearchQuery.trim()
|
||||
if (!searchActive || !selectedModule || query.length < 2) {
|
||||
setModuleSearchResults([])
|
||||
setModuleSearchRunning(false)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const searchRunId = searchRunIdRef.current + 1
|
||||
searchRunIdRef.current = searchRunId
|
||||
let fileIndex = 0
|
||||
const normalizedQuery = query.toLowerCase()
|
||||
const nextResults: ModuleSearchResult[] = []
|
||||
setModuleSearchRunning(true)
|
||||
|
||||
const searchChunk = (): void => {
|
||||
if (cancelled || searchRunIdRef.current !== searchRunId) return
|
||||
const chunkEnd = Math.min(moduleFilePaths.length, fileIndex + 12)
|
||||
|
||||
for (; fileIndex < chunkEnd; fileIndex += 1) {
|
||||
const file = moduleFilePaths[fileIndex]
|
||||
const content = editorFiles[file] ?? ''
|
||||
const lines = content.split('\n')
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index]
|
||||
if (!line.toLowerCase().includes(normalizedQuery)) continue
|
||||
nextResults.push({ file, lineNumber: index + 1, lineText: line.trim() || line })
|
||||
if (nextResults.length >= 200) break
|
||||
}
|
||||
|
||||
if (nextResults.length >= 200) break
|
||||
}
|
||||
|
||||
if (cancelled || searchRunIdRef.current !== searchRunId) return
|
||||
|
||||
if (fileIndex < moduleFilePaths.length && nextResults.length < 200) {
|
||||
window.setTimeout(searchChunk, 0)
|
||||
} else {
|
||||
setModuleSearchResults([...nextResults])
|
||||
setModuleSearchRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(searchChunk, 0)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [debouncedModuleSearchQuery, editorFiles, moduleFilePathKey, searchActive, selectedModule])
|
||||
|
||||
return {
|
||||
moduleSearchInputRef,
|
||||
moduleSearchQuery,
|
||||
setModuleSearchQuery,
|
||||
activeModuleSearchQuery,
|
||||
moduleSearchRunning,
|
||||
moduleSearchResults,
|
||||
requestSearchReveal,
|
||||
openSearchResult
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { Terminal as XTerm } from '@xterm/xterm'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { getModuleTerminalSessionId, stripInternalTerminalCommands } from './workspaceLogs'
|
||||
|
||||
type TerminalContextMenu = {
|
||||
text: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
type UseWorkspaceTerminalOptions = {
|
||||
moduleName?: string | null
|
||||
moduleFolderName?: string | null
|
||||
clonePathPreview: string
|
||||
dark: boolean
|
||||
terminalActive: boolean
|
||||
resetKey: string
|
||||
layoutEditorToPane: () => void
|
||||
}
|
||||
|
||||
type UseWorkspaceTerminalResult = {
|
||||
terminalContextMenu: TerminalContextMenu | null
|
||||
setTerminalContextMenu: React.Dispatch<React.SetStateAction<TerminalContextMenu | null>>
|
||||
terminalContextMenuRef: React.RefObject<HTMLDivElement | null>
|
||||
terminalHostRef: React.RefObject<HTMLDivElement | null>
|
||||
terminalPanelHeight: number
|
||||
fitTerminal: () => void
|
||||
copyTerminalSelection: () => void
|
||||
pasteTerminalClipboard: () => void
|
||||
startTerminalResize: (event: React.MouseEvent<HTMLDivElement>) => void
|
||||
}
|
||||
|
||||
export function useWorkspaceTerminal({
|
||||
moduleName,
|
||||
moduleFolderName,
|
||||
clonePathPreview,
|
||||
dark,
|
||||
terminalActive,
|
||||
resetKey,
|
||||
layoutEditorToPane
|
||||
}: UseWorkspaceTerminalOptions): UseWorkspaceTerminalResult {
|
||||
const [terminalContextMenu, setTerminalContextMenu] = useState<TerminalContextMenu | null>(null)
|
||||
const terminalContextMenuRef = useRef<HTMLDivElement>(null)
|
||||
const terminalHostRef = useRef<HTMLDivElement | null>(null)
|
||||
const terminalRef = useRef<XTerm | null>(null)
|
||||
const terminalFitRef = useRef<FitAddon | null>(null)
|
||||
const terminalSessionIdRef = useRef<string | null>(null)
|
||||
const terminalSuppressQueueRef = useRef<string[]>([])
|
||||
const terminalOutputBufferRef = useRef<Record<string, string>>({})
|
||||
const terminalFallbackPromptShownRef = useRef<Set<string>>(new Set())
|
||||
const [terminalPanelHeight, setTerminalPanelHeight] = useState(235)
|
||||
|
||||
const fitTerminal = (): void => {
|
||||
terminalFitRef.current?.fit()
|
||||
}
|
||||
|
||||
const copyTerminalSelection = (): void => {
|
||||
if (!terminalContextMenu?.text) return
|
||||
void navigator.clipboard.writeText(terminalContextMenu.text).catch((error) => console.error('[terminal] copy failed', error))
|
||||
setTerminalContextMenu(null)
|
||||
}
|
||||
|
||||
const pasteTerminalClipboard = (): void => {
|
||||
const terminalSessionId = terminalSessionIdRef.current
|
||||
if (!terminalSessionId) {
|
||||
setTerminalContextMenu(null)
|
||||
return
|
||||
}
|
||||
|
||||
void navigator.clipboard.readText()
|
||||
.then((text) => {
|
||||
if (text) window.localWorkspace.terminal.write(text, terminalSessionId)
|
||||
})
|
||||
.catch((error) => console.error('[terminal] paste failed', error))
|
||||
.finally(() => {
|
||||
setTerminalContextMenu(null)
|
||||
window.requestAnimationFrame(() => terminalRef.current?.focus())
|
||||
})
|
||||
}
|
||||
|
||||
const startTerminalResize = (event: React.MouseEvent<HTMLDivElement>): void => {
|
||||
event.preventDefault()
|
||||
const startY = event.clientY
|
||||
const startHeight = terminalPanelHeight
|
||||
|
||||
const relayoutWorkspace = (): void => {
|
||||
fitTerminal()
|
||||
layoutEditorToPane()
|
||||
}
|
||||
const onMouseMove = (moveEvent: MouseEvent): void => {
|
||||
const nextHeight = Math.max(150, Math.min(460, startHeight - (moveEvent.clientY - startY)))
|
||||
setTerminalPanelHeight(nextHeight)
|
||||
window.requestAnimationFrame(relayoutWorkspace)
|
||||
}
|
||||
const onMouseUp = (): void => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
window.requestAnimationFrame(relayoutWorkspace)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!moduleName || !terminalActive || !terminalHostRef.current || terminalRef.current) return
|
||||
const terminalSessionId = getModuleTerminalSessionId(moduleName)
|
||||
terminalSessionIdRef.current = terminalSessionId
|
||||
|
||||
const term = new XTerm({
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
fontFamily: 'JetBrains Mono, Consolas, Monaco, monospace',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.25,
|
||||
theme: {
|
||||
background: dark ? '#171717' : '#f9fafc',
|
||||
foreground: dark ? 'rgba(255,255,255,0.72)' : '#2f3747',
|
||||
cursor: '#93c5fd',
|
||||
selectionBackground: 'rgba(59,130,246,0.28)'
|
||||
}
|
||||
})
|
||||
const fitAddon = new FitAddon()
|
||||
term.loadAddon(fitAddon)
|
||||
term.open(terminalHostRef.current)
|
||||
fitAddon.fit()
|
||||
term.focus()
|
||||
term.attachCustomKeyEventHandler((event) => {
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) {
|
||||
event.stopPropagation()
|
||||
}
|
||||
return true
|
||||
})
|
||||
const terminalElement = terminalHostRef.current
|
||||
const focusTerminal = (): void => term.focus()
|
||||
const handleTerminalContextMenu = (event: MouseEvent): void => {
|
||||
const selection = term.getSelection().trim()
|
||||
event.preventDefault()
|
||||
setTerminalContextMenu({
|
||||
text: selection,
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
})
|
||||
}
|
||||
terminalElement.addEventListener('contextmenu', handleTerminalContextMenu)
|
||||
terminalElement.addEventListener('mousedown', focusTerminal)
|
||||
let terminalReceivedVisibleData = Boolean(terminalOutputBufferRef.current[terminalSessionId])
|
||||
if (terminalOutputBufferRef.current[terminalSessionId]) {
|
||||
term.write(terminalOutputBufferRef.current[terminalSessionId])
|
||||
}
|
||||
terminalRef.current = term
|
||||
terminalFitRef.current = fitAddon
|
||||
|
||||
const terminalApi = window.localWorkspace.terminal
|
||||
const removeDataListener = terminalApi.onData((data) => {
|
||||
const suppressQueue = terminalSuppressQueueRef.current
|
||||
if (suppressQueue.length && suppressQueue[0] === data) {
|
||||
suppressQueue.shift()
|
||||
return
|
||||
}
|
||||
|
||||
const nextData = stripInternalTerminalCommands(data)
|
||||
if (nextData) {
|
||||
terminalReceivedVisibleData = true
|
||||
terminalOutputBufferRef.current[terminalSessionId] = `${terminalOutputBufferRef.current[terminalSessionId] ?? ''}${nextData}`.slice(-60000)
|
||||
term.write(nextData)
|
||||
}
|
||||
}, terminalSessionId)
|
||||
const removeSuppressListener = terminalApi.onSuppressData((data) => {
|
||||
terminalSuppressQueueRef.current.push(data)
|
||||
}, terminalSessionId)
|
||||
const inputDisposable = term.onData((data) => terminalApi.write(data, terminalSessionId))
|
||||
|
||||
let fallbackPromptTimer: number | null = null
|
||||
let sizeSyncTimer: number | null = null
|
||||
let startFrame: number | null = null
|
||||
let startNestedFrame: number | null = null
|
||||
let disposed = false
|
||||
const startTerminal = new Promise<boolean>((resolvePromise) => {
|
||||
startFrame = window.requestAnimationFrame(() => {
|
||||
startNestedFrame = window.requestAnimationFrame(() => {
|
||||
fitAddon.fit()
|
||||
if (disposed) return resolvePromise(false)
|
||||
void window.localWorkspace.terminal
|
||||
.start(moduleName, term.cols, term.rows, terminalSessionId, clonePathPreview, moduleFolderName ?? undefined)
|
||||
.then(resolvePromise)
|
||||
.catch(() => resolvePromise(false))
|
||||
})
|
||||
})
|
||||
})
|
||||
void startTerminal.then((started) => {
|
||||
fitAddon.fit()
|
||||
if (!started) return
|
||||
sizeSyncTimer = window.setTimeout(() => {
|
||||
fitAddon.fit()
|
||||
void terminalApi.resize(term.cols, term.rows, terminalSessionId)
|
||||
}, 150)
|
||||
fallbackPromptTimer = window.setTimeout(() => {
|
||||
if (terminalFallbackPromptShownRef.current.has(terminalSessionId) && terminalOutputBufferRef.current[terminalSessionId]) return
|
||||
if (terminalReceivedVisibleData || terminalOutputBufferRef.current[terminalSessionId]) return
|
||||
const activeLine = term.buffer.active.getLine(term.buffer.active.cursorY)?.translateToString(true).trim()
|
||||
if (activeLine || term.buffer.active.baseY > 0 || term.buffer.active.cursorX > 0) return
|
||||
const fallbackPrompt = `${moduleName.replace(/[^a-zA-Z0-9_.-]+/g, '-')}: `
|
||||
terminalReceivedVisibleData = true
|
||||
terminalFallbackPromptShownRef.current.add(terminalSessionId)
|
||||
terminalOutputBufferRef.current[terminalSessionId] = fallbackPrompt
|
||||
term.write(fallbackPrompt)
|
||||
}, 500)
|
||||
})
|
||||
|
||||
const onResize = (): void => fitAddon.fit()
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
if (startFrame !== null) window.cancelAnimationFrame(startFrame)
|
||||
if (startNestedFrame !== null) window.cancelAnimationFrame(startNestedFrame)
|
||||
window.removeEventListener('resize', onResize)
|
||||
terminalElement.removeEventListener('contextmenu', handleTerminalContextMenu)
|
||||
terminalElement.removeEventListener('mousedown', focusTerminal)
|
||||
removeDataListener()
|
||||
removeSuppressListener()
|
||||
inputDisposable.dispose()
|
||||
if (fallbackPromptTimer !== null) window.clearTimeout(fallbackPromptTimer)
|
||||
if (sizeSyncTimer !== null) window.clearTimeout(sizeSyncTimer)
|
||||
// Full-screen programs such as vi cannot be restored from a partial raw output buffer.
|
||||
delete terminalOutputBufferRef.current[terminalSessionId]
|
||||
terminalFallbackPromptShownRef.current.delete(terminalSessionId)
|
||||
terminalApi.stop(terminalSessionId)
|
||||
term.dispose()
|
||||
terminalRef.current = null
|
||||
terminalFitRef.current = null
|
||||
terminalSessionIdRef.current = null
|
||||
terminalSuppressQueueRef.current = []
|
||||
}
|
||||
}, [clonePathPreview, dark, moduleFolderName, moduleName, resetKey, terminalActive])
|
||||
|
||||
return {
|
||||
terminalContextMenu,
|
||||
setTerminalContextMenu,
|
||||
terminalContextMenuRef,
|
||||
terminalHostRef,
|
||||
terminalPanelHeight,
|
||||
fitTerminal,
|
||||
copyTerminalSelection,
|
||||
pasteTerminalClipboard,
|
||||
startTerminalResize
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import type { EditorFilePath } from './workspaceFiles'
|
||||
|
||||
export type TreeContextMenuState = {
|
||||
path: string
|
||||
kind: 'file' | 'folder'
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type TreeClipboardState = {
|
||||
path: string
|
||||
kind: 'file' | 'folder'
|
||||
mode: 'cut' | 'copy'
|
||||
}
|
||||
|
||||
export type TreeDialogState =
|
||||
| { type: 'rename'; path: string; kind: 'file' | 'folder'; value: string }
|
||||
| { type: 'delete'; path: string; kind: 'file' | 'folder' }
|
||||
|
||||
export type NewFileDraftState = {
|
||||
kind: 'file' | 'folder'
|
||||
parent: string
|
||||
afterFile: EditorFilePath | null
|
||||
depth: number
|
||||
value: string
|
||||
}
|
||||
|
||||
const defaultOpenProjectFolders: Record<string, boolean> = {
|
||||
lms: true,
|
||||
src: true,
|
||||
'src/main': true,
|
||||
'src/preload': true,
|
||||
'src/renderer': true,
|
||||
'src/renderer/public': true,
|
||||
'src/renderer/public/assets': true,
|
||||
'src/renderer/public/assets/images': true,
|
||||
'src/renderer/src': true,
|
||||
'src/renderer/src/components': true
|
||||
}
|
||||
|
||||
export function useWorkspaceTreeState() {
|
||||
const [selectedTreePath, setSelectedTreePath] = useState<string | null>(null)
|
||||
const [selectedExampleTreePath, setSelectedExampleTreePath] = useState<string | null>(null)
|
||||
const [treeContextMenu, setTreeContextMenu] = useState<TreeContextMenuState | null>(null)
|
||||
const [treeClipboard, setTreeClipboard] = useState<TreeClipboardState | null>(null)
|
||||
const [treeDialog, setTreeDialog] = useState<TreeDialogState | null>(null)
|
||||
const [newFileDraft, setNewFileDraft] = useState<NewFileDraftState | null>(null)
|
||||
const [createdModuleFolders, setCreatedModuleFolders] = useState<string[]>([])
|
||||
const [loadedModuleFolders, setLoadedModuleFolders] = useState<Record<string, string[]>>({})
|
||||
const [loadedModuleFilePaths, setLoadedModuleFilePaths] = useState<Record<string, string[]>>({})
|
||||
const [moduleFileLoadingByName, setModuleFileLoadingByName] = useState<Record<string, boolean>>({})
|
||||
const [loadedModuleRoots, setLoadedModuleRoots] = useState<Record<string, string | null>>({})
|
||||
const [openProjectFolders, setOpenProjectFolders] = useState<Record<string, boolean>>(defaultOpenProjectFolders)
|
||||
const treeContextMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const toggleProjectFolder = (path: string): void => {
|
||||
setOpenProjectFolders((folders) => ({ ...folders, [path]: !folders[path] }))
|
||||
}
|
||||
|
||||
return {
|
||||
selectedTreePath,
|
||||
setSelectedTreePath,
|
||||
selectedExampleTreePath,
|
||||
setSelectedExampleTreePath,
|
||||
treeContextMenu,
|
||||
setTreeContextMenu,
|
||||
treeClipboard,
|
||||
setTreeClipboard,
|
||||
treeDialog,
|
||||
setTreeDialog,
|
||||
newFileDraft,
|
||||
setNewFileDraft,
|
||||
createdModuleFolders,
|
||||
setCreatedModuleFolders,
|
||||
loadedModuleFolders,
|
||||
setLoadedModuleFolders,
|
||||
loadedModuleFilePaths,
|
||||
setLoadedModuleFilePaths,
|
||||
moduleFileLoadingByName,
|
||||
setModuleFileLoadingByName,
|
||||
loadedModuleRoots,
|
||||
setLoadedModuleRoots,
|
||||
openProjectFolders,
|
||||
setOpenProjectFolders,
|
||||
treeContextMenuRef,
|
||||
toggleProjectFolder
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { EnvironmentProfile } from '../platforms/types'
|
||||
import type { RuntimeImageArtifact } from './types'
|
||||
import type { VisualizationStack } from './WorkspaceVisualizationPanel'
|
||||
|
||||
type NoticeTone = 'info' | 'warning' | 'error'
|
||||
|
||||
type UseWorkspaceVisualizationOptions = {
|
||||
profiles: EnvironmentProfile[]
|
||||
clonePathPreview: string
|
||||
rosDomainId: string
|
||||
onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void
|
||||
}
|
||||
|
||||
type UseWorkspaceVisualizationResult = {
|
||||
visualizationStacks: VisualizationStack[]
|
||||
visualizationStack: VisualizationStack
|
||||
setVisualizationStack: (stack: VisualizationStack) => void
|
||||
visualizationRunning: boolean
|
||||
visualizationBusy: boolean
|
||||
visualizationViewerUrl: string
|
||||
activeEnvironmentProfile: EnvironmentProfile | null
|
||||
activeRuntimeImages: RuntimeImageArtifact[]
|
||||
refreshVisualizationStatus: () => Promise<void>
|
||||
startVisualizationBridge: () => Promise<void>
|
||||
stopVisualizationBridge: () => Promise<void>
|
||||
}
|
||||
|
||||
function formatVisualizationStackLabel(stack: VisualizationStack | null | undefined): string {
|
||||
if (stack === 'nav2') return 'Nav2'
|
||||
if (stack === 'slam') return 'SLAM'
|
||||
return 'Autoware'
|
||||
}
|
||||
|
||||
export function useWorkspaceVisualization({
|
||||
profiles,
|
||||
clonePathPreview,
|
||||
rosDomainId,
|
||||
onTitlebarNotice
|
||||
}: UseWorkspaceVisualizationOptions): UseWorkspaceVisualizationResult {
|
||||
const [visualizationStack, setVisualizationStack] = useState<VisualizationStack>('autoware')
|
||||
const [visualizationRunning, setVisualizationRunning] = useState(false)
|
||||
const [, setVisualizationStatusMessage] = useState('대기')
|
||||
const [visualizationBusy, setVisualizationBusy] = useState(false)
|
||||
|
||||
const visualizationStacks = useMemo(() => (
|
||||
profiles
|
||||
.map((profile) => profile.profileType.toLowerCase())
|
||||
.filter((stack): stack is VisualizationStack => stack === 'autoware' || stack === 'nav2' || stack === 'slam')
|
||||
), [profiles])
|
||||
|
||||
const activeEnvironmentProfile = profiles.find((profile) => profile.profileType.toLowerCase() === visualizationStack) ?? profiles[0] ?? null
|
||||
const activeEnvironmentConfig = activeEnvironmentProfile?.config ?? {}
|
||||
const activeRuntimeImages = Array.isArray(activeEnvironmentConfig.runtimeImages)
|
||||
? activeEnvironmentConfig.runtimeImages.filter((image): image is RuntimeImageArtifact => (
|
||||
typeof image === 'object' && image !== null && typeof (image as RuntimeImageArtifact).image === 'string'
|
||||
))
|
||||
: []
|
||||
const activeEnvironmentAgent = typeof activeEnvironmentConfig.agent === 'object' && activeEnvironmentConfig.agent !== null
|
||||
? activeEnvironmentConfig.agent as Record<string, unknown>
|
||||
: null
|
||||
const activeEnvironmentAgentUrl = typeof activeEnvironmentAgent?.url === 'string'
|
||||
? activeEnvironmentAgent.url.replace(/\/+$/, '')
|
||||
: ''
|
||||
const visualizationRemote = activeEnvironmentConfig.target === 'remote-jetson' && activeEnvironmentAgentUrl
|
||||
const visualizationRemoteBridgeHttpUrl = (() => {
|
||||
if (!visualizationRemote) return ''
|
||||
try {
|
||||
const url = new URL(activeEnvironmentAgentUrl)
|
||||
url.port = '8765'
|
||||
return url.toString().replace(/\/+$/, '')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})()
|
||||
const visualizationBridgeHttpUrl = visualizationRemoteBridgeHttpUrl || 'http://localhost:8765'
|
||||
const visualizationBridgeUrl = visualizationRemote
|
||||
? visualizationBridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
|
||||
: 'ws://localhost:8765'
|
||||
const visualizationBackendUrl = visualizationBridgeHttpUrl
|
||||
const visualizationViewerUrl = visualizationStack === 'slam'
|
||||
? `/slam-viewer/index.html?directBridge=1&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}`
|
||||
: `/autoware-viewer/index.html?source=ros&stack=${visualizationStack}&bridge=${encodeURIComponent(visualizationBridgeUrl)}&backend=${encodeURIComponent(visualizationBackendUrl)}`
|
||||
|
||||
useEffect(() => {
|
||||
const profileType = profiles[0]?.profileType?.toLowerCase()
|
||||
if (profileType === 'slam' || profileType === 'nav2' || profileType === 'autoware') {
|
||||
setVisualizationStack(profileType)
|
||||
}
|
||||
}, [profiles.map((profile) => profile.id).join('|')])
|
||||
|
||||
const refreshVisualizationStatus = async (): Promise<void> => {
|
||||
const status = await window.localWorkspace.visualization.status().catch((error) => ({
|
||||
ok: false,
|
||||
running: false,
|
||||
stack: null,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
}))
|
||||
setVisualizationRunning(Boolean(status.running))
|
||||
if (status.stack) setVisualizationStack(status.stack)
|
||||
setVisualizationStatusMessage(status.running
|
||||
? formatVisualizationStackLabel(status.stack)
|
||||
: status.message
|
||||
? status.message
|
||||
: '대기')
|
||||
}
|
||||
|
||||
const startVisualizationBridge = async (): Promise<void> => {
|
||||
if (visualizationBusy) return
|
||||
if (!activeEnvironmentProfile) {
|
||||
setVisualizationStatusMessage('지원 안함')
|
||||
onTitlebarNotice('해당 주차는 시각화를 지원하지 않습니다', 'warning')
|
||||
return
|
||||
}
|
||||
setVisualizationBusy(true)
|
||||
setVisualizationStatusMessage('시작 중')
|
||||
try {
|
||||
const status = await window.localWorkspace.visualization.start(visualizationStack, clonePathPreview, rosDomainId, activeEnvironmentProfile.config)
|
||||
if (!status.ok) throw new Error(status.message ?? '시작 실패')
|
||||
setVisualizationRunning(Boolean(status.running))
|
||||
if (status.stack) setVisualizationStack(status.stack)
|
||||
setVisualizationStatusMessage(formatVisualizationStackLabel(status.stack))
|
||||
onTitlebarNotice('화면이 시작되었습니다', 'info')
|
||||
} catch (error) {
|
||||
setVisualizationRunning(false)
|
||||
setVisualizationStatusMessage(error instanceof Error ? error.message : '시작 실패')
|
||||
onTitlebarNotice('화면 시작에 실패했습니다', 'error')
|
||||
} finally {
|
||||
setVisualizationBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stopVisualizationBridge = async (): Promise<void> => {
|
||||
if (visualizationBusy) return
|
||||
setVisualizationBusy(true)
|
||||
setVisualizationStatusMessage('종료 중')
|
||||
try {
|
||||
const status = await window.localWorkspace.visualization.stop(activeEnvironmentProfile?.config)
|
||||
if (!status.ok) throw new Error(status.message ?? '종료 실패')
|
||||
setVisualizationRunning(false)
|
||||
setVisualizationStatusMessage('대기')
|
||||
onTitlebarNotice('화면이 종료되었습니다', 'info')
|
||||
} catch (error) {
|
||||
setVisualizationStatusMessage(error instanceof Error ? error.message : '종료 실패')
|
||||
onTitlebarNotice('화면 종료에 실패했습니다', 'error')
|
||||
} finally {
|
||||
setVisualizationBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
visualizationStacks,
|
||||
visualizationStack,
|
||||
setVisualizationStack,
|
||||
visualizationRunning,
|
||||
visualizationBusy,
|
||||
visualizationViewerUrl,
|
||||
activeEnvironmentProfile,
|
||||
activeRuntimeImages,
|
||||
refreshVisualizationStatus,
|
||||
startVisualizationBridge,
|
||||
stopVisualizationBridge
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { ModuleRow } from './types'
|
||||
|
||||
export type EditorFilePath = string
|
||||
|
||||
export type WorkspaceTreeItem =
|
||||
| { kind: 'folder'; name: string; path: string; parent?: string; depth: number }
|
||||
| { kind: 'file'; name: string; path: EditorFilePath; parent?: string; depth: number }
|
||||
|
||||
export function getEditorLanguage(file: string): string {
|
||||
if (file.endsWith('.tsx') || file.endsWith('.ts')) return 'typescript'
|
||||
if (/\.(c|cc|cpp|cxx|h|hh|hpp|hxx)$/i.test(file)) return 'cpp'
|
||||
if (file.endsWith('.json')) return 'json'
|
||||
if (file.endsWith('.xml')) return 'xml'
|
||||
if (file.endsWith('.html')) return 'html'
|
||||
if (file.endsWith('.css')) return 'css'
|
||||
if (file.endsWith('.py')) return 'python'
|
||||
if (file.endsWith('.sh')) return 'shell'
|
||||
if (file.endsWith('CMakeLists.txt') || file.endsWith('.cmake')) return 'cmake'
|
||||
if (file.endsWith('.md')) return 'markdown'
|
||||
if (file.endsWith('.yml') || file.endsWith('.yaml')) return 'yaml'
|
||||
return 'plaintext'
|
||||
}
|
||||
|
||||
export function getModuleSourceFiles(module: ModuleRow): Record<string, string> {
|
||||
const packageName = module.name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '')
|
||||
const className = module.name.replace(/[^a-zA-Z0-9]/g, '')
|
||||
const nodeName = `${packageName}_node`
|
||||
|
||||
return {
|
||||
[`modules/${module.name}/launch/${packageName}.launch.py`]: `from launch import LaunchDescription
|
||||
from launch_ros.actions import Node
|
||||
|
||||
def generate_launch_description():
|
||||
return LaunchDescription([
|
||||
Node(
|
||||
package='${packageName}',
|
||||
executable='${nodeName}',
|
||||
name='${nodeName}',
|
||||
output='screen',
|
||||
parameters=['config/${packageName}.yaml']
|
||||
)
|
||||
])
|
||||
`,
|
||||
[`modules/${module.name}/config/${packageName}.yaml`]: `/**:
|
||||
ros__parameters:
|
||||
enabled: ${module.state === 'running'}
|
||||
${module.parameters.length ? module.parameters.map((parameter) => ` ${parameter.replace(': ', ': ')}`).join('\n') : ' update_rate: 20.0'}
|
||||
`,
|
||||
[`modules/${module.name}/src/${nodeName}.cpp`]: `#include <rclcpp/rclcpp.hpp>
|
||||
|
||||
class ${className}Node : public rclcpp::Node
|
||||
{
|
||||
public:
|
||||
${className}Node() : Node("${nodeName}")
|
||||
{
|
||||
timer_ = create_wall_timer(
|
||||
std::chrono::milliseconds(100),
|
||||
std::bind(&${className}Node::onTimer, this));
|
||||
}
|
||||
|
||||
private:
|
||||
void onTimer()
|
||||
{
|
||||
RCLCPP_INFO_THROTTLE(get_logger(), *get_clock(), 3000, "${module.name} running");
|
||||
}
|
||||
|
||||
rclcpp::TimerBase::SharedPtr timer_;
|
||||
};
|
||||
|
||||
int main(int argc, char ** argv)
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<${className}Node>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
`,
|
||||
[`modules/${module.name}/include/${packageName}/${nodeName}.hpp`]: `#pragma once
|
||||
|
||||
#include <rclcpp/rclcpp.hpp>
|
||||
|
||||
class ${className}Node : public rclcpp::Node
|
||||
{
|
||||
public:
|
||||
${className}Node();
|
||||
|
||||
private:
|
||||
void onTimer();
|
||||
rclcpp::TimerBase::SharedPtr timer_;
|
||||
};
|
||||
`,
|
||||
[`modules/${module.name}/CMakeLists.txt`]: `cmake_minimum_required(VERSION 3.8)
|
||||
project(${packageName})
|
||||
|
||||
find_package(ament_cmake REQUIRED)
|
||||
find_package(rclcpp REQUIRED)
|
||||
|
||||
add_executable(${nodeName} src/${nodeName}.cpp)
|
||||
ament_target_dependencies(${nodeName} rclcpp)
|
||||
|
||||
install(TARGETS ${nodeName}
|
||||
DESTINATION lib/\${PROJECT_NAME})
|
||||
|
||||
install(DIRECTORY launch config
|
||||
DESTINATION share/\${PROJECT_NAME})
|
||||
|
||||
ament_package()
|
||||
`,
|
||||
[`modules/${module.name}/package.xml`]: `<?xml version="1.0"?>
|
||||
<package format="3">
|
||||
<name>${packageName}</name>
|
||||
<version>0.1.0</version>
|
||||
<description>${module.category} module for autonomy practice.</description>
|
||||
<maintainer email="edu@autonomy.local">Autonomy Studio</maintainer>
|
||||
<license>Apache-2.0</license>
|
||||
|
||||
<buildtool_depend>ament_cmake</buildtool_depend>
|
||||
<depend>rclcpp</depend>
|
||||
|
||||
<export>
|
||||
<build_type>ament_cmake</build_type>
|
||||
</export>
|
||||
</package>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
export function buildModuleTreeItems(
|
||||
module: ModuleRow,
|
||||
filePaths = Object.keys(getModuleSourceFiles(module)),
|
||||
folderPaths: string[] = []
|
||||
): WorkspaceTreeItem[] {
|
||||
const rootPath = `modules/${module.name}`
|
||||
return buildPathTreeItems(rootPath, filePaths, folderPaths)
|
||||
}
|
||||
|
||||
export function buildPathTreeItems(rootPath: string, filePaths: string[], folderPaths: string[] = []): WorkspaceTreeItem[] {
|
||||
const sourceFiles = filePaths.filter((filePath) => filePath.startsWith(`${rootPath}/`))
|
||||
const folderMap = new Map<string, { kind: 'folder'; name: string; path: string; parent?: string; depth: number }>()
|
||||
const fileItems: Array<{ kind: 'file'; name: string; path: EditorFilePath; parent?: string; depth: number }> = []
|
||||
|
||||
sourceFiles.forEach((filePath) => {
|
||||
const parts = filePath.split('/')
|
||||
const rootDepth = rootPath.split('/').length
|
||||
for (let index = rootDepth; index < parts.length - 1; index += 1) {
|
||||
const folderPath = parts.slice(0, index + 1).join('/')
|
||||
if (!folderMap.has(folderPath)) {
|
||||
folderMap.set(folderPath, {
|
||||
kind: 'folder',
|
||||
name: parts[index],
|
||||
path: folderPath,
|
||||
parent: parts.slice(0, index).join('/'),
|
||||
depth: index - rootDepth + 1
|
||||
})
|
||||
}
|
||||
}
|
||||
fileItems.push({
|
||||
kind: 'file',
|
||||
name: parts.at(-1) ?? filePath,
|
||||
path: filePath,
|
||||
parent: parts.slice(0, -1).join('/'),
|
||||
depth: parts.length - rootDepth
|
||||
})
|
||||
})
|
||||
folderPaths.forEach((folderPath) => {
|
||||
const parts = folderPath.split('/')
|
||||
const rootDepth = rootPath.split('/').length
|
||||
for (let index = rootDepth; index < parts.length; index += 1) {
|
||||
const currentFolderPath = parts.slice(0, index + 1).join('/')
|
||||
if (!folderMap.has(currentFolderPath)) {
|
||||
folderMap.set(currentFolderPath, {
|
||||
kind: 'folder',
|
||||
name: parts[index],
|
||||
path: currentFolderPath,
|
||||
parent: parts.slice(0, index).join('/'),
|
||||
depth: index - rootDepth + 1
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const childrenByParent = new Map<string, WorkspaceTreeItem[]>()
|
||||
;[...folderMap.values(), ...fileItems].forEach((item) => {
|
||||
if (!item.parent) return
|
||||
childrenByParent.set(item.parent, [...(childrenByParent.get(item.parent) ?? []), item])
|
||||
})
|
||||
|
||||
const sortTreeItems = (a: WorkspaceTreeItem, b: WorkspaceTreeItem): number => {
|
||||
if (a.kind !== b.kind) return a.kind === 'folder' ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
const orderedItems: WorkspaceTreeItem[] = []
|
||||
const visitChildren = (parentPath: string): void => {
|
||||
;(childrenByParent.get(parentPath) ?? []).sort(sortTreeItems).forEach((item) => {
|
||||
orderedItems.push(item)
|
||||
if (item.kind === 'folder') visitChildren(item.path)
|
||||
})
|
||||
}
|
||||
|
||||
visitChildren(rootPath)
|
||||
|
||||
return orderedItems
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export const formatSessionClock = (value?: string | null): string | null => {
|
||||
if (!value) return null
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
return date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
}
|
||||
|
||||
export const formatControlRemainingTime = (endsAt?: string | null): string => {
|
||||
if (!endsAt) return '00:00'
|
||||
const endTime = new Date(endsAt).getTime()
|
||||
if (Number.isNaN(endTime)) return '00:00'
|
||||
const remainingSeconds = Math.max(0, Math.ceil((endTime - Date.now()) / 1000))
|
||||
const hours = Math.floor(remainingSeconds / 3600)
|
||||
const minutes = Math.floor((remainingSeconds % 3600) / 60)
|
||||
const seconds = remainingSeconds % 60
|
||||
const paddedMinutes = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
|
||||
const paddedSeconds = String(seconds).padStart(2, '0')
|
||||
return hours > 0 ? `${hours}:${paddedMinutes}:${paddedSeconds}` : `${paddedMinutes}:${paddedSeconds}`
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ModuleRow } from './types'
|
||||
|
||||
export const maxVisibleModuleLogEntries = 200
|
||||
|
||||
export type ModuleLogEntry = {
|
||||
time: string
|
||||
level: 'INFO' | 'WARN' | 'ERROR'
|
||||
source: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export function getModuleTerminalSessionId(moduleName: string): string {
|
||||
return `lms-module-terminal:${moduleName}`
|
||||
}
|
||||
|
||||
export function stripInternalTerminalCommands(data: string): string {
|
||||
return data
|
||||
.replace(/(?:\x1b\[\?2004[lh])?\r*stty cols \d+ rows \d+\r?\n?/g, '')
|
||||
.replace(/(?:^|\r?\n)cd '(?:[^'\\]|\\.)*'\r?\n?/g, '')
|
||||
.replace(/(?:^|\r?\n)cd "(?:[^"\\]|\\.)*"\r?\n?/g, '')
|
||||
.replace(/(?:^|\r?\n)cd [^\r\n]+\r?\n?/g, '')
|
||||
}
|
||||
|
||||
function stripLogControlSequences(line: string): string {
|
||||
return line
|
||||
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '')
|
||||
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
|
||||
.replace(/␛\][^␇]*(?:␇|$)/g, '')
|
||||
.replace(/␛\[[0-?]*[ -/]*[@-~]/g, '')
|
||||
}
|
||||
|
||||
function parseLogDate(value?: string): Date | null {
|
||||
if (!value) return null
|
||||
const normalized = value.replace(/(\.\d{3})\d+/, '$1')
|
||||
const date = new Date(normalized)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
export function parseModuleLogEntries(module: ModuleRow, logs: string): ModuleLogEntry[] {
|
||||
return logs
|
||||
.split(/\r?\n/)
|
||||
.map((line) => stripLogControlSequences(line).trimEnd())
|
||||
.filter(Boolean)
|
||||
.map((line): ModuleLogEntry => {
|
||||
const timestampMatch = line.match(/^(\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)\s+(.*)$/)
|
||||
const rosEpochMatch = line.match(/\[(\d{10}\.\d+)\]/)
|
||||
const bracketLevelMatch = line.match(/\b(ERROR|ERR|WARN|WARNING|INFO|DEBUG|TRACE|FATAL)\b/i)
|
||||
const levelToken = bracketLevelMatch?.[1]?.toUpperCase()
|
||||
const level: ModuleLogEntry['level'] =
|
||||
levelToken === 'ERROR' || levelToken === 'ERR' || levelToken === 'FATAL'
|
||||
? 'ERROR'
|
||||
: levelToken === 'WARN' || levelToken === 'WARNING'
|
||||
? 'WARN'
|
||||
: 'INFO'
|
||||
const date = parseLogDate(timestampMatch?.[1]) ?? (rosEpochMatch ? new Date(Number(rosEpochMatch[1]) * 1000) : null)
|
||||
const time = date && !Number.isNaN(date.getTime())
|
||||
? date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
|
||||
: '--:--:--'
|
||||
|
||||
return {
|
||||
time,
|
||||
level,
|
||||
source: module.name,
|
||||
message: timestampMatch?.[2] ?? line
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { CurriculumWeek } from '../curriculum/types'
|
||||
import type { ModuleRow } from './types'
|
||||
|
||||
export const compareModulePriority = (a: ModuleRow, b: ModuleRow): number => a.sortOrder - b.sortOrder
|
||||
|
||||
export const compareModuleShutdownPriority = (a: ModuleRow, b: ModuleRow): number => b.sortOrder - a.sortOrder
|
||||
|
||||
export function makeUninstalledModuleRows(curriculumModules: CurriculumWeek['modules']): ModuleRow[] {
|
||||
return (curriculumModules ?? [])
|
||||
.map((module) => ({
|
||||
state: 'uninstalled' as const,
|
||||
sortOrder: module.sortOrder,
|
||||
category: module.category,
|
||||
name: module.name,
|
||||
folderName: module.folderName,
|
||||
sourcePath: module.templateRepositoryUrl ?? '',
|
||||
workspacePath: null,
|
||||
templateRepositoryUrl: module.templateRepositoryUrl ?? null,
|
||||
sourceType: module.sourceType ?? null,
|
||||
prebuiltArtifacts: module.prebuiltArtifacts ?? null,
|
||||
parameters: [],
|
||||
cpu: null,
|
||||
memory: null,
|
||||
runtime: '-'
|
||||
}))
|
||||
.sort(compareModulePriority)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export function getPathName(path: string): string {
|
||||
return path.split('/').pop() ?? path
|
||||
}
|
||||
|
||||
export function getPathParent(path: string): string {
|
||||
return path.split('/').slice(0, -1).join('/')
|
||||
}
|
||||
|
||||
export function replacePathPrefix(path: string, fromPath: string, toPath: string): string {
|
||||
return path === fromPath ? toPath : path.startsWith(`${fromPath}/`) ? `${toPath}${path.slice(fromPath.length)}` : path
|
||||
}
|
||||
|
||||
export function makeUniquePath(basePath: string, files: Record<string, string>, folders: string[]): string {
|
||||
if (files[basePath] === undefined && !folders.includes(basePath)) return basePath
|
||||
const parent = getPathParent(basePath)
|
||||
const name = getPathName(basePath)
|
||||
const dotIndex = name.lastIndexOf('.')
|
||||
const stem = dotIndex > 0 ? name.slice(0, dotIndex) : name
|
||||
const ext = dotIndex > 0 ? name.slice(dotIndex) : ''
|
||||
let index = 1
|
||||
|
||||
while (true) {
|
||||
const nextPath = `${parent}/${stem} copy${index > 1 ? ` ${index}` : ''}${ext}`
|
||||
if (files[nextPath] === undefined && !folders.includes(nextPath)) return nextPath
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user