feat: add LMS web client

This commit is contained in:
2026-07-15 13:41:00 +09:00
commit 05ff2644e8
308 changed files with 487923 additions and 0 deletions
@@ -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
}
}