fix: detect remote SLAM bridge and clear loading state on response
This commit is contained in:
@@ -15,9 +15,9 @@ const artifacts = [
|
||||
{
|
||||
image: jetsonBridgeImage,
|
||||
architecture: 'arm64',
|
||||
url: `${root}/arm64/20260819-0659d4cf5578/slam-ros-bridge-jetson-orin.tar.zst`,
|
||||
fileName: 'slam-ros-bridge-jetson-orin-0659d4cf5578.tar.zst',
|
||||
checksumSha256: '0659d4cf5578c43bc9c25e4a4fc5d226ccd5b7bec43becfdff83b5390717114e',
|
||||
url: `${root}/arm64/20261013-21acdf0cbdee/slam-ros-bridge-jetson-orin.tar.zst`,
|
||||
fileName: 'slam-ros-bridge-jetson-orin-21acdf0cbdee.tar.zst',
|
||||
checksumSha256: '21acdf0cbdee45d8b7b9dba78c3d321d67d8d7a2467511c4af0826e36ea0c74f',
|
||||
required: true,
|
||||
},
|
||||
]
|
||||
|
||||
+21
-4
@@ -2462,9 +2462,12 @@ async function readRemoteVisualizationBridgeStatus(target: RemoteWorkspaceTarget
|
||||
'docker ps --format "{{.Names}}\\t{{.Status}}" 2>/dev/null || true',
|
||||
10_000,
|
||||
)
|
||||
const nav2 = output.split('\n').find((line) => line.startsWith('nav2-ros-bridge\t'))
|
||||
const autoware = output.split('\n').find((line) => line.startsWith('autoware-ros-bridge\t'))
|
||||
const lines = output.split('\n')
|
||||
const nav2 = lines.find((line) => line.startsWith('nav2-ros-bridge\t'))
|
||||
const slam = lines.find((line) => line.startsWith('slam-ros-bridge\t'))
|
||||
const autoware = lines.find((line) => line.startsWith('autoware-ros-bridge\t'))
|
||||
if (nav2) return { ok: true, running: true, stack: 'nav2' }
|
||||
if (slam) return { ok: true, running: true, stack: 'slam' }
|
||||
if (autoware) return { ok: true, running: true, stack: 'autoware' }
|
||||
return { ok: true, running: false, stack: null }
|
||||
} catch (error) {
|
||||
@@ -2472,6 +2475,18 @@ async function readRemoteVisualizationBridgeStatus(target: RemoteWorkspaceTarget
|
||||
}
|
||||
}
|
||||
|
||||
const managedVisualizationBridgeEnvKeys = new Set([
|
||||
'ROS_DOMAIN_ID',
|
||||
'ROS_LOCALHOST_ONLY',
|
||||
'FASTDDS_DEFAULT_PROFILES_FILE',
|
||||
'FASTRTPS_DEFAULT_PROFILES_FILE',
|
||||
'AUTONOMY_STUDIO_ROS_NETWORK',
|
||||
])
|
||||
|
||||
function isManagedVisualizationBridgeEnvKey(key: string): boolean {
|
||||
return managedVisualizationBridgeEnvKeys.has(key)
|
||||
}
|
||||
|
||||
async function startRemoteVisualizationBridge(
|
||||
target: RemoteWorkspaceTarget,
|
||||
stackInput?: string | null,
|
||||
@@ -2486,7 +2501,7 @@ async function startRemoteVisualizationBridge(
|
||||
const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(normalized, '~/.autonomy-studio/runtime/nav2', rosNetwork, rosDomainId)
|
||||
const containerName = visualizationBridgeContainerName(stack)
|
||||
const bridgeEnvArgs = Object.entries(environmentProfile?.bridge?.env ?? {})
|
||||
.filter(([key]) => /^[A-Z_][A-Z0-9_]*$/.test(key))
|
||||
.filter(([key]) => /^[A-Z_][A-Z0-9_]*$/.test(key) && !isManagedVisualizationBridgeEnvKey(key))
|
||||
.map(([key, value]) => `-e ${quoteShellSingle(`${key}=${value}`)}`)
|
||||
.join(' ')
|
||||
let image = resolveVisualizationBridgeImage(stack, environmentProfile?.bridge, environmentProfile?.runtimeImages)
|
||||
@@ -4022,7 +4037,9 @@ async function startVisualizationBridge(
|
||||
CMD_VEL_TOPIC: '/cmd_vel',
|
||||
NAVIGATE_TO_POSE_ACTION: '/navigate_to_pose'
|
||||
} : {})
|
||||
Object.entries(bridgeEnv).forEach(([key, value]) => args.push('-e', `${key}=${value}`))
|
||||
Object.entries(bridgeEnv).forEach(([key, value]) => {
|
||||
if (!isManagedVisualizationBridgeEnvKey(key)) args.push('-e', `${key}=${value}`)
|
||||
})
|
||||
if (usesSlamBridge) {
|
||||
args.push('-e', `PORT=${bridgePort}`)
|
||||
}
|
||||
|
||||
@@ -3135,6 +3135,9 @@ renderer.domElement.addEventListener('click', async (event) => {
|
||||
});
|
||||
|
||||
function applySlamState(state) {
|
||||
// Any valid state payload confirms that the bridge is responding. Missing
|
||||
// ROS data is reported separately by the topic status indicators.
|
||||
markVisualizationResponseReceived();
|
||||
latestSlamState = state;
|
||||
latestSlamStateReceivedAt = Date.now();
|
||||
resetNavigationPoseForNewSession(state);
|
||||
@@ -3150,7 +3153,6 @@ function applySlamState(state) {
|
||||
clearNavigationGoalMarker();
|
||||
navigationGoalId = null;
|
||||
}
|
||||
if (state?.pose || state?.pose_3d || state?.scan || state?.map || state?.point_cloud || state?.sensor_point_cloud) markVisualizationResponseReceived();
|
||||
if (NAV2_VIEW_MODE === 'navigation' && state?.tf?.map_to_odom) {
|
||||
navigationMapToOdom = state.tf.map_to_odom;
|
||||
updateNavigationMapTransform();
|
||||
|
||||
@@ -990,7 +990,7 @@ function LocalPcWorkspaceMockup({
|
||||
const surface = dark ? 'bg-[#202020] text-[#e6e6e6]' : 'bg-[#eef1f6] text-[#151827]'
|
||||
const topbar = dark
|
||||
? 'border-white/10 bg-[#303030]'
|
||||
: 'border-[#cbbdde] bg-[linear-gradient(90deg,#e6ddf1_0%,#e5def1_52%,#dce9f5_100%)]'
|
||||
: 'border-[#cbbdde] bg-[linear-gradient(90deg,#f8f6fb_0%,#f5f2f9_52%,#dce9f5_100%)]'
|
||||
const panel = dark ? 'border-white/10 bg-[#252525]' : 'border-[#cfd6e4] bg-[#e9edf4]'
|
||||
const textStrong = dark ? 'text-white' : 'text-[#111520]'
|
||||
const textMid = dark ? 'text-white/70' : 'text-[#4f596b]'
|
||||
@@ -1663,6 +1663,7 @@ function LocalPcWorkspaceMockup({
|
||||
performDeleteTreeTarget,
|
||||
requestDeleteTreeTarget,
|
||||
pasteTreeClipboard,
|
||||
moveTreeTarget,
|
||||
refreshModuleFiles
|
||||
} = useWorkspaceFileActions({
|
||||
selectedModule,
|
||||
@@ -3681,7 +3682,7 @@ function LocalPcWorkspaceMockup({
|
||||
|
||||
{workspaceActive && (
|
||||
<div
|
||||
className={['border-b', gitActive ? 'border-transparent' : dark ? 'border-white/10' : 'border-[#d8dee9]', dark ? 'bg-[#252525]' : 'bg-[#eef1f6]'].join(' ')}
|
||||
className={['border-b', gitActive ? 'border-transparent' : dark ? 'border-white/10' : 'border-[#d8dee9]', dark ? 'bg-[#252525]' : 'bg-white'].join(' ')}
|
||||
style={{ gridColumn: '4 / span 3', gridRow: 1 }}
|
||||
>
|
||||
<WorkspaceActivityHeader
|
||||
@@ -3785,6 +3786,7 @@ function LocalPcWorkspaceMockup({
|
||||
setTreeContextMenu(null)
|
||||
}}
|
||||
onPasteTreeClipboard={pasteTreeClipboard}
|
||||
onMoveTreeTarget={(sourcePath, sourceKind, targetFolder) => void moveTreeTarget(sourcePath, sourceKind, targetFolder)}
|
||||
onCopyTreePath={() => void copyTreePathToClipboard()}
|
||||
onRenameTreeTarget={requestRenameTreeTarget}
|
||||
onDeleteTreeTarget={requestDeleteTreeTarget}
|
||||
@@ -4112,6 +4114,7 @@ function LocalPcWorkspaceMockup({
|
||||
)}
|
||||
{worldEditorOpen && (
|
||||
<WorldEditorModal
|
||||
dark={dark}
|
||||
workspaceRoot={clonePathPreview}
|
||||
onSaved={() => {
|
||||
setWorldLayoutVersion((version) => version + 1)
|
||||
@@ -5826,12 +5829,12 @@ export default function App(): React.JSX.Element {
|
||||
const controlStatus = platform.control?.status
|
||||
const copy = controlStatus === 'QUEUED'
|
||||
? {
|
||||
title: `${platform.title} 대기 예약 취소 중`,
|
||||
description: '대기열에서 요청을 제거하고 플랫폼 상태를 다시 확인하고 있습니다.'
|
||||
title: t('{{platform}} 대기 예약 취소 중', { platform: platform.title }),
|
||||
description: t('대기열에서 요청을 제거하고 플랫폼 상태를 다시 확인하고 있습니다.')
|
||||
}
|
||||
: {
|
||||
title: `${platform.title} 제어권 요청 중`,
|
||||
description: '플랫폼 사용 가능 여부와 대기열 상태를 확인하고 있습니다.'
|
||||
title: t('{{platform}} 제어권 요청 중', { platform: platform.title }),
|
||||
description: t('플랫폼 사용 가능 여부와 대기열 상태를 확인하고 있습니다.')
|
||||
}
|
||||
|
||||
const loadingStartedAt = Date.now()
|
||||
@@ -5846,12 +5849,14 @@ export default function App(): React.JSX.Element {
|
||||
shouldWaitForControlPaint = true
|
||||
}
|
||||
if (result.method === 'DELETE') {
|
||||
showTitlebarNotice(`${platform.title} 대기 예약이 취소되었습니다`, 'info', true)
|
||||
showTitlebarNotice(t('{{platform}} 대기 예약이 취소되었습니다', { platform: platform.title }), 'info', true)
|
||||
} else if (payload?.result === 'GRANTED' || payload?.result === 'ALREADY_GRANTED') {
|
||||
showTitlebarNotice(`${platform.title} 제어권이 부여되었습니다`, 'info', true)
|
||||
showTitlebarNotice(t('{{platform}} 제어권이 부여되었습니다', { platform: platform.title }), 'info', true)
|
||||
} else if (payload?.result === 'QUEUED') {
|
||||
const position = payload.queue?.position
|
||||
showTitlebarNotice(`${platform.title} 대기 예약되었습니다${position ? ` (${position}번째)` : ''}`, 'info', true)
|
||||
showTitlebarNotice(position
|
||||
? t('{{platform}} 대기 예약되었습니다 ({{position}}번째)', { platform: platform.title, position })
|
||||
: t('{{platform}} 대기 예약되었습니다', { platform: platform.title }), 'info', true)
|
||||
}
|
||||
} catch {
|
||||
// Keep the existing curriculum visible when a control request fails.
|
||||
|
||||
@@ -166,7 +166,7 @@ export function RunningPlatformCloseModal({
|
||||
title={t("개발 스튜디오 종료")}
|
||||
message={t("{{v0}} 개발 스튜디오를 종료할까요?", { v0: platformTitle === '로컬 PC' ? t('로컬 PC') : platformTitle })}
|
||||
detail={t("현재 열린 개발 스튜디오 세션만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.")}
|
||||
confirmLabel={t("종료")}
|
||||
confirmLabel={t("확인")}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
|
||||
@@ -1046,7 +1046,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
||||
type="button"
|
||||
onClick={handleConfirmClose}
|
||||
className="cursor-pointer rounded-md border border-red-400/35 bg-red-500/15 px-5 py-2 text-[12px] font-bold text-red-100 transition-colors hover:border-red-400/50 hover:bg-red-500/24"
|
||||
>{t("종료")}</button>
|
||||
>{t("앱 종료")}</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function CurriculumHomeScreen<Week extends CurriculumHomeWeek>({
|
||||
</div>
|
||||
</div>
|
||||
<section className="grid min-h-[680px] grid-cols-[300px_minmax(0,1fr)] overflow-hidden rounded-xl border border-[#d5dbea] bg-white shadow-[0_18px_42px_rgba(20,27,44,0.12)]">
|
||||
<div className="border-r border-[#e4e7f0] bg-[#f6f3ff] p-5">
|
||||
<div className="border-r border-[#e4e7f0] bg-white p-5">
|
||||
<div>
|
||||
<h3 className="text-[19px] font-bold">{t("주차별 커리큘럼")}</h3>
|
||||
<p className="mt-1 text-[11px] font-medium text-[#5f6878]">{curriculumSummary}</p>
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function LearningInfoCard({
|
||||
disabled={studioDisabled}
|
||||
onClick={onStartStudio}
|
||||
className={[
|
||||
'flex h-[39px] w-full items-center justify-center gap-2 rounded-md border-0 text-[14px] font-bold transition-all duration-150 ease-out',
|
||||
'flex h-[39px] w-full items-center justify-center gap-2 rounded-md border-0 text-[14px] font-bold leading-none transition-all duration-150 ease-out',
|
||||
studioDisabled
|
||||
? 'cursor-not-allowed bg-[#d8dce5] text-[#8a91a0]'
|
||||
: 'cursor-pointer bg-[#714ef6] text-white shadow-[0_8px_18px_rgba(113,78,246,0.20)] hover:-translate-y-px hover:bg-[#6040df] hover:shadow-[0_10px_20px_rgba(113,78,246,0.26)] active:translate-y-0 active:shadow-[0_6px_14px_rgba(113,78,246,0.18)]'
|
||||
@@ -97,7 +97,7 @@ export default function LearningInfoCard({
|
||||
disabled={!hasMaterials}
|
||||
onClick={onOpenMaterials}
|
||||
className={[
|
||||
'mt-2.5 flex h-[39px] w-full items-center justify-center gap-2 rounded-md border text-[14px] font-bold transition-all duration-150 ease-out',
|
||||
'mt-2.5 flex h-[39px] w-full items-center justify-center gap-2 rounded-md border text-[14px] font-bold leading-none transition-all duration-150 ease-out',
|
||||
hasMaterials
|
||||
? 'cursor-pointer border-[#714ef6] bg-white text-[#714ef6] hover:-translate-y-px hover:bg-[#f5f2ff] hover:shadow-[0_8px_16px_rgba(113,78,246,0.13)] active:translate-y-0 active:shadow-none'
|
||||
: 'cursor-not-allowed border-[#d8dce5] bg-[#f3f4f7] text-[#9aa1af]'
|
||||
|
||||
@@ -31,7 +31,7 @@ export function RunningPlatformCard({
|
||||
<span className={['absolute right-2.5 top-2.5 h-2 w-2 rounded-full ring-2 ring-white', dotClassName].join(' ')} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-[15px] font-bold text-[#111520]">{platform.platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platform.title}</h3>
|
||||
<h3 className="truncate text-[15px] font-bold text-[#111520]">{platform.platformKey === 'futuredrive-local-pc' ? t('시뮬레이션') : platform.title}</h3>
|
||||
<p className="mt-1 text-[12px] font-medium text-[#5f6878]">{weekTitle}</p>
|
||||
</div>
|
||||
<div className="grid shrink-0 gap-2">
|
||||
@@ -103,7 +103,7 @@ export function PlatformOptionCard({
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-[15px] font-bold text-[#111520]">{platform.platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platform.title}</h3>
|
||||
<h3 className="truncate text-[15px] font-bold text-[#111520]">{platform.platformKey === 'futuredrive-local-pc' ? t('시뮬레이션') : platform.title}</h3>
|
||||
</div>
|
||||
<p className="mt-1 text-[12px] font-medium text-[#5f6878]">{platform.description}</p>
|
||||
<div className="mt-2 flex items-center gap-1.5 text-[11px] font-bold text-[#5f6878]">
|
||||
@@ -142,9 +142,9 @@ export function PlatformOptionCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSettings}
|
||||
className="flex h-8 w-[86px] cursor-pointer items-center justify-center gap-1.5 rounded-md border border-[#94a3b8] bg-white px-3 text-[12px] font-bold text-[#4f596b] shadow-[0_1px_2px_rgba(20,27,44,0.08),inset_0_1px_0_rgba(255,255,255,0.70)] transition hover:border-[#64748b] hover:bg-[#f8f9fc] hover:text-[#111520]"
|
||||
className="flex h-8 min-w-[86px] cursor-pointer items-center justify-center gap-1.5 rounded-md border border-[#94a3b8] bg-white px-3 text-[12px] font-bold text-[#4f596b] shadow-[0_1px_2px_rgba(20,27,44,0.08),inset_0_1px_0_rgba(255,255,255,0.70)] transition hover:border-[#64748b] hover:bg-[#f8f9fc] hover:text-[#111520]"
|
||||
>
|
||||
<Settings size={14} />{t("설정")}</button>
|
||||
<Settings className="shrink-0" size={14} />{t("설정")}</button>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ type ModuleListHeaderProps = {
|
||||
export default function ModuleListHeader({ dark }: ModuleListHeaderProps): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
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-[#c4b5df] bg-[#e2e8f0] text-[#38205b]'].join(' ')}>
|
||||
<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-[#cfc2e1] bg-[#f8f6fb] text-[#38205b]'].join(' ')}>
|
||||
<span className="text-center">{t("상태")}</span>
|
||||
<span className="text-center">{t("분류")}</span>
|
||||
<span>{t("모듈명")}</span>
|
||||
|
||||
@@ -192,7 +192,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
useEffect(() => { if (!busy) setProgress('') }, [busy])
|
||||
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-[#dce2eb] text-[#3e4c62]',
|
||||
stopped: dark ? 'bg-white/8 text-white/45' : 'bg-[#edf0f4] text-[#3e4c62]',
|
||||
uninstalled: dark ? 'bg-[#2d2d2d] text-white/45' : 'bg-[#e7deef] text-[#60486f]'
|
||||
}
|
||||
const controlActions: Array<{
|
||||
@@ -300,7 +300,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
<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-[#aeb9cb]'].join(' ')}>
|
||||
<span className={['h-1.5 w-[42px] overflow-hidden rounded-full', dark ? 'bg-white/10' : 'bg-[#e3e7ee]'].join(' ')}>
|
||||
<span className="block h-full rounded-full bg-[#2f8dff]" style={{ width: `${Math.min(100, module.cpu)}%` }} />
|
||||
</span>
|
||||
</>
|
||||
@@ -314,7 +314,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
<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-[#aeb9cb]'].join(' ')}>
|
||||
<span className={['h-1.5 w-[42px] overflow-hidden rounded-full', dark ? 'bg-white/10' : 'bg-[#e3e7ee]'].join(' ')}>
|
||||
<span className="block h-full rounded-full bg-[#2fbda0]" style={{ width: `${Math.min(100, module.memory)}%` }} />
|
||||
</span>
|
||||
</>
|
||||
@@ -337,7 +337,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
'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]'
|
||||
: 'border-[#a78bfa] bg-[#faf7ff] text-[#7c3aed] hover:border-[#7c3aed] hover:bg-[#f3efff]'
|
||||
].join(' ')}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function ModuleListToolbar({
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={['flex min-h-10 flex-wrap items-end justify-between gap-x-3 gap-y-2 px-3 pt-2', dark ? 'bg-[#252525]' : 'bg-[#edf1f7]'].join(' ')}>
|
||||
<div className={['flex min-h-10 flex-wrap items-end justify-between gap-x-3 gap-y-2 px-3 pt-2', dark ? 'bg-[#252525]' : 'bg-white'].join(' ')}>
|
||||
<div className="flex items-end">
|
||||
{FILTER_TABS.map(({ id, label }) => {
|
||||
const active = filter === id
|
||||
@@ -90,10 +90,10 @@ export default function ModuleListToolbar({
|
||||
active
|
||||
? dark
|
||||
? 'border-white/10 bg-[#2b2b2b] text-white'
|
||||
: 'border-[#c4b5df] bg-[#e2e8f0] text-[#38205b]'
|
||||
: 'border-[#cfc2e1] bg-[#f8f6fb] text-[#38205b]'
|
||||
: dark
|
||||
? 'border-white/8 bg-[#202020] text-white/45 hover:text-white/70'
|
||||
: 'border-[#d4c9e4] bg-[#eee8f6] text-[#475569] hover:bg-[#e8def3] hover:text-[#38205b]'
|
||||
: 'border-[#d8cee6] bg-white text-[#475569] hover:bg-[#f7f4fa] hover:text-[#38205b]'
|
||||
].join(' ')}
|
||||
>
|
||||
{t(label)}
|
||||
|
||||
@@ -259,7 +259,7 @@ export default function ModuleListView({
|
||||
|
||||
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 gap-4 p-3', dark ? 'bg-[#252525]' : 'border-b border-[#cbd5e1] bg-[#e8edf5]'].join(' ')}>
|
||||
<div className={['flex h-[69px] items-center justify-between gap-4 p-3', dark ? 'bg-[#252525]' : 'border-b border-[#cbd5e1] bg-white'].join(' ')}>
|
||||
<div>
|
||||
<strong className={['block text-[13px] font-bold', dark ? 'text-white' : 'text-[#111520]'].join(' ')}>{t("모듈 리스트")}</strong>
|
||||
<span className={['mt-1 block text-[11px] font-medium', dark ? 'text-white/40' : 'text-[#727b8c]'].join(' ')}>{t("자율주행 알고리즘 모듈 관리")}</span>
|
||||
|
||||
@@ -52,7 +52,7 @@ function WorkspaceEditorPane({
|
||||
<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(' ')}>{t("파일을 선택하세요")}</strong>
|
||||
<strong className={['block text-[12px] font-semibold', textDim].join(' ')}>{t("파일을 선택하세요")}</strong>
|
||||
</div>
|
||||
</div>
|
||||
) : activeFileIsImage ? (
|
||||
|
||||
@@ -38,7 +38,7 @@ function WorkspaceEditorTabs({
|
||||
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-[#d4c9e4] bg-[#eee8f6]'].join(' ')}>
|
||||
<div className={['min-w-0 overflow-hidden border-b pl-1 pr-2', dark ? 'border-white/10 bg-[#252525]' : 'border-[#d4c9e4] bg-white'].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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { t, useLocaleRender } from '../../i18n'
|
||||
import { ChevronDown, FilePlus, FileText, Folder, FolderPlus, ListCollapse, RefreshCw, Search, X } from 'lucide-react'
|
||||
import { Fragment } from 'react'
|
||||
import { Fragment, useState } from 'react'
|
||||
import type React from 'react'
|
||||
|
||||
type WorkspaceTreeItem =
|
||||
@@ -78,6 +78,7 @@ type WorkspaceFilePanelProps = {
|
||||
onCutTreeTarget: () => void
|
||||
onCopyTreeTarget: () => void
|
||||
onPasteTreeClipboard: () => void
|
||||
onMoveTreeTarget: (sourcePath: string, sourceKind: 'file' | 'folder', targetFolder: string) => void
|
||||
onCopyTreePath: () => void
|
||||
onRenameTreeTarget: () => void
|
||||
onDeleteTreeTarget: () => void
|
||||
@@ -195,6 +196,7 @@ export default function WorkspaceFilePanel({
|
||||
onCutTreeTarget,
|
||||
onCopyTreeTarget,
|
||||
onPasteTreeClipboard,
|
||||
onMoveTreeTarget,
|
||||
onCopyTreePath,
|
||||
onRenameTreeTarget,
|
||||
onDeleteTreeTarget
|
||||
@@ -202,7 +204,7 @@ export default function WorkspaceFilePanel({
|
||||
useLocaleRender()
|
||||
return (
|
||||
<aside
|
||||
className={['flex min-h-0 flex-col border-r transition-opacity', workspaceActive ? '' : 'pointer-events-none overflow-hidden border-r-0 opacity-0', dark ? 'border-white/10 bg-[#242424]' : 'border-[#cfd6e4] bg-[#edf1f7]'].join(' ')}
|
||||
className={['flex min-h-0 flex-col border-r transition-opacity', workspaceActive ? '' : 'pointer-events-none overflow-hidden border-r-0 opacity-0', dark ? 'border-white/10 bg-[#242424]' : 'border-[#cfd6e4] bg-white'].join(' ')}
|
||||
style={workspaceActive ? { gridColumn: 4, gridRow: 2 } : undefined}
|
||||
>
|
||||
<div className={['flex h-11 items-center justify-between border-b px-3', borderSoft].join(' ')}>
|
||||
@@ -273,6 +275,7 @@ export default function WorkspaceFilePanel({
|
||||
onCutTreeTarget={onCutTreeTarget}
|
||||
onCopyTreeTarget={onCopyTreeTarget}
|
||||
onPasteTreeClipboard={onPasteTreeClipboard}
|
||||
onMoveTreeTarget={onMoveTreeTarget}
|
||||
onCopyTreePath={onCopyTreePath}
|
||||
onRenameTreeTarget={onRenameTreeTarget}
|
||||
onDeleteTreeTarget={onDeleteTreeTarget}
|
||||
@@ -301,7 +304,7 @@ function SearchPanel({
|
||||
>): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
return (
|
||||
<div className={['flex min-h-0 flex-1 flex-col px-2 py-2 text-[13px] font-medium', textMid].join(' ')}>
|
||||
<div className={['flex min-h-0 flex-1 flex-col px-2 py-2 text-[13px] font-medium', textMid, dark ? '' : 'bg-white'].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
|
||||
@@ -402,15 +405,39 @@ function ExplorerPanel(props: Omit<WorkspaceFilePanelProps,
|
||||
onCutTreeTarget,
|
||||
onCopyTreeTarget,
|
||||
onPasteTreeClipboard,
|
||||
onMoveTreeTarget,
|
||||
onCopyTreePath,
|
||||
onRenameTreeTarget,
|
||||
onDeleteTreeTarget
|
||||
} = props
|
||||
const [draggedTreeItem, setDraggedTreeItem] = useState<{ path: string; kind: 'file' | 'folder' } | null>(null)
|
||||
const [dropTargetFolder, setDropTargetFolder] = useState<string | null>(null)
|
||||
|
||||
const finishTreeDrag = (): void => {
|
||||
setDraggedTreeItem(null)
|
||||
setDropTargetFolder(null)
|
||||
}
|
||||
|
||||
const dropIntoFolder = (event: React.DragEvent, targetFolder: string): void => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (draggedTreeItem) onMoveTreeTarget(draggedTreeItem.path, draggedTreeItem.kind, targetFolder)
|
||||
finishTreeDrag()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={['thin-y-scrollbar min-h-0 flex-1 overflow-y-auto px-2 py-2 text-[13px] font-medium', textMid].join(' ')}>
|
||||
<div className={['thin-y-scrollbar min-h-0 flex-1 overflow-y-auto px-2 py-2 text-[13px] font-medium', textMid, dark ? '' : 'bg-white'].join(' ')}>
|
||||
<button
|
||||
type="button"
|
||||
onDragOver={(event) => {
|
||||
if (!draggedTreeItem || exampleActive) return
|
||||
if (draggedTreeItem.path === workspaceRootPath) return
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
setDropTargetFolder(workspaceRootPath)
|
||||
}}
|
||||
onDragLeave={() => setDropTargetFolder((current) => current === workspaceRootPath ? null : current)}
|
||||
onDrop={(event) => dropIntoFolder(event, workspaceRootPath)}
|
||||
onClick={onSelectRoot}
|
||||
onContextMenu={(event) => {
|
||||
if (exampleActive) return
|
||||
@@ -423,7 +450,9 @@ function ExplorerPanel(props: Omit<WorkspaceFilePanelProps,
|
||||
? 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]'
|
||||
: dropTargetFolder === workspaceRootPath
|
||||
? dark ? 'bg-[#2563eb]/18 text-white outline outline-1 outline-[#3b82f6]/55' : 'bg-[#e8f2ff] text-[#111520] outline outline-1 outline-[#2563eb]/40'
|
||||
: 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(' ')} />
|
||||
@@ -465,6 +494,19 @@ function ExplorerPanel(props: Omit<WorkspaceFilePanelProps,
|
||||
onDraftChange={onDraftChange}
|
||||
onCommitCreateFile={onCommitCreateFile}
|
||||
onCancelCreateFile={onCancelCreateFile}
|
||||
draggedTreeItem={draggedTreeItem}
|
||||
dropTarget={dropTargetFolder === item.path}
|
||||
onDragStart={setDraggedTreeItem}
|
||||
onDragEnd={finishTreeDrag}
|
||||
onDragOver={(event) => {
|
||||
if (!draggedTreeItem || exampleActive) return
|
||||
if (item.path === draggedTreeItem.path || item.path.startsWith(`${draggedTreeItem.path}/`)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
setDropTargetFolder(item.path)
|
||||
}}
|
||||
onDrop={(event) => dropIntoFolder(event, item.path)}
|
||||
/>
|
||||
) : (
|
||||
<FileRow
|
||||
@@ -479,6 +521,9 @@ function ExplorerPanel(props: Omit<WorkspaceFilePanelProps,
|
||||
onDraftChange={onDraftChange}
|
||||
onCommitCreateFile={onCommitCreateFile}
|
||||
onCancelCreateFile={onCancelCreateFile}
|
||||
draggedTreeItem={draggedTreeItem}
|
||||
onDragStart={setDraggedTreeItem}
|
||||
onDragEnd={finishTreeDrag}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
@@ -514,7 +559,13 @@ function FolderRow({
|
||||
onFolderContextMenu,
|
||||
onDraftChange,
|
||||
onCommitCreateFile,
|
||||
onCancelCreateFile
|
||||
onCancelCreateFile,
|
||||
draggedTreeItem,
|
||||
dropTarget,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDrop
|
||||
}: {
|
||||
dark: boolean
|
||||
item: Extract<WorkspaceTreeItem, { kind: 'folder' }>
|
||||
@@ -527,12 +578,27 @@ function FolderRow({
|
||||
onDraftChange: (value: string) => void
|
||||
onCommitCreateFile: () => void
|
||||
onCancelCreateFile: () => void
|
||||
draggedTreeItem: { path: string; kind: 'file' | 'folder' } | null
|
||||
dropTarget: boolean
|
||||
onDragStart: (item: { path: string; kind: 'file' | 'folder' }) => void
|
||||
onDragEnd: () => void
|
||||
onDragOver: (event: React.DragEvent<HTMLButtonElement>) => void
|
||||
onDrop: (event: React.DragEvent<HTMLButtonElement>) => void
|
||||
}): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
return (
|
||||
<Fragment>
|
||||
<button
|
||||
type="button"
|
||||
draggable={!exampleActive}
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', item.path)
|
||||
onDragStart({ path: item.path, kind: 'folder' })
|
||||
}}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onClick={() => onSelectFolder(item.path)}
|
||||
onContextMenu={(event) => {
|
||||
if (exampleActive) return
|
||||
@@ -542,7 +608,12 @@ function FolderRow({
|
||||
style={{ paddingLeft: `${item.depth * 10}px` }}
|
||||
className={[
|
||||
'flex h-[22px] w-full cursor-pointer items-center pr-2 text-left transition',
|
||||
active
|
||||
draggedTreeItem?.path === item.path ? 'opacity-45' : '',
|
||||
dropTarget
|
||||
? dark
|
||||
? 'bg-[#2563eb]/18 text-white outline outline-1 outline-[#3b82f6]/55'
|
||||
: 'bg-[#e8f2ff] text-[#111520] outline outline-1 outline-[#2563eb]/40'
|
||||
: active
|
||||
? dark
|
||||
? 'bg-[#37373d] text-white'
|
||||
: 'bg-[#dbe5f1] text-[#111520]'
|
||||
@@ -572,7 +643,10 @@ function FileRow({
|
||||
onFileContextMenu,
|
||||
onDraftChange,
|
||||
onCommitCreateFile,
|
||||
onCancelCreateFile
|
||||
onCancelCreateFile,
|
||||
draggedTreeItem,
|
||||
onDragStart,
|
||||
onDragEnd
|
||||
}: {
|
||||
dark: boolean
|
||||
item: Extract<WorkspaceTreeItem, { kind: 'file' }>
|
||||
@@ -584,12 +658,22 @@ function FileRow({
|
||||
onDraftChange: (value: string) => void
|
||||
onCommitCreateFile: () => void
|
||||
onCancelCreateFile: () => void
|
||||
draggedTreeItem: { path: string; kind: 'file' | 'folder' } | null
|
||||
onDragStart: (item: { path: string; kind: 'file' | 'folder' }) => void
|
||||
onDragEnd: () => void
|
||||
}): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
return (
|
||||
<Fragment>
|
||||
<button
|
||||
type="button"
|
||||
draggable={!exampleActive}
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', item.path)
|
||||
onDragStart({ path: item.path, kind: 'file' })
|
||||
}}
|
||||
onDragEnd={onDragEnd}
|
||||
onClick={() => onOpenFile(item.path)}
|
||||
onContextMenu={(event) => {
|
||||
if (exampleActive) return
|
||||
@@ -599,6 +683,7 @@ function FileRow({
|
||||
style={{ paddingLeft: `${item.depth * 10}px` }}
|
||||
className={[
|
||||
'flex h-[22px] w-full cursor-pointer items-center pr-2 text-left transition',
|
||||
draggedTreeItem?.path === item.path ? 'opacity-45' : '',
|
||||
active
|
||||
? dark
|
||||
? 'bg-[#37373d] text-white'
|
||||
|
||||
@@ -104,7 +104,7 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
|
||||
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, dark ? '' : 'bg-[#edf1f7]'].join(' ')}>
|
||||
<div className={['border-b p-3', borderSoft, dark ? '' : 'bg-white'].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} />{t("학습가이드")}</h3>
|
||||
@@ -164,10 +164,10 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
|
||||
expanded ? 'px-2.5 pb-4 pt-2.5' : 'p-3',
|
||||
canToggle ? 'cursor-pointer' : '',
|
||||
active
|
||||
? dark ? 'border-[#60a5fa]/42 bg-[#303030]' : 'border-[#8aade4] bg-[#f5f9ff]'
|
||||
? dark ? 'border-[#60a5fa]/42 bg-[#303030]' : 'border-[#8aade4] bg-white'
|
||||
: canToggle
|
||||
? dark ? 'border-white/12 bg-[#292929] opacity-92 hover:border-white/28 hover:bg-[#303030] hover:opacity-100' : 'border-[#c5cfdd] bg-[#dce5f0] hover:border-[#9cacc2] hover:bg-[#edf3fb]'
|
||||
: dark ? 'border-white/9 bg-[#292929] opacity-55' : 'border-[#cbd4e0] bg-[#dde3ec]'
|
||||
? dark ? 'border-white/12 bg-[#292929] opacity-92 hover:border-white/28 hover:bg-[#303030] hover:opacity-100' : 'border-[#c5cfdd] bg-white hover:border-[#9cacc2] hover:bg-[#f8fafc]'
|
||||
: dark ? 'border-white/9 bg-[#292929] opacity-55' : 'border-[#cbd4e0] bg-white'
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['flex gap-2.5', expanded ? 'items-start' : 'items-center'].join(' ')}>
|
||||
|
||||
@@ -38,7 +38,7 @@ function WorkspaceLogView({
|
||||
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 h-11 items-center justify-between border-b px-4', borderSoft, dark ? '' : 'bg-white'].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(' ')}>{t("실시간 로그")}</span>
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function WorkspaceModuleTabs({
|
||||
'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-[#c4b5df] bg-[#e2e8f0] text-[#38205b]'
|
||||
: dark ? 'border-white/10 bg-[#252525] text-white/45 hover:bg-[#2b2b2b] hover:text-white/75' : 'border-[#d4c9e4] bg-[#eee8f6] text-[#475569] hover:bg-[#e8def3] hover:text-[#38205b]'
|
||||
: dark ? 'border-white/10 bg-[#252525] text-white/45 hover:bg-[#2b2b2b] hover:text-white/75' : 'border-[#d4c9e4] bg-white text-[#475569] hover:bg-[#f7f4fa] hover:text-[#38205b]'
|
||||
].join(' ')}
|
||||
>
|
||||
{moduleControlling ? (
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function WorkspaceNav({
|
||||
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-[#cbbdde] bg-[linear-gradient(180deg,#e6ddf1_0%,#e8e1f2_55%,#e4eef7_100%)] text-[#475569]'].join(' ')}
|
||||
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-[#cbbdde] bg-[linear-gradient(180deg,#f8f6fb_0%,#f6f3fa_55%,#e4eef7_100%)] text-[#475569]'].join(' ')}
|
||||
style={workspaceActive ? { gridColumn: 3, gridRow: '1 / span 2' } : { gridColumn: 3, gridRow: 1 }}
|
||||
>
|
||||
{WORKSPACE_NAV_ITEMS.map(({ id, Icon, label }) => {
|
||||
|
||||
@@ -148,10 +148,10 @@ export default function WorkspaceQaPanel({
|
||||
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(' ')}
|
||||
className={['flex min-w-0 flex-col border-l transition-opacity', minimized ? 'pointer-events-none overflow-hidden border-l-0 opacity-0' : '', panelClassName, dark ? '' : '!bg-white'].join(' ')}
|
||||
style={workspaceActive ? { gridColumn: 8, gridRow: '1 / span 2' } : { gridColumn: 8, gridRow: 1 }}
|
||||
>
|
||||
<div className={['border-b p-3', borderSoft, dark ? '' : 'bg-[#edf1f7]'].join(' ')}>
|
||||
<div className={['border-b p-3', borderSoft, dark ? '' : 'bg-white'].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} />{t("질의응답")}</h3>
|
||||
|
||||
@@ -111,7 +111,7 @@ function RuntimeLane({
|
||||
</div>
|
||||
)
|
||||
}) : (
|
||||
<div className={['rounded-md border border-dashed px-3 py-8 text-center text-[11px]', dark ? 'border-white/12 text-white/38' : 'border-[#aebace] text-[#526078]'].join(' ')}>{t("실행 중인 모듈이 없습니다.")}</div>
|
||||
<div className={['grid h-full min-h-[120px] place-items-center px-3 text-center text-[12px] font-semibold', dark ? 'text-white/45' : 'text-[#727b8c]'].join(' ')}>{t("실행 중인 모듈이 없습니다.")}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -326,9 +326,7 @@ export default function WorkspaceRosConnectionsView({
|
||||
<div className="mb-3 flex items-center justify-between px-1">
|
||||
<div>
|
||||
<strong className={['block text-[12px]', dark ? 'text-white/88' : 'text-[#1f2937]'].join(' ')}>{t("토픽 연결")}</strong>
|
||||
<span className={['mt-0.5 block text-[10px]', dark ? 'text-white/42' : 'text-[#526078]'].join(' ')}>{t("발행 노드에서 구독 노드로 전달")}</span>
|
||||
</div>
|
||||
<span className={['text-[10px] font-semibold', dark ? 'text-white/42' : 'text-[#526078]'].join(' ')}>{activeConnections}/{connections.length}{t("활성")}</span>
|
||||
</div>
|
||||
<div className="thin-y-scrollbar min-h-0 space-y-2 overflow-y-auto overscroll-contain">
|
||||
{visibleConnections.length ? visibleConnections.map((connection) => {
|
||||
@@ -367,7 +365,7 @@ export default function WorkspaceRosConnectionsView({
|
||||
</button>
|
||||
)
|
||||
}) : (
|
||||
<div className={['rounded-md border border-dashed px-3 py-8 text-center text-[11px]', dark ? 'border-white/12 text-white/38' : 'border-[#aebace] text-[#526078]'].join(' ')}>{rosGraphLoading ? t("조회 중...") : rosGraph.message ? t("ROS 그래프 조회에 실패했습니다.") : activeOnly ? t("활성 연결이 없습니다.") : t("조회된 ROS 토픽이 없습니다.")}</div>
|
||||
<div className={['grid h-full min-h-[120px] place-items-center px-3 text-center text-[12px] font-semibold', dark ? 'text-white/45' : 'text-[#727b8c]'].join(' ')}>{rosGraphLoading ? t("조회 중...") : rosGraph.message ? t("ROS 그래프 조회에 실패했습니다.") : activeOnly ? t("활성 연결이 없습니다.") : t("조회된 ROS 토픽이 없습니다.")}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -148,7 +148,7 @@ function WorkspaceSourceControlView({
|
||||
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 h-10 shrink-0 items-end justify-between px-3', dark ? 'bg-[#252525]' : 'bg-white'].join(' ')}>
|
||||
<div className="flex items-end">
|
||||
{[
|
||||
{ key: 'source' as const, label: t("소스 제어") },
|
||||
@@ -163,10 +163,10 @@ function WorkspaceSourceControlView({
|
||||
gitPanelTab === tab.key
|
||||
? dark
|
||||
? 'border-white/10 bg-[#2b2b2b] text-white'
|
||||
: 'border-[#c4b5df] bg-[#e2e8f0] text-[#38205b]'
|
||||
: 'border-[#cfc2e1] bg-[#f8f6fb] text-[#38205b]'
|
||||
: dark
|
||||
? 'border-white/8 bg-[#202020] text-white/45 hover:text-white/70'
|
||||
: 'border-[#d4c9e4] bg-[#eee8f6] text-[#475569] hover:bg-[#e8def3] hover:text-[#38205b]'
|
||||
: 'border-[#d8cee6] bg-white text-[#475569] hover:bg-[#f7f4fa] hover:text-[#38205b]'
|
||||
].join(' ')}
|
||||
>
|
||||
{t(tab.label)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'
|
||||
type WorldObjectType = WorldLayoutObject['type']
|
||||
|
||||
type WorldEditorModalProps = {
|
||||
dark: boolean
|
||||
workspaceRoot: string
|
||||
onClose: () => void
|
||||
onSaved?: (layout: WorldLayout) => void
|
||||
@@ -173,7 +174,7 @@ async function loadWorldSdf(parent: THREE.Group): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: WorldEditorModalProps): React.JSX.Element {
|
||||
export default function WorldEditorModal({ dark, workspaceRoot, onClose, onSaved }: WorldEditorModalProps): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
const canvasHostRef = useRef<HTMLDivElement>(null)
|
||||
const viewerRef = useRef<HTMLIFrameElement>(null)
|
||||
@@ -436,32 +437,32 @@ export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: Wo
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-[170] flex items-center justify-center bg-[#050914]/78 p-6 backdrop-blur-sm">
|
||||
<section className="flex h-[min(820px,calc(100vh-64px))] w-[min(1280px,calc(100vw-64px))] flex-col overflow-hidden rounded-lg border border-white/12 bg-[#111a2a] shadow-[0_24px_72px_rgba(0,0,0,0.5)]">
|
||||
<header className="flex h-12 shrink-0 items-center justify-between border-b border-white/10 bg-[#162238] px-4">
|
||||
<div className="absolute inset-0 z-[170] flex items-center justify-center p-6">
|
||||
<section className={['flex h-[min(820px,calc(100vh-64px))] w-[min(1280px,calc(100vw-64px))] flex-col overflow-hidden rounded-lg border shadow-[0_24px_72px_rgba(0,0,0,0.28)]', dark ? 'border-white/12 bg-[#111a2a]' : 'border-[#cfd6e4] bg-white'].join(' ')}>
|
||||
<header className={['flex h-12 shrink-0 items-center justify-between border-b px-4', dark ? 'border-white/10 bg-[#162238]' : 'border-[#d8dee9] bg-white'].join(' ')}>
|
||||
<div>
|
||||
<h2 className="text-[13px] font-bold text-white">{t("맵 설정")}</h2>
|
||||
<p className="mt-0.5 text-[10px] font-medium text-white/42">{t("주차 워크스페이스에 월드 배치를 저장합니다.")}</p>
|
||||
<h2 className={['text-[13px] font-bold', dark ? 'text-white' : 'text-[#111520]'].join(' ')}>{t("맵 설정")}</h2>
|
||||
<p className={['mt-0.5 text-[10px] font-medium', dark ? 'text-white/42' : 'text-[#727b8c]'].join(' ')}>{t("주차 워크스페이스에 월드 배치를 저장합니다.")}</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="grid size-7 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/10 hover:text-white" aria-label={t("닫기")}><X size={16} /></button>
|
||||
<button type="button" onClick={onClose} className={['grid size-7 cursor-pointer place-items-center rounded-md transition', dark ? 'text-white/55 hover:bg-white/10 hover:text-white' : 'text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')} aria-label={t("닫기")}><X size={16} /></button>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<aside className="flex w-[214px] shrink-0 flex-col border-r border-white/10 bg-[#101a2a]">
|
||||
<div className="border-b border-white/10 px-3 py-3">
|
||||
<p className="text-[10px] font-bold tracking-[0.1em] text-white/38">SCENE</p>
|
||||
<p className="mt-1 text-[11px] font-semibold text-white/66">{t("월드 오브젝트")}</p>
|
||||
<aside className={['flex w-[214px] shrink-0 flex-col border-r', dark ? 'border-white/10 bg-[#101a2a]' : 'border-[#d8dee9] bg-[#fbfcff]'].join(' ')}>
|
||||
<div className={['border-b px-3 py-3', dark ? 'border-white/10' : 'border-[#e2e7ef]'].join(' ')}>
|
||||
<p className={['text-[10px] font-bold tracking-[0.1em]', dark ? 'text-white/38' : 'text-[#8a94a5]'].join(' ')}>SCENE</p>
|
||||
<p className={['mt-1 text-[11px] font-semibold', dark ? 'text-white/66' : 'text-[#334155]'].join(' ')}>{t("월드 오브젝트")}</p>
|
||||
</div>
|
||||
<div className="border-b border-white/10 px-3 py-3">
|
||||
<p className="mb-2 text-[10px] font-bold tracking-[0.08em] text-white/38">{t("맵 크기")}</p>
|
||||
<div className={['border-b px-3 py-3', dark ? 'border-white/10' : 'border-[#e2e7ef]'].join(' ')}>
|
||||
<p className={['mb-2 text-[10px] font-bold tracking-[0.08em]', dark ? 'text-white/38' : 'text-[#687284]'].join(' ')}>{t("맵 크기")}</p>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<label className="min-w-0">
|
||||
<span className="mb-1 block text-[10px] font-bold text-white/38">{t("가로 (m)")}</span>
|
||||
<input type="number" min="2" max="40" step="0.5" value={layout.bounds.width} onChange={(event) => updateBounds('width', event.target.value)} className="h-8 w-full rounded border border-white/12 bg-[#0d1728] px-2 text-[11px] font-medium text-white outline-none transition focus:border-[#60a5fa]" />
|
||||
<span className={['mb-1 block text-[10px] font-bold', dark ? 'text-white/38' : 'text-[#687284]'].join(' ')}>{t("가로 (m)")}</span>
|
||||
<input type="number" min="2" max="40" step="0.5" value={layout.bounds.width} onChange={(event) => updateBounds('width', event.target.value)} className={['h-8 w-full rounded border px-2 text-[11px] font-medium outline-none transition focus:border-[#60a5fa]', dark ? 'border-white/12 bg-[#0d1728] text-white' : 'border-[#cfd6e4] bg-white text-[#111520]'].join(' ')} />
|
||||
</label>
|
||||
<label className="min-w-0">
|
||||
<span className="mb-1 block text-[10px] font-bold text-white/38">{t("세로 (m)")}</span>
|
||||
<input type="number" min="2" max="40" step="0.5" value={layout.bounds.depth} onChange={(event) => updateBounds('depth', event.target.value)} className="h-8 w-full rounded border border-white/12 bg-[#0d1728] px-2 text-[11px] font-medium text-white outline-none transition focus:border-[#60a5fa]" />
|
||||
<span className={['mb-1 block text-[10px] font-bold', dark ? 'text-white/38' : 'text-[#687284]'].join(' ')}>{t("세로 (m)")}</span>
|
||||
<input type="number" min="2" max="40" step="0.5" value={layout.bounds.depth} onChange={(event) => updateBounds('depth', event.target.value)} className={['h-8 w-full rounded border px-2 text-[11px] font-medium outline-none transition focus:border-[#60a5fa]', dark ? 'border-white/12 bg-[#0d1728] text-white' : 'border-[#cfd6e4] bg-white text-[#111520]'].join(' ')} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -470,23 +471,23 @@ export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: Wo
|
||||
const Icon = item.type === 'pillar' ? Cylinder : item.type === 'wall' ? PanelsTopLeft : Box
|
||||
const selected = item.id === selectedId
|
||||
return (
|
||||
<button key={item.id} type="button" onClick={() => setSelectedId(item.id)} className={`flex h-9 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left transition ${selected ? 'bg-[#3b7ffa]/20 text-[#d9e7ff]' : 'text-white/60 hover:bg-white/[0.06] hover:text-white'}`}>
|
||||
<Icon size={13} className={selected ? 'text-[#75a4ff]' : 'text-white/38'} />
|
||||
<button key={item.id} type="button" onClick={() => setSelectedId(item.id)} className={['flex h-9 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left transition', selected ? (dark ? 'bg-[#3b7ffa]/20 text-[#d9e7ff]' : 'bg-[#e8f2ff] text-[#1d4ed8]') : (dark ? 'text-white/60 hover:bg-white/[0.06] hover:text-white' : 'text-[#526078] hover:bg-[#eef1f7] hover:text-[#111520]')].join(' ')}>
|
||||
<Icon size={13} className={selected ? (dark ? 'text-[#75a4ff]' : 'text-[#2563eb]') : (dark ? 'text-white/38' : 'text-[#8a94a5]')} />
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] font-semibold">{objectLabels[item.type]}</span>
|
||||
<span className="text-[10px] text-white/30">{item.id.split('-').at(-1)}</span>
|
||||
<span className={['text-[10px]', dark ? 'text-white/30' : 'text-[#9aa3b2]'].join(' ')}>{item.id.split('-').at(-1)}</span>
|
||||
</button>
|
||||
)
|
||||
}) : <div className="px-3 py-8 text-center text-[11px] leading-relaxed text-white/35">{t("오브젝트를 추가해")}<br />{t("맵을 구성하세요.")}</div>}
|
||||
}) : <div className={['px-3 py-8 text-center text-[11px] leading-relaxed', dark ? 'text-white/35' : 'text-[#7b8494]'].join(' ')}>{t("오브젝트를 추가해")}<br />{t("맵을 구성하세요.")}</div>}
|
||||
</div>
|
||||
<div className="border-t border-white/10 p-2">
|
||||
<p className="mb-2 px-1 text-[10px] font-bold tracking-[0.08em] text-white/35">ADD OBJECT</p>
|
||||
<div className={['border-t p-2', dark ? 'border-white/10' : 'border-[#e2e7ef]'].join(' ')}>
|
||||
<p className={['mb-2 px-1 text-[10px] font-bold tracking-[0.08em]', dark ? 'text-white/35' : 'text-[#687284]'].join(' ')}>ADD OBJECT</p>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{([
|
||||
['pillar', Cylinder, t("기둥")],
|
||||
['wall', PanelsTopLeft, t("벽")],
|
||||
['box', Box, t("박스")],
|
||||
] as const).map(([type, Icon, label]) => (
|
||||
<button key={type} type="button" onClick={() => addObject(type)} title={t("{{v0}} 추가", { v0: label })} className="flex h-12 cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-white/10 text-[10px] font-semibold text-white/60 transition hover:border-[#60a5fa]/55 hover:bg-[#1a2c49] hover:text-white"><Icon size={14} />{t(label)}</button>
|
||||
<button key={type} type="button" onClick={() => addObject(type)} title={t("{{v0}} 추가", { v0: label })} className={['flex h-12 cursor-pointer flex-col items-center justify-center gap-1 rounded-md border text-[10px] font-semibold transition', dark ? 'border-white/10 text-white/60 hover:border-[#60a5fa]/55 hover:bg-[#1a2c49] hover:text-white' : 'border-[#cfd6e4] bg-white text-[#526078] hover:border-[#60a5fa] hover:bg-[#edf6ff] hover:text-[#1d4ed8]'].join(' ')}><Icon size={14} />{t(label)}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -495,37 +496,37 @@ export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: Wo
|
||||
<div ref={canvasHostRef} className="pointer-events-none absolute inset-0 opacity-0" />
|
||||
<iframe ref={viewerRef} title="TurtleBot3 World Editor" src="./slam-viewer/index.html?editor=1&mapKey=nav2-custom" className="absolute inset-0 z-10 size-full border-0" />
|
||||
{loading && <div className="absolute inset-0 z-20 grid place-items-center bg-[#0b1220] text-[12px] font-semibold text-white/58"><span className="flex items-center gap-2"><LoaderCircle size={16} className="animate-spin" />{t("월드 불러오는 중")}</span></div>}
|
||||
<div className="absolute left-3 top-3 z-20 flex overflow-hidden rounded-md border border-white/15 bg-[#15223a]/95 shadow">
|
||||
<div className={['absolute left-3 top-3 z-20 flex overflow-hidden rounded-md border shadow', dark ? 'border-white/15 bg-[#15223a]/95' : 'border-[#cfd6e4] bg-white/95'].join(' ')}>
|
||||
{([
|
||||
['translate', Move3D, t("이동")],
|
||||
['rotate', Rotate3D, t("회전")],
|
||||
['scale', Maximize2, t("크기")],
|
||||
] as const).map(([mode, Icon, label]) => (
|
||||
<button key={mode} type="button" onClick={() => selectTransformMode(mode)} title={t(label)} aria-label={t(label)} className={`flex h-8 cursor-pointer items-center gap-1.5 px-2.5 text-[11px] font-bold transition ${transformMode === mode ? 'bg-[#3b7ffa] text-white' : 'text-white/60 hover:bg-white/10 hover:text-white'}`}><Icon size={14} />{t(label)}</button>
|
||||
<button key={mode} type="button" onClick={() => selectTransformMode(mode)} title={t(label)} aria-label={t(label)} className={['flex h-8 cursor-pointer items-center gap-1.5 px-2.5 text-[11px] font-bold transition', transformMode === mode ? 'bg-[#3b7ffa] text-white' : (dark ? 'text-white/60 hover:bg-white/10 hover:text-white' : 'text-[#526078] hover:bg-[#eef1f7] hover:text-[#111520]')].join(' ')}><Icon size={14} />{t(label)}</button>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside className="flex w-[270px] shrink-0 flex-col border-l border-white/10 bg-[#131e31]">
|
||||
<div className="border-b border-white/10 px-3 py-3"><span className="text-[11px] font-bold text-white/70">{t("선택한 오브젝트")}</span></div>
|
||||
<aside className={['flex w-[270px] shrink-0 flex-col border-l', dark ? 'border-white/10 bg-[#131e31]' : 'border-[#d8dee9] bg-[#fbfcff]'].join(' ')}>
|
||||
<div className={['border-b px-3 py-3', dark ? 'border-white/10' : 'border-[#e2e7ef]'].join(' ')}><span className={['text-[11px] font-bold', dark ? 'text-white/70' : 'text-[#334155]'].join(' ')}>{t("선택한 오브젝트")}</span></div>
|
||||
{selected ? (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div className="mb-4 flex items-center justify-between"><span className="rounded bg-[#3b7ffa]/18 px-2 py-1 text-[11px] font-bold text-[#a8c7ff]">{objectLabels[selected.type]}</span><button type="button" onClick={removeSelected} className="grid size-7 cursor-pointer place-items-center rounded border border-red-400/25 text-red-300/80 transition hover:bg-red-500/12 hover:text-red-200" title={t("삭제")}><Trash2 size={14} /></button></div>
|
||||
<div className="mb-4 flex items-center justify-between"><span className={['rounded px-2 py-1 text-[11px] font-bold', dark ? 'bg-[#3b7ffa]/18 text-[#a8c7ff]' : 'bg-[#e8f2ff] text-[#1d4ed8]'].join(' ')}>{objectLabels[selected.type]}</span><button type="button" onClick={removeSelected} className={['grid size-7 cursor-pointer place-items-center rounded border transition', dark ? 'border-red-400/25 text-red-300/80 hover:bg-red-500/12 hover:text-red-200' : 'border-[#f0a4a4] bg-white text-[#c24141] hover:bg-[#fff1f2] hover:text-[#b91c1c]'].join(' ')} title={t("삭제")}><Trash2 size={14} /></button></div>
|
||||
{(['position', 'rotation', 'size'] as const).map((key) => {
|
||||
const indexes = key === 'position' ? [0, 1] : key === 'rotation' ? [2] : [0, 1, 2]
|
||||
return <div key={key} className="mb-4"><p className="mb-1.5 text-[10px] font-bold tracking-[0.08em] text-white/38">{key === 'position' ? t("위치") : key === 'rotation' ? t("회전") : t("크기")}</p><div className={`grid gap-1.5 ${indexes.length === 1 ? 'grid-cols-1' : indexes.length === 2 ? 'grid-cols-2' : 'grid-cols-3'}`}>{indexes.map((index) => <label key={index} className="min-w-0"><span className="mb-1 block text-[10px] font-bold text-white/38">{key === 'rotation' ? t("각도 (Z)") : ['X', 'Y', t("높이")][index]}</span><input type="number" step="0.1" value={Number(selected[key][index].toFixed(2))} onChange={(event) => updateTuple(key, index, event.target.value)} className="h-8 w-full rounded border border-white/12 bg-[#0d1728] px-2 text-[11px] font-medium text-white outline-none transition focus:border-[#60a5fa]" /></label>)}</div></div>
|
||||
return <div key={key} className="mb-4"><p className={['mb-1.5 text-[10px] font-bold tracking-[0.08em]', dark ? 'text-white/38' : 'text-[#687284]'].join(' ')}>{key === 'position' ? t("위치") : key === 'rotation' ? t("회전") : t("크기")}</p><div className={`grid gap-1.5 ${indexes.length === 1 ? 'grid-cols-1' : indexes.length === 2 ? 'grid-cols-2' : 'grid-cols-3'}`}>{indexes.map((index) => <label key={index} className="min-w-0"><span className={['mb-1 block text-[10px] font-bold', dark ? 'text-white/38' : 'text-[#687284]'].join(' ')}>{key === 'rotation' ? t("각도 (Z)") : ['X', 'Y', t("높이")][index]}</span><input type="number" step="0.1" value={Number(selected[key][index].toFixed(2))} onChange={(event) => updateTuple(key, index, event.target.value)} className={['h-8 w-full rounded border px-2 text-[11px] font-medium outline-none transition focus:border-[#60a5fa]', dark ? 'border-white/12 bg-[#0d1728] text-white' : 'border-[#cfd6e4] bg-white text-[#111520]'].join(' ')} /></label>)}</div></div>
|
||||
})}
|
||||
</div>
|
||||
) : <div className="grid min-h-0 flex-1 place-items-center px-5 text-center text-[11px] leading-relaxed text-white/35">{t("오브젝트를 선택하거나 추가하세요.")}</div>}
|
||||
<div className="border-t border-white/10 p-3"><button type="button" onClick={resetLayout} className="flex h-8 w-full cursor-pointer items-center justify-center gap-1.5 rounded border border-white/12 text-[11px] font-semibold text-white/54 transition hover:bg-white/8 hover:text-white"><RotateCcw size={13} />{t("모두 비우기")}</button></div>
|
||||
) : <div className={['grid min-h-0 flex-1 place-items-center px-5 text-center text-[11px] leading-relaxed', dark ? 'text-white/35' : 'text-[#7b8494]'].join(' ')}>{t("오브젝트를 선택하거나 추가하세요.")}</div>}
|
||||
<div className={['border-t p-3', dark ? 'border-white/10' : 'border-[#e2e7ef]'].join(' ')}><button type="button" onClick={resetLayout} className={['flex h-8 w-full cursor-pointer items-center justify-center gap-1.5 rounded border text-[11px] font-semibold transition', dark ? 'border-white/12 text-white/54 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] bg-white text-[#526078] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')}><RotateCcw size={13} />{t("모두 비우기")}</button></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<footer className="flex h-12 shrink-0 items-center justify-between border-t border-white/10 bg-[#162238] px-4">
|
||||
<span className="text-[10px] font-medium text-white/38">{t("저장 위치: world-layout.json")}</span>
|
||||
<div className="flex items-center gap-2"><button type="button" onClick={onClose} className="h-8 cursor-pointer rounded-md border border-white/12 bg-[#1c2a42] px-3 text-[11px] font-semibold text-white/72 transition hover:bg-[#263a59] hover:text-white">{t("취소")}</button><button type="button" disabled={saving || loading} onClick={() => void save()} className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md bg-[#3b7ffa] px-3 text-[11px] font-bold text-white transition hover:bg-[#4a89ff] disabled:cursor-default disabled:opacity-55">{saving ? <LoaderCircle size={14} className="animate-spin" /> : <Save size={14} />}{t("저장")}</button></div>
|
||||
<footer className={['flex h-12 shrink-0 items-center justify-between border-t px-4', dark ? 'border-white/10 bg-[#162238]' : 'border-[#d8dee9] bg-white'].join(' ')}>
|
||||
<span className={['text-[10px] font-medium', dark ? 'text-white/38' : 'text-[#7b8494]'].join(' ')}>{t("저장 위치: world-layout.json")}</span>
|
||||
<div className="flex items-center gap-2"><button type="button" onClick={onClose} className={['h-8 cursor-pointer rounded-md border px-3 text-[11px] font-semibold transition', dark ? 'border-white/12 bg-[#1c2a42] text-white/72 hover:bg-[#263a59] hover:text-white' : 'border-[#cfd6e4] bg-white text-[#526078] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')}>{t("취소")}</button><button type="button" disabled={saving || loading} onClick={() => void save()} className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md bg-[#3b7ffa] px-3 text-[11px] font-bold text-white transition hover:bg-[#4a89ff] disabled:cursor-default disabled:opacity-55">{saving ? <LoaderCircle size={14} className="animate-spin" /> : <Save size={14} />}{t("저장")}</button></div>
|
||||
</footer>
|
||||
{error && <div className="absolute bottom-14 left-4 rounded border border-red-400/25 bg-[#3a1f29] px-3 py-2 text-[11px] font-semibold text-red-200">{t(error)}</div>}
|
||||
{error && <div className={['absolute bottom-14 left-4 rounded border px-3 py-2 text-[11px] font-semibold', dark ? 'border-red-400/25 bg-[#3a1f29] text-red-200' : 'border-[#fecaca] bg-[#fff1f2] text-[#b91c1c]'].join(' ')}>{t(error)}</div>}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -71,6 +71,7 @@ type UseWorkspaceFileActionsResult = {
|
||||
performDeleteTreeTarget: (targetPath: string) => Promise<void>
|
||||
requestDeleteTreeTarget: () => void
|
||||
pasteTreeClipboard: () => Promise<void>
|
||||
moveTreeTarget: (sourcePath: string, sourceKind: 'file' | 'folder', targetFolder: string) => Promise<void>
|
||||
refreshModuleFiles: () => void
|
||||
}
|
||||
|
||||
@@ -456,6 +457,59 @@ export function useWorkspaceFileActions({
|
||||
setTreeContextMenu(null)
|
||||
}
|
||||
|
||||
const moveTreeTarget = async (sourcePath: string, sourceKind: 'file' | 'folder', targetFolder: string): Promise<void> => {
|
||||
if (!selectedModule) return
|
||||
const rootPath = `modules/${selectedModule.name}`
|
||||
if (sourcePath === rootPath || !sourcePath.startsWith(`${rootPath}/`)) return
|
||||
if (targetFolder !== rootPath && !targetFolder.startsWith(`${rootPath}/`)) return
|
||||
if (targetFolder === getPathParent(sourcePath)) return
|
||||
if (targetFolder === sourcePath || targetFolder.startsWith(`${sourcePath}/`)) return
|
||||
|
||||
const allFolders = Array.from(new Set([...(loadedModuleFolders[selectedModule.name] ?? []), ...createdModuleFolders.filter((folder) => folder.startsWith(rootPath))]))
|
||||
const knownFiles = Object.fromEntries((loadedModuleFilePaths[selectedModule.name] ?? []).map((path) => [path, '']))
|
||||
const targetPath = makeUniquePath(`${targetFolder}/${getPathName(sourcePath)}`, { ...knownFiles, ...editorFiles }, allFolders)
|
||||
|
||||
try {
|
||||
if (remoteWorkspace) {
|
||||
await window.remoteWorkspace.moduleFiles.copy(getRemoteWorkspaceTarget(), selectedModule.name, sourcePath, targetPath, 'cut', selectedModule.folderName)
|
||||
} else {
|
||||
await window.localWorkspace.moduleFiles.copy(selectedModule.name, sourcePath, targetPath, 'cut', clonePathPreview, selectedModule.folderName)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[module-files] drag move failed', error)
|
||||
return
|
||||
}
|
||||
|
||||
const moveFileEntries = (files: Record<string, string>): Record<string, string> => {
|
||||
const nextFiles = { ...files }
|
||||
Object.entries(files).forEach(([path, content]) => {
|
||||
if (path === sourcePath || path.startsWith(`${sourcePath}/`)) {
|
||||
nextFiles[replacePathPrefix(path, sourcePath, targetPath)] = content
|
||||
delete nextFiles[path]
|
||||
}
|
||||
})
|
||||
return nextFiles
|
||||
}
|
||||
setEditorFiles(moveFileEntries)
|
||||
setSavedEditorFiles(moveFileEntries)
|
||||
setDirtyEditorFiles((dirtyFiles) => {
|
||||
const nextDirtyFiles = { ...dirtyFiles }
|
||||
Object.entries(dirtyFiles).forEach(([path, dirty]) => {
|
||||
if (path === sourcePath || path.startsWith(`${sourcePath}/`)) {
|
||||
if (dirty) nextDirtyFiles[replacePathPrefix(path, sourcePath, targetPath)] = true
|
||||
delete nextDirtyFiles[path]
|
||||
}
|
||||
})
|
||||
return nextDirtyFiles
|
||||
})
|
||||
updateModulePaths((paths) => Array.from(new Set(paths.map((path) => replacePathPrefix(path, sourcePath, targetPath)))))
|
||||
updateOpenPathReferences(sourcePath, targetPath)
|
||||
setOpenProjectFolders((folders) => ({ ...folders, [targetFolder]: true, [targetPath]: sourceKind === 'folder' }))
|
||||
setTreeClipboard(null)
|
||||
setTreeContextMenu(null)
|
||||
if (installedGitModules.length) void refreshGitStatuses(selectedGitTarget)
|
||||
}
|
||||
|
||||
const refreshModuleFiles = (): void => {
|
||||
if (!selectedModule) return
|
||||
setNewFileDraft(null)
|
||||
@@ -481,6 +535,7 @@ export function useWorkspaceFileActions({
|
||||
performDeleteTreeTarget,
|
||||
requestDeleteTreeTarget,
|
||||
pasteTreeClipboard,
|
||||
moveTreeTarget,
|
||||
refreshModuleFiles
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,12 @@
|
||||
"현재 개발 스튜디오 연결을 종료하고 선택한 주차를 시작할 준비를 하고 있습니다.": "Closing the current studio to prepare the selected week.",
|
||||
"대기열에서 요청을 제거하고 플랫폼 상태를 다시 확인하고 있습니다.": "Removing your request from the queue and checking platform status.",
|
||||
"플랫폼 사용 가능 여부와 대기열 상태를 확인하고 있습니다.": "Checking platform availability and queue status.",
|
||||
"{{platform}} 대기 예약 취소 중": "Canceling the queue request for {{platform}}",
|
||||
"{{platform}} 제어권 요청 중": "Requesting control of {{platform}}",
|
||||
"{{platform}} 대기 예약이 취소되었습니다": "Queue request canceled for {{platform}}",
|
||||
"{{platform}} 제어권이 부여되었습니다": "Control granted for {{platform}}",
|
||||
"{{platform}} 대기 예약되었습니다": "Added to the queue for {{platform}}",
|
||||
"{{platform}} 대기 예약되었습니다 ({{position}}번째)": "Added to the queue for {{platform}} (position {{position}})",
|
||||
"다운로드 중...": "Downloading...",
|
||||
"다운로드 및 이미지 준비 중...": "Downloading and preparing image...",
|
||||
"모듈 빌드 중...": "Building module...",
|
||||
@@ -459,6 +465,7 @@
|
||||
"알림은 최대 하루동안만 저장됩니다.": "Notifications are kept for up to one day.",
|
||||
"앱 로그": "App logs",
|
||||
"앱 로그 닫기": "Close app logs",
|
||||
"앱 종료": "Quit",
|
||||
"앱을 종료하시겠습니까?": "Quit the app?",
|
||||
"에디터": "Editor",
|
||||
"연결": "Connect",
|
||||
@@ -545,7 +552,7 @@
|
||||
"종료 {{v0}}": "Ends {{v0}}",
|
||||
"종료 중": "Stopping",
|
||||
"종료 후에는 실행 버튼으로 다시 시작할 수 있습니다.": "You can restart it with the Run button.",
|
||||
"종료하고 새로 시작": "Stop and start new",
|
||||
"종료하고 새로 시작": "Close and start new",
|
||||
"종료하고 저장": "Stop and save",
|
||||
"주차": " week",
|
||||
"주차 ·": " ·",
|
||||
@@ -643,7 +650,7 @@
|
||||
"핵심 개념": "Key concepts",
|
||||
"현재 맵": "Current map",
|
||||
"현재 실행 중인 개발 스튜디오 세션을 종료한 뒤 다른 강좌를 선택해주세요.": "Close the running studio session before selecting another course.",
|
||||
"현재 실행 중인 개발 스튜디오를 종료한 뒤 선택한 주차를 새로 시작할 수 있습니다.": "Stop the current studio before starting the selected week.",
|
||||
"현재 실행 중인 개발 스튜디오를 종료한 뒤 선택한 주차를 새로 시작할 수 있습니다.": "Close the current studio before starting the selected week.",
|
||||
"현재 열린 개발 스튜디오 세션만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.": "Only the current studio session will close. Platform control is retained.",
|
||||
"현재 주차의 학습을 완료할까요?": "Mark this week as complete?",
|
||||
"현재 채팅": "Current chat",
|
||||
|
||||
Reference in New Issue
Block a user