init..
This commit is contained in:
+339
-2230
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
export const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:4400/api').replace(/\/+$/, '')
|
||||
export const LMS_SOCKET_BASE_URL = LMS_API_BASE_URL.replace(/\/api$/, '')
|
||||
export const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_LAUNCHER_API_BASE_URL || 'http://localhost:4200/api').replace(/\/+$/, '')
|
||||
|
||||
export const SELECTED_COURSE_STORAGE_KEY = 'lms-selected-course-id'
|
||||
export const LMS_AUTH_SESSION_STORAGE_KEY = 'autonomy-studio.lms.auth-session'
|
||||
export const STUDIO_SOURCE_SETTINGS_STORAGE_KEY = 'autonomy-studio.lms.source-settings'
|
||||
export const STUDIO_RUNTIME_SESSION_STORAGE_KEY = 'autonomy-studio.lms.runtime-session'
|
||||
export const STUDIO_MODULE_PARAMETERS_STORAGE_KEY = 'autonomy-studio.lms.module-parameters'
|
||||
|
||||
export function launcherApiUrl(path: string): string {
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
if (normalizedPath.startsWith('/api/')) {
|
||||
return `${LAUNCHER_API_BASE_URL.replace(/\/api$/, '')}${normalizedPath}`
|
||||
}
|
||||
return `${LAUNCHER_API_BASE_URL}${normalizedPath}`
|
||||
}
|
||||
Vendored
+30
-8
@@ -9,6 +9,28 @@ interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
type ModulePrebuiltArtifact = {
|
||||
type?: string | null
|
||||
url?: string | null
|
||||
fileName?: string | null
|
||||
checksumSha256?: string | null
|
||||
extractPath?: string | null
|
||||
required?: boolean | null
|
||||
}
|
||||
|
||||
type RuntimeImageArtifact = {
|
||||
image?: string | null
|
||||
url?: string | null
|
||||
fileName?: string | null
|
||||
checksumSha256?: string | null
|
||||
required?: boolean | null
|
||||
}
|
||||
|
||||
type ModuleInstallOptions = {
|
||||
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
|
||||
runtimeImages?: RuntimeImageArtifact[] | null
|
||||
}
|
||||
|
||||
interface Window {
|
||||
launcherAuth?: {
|
||||
appUrl: string | null
|
||||
@@ -37,7 +59,7 @@ interface Window {
|
||||
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
|
||||
}
|
||||
modules: {
|
||||
list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => Promise<{
|
||||
list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null; sourceType?: string | null; prebuiltArtifacts?: ModulePrebuiltArtifact[] | null }>) => Promise<{
|
||||
cacheRoot: string
|
||||
workspaceRoot: string | null
|
||||
modules: Array<{
|
||||
@@ -55,7 +77,7 @@ interface Window {
|
||||
runtime: string
|
||||
}>
|
||||
}>
|
||||
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null) => Promise<{
|
||||
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => Promise<{
|
||||
ok: boolean
|
||||
module: {
|
||||
state: 'running' | 'stopped' | 'uninstalled'
|
||||
@@ -73,11 +95,11 @@ interface Window {
|
||||
}
|
||||
}>
|
||||
delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
|
||||
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string) => Promise<{ ok: boolean; message: string }>
|
||||
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
|
||||
stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; message: string }>
|
||||
stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => Promise<{ ok: boolean; message: string }>
|
||||
stopRunning: () => Promise<{ ok: boolean; message: string }>
|
||||
restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string) => Promise<{ ok: boolean; message: string }>
|
||||
restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
|
||||
logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }>
|
||||
updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => Promise<{ ok: boolean; parameters: string[] }>
|
||||
}
|
||||
@@ -194,8 +216,8 @@ interface Window {
|
||||
delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType<Window['moduleFiles']['delete']>
|
||||
}
|
||||
modules: {
|
||||
list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ReturnType<Window['modules']['list']>
|
||||
install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null) => Promise<{
|
||||
list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null; sourceType?: string | null; prebuiltArtifacts?: ModulePrebuiltArtifact[] | null }>) => ReturnType<Window['modules']['list']>
|
||||
install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => Promise<{
|
||||
ok: boolean
|
||||
module: {
|
||||
state: 'running' | 'stopped' | 'uninstalled'
|
||||
@@ -212,11 +234,11 @@ interface Window {
|
||||
runtime: string
|
||||
}
|
||||
}>
|
||||
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string) => Promise<{ ok: boolean; message: string }>
|
||||
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
|
||||
stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; message: string }>
|
||||
stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => Promise<{ ok: boolean; message: string }>
|
||||
resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => Promise<{ ok: boolean; message: string }>
|
||||
restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string) => Promise<{ ok: boolean; message: string }>
|
||||
restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
|
||||
logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }>
|
||||
}
|
||||
git: {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { LMS_API_BASE_URL, LMS_AUTH_SESSION_STORAGE_KEY } from '../../config/appConfig'
|
||||
|
||||
export type LmsAuthSession = {
|
||||
accessToken: string
|
||||
accessTokenExpiresIn?: number
|
||||
refreshToken?: string
|
||||
refreshTokenExpiresIn?: number
|
||||
tokenType: 'Bearer'
|
||||
user: {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
role: string
|
||||
roles?: string[]
|
||||
primaryAffiliation?: {
|
||||
id: string
|
||||
organizationId: string
|
||||
organizationName: string
|
||||
organizationType: string
|
||||
department: string | null
|
||||
position: string | null
|
||||
} | null
|
||||
currentLicense?: {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
startsAt: string
|
||||
expiresAt: string | null
|
||||
} | null
|
||||
}
|
||||
}
|
||||
|
||||
let handoffExchangePromise: Promise<LmsAuthSession | null> | null = null
|
||||
|
||||
export function readStoredAuthSession(): LmsAuthSession | null {
|
||||
try {
|
||||
const rawSession = window.sessionStorage.getItem(LMS_AUTH_SESSION_STORAGE_KEY)
|
||||
if (!rawSession) return null
|
||||
|
||||
const session = JSON.parse(rawSession) as LmsAuthSession
|
||||
return session?.accessToken ? session : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function storeAuthSession(session: LmsAuthSession): void {
|
||||
try {
|
||||
window.sessionStorage.setItem(LMS_AUTH_SESSION_STORAGE_KEY, JSON.stringify(session))
|
||||
} catch {
|
||||
// Session persistence is best-effort; the handoff flow can still continue without it.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredAuthSession(): void {
|
||||
window.sessionStorage.removeItem(LMS_AUTH_SESSION_STORAGE_KEY)
|
||||
}
|
||||
|
||||
export function exchangeLauncherHandoff(): Promise<LmsAuthSession | null> {
|
||||
const storedSession = readStoredAuthSession()
|
||||
if (storedSession) {
|
||||
console.info('[lms-auth] stored session restored')
|
||||
return Promise.resolve(storedSession)
|
||||
}
|
||||
|
||||
const handoffToken = window.launcherAuth?.handoffToken
|
||||
const audienceApp = window.launcherAuth?.audienceApp ?? 'LMS'
|
||||
|
||||
console.info('[lms-auth] launcher handoff state', {
|
||||
hasHandoffToken: Boolean(handoffToken),
|
||||
audienceApp
|
||||
})
|
||||
|
||||
if (!handoffToken) return Promise.resolve(null)
|
||||
if (handoffExchangePromise) return handoffExchangePromise
|
||||
|
||||
handoffExchangePromise = fetch(`${LMS_API_BASE_URL}/auth/handoff/exchange`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ handoffToken, audienceApp })
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '')
|
||||
console.warn('[lms-auth] launcher handoff exchange failed', response.status, detail)
|
||||
return null
|
||||
}
|
||||
|
||||
const session = await response.json() as LmsAuthSession
|
||||
console.info('[lms-auth] launcher handoff exchange succeeded', {
|
||||
userEmail: session.user?.email ?? null,
|
||||
hasRefreshToken: Boolean(session.refreshToken)
|
||||
})
|
||||
if (session?.accessToken) storeAuthSession(session)
|
||||
return session
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[lms-auth] launcher handoff exchange error', error)
|
||||
return null
|
||||
})
|
||||
|
||||
return handoffExchangePromise
|
||||
}
|
||||
|
||||
export async function refreshStoredAuthSession(session: LmsAuthSession): Promise<LmsAuthSession | null> {
|
||||
if (!session.refreshToken) return null
|
||||
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ refreshToken: session.refreshToken })
|
||||
})
|
||||
|
||||
if (!response.ok) return null
|
||||
|
||||
const nextSession = await response.json() as LmsAuthSession
|
||||
if (!nextSession?.accessToken) return null
|
||||
|
||||
storeAuthSession(nextSession)
|
||||
return nextSession
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export default function LearningInfoCard({
|
||||
}: LearningInfoCardProps): React.JSX.Element {
|
||||
return (
|
||||
<article className="rounded-lg bg-white p-4">
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-medium text-[#626b7a]">
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-[#626b7a]">
|
||||
<BookOpen size={13} className="text-[#714ef6]" />
|
||||
학습 정보
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { LMS_API_BASE_URL, launcherApiUrl } from '../../config/appConfig'
|
||||
import type { CurriculumResponse, DownloadResource, StudentCourseOption } from './types'
|
||||
|
||||
type AuthFetch = (input: RequestInfo | URL, init?: RequestInit, options?: { invalidateOnRefreshFailure?: boolean }) => Promise<Response>
|
||||
|
||||
export async function fetchStudentCourses(fetchWithFreshAuth: AuthFetch): Promise<StudentCourseOption[]> {
|
||||
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/courses`)
|
||||
const payload = response.ok ? await response.json() as { courses?: StudentCourseOption[] } : null
|
||||
return payload?.courses ?? []
|
||||
}
|
||||
|
||||
export async function fetchCurriculum(fetchWithFreshAuth: AuthFetch, courseId: string): Promise<CurriculumResponse | null> {
|
||||
const params = new URLSearchParams()
|
||||
if (courseId) params.set('courseId', courseId)
|
||||
const query = params.toString() ? `?${params.toString()}` : ''
|
||||
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum${query}`)
|
||||
return response.ok ? await response.json() as CurriculumResponse : null
|
||||
}
|
||||
|
||||
export function resolveDownloadUrl(resource: DownloadResource): string {
|
||||
if (/^https?:\/\//i.test(resource.downloadUrl)) return resource.downloadUrl
|
||||
if (resource.downloadUrl.startsWith('/curriculum/')) return `${LMS_API_BASE_URL}${resource.downloadUrl}`
|
||||
return launcherApiUrl(resource.downloadUrl)
|
||||
}
|
||||
|
||||
export function getDownloadResourceWeek(resource: DownloadResource): number | null {
|
||||
const explicitWeek =
|
||||
resource.week ??
|
||||
resource.weekNumber ??
|
||||
resource.metadata?.week ??
|
||||
resource.metadata?.weekNumber
|
||||
const parsedExplicitWeek = Number(explicitWeek)
|
||||
if (Number.isInteger(parsedExplicitWeek) && parsedExplicitWeek > 0) return parsedExplicitWeek
|
||||
|
||||
const searchable = [resource.contextKey, resource.title, resource.fileName].filter(Boolean).join(' ')
|
||||
const match = searchable.match(/(?:week|주차)[\s_-]*(\d+)|(\d+)[\s_-]*주차/i)
|
||||
const parsedMatch = Number(match?.[1] ?? match?.[2])
|
||||
return Number.isInteger(parsedMatch) && parsedMatch > 0 ? parsedMatch : null
|
||||
}
|
||||
|
||||
export function getDownloadResourceSizeLabel(resource: DownloadResource): string {
|
||||
const bytes = Number(resource.fileSizeBytes)
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return resource.fileName
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`
|
||||
return `${bytes} B`
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { LearningStatItem } from './LearningStatsCard'
|
||||
import type { EnvironmentProfile, PlatformOption } from '../platforms/types'
|
||||
import type { ModulePrebuiltArtifact } from '../workspace/types'
|
||||
|
||||
export type DownloadResource = {
|
||||
id: string
|
||||
appKey?: string
|
||||
contextKey?: string
|
||||
title: string
|
||||
description?: string | null
|
||||
fileName: string
|
||||
contentType?: string | null
|
||||
fileSizeBytes?: string | null
|
||||
week?: number | string | null
|
||||
weekNumber?: number | string | null
|
||||
metadata?: {
|
||||
week?: number | string | null
|
||||
weekNumber?: number | string | null
|
||||
} | null
|
||||
downloadUrl: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export type LearningStat = LearningStatItem
|
||||
|
||||
export type CurriculumWeek = {
|
||||
week: number
|
||||
title: string
|
||||
tags: string[]
|
||||
progress: number
|
||||
state: 'done' | 'current' | 'locked' | 'ready'
|
||||
topics: string[]
|
||||
duration: string
|
||||
github: string
|
||||
detail: {
|
||||
overview: string
|
||||
topicDetails: string[]
|
||||
topicVideoUrls?: string[]
|
||||
practiceContents?: Array<{
|
||||
title: string
|
||||
description: string
|
||||
}>
|
||||
references?: DownloadResource[]
|
||||
exampleCodes?: DownloadResource[]
|
||||
deliverable: string
|
||||
requiresSourceSubmission: boolean
|
||||
sourceSubmitted?: boolean
|
||||
sourceSubmittedAt?: string | null
|
||||
}
|
||||
modules?: Array<{
|
||||
id: string
|
||||
sortOrder: number
|
||||
category: string
|
||||
name: string
|
||||
folderName: string
|
||||
templateRepositoryUrl?: string | null
|
||||
sourceType?: string | null
|
||||
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
|
||||
dockerImage?: string | null
|
||||
}>
|
||||
environmentProfiles?: EnvironmentProfile[]
|
||||
}
|
||||
|
||||
export type CurriculumResponse = {
|
||||
course?: {
|
||||
id: string
|
||||
slug: string
|
||||
title: string
|
||||
subtitle?: string | null
|
||||
description?: string | null
|
||||
organizationName: string
|
||||
teacher?: {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
primaryOrganizationName?: string | null
|
||||
}
|
||||
}
|
||||
weeks?: CurriculumWeek[]
|
||||
activeWeek?: number | null
|
||||
activeLearningStep?: string | null
|
||||
learningStats?: LearningStat[]
|
||||
platforms?: PlatformOption[]
|
||||
}
|
||||
|
||||
export type StudentCourseOption = NonNullable<CurriculumResponse['course']> & {
|
||||
progress?: number
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { LMS_API_BASE_URL } from '../../config/appConfig'
|
||||
import type { PlatformControlActionResponse, PlatformOption } from './types'
|
||||
|
||||
type AuthFetch = (input: RequestInfo | URL, init?: RequestInit, options?: { invalidateOnRefreshFailure?: boolean }) => Promise<Response>
|
||||
|
||||
export type PlatformControlWarningPayload = {
|
||||
type: 'platform.control.warning'
|
||||
sessionId: string
|
||||
platformId?: string | null
|
||||
instructorPlatformId?: string | null
|
||||
endsAt: string
|
||||
warningAt: string
|
||||
}
|
||||
|
||||
export type PlatformControlGrantedPayload = {
|
||||
type: 'platform.control.granted'
|
||||
sessionId: string
|
||||
startedAt: string
|
||||
endsAt: string
|
||||
warningAt: string
|
||||
}
|
||||
|
||||
export type PlatformControlTerminatedPayload = {
|
||||
type: 'platform.control.terminated'
|
||||
sessionId: string
|
||||
platformId?: string | null
|
||||
instructorPlatformId?: string | null
|
||||
reason: string
|
||||
message: string
|
||||
endedAt: string
|
||||
}
|
||||
|
||||
export function isPlatformUnavailable(platform: PlatformOption): boolean {
|
||||
return (
|
||||
platform.state === 'locked' ||
|
||||
platform.action === '연결 불가' ||
|
||||
platform.status.includes('연결 불가') ||
|
||||
platform.status.includes('연결 끊김') ||
|
||||
(platform.connection ? !platform.connection.headscaleOnline || !platform.connection.agentHealthy : false)
|
||||
)
|
||||
}
|
||||
|
||||
export function getPlatformDotClass(platform: PlatformOption): string {
|
||||
if (isPlatformUnavailable(platform)) return 'bg-[#ef4444] shadow-[0_0_6px_rgba(239,68,68,0.55)]'
|
||||
if (platform.state === 'reserved' || platform.control?.status === 'QUEUED') return 'bg-[#f59e0b] shadow-[0_0_6px_rgba(245,158,11,0.55)]'
|
||||
return 'bg-[#2fbda0] shadow-[0_0_6px_rgba(47,189,160,0.55)]'
|
||||
}
|
||||
|
||||
export async function fetchPlatformStatuses(fetchWithFreshAuth: AuthFetch, platformIds: string[]): Promise<Array<Partial<PlatformOption> & { id: string }>> {
|
||||
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/platform-control/platforms/statuses`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ platformIds })
|
||||
}, { invalidateOnRefreshFailure: false })
|
||||
|
||||
if (!response.ok) return []
|
||||
const payload = await response.json() as { platforms?: Array<Partial<PlatformOption> & { id: string }> }
|
||||
return payload.platforms ?? []
|
||||
}
|
||||
|
||||
export async function connectPlatformControl(fetchWithFreshAuth: AuthFetch, platformId: string): Promise<{ environmentConfigOverride?: unknown }> {
|
||||
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/platform-control/platforms/${encodeURIComponent(platformId)}/connect`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) throw new Error('platform-connect failed')
|
||||
return response.json() as Promise<{ environmentConfigOverride?: unknown }>
|
||||
}
|
||||
|
||||
export async function releasePlatformControl(fetchWithFreshAuth: AuthFetch, platformId: string): Promise<void> {
|
||||
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/platform-control/platforms/${encodeURIComponent(platformId)}/release`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) throw new Error('platform-release failed')
|
||||
}
|
||||
|
||||
export async function requestOrCancelPlatformControl(
|
||||
fetchWithFreshAuth: AuthFetch,
|
||||
platform: PlatformOption
|
||||
): Promise<{ method: 'POST' | 'DELETE'; payload: PlatformControlActionResponse | null }> {
|
||||
const controlStatus = platform.control?.status
|
||||
const method = controlStatus === 'QUEUED' ? 'DELETE' : 'POST'
|
||||
const suffix = controlStatus === 'QUEUED' ? 'queue' : 'request'
|
||||
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/platform-control/platforms/${encodeURIComponent(platform.id)}/${suffix}`, {
|
||||
method,
|
||||
})
|
||||
if (!response.ok) throw new Error('platform-control failed')
|
||||
const payload = await response.json().catch(() => null) as PlatformControlActionResponse | null
|
||||
return { method, payload }
|
||||
}
|
||||
@@ -4,7 +4,7 @@ type WorkspaceActivityHeaderProps = {
|
||||
dark: boolean
|
||||
textStrong: string
|
||||
mode: 'settings' | 'examples' | 'git' | 'empty' | 'module'
|
||||
gitPanelTab: 'changes' | 'history'
|
||||
gitPanelTab: 'source' | 'history'
|
||||
gitSubmissionCount: number
|
||||
gitChangedFileCount: number
|
||||
children?: React.ReactNode
|
||||
|
||||
@@ -7,13 +7,15 @@ type WorkspaceModuleTab = {
|
||||
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: WorkspaceActivity) => void
|
||||
onActivate: (module: WorkspaceModuleTab, activity: WorkspaceModuleTabActivity) => void
|
||||
onClose: (moduleName: string) => void
|
||||
onOpenContextMenu: (moduleName: string, x: number, y: number) => void
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function WorkspaceNav({
|
||||
return (
|
||||
<nav
|
||||
data-help="workspace-nav"
|
||||
className={['flex flex-col items-center gap-3 border-r py-3', dark ? 'border-white/10 bg-[#303030] text-white/65' : 'border-[#cfd6e4] bg-[#f3f5f9] text-[#687284]'].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-[#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 }) => {
|
||||
@@ -70,7 +70,7 @@ export default function WorkspaceNav({
|
||||
onSelect(id)
|
||||
}}
|
||||
className={[
|
||||
'group relative grid h-8 w-8 place-items-center rounded-lg transition',
|
||||
'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
|
||||
|
||||
@@ -70,6 +70,8 @@ export default function WorkspaceQaPanel({
|
||||
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
|
||||
@@ -82,6 +84,52 @@ export default function WorkspaceQaPanel({
|
||||
}
|
||||
}, [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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Copy } from 'lucide-react'
|
||||
import { ClipboardPaste, Copy } from 'lucide-react'
|
||||
|
||||
type TerminalContextMenuState = {
|
||||
text: string
|
||||
@@ -11,15 +11,18 @@ type Props = {
|
||||
menu: TerminalContextMenuState | null
|
||||
menuRef: React.RefObject<HTMLDivElement | null>
|
||||
onCopy: () => void
|
||||
onPaste: () => void
|
||||
}
|
||||
|
||||
function WorkspaceTerminalContextMenu({
|
||||
dark,
|
||||
menu,
|
||||
menuRef,
|
||||
onCopy
|
||||
onCopy,
|
||||
onPaste
|
||||
}: Props): React.JSX.Element | null {
|
||||
if (!menu) return null
|
||||
const hasSelection = Boolean(menu.text.trim())
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -30,16 +33,29 @@ function WorkspaceTerminalContextMenu({
|
||||
].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={onCopy}
|
||||
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(' ')}
|
||||
>
|
||||
<Copy size={13} />
|
||||
복사
|
||||
<ClipboardPaste size={13} />
|
||||
붙여넣기
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
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()}`
|
||||
@@ -25,3 +28,30 @@ export function serializeModuleParameters(parameters: ModuleParameterPair[]): st
|
||||
.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,69 @@
|
||||
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'
|
||||
}
|
||||
|
||||
export function makeWeekWorkspaceRoot(workspaceRoot: string, studentSlug: string, weekNumber: number): string {
|
||||
const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '')
|
||||
const suffix = `${studentSlug}/week-${weekNumber}`
|
||||
if (normalizedRoot === suffix || normalizedRoot.endsWith(`/${suffix}`)) {
|
||||
return normalizedRoot
|
||||
}
|
||||
return `${normalizedRoot}/${suffix}`
|
||||
}
|
||||
|
||||
export function makePlatformWorkspaceRoot(workspaceRoot: string, studentSlug: string, weekNumber: number, platformKey: string): string {
|
||||
const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '')
|
||||
if (platformKey === 'futuredrive-local-pc') return makeWeekWorkspaceRoot(normalizedRoot, studentSlug, 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}`
|
||||
}
|
||||
@@ -13,8 +13,27 @@ export type ModuleRow = {
|
||||
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,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,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,314 @@
|
||||
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') => 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'): 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 === '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 === '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 === '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,211 @@
|
||||
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))
|
||||
}
|
||||
|
||||
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()
|
||||
const terminalElement = terminalHostRef.current
|
||||
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)
|
||||
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
|
||||
const startTerminal = window.localWorkspace.terminal.start(moduleName, term.cols, term.rows, terminalSessionId, clonePathPreview, moduleFolderName ?? undefined)
|
||||
void startTerminal.then((started) => {
|
||||
fitAddon.fit()
|
||||
if (!started) return
|
||||
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 () => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
terminalElement.removeEventListener('contextmenu', handleTerminalContextMenu)
|
||||
removeDataListener()
|
||||
removeSuppressListener()
|
||||
inputDisposable.dispose()
|
||||
if (fallbackPromptTimer !== null) window.clearTimeout(fallbackPromptTimer)
|
||||
term.dispose()
|
||||
terminalRef.current = null
|
||||
terminalFitRef.current = null
|
||||
terminalSessionIdRef.current = null
|
||||
terminalSuppressQueueRef.current = []
|
||||
}
|
||||
}, [clonePathPreview, dark, moduleFolderName, moduleName, resetKey, terminalActive])
|
||||
|
||||
return {
|
||||
terminalContextMenu,
|
||||
setTerminalContextMenu,
|
||||
terminalContextMenuRef,
|
||||
terminalHostRef,
|
||||
terminalPanelHeight,
|
||||
fitTerminal,
|
||||
copyTerminalSelection,
|
||||
pasteTerminalClipboard,
|
||||
startTerminalResize
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import type { EditorFilePath } from './workspaceFiles'
|
||||
|
||||
export type TreeContextMenuState = {
|
||||
path: string
|
||||
kind: 'file' | 'folder'
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type TreeClipboardState = {
|
||||
path: string
|
||||
kind: 'file' | 'folder'
|
||||
mode: 'cut' | 'copy'
|
||||
}
|
||||
|
||||
export type TreeDialogState =
|
||||
| { type: 'rename'; path: string; kind: 'file' | 'folder'; value: string }
|
||||
| { type: 'delete'; path: string; kind: 'file' | 'folder' }
|
||||
|
||||
export type NewFileDraftState = {
|
||||
kind: 'file' | 'folder'
|
||||
parent: string
|
||||
afterFile: EditorFilePath | null
|
||||
depth: number
|
||||
value: string
|
||||
}
|
||||
|
||||
const defaultOpenProjectFolders: Record<string, boolean> = {
|
||||
lms: true,
|
||||
src: true,
|
||||
'src/main': true,
|
||||
'src/preload': true,
|
||||
'src/renderer': true,
|
||||
'src/renderer/public': true,
|
||||
'src/renderer/public/assets': true,
|
||||
'src/renderer/public/assets/images': true,
|
||||
'src/renderer/src': true,
|
||||
'src/renderer/src/components': true
|
||||
}
|
||||
|
||||
export function useWorkspaceTreeState() {
|
||||
const [selectedTreePath, setSelectedTreePath] = useState<string | null>(null)
|
||||
const [selectedExampleTreePath, setSelectedExampleTreePath] = useState<string | null>(null)
|
||||
const [treeContextMenu, setTreeContextMenu] = useState<TreeContextMenuState | null>(null)
|
||||
const [treeClipboard, setTreeClipboard] = useState<TreeClipboardState | null>(null)
|
||||
const [treeDialog, setTreeDialog] = useState<TreeDialogState | null>(null)
|
||||
const [newFileDraft, setNewFileDraft] = useState<NewFileDraftState | null>(null)
|
||||
const [createdModuleFolders, setCreatedModuleFolders] = useState<string[]>([])
|
||||
const [loadedModuleFolders, setLoadedModuleFolders] = useState<Record<string, string[]>>({})
|
||||
const [loadedModuleFilePaths, setLoadedModuleFilePaths] = useState<Record<string, string[]>>({})
|
||||
const [moduleFileLoadingByName, setModuleFileLoadingByName] = useState<Record<string, boolean>>({})
|
||||
const [loadedModuleRoots, setLoadedModuleRoots] = useState<Record<string, string | null>>({})
|
||||
const [openProjectFolders, setOpenProjectFolders] = useState<Record<string, boolean>>(defaultOpenProjectFolders)
|
||||
const treeContextMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const toggleProjectFolder = (path: string): void => {
|
||||
setOpenProjectFolders((folders) => ({ ...folders, [path]: !folders[path] }))
|
||||
}
|
||||
|
||||
return {
|
||||
selectedTreePath,
|
||||
setSelectedTreePath,
|
||||
selectedExampleTreePath,
|
||||
setSelectedExampleTreePath,
|
||||
treeContextMenu,
|
||||
setTreeContextMenu,
|
||||
treeClipboard,
|
||||
setTreeClipboard,
|
||||
treeDialog,
|
||||
setTreeDialog,
|
||||
newFileDraft,
|
||||
setNewFileDraft,
|
||||
createdModuleFolders,
|
||||
setCreatedModuleFolders,
|
||||
loadedModuleFolders,
|
||||
setLoadedModuleFolders,
|
||||
loadedModuleFilePaths,
|
||||
setLoadedModuleFilePaths,
|
||||
moduleFileLoadingByName,
|
||||
setModuleFileLoadingByName,
|
||||
loadedModuleRoots,
|
||||
setLoadedModuleRoots,
|
||||
openProjectFolders,
|
||||
setOpenProjectFolders,
|
||||
treeContextMenuRef,
|
||||
toggleProjectFolder
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { EnvironmentProfile } from '../platforms/types'
|
||||
import type { RuntimeImageArtifact } from './types'
|
||||
import type { VisualizationStack } from './WorkspaceVisualizationPanel'
|
||||
|
||||
type NoticeTone = 'info' | 'warning' | 'error'
|
||||
|
||||
type UseWorkspaceVisualizationOptions = {
|
||||
profiles: EnvironmentProfile[]
|
||||
clonePathPreview: string
|
||||
rosDomainId: string
|
||||
onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void
|
||||
}
|
||||
|
||||
type UseWorkspaceVisualizationResult = {
|
||||
visualizationStacks: VisualizationStack[]
|
||||
visualizationStack: VisualizationStack
|
||||
setVisualizationStack: (stack: VisualizationStack) => void
|
||||
visualizationRunning: boolean
|
||||
visualizationBusy: boolean
|
||||
visualizationViewerUrl: string
|
||||
activeEnvironmentProfile: EnvironmentProfile | null
|
||||
activeRuntimeImages: RuntimeImageArtifact[]
|
||||
refreshVisualizationStatus: () => Promise<void>
|
||||
startVisualizationBridge: () => Promise<void>
|
||||
stopVisualizationBridge: () => Promise<void>
|
||||
}
|
||||
|
||||
function formatVisualizationStackLabel(stack: VisualizationStack | null | undefined): string {
|
||||
if (stack === 'nav2') return 'Nav2'
|
||||
if (stack === 'slam') return 'SLAM'
|
||||
return 'Autoware'
|
||||
}
|
||||
|
||||
export function useWorkspaceVisualization({
|
||||
profiles,
|
||||
clonePathPreview,
|
||||
rosDomainId,
|
||||
onTitlebarNotice
|
||||
}: UseWorkspaceVisualizationOptions): UseWorkspaceVisualizationResult {
|
||||
const [visualizationStack, setVisualizationStack] = useState<VisualizationStack>('autoware')
|
||||
const [visualizationRunning, setVisualizationRunning] = useState(false)
|
||||
const [, setVisualizationStatusMessage] = useState('대기')
|
||||
const [visualizationBusy, setVisualizationBusy] = useState(false)
|
||||
|
||||
const visualizationStacks = useMemo(() => (
|
||||
profiles
|
||||
.map((profile) => profile.profileType.toLowerCase())
|
||||
.filter((stack): stack is VisualizationStack => stack === 'autoware' || stack === 'nav2' || stack === 'slam')
|
||||
), [profiles])
|
||||
|
||||
const activeEnvironmentProfile = profiles.find((profile) => profile.profileType.toLowerCase() === visualizationStack) ?? profiles[0] ?? null
|
||||
const activeEnvironmentConfig = activeEnvironmentProfile?.config ?? {}
|
||||
const activeRuntimeImages = Array.isArray(activeEnvironmentConfig.runtimeImages)
|
||||
? activeEnvironmentConfig.runtimeImages.filter((image): image is RuntimeImageArtifact => (
|
||||
typeof image === 'object' && image !== null && typeof (image as RuntimeImageArtifact).image === 'string'
|
||||
))
|
||||
: []
|
||||
const activeEnvironmentAgent = typeof activeEnvironmentConfig.agent === 'object' && activeEnvironmentConfig.agent !== null
|
||||
? activeEnvironmentConfig.agent as Record<string, unknown>
|
||||
: null
|
||||
const activeEnvironmentAgentUrl = typeof activeEnvironmentAgent?.url === 'string'
|
||||
? activeEnvironmentAgent.url.replace(/\/+$/, '')
|
||||
: ''
|
||||
const visualizationRemote = activeEnvironmentConfig.target === 'remote-jetson' && activeEnvironmentAgentUrl
|
||||
const visualizationRemoteBridgeHttpUrl = (() => {
|
||||
if (!visualizationRemote) return ''
|
||||
try {
|
||||
const url = new URL(activeEnvironmentAgentUrl)
|
||||
url.port = '8765'
|
||||
return url.toString().replace(/\/+$/, '')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})()
|
||||
const visualizationBridgeHttpUrl = visualizationRemoteBridgeHttpUrl || 'http://localhost:8765'
|
||||
const visualizationBridgeUrl = visualizationRemote
|
||||
? visualizationBridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
|
||||
: 'ws://localhost:8765'
|
||||
const visualizationBackendUrl = visualizationBridgeHttpUrl
|
||||
const visualizationViewerUrl = visualizationStack === 'slam'
|
||||
? `/slam-viewer/index.html?directBridge=1&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}`
|
||||
: `/autoware-viewer/index.html?source=ros&stack=${visualizationStack}&bridge=${encodeURIComponent(visualizationBridgeUrl)}&backend=${encodeURIComponent(visualizationBackendUrl)}`
|
||||
|
||||
useEffect(() => {
|
||||
const profileType = profiles[0]?.profileType?.toLowerCase()
|
||||
if (profileType === 'slam' || profileType === 'nav2' || profileType === 'autoware') {
|
||||
setVisualizationStack(profileType)
|
||||
}
|
||||
}, [profiles.map((profile) => profile.id).join('|')])
|
||||
|
||||
const refreshVisualizationStatus = async (): Promise<void> => {
|
||||
const status = await window.localWorkspace.visualization.status().catch((error) => ({
|
||||
ok: false,
|
||||
running: false,
|
||||
stack: null,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
}))
|
||||
setVisualizationRunning(Boolean(status.running))
|
||||
if (status.stack) setVisualizationStack(status.stack)
|
||||
setVisualizationStatusMessage(status.running
|
||||
? formatVisualizationStackLabel(status.stack)
|
||||
: status.message
|
||||
? status.message
|
||||
: '대기')
|
||||
}
|
||||
|
||||
const startVisualizationBridge = async (): Promise<void> => {
|
||||
if (visualizationBusy) return
|
||||
if (!activeEnvironmentProfile) {
|
||||
setVisualizationStatusMessage('지원 안함')
|
||||
onTitlebarNotice('해당 주차는 시각화를 지원하지 않습니다', 'warning')
|
||||
return
|
||||
}
|
||||
setVisualizationBusy(true)
|
||||
setVisualizationStatusMessage('시작 중')
|
||||
try {
|
||||
const status = await window.localWorkspace.visualization.start(visualizationStack, clonePathPreview, rosDomainId, activeEnvironmentProfile.config)
|
||||
if (!status.ok) throw new Error(status.message ?? '시작 실패')
|
||||
setVisualizationRunning(Boolean(status.running))
|
||||
if (status.stack) setVisualizationStack(status.stack)
|
||||
setVisualizationStatusMessage(formatVisualizationStackLabel(status.stack))
|
||||
onTitlebarNotice('화면이 시작되었습니다', 'info')
|
||||
} catch (error) {
|
||||
setVisualizationRunning(false)
|
||||
setVisualizationStatusMessage(error instanceof Error ? error.message : '시작 실패')
|
||||
onTitlebarNotice('화면 시작에 실패했습니다', 'error')
|
||||
} finally {
|
||||
setVisualizationBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stopVisualizationBridge = async (): Promise<void> => {
|
||||
if (visualizationBusy) return
|
||||
setVisualizationBusy(true)
|
||||
setVisualizationStatusMessage('종료 중')
|
||||
try {
|
||||
const status = await window.localWorkspace.visualization.stop(activeEnvironmentProfile?.config)
|
||||
if (!status.ok) throw new Error(status.message ?? '종료 실패')
|
||||
setVisualizationRunning(false)
|
||||
setVisualizationStatusMessage('대기')
|
||||
onTitlebarNotice('화면이 종료되었습니다', 'info')
|
||||
} catch (error) {
|
||||
setVisualizationStatusMessage(error instanceof Error ? error.message : '종료 실패')
|
||||
onTitlebarNotice('화면 종료에 실패했습니다', 'error')
|
||||
} finally {
|
||||
setVisualizationBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
visualizationStacks,
|
||||
visualizationStack,
|
||||
setVisualizationStack,
|
||||
visualizationRunning,
|
||||
visualizationBusy,
|
||||
visualizationViewerUrl,
|
||||
activeEnvironmentProfile,
|
||||
activeRuntimeImages,
|
||||
refreshVisualizationStatus,
|
||||
startVisualizationBridge,
|
||||
stopVisualizationBridge
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { ModuleRow } from './types'
|
||||
|
||||
export type EditorFilePath = string
|
||||
|
||||
export type WorkspaceTreeItem =
|
||||
| { kind: 'folder'; name: string; path: string; parent?: string; depth: number }
|
||||
| { kind: 'file'; name: string; path: EditorFilePath; parent?: string; depth: number }
|
||||
|
||||
export function getEditorLanguage(file: string): string {
|
||||
if (file.endsWith('.tsx') || file.endsWith('.ts')) return 'typescript'
|
||||
if (/\.(c|cc|cpp|cxx|h|hh|hpp|hxx)$/i.test(file)) return 'cpp'
|
||||
if (file.endsWith('.json')) return 'json'
|
||||
if (file.endsWith('.xml')) return 'xml'
|
||||
if (file.endsWith('.html')) return 'html'
|
||||
if (file.endsWith('.css')) return 'css'
|
||||
if (file.endsWith('.py')) return 'python'
|
||||
if (file.endsWith('.sh')) return 'shell'
|
||||
if (file.endsWith('CMakeLists.txt') || file.endsWith('.cmake')) return 'cmake'
|
||||
if (file.endsWith('.md')) return 'markdown'
|
||||
if (file.endsWith('.yml') || file.endsWith('.yaml')) return 'yaml'
|
||||
return 'plaintext'
|
||||
}
|
||||
|
||||
export function getModuleSourceFiles(module: ModuleRow): Record<string, string> {
|
||||
const packageName = module.name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '')
|
||||
const className = module.name.replace(/[^a-zA-Z0-9]/g, '')
|
||||
const nodeName = `${packageName}_node`
|
||||
|
||||
return {
|
||||
[`modules/${module.name}/launch/${packageName}.launch.py`]: `from launch import LaunchDescription
|
||||
from launch_ros.actions import Node
|
||||
|
||||
def generate_launch_description():
|
||||
return LaunchDescription([
|
||||
Node(
|
||||
package='${packageName}',
|
||||
executable='${nodeName}',
|
||||
name='${nodeName}',
|
||||
output='screen',
|
||||
parameters=['config/${packageName}.yaml']
|
||||
)
|
||||
])
|
||||
`,
|
||||
[`modules/${module.name}/config/${packageName}.yaml`]: `/**:
|
||||
ros__parameters:
|
||||
enabled: ${module.state === 'running'}
|
||||
${module.parameters.length ? module.parameters.map((parameter) => ` ${parameter.replace(': ', ': ')}`).join('\n') : ' update_rate: 20.0'}
|
||||
`,
|
||||
[`modules/${module.name}/src/${nodeName}.cpp`]: `#include <rclcpp/rclcpp.hpp>
|
||||
|
||||
class ${className}Node : public rclcpp::Node
|
||||
{
|
||||
public:
|
||||
${className}Node() : Node("${nodeName}")
|
||||
{
|
||||
timer_ = create_wall_timer(
|
||||
std::chrono::milliseconds(100),
|
||||
std::bind(&${className}Node::onTimer, this));
|
||||
}
|
||||
|
||||
private:
|
||||
void onTimer()
|
||||
{
|
||||
RCLCPP_INFO_THROTTLE(get_logger(), *get_clock(), 3000, "${module.name} running");
|
||||
}
|
||||
|
||||
rclcpp::TimerBase::SharedPtr timer_;
|
||||
};
|
||||
|
||||
int main(int argc, char ** argv)
|
||||
{
|
||||
rclcpp::init(argc, argv);
|
||||
rclcpp::spin(std::make_shared<${className}Node>());
|
||||
rclcpp::shutdown();
|
||||
return 0;
|
||||
}
|
||||
`,
|
||||
[`modules/${module.name}/include/${packageName}/${nodeName}.hpp`]: `#pragma once
|
||||
|
||||
#include <rclcpp/rclcpp.hpp>
|
||||
|
||||
class ${className}Node : public rclcpp::Node
|
||||
{
|
||||
public:
|
||||
${className}Node();
|
||||
|
||||
private:
|
||||
void onTimer();
|
||||
rclcpp::TimerBase::SharedPtr timer_;
|
||||
};
|
||||
`,
|
||||
[`modules/${module.name}/CMakeLists.txt`]: `cmake_minimum_required(VERSION 3.8)
|
||||
project(${packageName})
|
||||
|
||||
find_package(ament_cmake REQUIRED)
|
||||
find_package(rclcpp REQUIRED)
|
||||
|
||||
add_executable(${nodeName} src/${nodeName}.cpp)
|
||||
ament_target_dependencies(${nodeName} rclcpp)
|
||||
|
||||
install(TARGETS ${nodeName}
|
||||
DESTINATION lib/\${PROJECT_NAME})
|
||||
|
||||
install(DIRECTORY launch config
|
||||
DESTINATION share/\${PROJECT_NAME})
|
||||
|
||||
ament_package()
|
||||
`,
|
||||
[`modules/${module.name}/package.xml`]: `<?xml version="1.0"?>
|
||||
<package format="3">
|
||||
<name>${packageName}</name>
|
||||
<version>0.1.0</version>
|
||||
<description>${module.category} module for autonomy practice.</description>
|
||||
<maintainer email="edu@autonomy.local">Autonomy Studio</maintainer>
|
||||
<license>Apache-2.0</license>
|
||||
|
||||
<buildtool_depend>ament_cmake</buildtool_depend>
|
||||
<depend>rclcpp</depend>
|
||||
|
||||
<export>
|
||||
<build_type>ament_cmake</build_type>
|
||||
</export>
|
||||
</package>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
export function buildModuleTreeItems(
|
||||
module: ModuleRow,
|
||||
filePaths = Object.keys(getModuleSourceFiles(module)),
|
||||
folderPaths: string[] = []
|
||||
): WorkspaceTreeItem[] {
|
||||
const rootPath = `modules/${module.name}`
|
||||
return buildPathTreeItems(rootPath, filePaths, folderPaths)
|
||||
}
|
||||
|
||||
export function buildPathTreeItems(rootPath: string, filePaths: string[], folderPaths: string[] = []): WorkspaceTreeItem[] {
|
||||
const sourceFiles = filePaths.filter((filePath) => filePath.startsWith(`${rootPath}/`))
|
||||
const folderMap = new Map<string, { kind: 'folder'; name: string; path: string; parent?: string; depth: number }>()
|
||||
const fileItems: Array<{ kind: 'file'; name: string; path: EditorFilePath; parent?: string; depth: number }> = []
|
||||
|
||||
sourceFiles.forEach((filePath) => {
|
||||
const parts = filePath.split('/')
|
||||
const rootDepth = rootPath.split('/').length
|
||||
for (let index = rootDepth; index < parts.length - 1; index += 1) {
|
||||
const folderPath = parts.slice(0, index + 1).join('/')
|
||||
if (!folderMap.has(folderPath)) {
|
||||
folderMap.set(folderPath, {
|
||||
kind: 'folder',
|
||||
name: parts[index],
|
||||
path: folderPath,
|
||||
parent: parts.slice(0, index).join('/'),
|
||||
depth: index - rootDepth + 1
|
||||
})
|
||||
}
|
||||
}
|
||||
fileItems.push({
|
||||
kind: 'file',
|
||||
name: parts.at(-1) ?? filePath,
|
||||
path: filePath,
|
||||
parent: parts.slice(0, -1).join('/'),
|
||||
depth: parts.length - rootDepth
|
||||
})
|
||||
})
|
||||
folderPaths.forEach((folderPath) => {
|
||||
const parts = folderPath.split('/')
|
||||
const rootDepth = rootPath.split('/').length
|
||||
for (let index = rootDepth; index < parts.length; index += 1) {
|
||||
const currentFolderPath = parts.slice(0, index + 1).join('/')
|
||||
if (!folderMap.has(currentFolderPath)) {
|
||||
folderMap.set(currentFolderPath, {
|
||||
kind: 'folder',
|
||||
name: parts[index],
|
||||
path: currentFolderPath,
|
||||
parent: parts.slice(0, index).join('/'),
|
||||
depth: index - rootDepth + 1
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const childrenByParent = new Map<string, WorkspaceTreeItem[]>()
|
||||
;[...folderMap.values(), ...fileItems].forEach((item) => {
|
||||
if (!item.parent) return
|
||||
childrenByParent.set(item.parent, [...(childrenByParent.get(item.parent) ?? []), item])
|
||||
})
|
||||
|
||||
const sortTreeItems = (a: WorkspaceTreeItem, b: WorkspaceTreeItem): number => {
|
||||
if (a.kind !== b.kind) return a.kind === 'folder' ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
const orderedItems: WorkspaceTreeItem[] = []
|
||||
const visitChildren = (parentPath: string): void => {
|
||||
;(childrenByParent.get(parentPath) ?? []).sort(sortTreeItems).forEach((item) => {
|
||||
orderedItems.push(item)
|
||||
if (item.kind === 'folder') visitChildren(item.path)
|
||||
})
|
||||
}
|
||||
|
||||
visitChildren(rootPath)
|
||||
|
||||
return orderedItems
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export const formatSessionClock = (value?: string | null): string | null => {
|
||||
if (!value) return null
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
return date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
}
|
||||
|
||||
export const formatControlRemainingTime = (endsAt?: string | null): string => {
|
||||
if (!endsAt) return '00:00'
|
||||
const endTime = new Date(endsAt).getTime()
|
||||
if (Number.isNaN(endTime)) return '00:00'
|
||||
const remainingSeconds = Math.max(0, Math.ceil((endTime - Date.now()) / 1000))
|
||||
const hours = Math.floor(remainingSeconds / 3600)
|
||||
const minutes = Math.floor((remainingSeconds % 3600) / 60)
|
||||
const seconds = remainingSeconds % 60
|
||||
const paddedMinutes = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
|
||||
const paddedSeconds = String(seconds).padStart(2, '0')
|
||||
return hours > 0 ? `${hours}:${paddedMinutes}:${paddedSeconds}` : `${paddedMinutes}:${paddedSeconds}`
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ModuleRow } from './types'
|
||||
|
||||
export const maxVisibleModuleLogEntries = 200
|
||||
|
||||
export type ModuleLogEntry = {
|
||||
time: string
|
||||
level: 'INFO' | 'WARN' | 'ERROR'
|
||||
source: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export function getModuleTerminalSessionId(moduleName: string): string {
|
||||
return `lms-module-terminal:${moduleName}`
|
||||
}
|
||||
|
||||
export function stripInternalTerminalCommands(data: string): string {
|
||||
return data
|
||||
.replace(/(?:\x1b\[\?2004[lh])?\r*stty cols \d+ rows \d+\r?\n?/g, '')
|
||||
.replace(/(?:^|\r?\n)cd '(?:[^'\\]|\\.)*'\r?\n?/g, '')
|
||||
.replace(/(?:^|\r?\n)cd "(?:[^"\\]|\\.)*"\r?\n?/g, '')
|
||||
.replace(/(?:^|\r?\n)cd [^\r\n]+\r?\n?/g, '')
|
||||
}
|
||||
|
||||
function stripLogControlSequences(line: string): string {
|
||||
return line
|
||||
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '')
|
||||
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
|
||||
.replace(/␛\][^␇]*(?:␇|$)/g, '')
|
||||
.replace(/␛\[[0-?]*[ -/]*[@-~]/g, '')
|
||||
}
|
||||
|
||||
function parseLogDate(value?: string): Date | null {
|
||||
if (!value) return null
|
||||
const normalized = value.replace(/(\.\d{3})\d+/, '$1')
|
||||
const date = new Date(normalized)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
export function parseModuleLogEntries(module: ModuleRow, logs: string): ModuleLogEntry[] {
|
||||
return logs
|
||||
.split(/\r?\n/)
|
||||
.map((line) => stripLogControlSequences(line).trimEnd())
|
||||
.filter(Boolean)
|
||||
.map((line): ModuleLogEntry => {
|
||||
const timestampMatch = line.match(/^(\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)\s+(.*)$/)
|
||||
const rosEpochMatch = line.match(/\[(\d{10}\.\d+)\]/)
|
||||
const bracketLevelMatch = line.match(/\b(ERROR|ERR|WARN|WARNING|INFO|DEBUG|TRACE|FATAL)\b/i)
|
||||
const levelToken = bracketLevelMatch?.[1]?.toUpperCase()
|
||||
const level: ModuleLogEntry['level'] =
|
||||
levelToken === 'ERROR' || levelToken === 'ERR' || levelToken === 'FATAL'
|
||||
? 'ERROR'
|
||||
: levelToken === 'WARN' || levelToken === 'WARNING'
|
||||
? 'WARN'
|
||||
: 'INFO'
|
||||
const date = parseLogDate(timestampMatch?.[1]) ?? (rosEpochMatch ? new Date(Number(rosEpochMatch[1]) * 1000) : null)
|
||||
const time = date && !Number.isNaN(date.getTime())
|
||||
? date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
|
||||
: '--:--:--'
|
||||
|
||||
return {
|
||||
time,
|
||||
level,
|
||||
source: module.name,
|
||||
message: timestampMatch?.[2] ?? line
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { CurriculumWeek } from '../curriculum/types'
|
||||
import type { ModuleRow } from './types'
|
||||
|
||||
export const compareModulePriority = (a: ModuleRow, b: ModuleRow): number => a.sortOrder - b.sortOrder
|
||||
|
||||
export const compareModuleShutdownPriority = (a: ModuleRow, b: ModuleRow): number => b.sortOrder - a.sortOrder
|
||||
|
||||
export function makeUninstalledModuleRows(curriculumModules: CurriculumWeek['modules']): ModuleRow[] {
|
||||
return (curriculumModules ?? [])
|
||||
.map((module) => ({
|
||||
state: 'uninstalled' as const,
|
||||
sortOrder: module.sortOrder,
|
||||
category: module.category,
|
||||
name: module.name,
|
||||
folderName: module.folderName,
|
||||
sourcePath: module.templateRepositoryUrl ?? '',
|
||||
workspacePath: null,
|
||||
templateRepositoryUrl: module.templateRepositoryUrl ?? null,
|
||||
sourceType: module.sourceType ?? null,
|
||||
prebuiltArtifacts: module.prebuiltArtifacts ?? null,
|
||||
parameters: [],
|
||||
cpu: null,
|
||||
memory: null,
|
||||
runtime: '-'
|
||||
}))
|
||||
.sort(compareModulePriority)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export function getPathName(path: string): string {
|
||||
return path.split('/').pop() ?? path
|
||||
}
|
||||
|
||||
export function getPathParent(path: string): string {
|
||||
return path.split('/').slice(0, -1).join('/')
|
||||
}
|
||||
|
||||
export function replacePathPrefix(path: string, fromPath: string, toPath: string): string {
|
||||
return path === fromPath ? toPath : path.startsWith(`${fromPath}/`) ? `${toPath}${path.slice(fromPath.length)}` : path
|
||||
}
|
||||
|
||||
export function makeUniquePath(basePath: string, files: Record<string, string>, folders: string[]): string {
|
||||
if (files[basePath] === undefined && !folders.includes(basePath)) return basePath
|
||||
const parent = getPathParent(basePath)
|
||||
const name = getPathName(basePath)
|
||||
const dotIndex = name.lastIndexOf('.')
|
||||
const stem = dotIndex > 0 ? name.slice(0, dotIndex) : name
|
||||
const ext = dotIndex > 0 ? name.slice(dotIndex) : ''
|
||||
let index = 1
|
||||
|
||||
while (true) {
|
||||
const nextPath = `${parent}/${stem} copy${index > 1 ? ` ${index}` : ''}${ext}`
|
||||
if (files[nextPath] === undefined && !folders.includes(nextPath)) return nextPath
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user