feat: support simultaneous Tailscale and LAN communication for Yahboom cameras
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { readdir, readFile, realpath } from 'fs/promises'
|
||||
import { resolve, sep } from 'path'
|
||||
import type { LocalGitModule } from '../shared/gitModuleImport'
|
||||
|
||||
export async function localGitModulePath(root: string, folder: string): Promise<string> {
|
||||
if (!/^\[lms-nav2-custom\][a-z][a-z0-9_]*_v[^/\\]+_ws$/.test(folder)) throw new Error('Invalid custom module folder')
|
||||
const base = await realpath(root)
|
||||
const target = await realpath(resolve(base, folder))
|
||||
if (!target.startsWith(base + sep)) throw new Error('Module is outside the local workspace')
|
||||
return target
|
||||
}
|
||||
|
||||
export async function listLocalGitModules(root: string): Promise<LocalGitModule[]> {
|
||||
const entries = await readdir(root, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return []
|
||||
throw error
|
||||
})
|
||||
const modules: LocalGitModule[] = []
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !entry.name.startsWith('[lms-nav2-custom]')) continue
|
||||
try {
|
||||
const target = await localGitModulePath(root, entry.name)
|
||||
const data = JSON.parse(await readFile(resolve(target, '.autonomy-studio-module.json'), 'utf8'))
|
||||
if (data.sourceType !== 'CUSTOM' || !['name', 'category', 'packageName', 'launchFile'].every(key => typeof data[key] === 'string' && data[key])) continue
|
||||
modules.push({ name: data.name, category: data.category, packageName: data.packageName, launchFile: data.launchFile, folderName: entry.name })
|
||||
} catch { /* Ignore incomplete module folders. */ }
|
||||
}
|
||||
return modules.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
export function repositoryAddress(value: string): string {
|
||||
// Do not allow local paths, Git remote helpers, or credentials in the UI.
|
||||
const normalized = /^git@[^:]+:.+/.test(value) ? value.replace(/^git@([^:]+):(.+)$/, 'ssh://git@$1/$2') : value
|
||||
const url = new URL(normalized)
|
||||
if (!['https:', 'http:', 'ssh:'].includes(url.protocol)) throw new Error('Unsupported Git repository protocol')
|
||||
url.password = ''
|
||||
if (url.protocol !== 'ssh:') url.username = ''
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export function remoteBranches(output: string): Array<{ name: string; commit: string }> {
|
||||
return output.split('\n').flatMap(line => {
|
||||
const match = line.trim().match(/^([a-f0-9]{40,64})\s+refs\/heads\/(.+)$/)
|
||||
return match ? [{ name: match[2], commit: match[1] }] : []
|
||||
}).sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
+136
-13
@@ -5,6 +5,8 @@ import { cameraRuntimeArtifacts } from './cameraRuntime'
|
||||
import { remoteModuleFilesScript } from './remoteModuleFiles'
|
||||
import { resolveContainerTerminal } from './containerTerminal'
|
||||
import { remoteShellCommand, remoteShellEnvironment } from './remoteShell'
|
||||
import { listLocalGitModules, localGitModulePath, remoteBranches, repositoryAddress } from './gitModuleImport'
|
||||
import type { GitModuleSource, GitModuleImportInput } from '../shared/gitModuleImport'
|
||||
import { yahboomDevicePreflight, yahboomModuleCases, yahboomRuntimeImage, yahboomRuntimeArtifacts, yahboomSimulation } from './yahboomRuntime'
|
||||
import { visualizationProgress, reportVisualizationProgress } from './visualizationProgress'
|
||||
import { moduleProgress, reportModuleProgress, createModuleProgressParser } from './moduleProgress'
|
||||
@@ -1273,6 +1275,82 @@ async function createRemoteCustomWorkspaceModule(
|
||||
return { ok: true, workspacePath: output.trim().split('\n')[0] || remotePathJoin(target.workspaceRoot, module.folderName) }
|
||||
}
|
||||
|
||||
async function inspectLocalGitModule(workspaceRoot: string, folder: string): Promise<GitModuleSource> {
|
||||
const workspace = expandUserPath(workspaceRoot)
|
||||
if (!workspace) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.')
|
||||
const root = await localGitModulePath(workspace, folder)
|
||||
const git = (args: string[]) => runCommandOutput('git', args, root, { ...process.env, GIT_TERMINAL_PROMPT: '0' }, 15_000, 'stdout')
|
||||
const top = (await git(['rev-parse', '--show-toplevel'])).trim()
|
||||
if (resolve(top) !== root) throw new Error('모듈 자체의 Git 저장소가 필요합니다.')
|
||||
const remote = await git(['remote', 'get-url', 'student']).catch(() => git(['remote', 'get-url', 'origin']))
|
||||
const repository = repositoryAddress(remote.trim())
|
||||
const currentBranch = (await git(['branch', '--show-current'])).trim()
|
||||
const dirty = Boolean((await git(['status', '--porcelain'])).trim())
|
||||
const head = (await git(['rev-parse', 'HEAD'])).trim()
|
||||
let branches: GitModuleSource['branches']
|
||||
try {
|
||||
branches = remoteBranches(await git(['ls-remote', '--heads', withGitCredential(repository)]))
|
||||
} catch { throw new Error('Git 저장소를 조회하지 못했습니다. 접근 권한과 네트워크를 확인하세요.') }
|
||||
return { repository, currentBranch, branches, dirty, pushed: branches.some(branch => branch.name === currentBranch && branch.commit === head) }
|
||||
}
|
||||
|
||||
async function importRemoteGitModule(targetInput: RemoteWorkspaceTarget, workspaceRoot: string, input: GitModuleImportInput & { folderName: string }): Promise<{ ok: boolean; workspacePath: string }> {
|
||||
const target = normalizeRemoteTarget(targetInput)
|
||||
const module = validateCustomModuleScaffold(input)
|
||||
const source = await inspectLocalGitModule(workspaceRoot, input.sourceFolder)
|
||||
if (source.dirty || !source.pushed) throw new Error('로컬 변경 사항을 커밋하고 푸시한 뒤 다시 시도하세요.')
|
||||
const branch = source.branches.find(branch => branch.name === input.branch && branch.commit === input.commit)
|
||||
if (!branch) throw new Error('브랜치가 변경되었습니다. 저장소 정보를 새로고침하세요.')
|
||||
const repository = remoteRepositoryUrl(source.repository)
|
||||
const authenticated = withGitCredential(repository)
|
||||
const url = new URL(authenticated)
|
||||
const authorization = ['http:', 'https:'].includes(url.protocol) && url.username
|
||||
? `Authorization: Basic ${Buffer.from(`${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`).toString('base64')}` : ''
|
||||
const config = encodeBase64(customModuleConfigContent(module))
|
||||
// Stage the clone and publish only after validation. Never overwrite an existing workspace.
|
||||
const script = [
|
||||
'set -e',
|
||||
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
|
||||
...expandRemotePathVar('ROOT'),
|
||||
'mkdir -p "$ROOT"',
|
||||
`TARGET="$ROOT/"${quoteShellSingle(module.folderName)}`,
|
||||
'if [ -e "$TARGET" ] || [ -L "$TARGET" ]; then echo "Module folder already exists" >&2; exit 1; fi',
|
||||
'STAGING=$(mktemp -d "$ROOT/.git-import-XXXXXXXX")',
|
||||
'trap \'rm -rf -- "$STAGING"\' EXIT',
|
||||
`AUTH=$(printf %s ${quoteShellSingle(encodeBase64(authorization))} | base64 -d)`,
|
||||
'export GIT_TERMINAL_PROMPT=0',
|
||||
`if ! GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="$AUTH" git clone --single-branch --branch ${quoteShellSingle(branch.name)} -- ${quoteShellSingle(repository)} "$STAGING/module" 2>"$STAGING/clone-error"; then echo "Git clone failed: check repository access from the platform" >&2; exit 1; fi`,
|
||||
'unset AUTH',
|
||||
`test "$(git -C "$STAGING/module" rev-parse HEAD)" = ${quoteShellSingle(branch.commit)} || { echo "Branch changed; refresh and retry" >&2; exit 1; }`,
|
||||
`python3 - "$STAGING/module" ${quoteShellSingle(config)} <<'PY'`,
|
||||
'import base64, json, os, subprocess, sys',
|
||||
'root = sys.argv[1]',
|
||||
'config = json.loads(base64.b64decode(sys.argv[2]))',
|
||||
'paths = subprocess.check_output(["git", "-C", root, "ls-tree", "-r", "--name-only", "HEAD"]).decode().splitlines()',
|
||||
'if any(set(path.split("/")) & {"build", "install", "log", "__pycache__"} for path in paths): raise SystemExit("Repository contains build artifacts; remove them before importing")',
|
||||
'for path in paths:',
|
||||
' if os.path.islink(os.path.join(root, path)): raise SystemExit("Repository symlinks are not supported for module import")',
|
||||
'package = config["packageName"]',
|
||||
'candidates = [root, os.path.join(root, "src", package)]',
|
||||
'if not any(os.path.isfile(os.path.join(p, "package.xml")) and os.path.isfile(os.path.join(p, "launch", config["launchFile"])) for p in candidates): raise SystemExit("Package or launch file is missing in the selected branch")',
|
||||
'for script in ("build.sh", "run.sh"):',
|
||||
' if not os.path.isfile(os.path.join(root, script)): raise SystemExit("Module build.sh/run.sh is missing")',
|
||||
' os.chmod(os.path.join(root, script), 0o755)',
|
||||
'with open(os.path.join(root, ".autonomy-studio-module.json"), "w") as fp: json.dump(config, fp, indent=2)',
|
||||
'PY',
|
||||
'mv -T -n "$STAGING/module" "$TARGET"',
|
||||
'test ! -d "$STAGING/module" || { echo "Module folder already exists" >&2; exit 1; }',
|
||||
'printf "%s" "$TARGET"',
|
||||
].join('\n')
|
||||
try {
|
||||
const workspacePath = (await runSshOutput(target, script, 120_000)).trim()
|
||||
return { ok: true, workspacePath }
|
||||
} catch {
|
||||
// SSH failures can include the command (and its temporary authorization header).
|
||||
throw new Error('Git 모듈 복제에 실패했습니다. 원격 저장소 접근, 기존 폴더, 소스 및 빌드 산출물 포함 여부를 확인하세요.')
|
||||
}
|
||||
}
|
||||
|
||||
async function transferLocalWorkspaceModuleToRemote(
|
||||
targetInput: RemoteWorkspaceTarget,
|
||||
sourceWorkspaceRoot: string | null | undefined,
|
||||
@@ -1774,9 +1852,11 @@ async function runCommand(command: string, args: string[], cwd?: string, env?: N
|
||||
await runCommandOutput(command, args, cwd, env, timeoutMs)
|
||||
}
|
||||
|
||||
async function listLocalDockerImages(): Promise<{ images: string[]; message?: string }> {
|
||||
async function listWorkspaceDockerImages(target?: RemoteWorkspaceTarget): Promise<{ images: string[]; message?: string }> {
|
||||
try {
|
||||
const output = await runCommandOutput('docker', ['image', 'ls', '--format', '{{json .}}'], undefined, undefined, 10_000)
|
||||
const output = target
|
||||
? await runSshOutput(target, "docker image ls --format '{{json .}}'", 10_000)
|
||||
: await runCommandOutput('docker', ['image', 'ls', '--format', '{{json .}}'], undefined, undefined, 10_000)
|
||||
const images = Array.from(new Set(output.split('\n').flatMap((line) => {
|
||||
if (!line.trim()) return []
|
||||
try {
|
||||
@@ -1794,7 +1874,7 @@ async function listLocalDockerImages(): Promise<{ images: string[]; message?: st
|
||||
} catch (error) {
|
||||
return {
|
||||
images: [],
|
||||
message: error instanceof Error ? error.message : '로컬 Docker 이미지를 불러오지 못했습니다.'
|
||||
message: error instanceof Error ? error.message : 'Docker 이미지를 불러오지 못했습니다.'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4337,7 +4417,7 @@ function parseRosDomainId(input?: string | null): number {
|
||||
return Number.isInteger(value) && value >= 0 && value <= 232 ? value : 0
|
||||
}
|
||||
|
||||
function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDomainId?: string | null): string {
|
||||
function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDomainId?: string | null, lanAddresses: string[] = []): string {
|
||||
const domainId = parseRosDomainId(rosDomainId)
|
||||
const discoveryBasePort = 7400 + (250 * domainId)
|
||||
// Fast DDS can bind the default discovery port itself (d0), while other
|
||||
@@ -4347,7 +4427,7 @@ function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDo
|
||||
discoveryBasePort,
|
||||
...Array.from({ length: 121 }, (_, participantId) => discoveryBasePort + 10 + (2 * participantId))
|
||||
]
|
||||
const localAddresses = Array.from(new Set(['127.0.0.1', localAddress]))
|
||||
const localAddresses = Array.from(new Set(['127.0.0.1', localAddress, ...lanAddresses]))
|
||||
const peerAddresses = Array.from(new Set(['127.0.0.1', peerAddress]))
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8" ?>',
|
||||
@@ -4371,6 +4451,11 @@ function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDo
|
||||
' <transport_id>autonomy_studio_udp</transport_id>',
|
||||
' </userTransports>',
|
||||
' <builtin>',
|
||||
...(lanAddresses.length ? [
|
||||
' <metatrafficMulticastLocatorList>',
|
||||
` <locator><udpv4><address>239.255.0.1</address><port>${discoveryBasePort}</port></udpv4></locator>`,
|
||||
' </metatrafficMulticastLocatorList>',
|
||||
] : []),
|
||||
' <metatrafficUnicastLocatorList>',
|
||||
...localAddresses.flatMap((address) => [
|
||||
' <locator>',
|
||||
@@ -4382,6 +4467,9 @@ function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDo
|
||||
]),
|
||||
' </metatrafficUnicastLocatorList>',
|
||||
' <initialPeersList>',
|
||||
...(lanAddresses.length ? [
|
||||
` <locator><udpv4><address>239.255.0.1</address><port>${discoveryBasePort}</port></udpv4></locator>`,
|
||||
] : []),
|
||||
...peerAddresses.flatMap((address) => peers.flatMap((port) => [
|
||||
' <locator>',
|
||||
' <udpv4>',
|
||||
@@ -4467,25 +4555,51 @@ async function prepareLocalRosNetworkConfig(
|
||||
return configPath
|
||||
}
|
||||
|
||||
function parseCameraLanAddresses(output: string): string[] {
|
||||
const interfaces = output.split('\n').filter((line) => line.trim()).flatMap((line) => JSON.parse(line))
|
||||
return Array.from(new Set<string>(interfaces.flatMap((item) =>
|
||||
(item.addr_info ?? [])
|
||||
.filter((address: { family?: string; scope?: string; local?: string }) =>
|
||||
address.family === 'inet' && address.scope === 'global'
|
||||
&& typeof address.local === 'string'
|
||||
&& /^(?:\d{1,3}\.){3}\d{1,3}$/.test(address.local)
|
||||
&& address.local.split('.').every((part) => Number(part) <= 255))
|
||||
.map((address: { local: string }) => address.local),
|
||||
)))
|
||||
}
|
||||
|
||||
function cameraLanEnabled(folderName: string): boolean {
|
||||
return /^\[lms-nav2-yahboom-camera\]yahboom_camera_v[^/]+_ws$/.test(folderName)
|
||||
}
|
||||
|
||||
async function prepareRemoteRosNetworkConfig(
|
||||
target: RemoteWorkspaceTarget,
|
||||
runtimeRoot: string,
|
||||
network?: RosNetworkConfig | null,
|
||||
rosDomainId?: string | null,
|
||||
allowCameraLan = false,
|
||||
): Promise<string | null> {
|
||||
if (!rosNetworkEnabled(network)) return null
|
||||
if (!rosNetworkEnabled(network) && !allowCameraLan) return null
|
||||
|
||||
const [peerAddress, localAddress] = await Promise.all([
|
||||
readLocalTailscaleIpv4(),
|
||||
readRemoteTailscaleIpv4(target),
|
||||
rosNetworkEnabled(network) ? readLocalTailscaleIpv4() : Promise.resolve('127.0.0.1'),
|
||||
rosNetworkEnabled(network) ? readRemoteTailscaleIpv4(target) : Promise.resolve('127.0.0.1'),
|
||||
])
|
||||
const configPath = `${runtimeRoot}/docker/fastdds-peer-${peerAddress.replace(/[^0-9]/g, '-')}-d${parseRosDomainId(rosDomainId)}.xml`
|
||||
const lanAddresses = allowCameraLan ? parseCameraLanAddresses(await runSshOutput(target, [
|
||||
'set -e',
|
||||
'for interface in /sys/class/net/*; do',
|
||||
' [ -e "$interface/device" ] || continue',
|
||||
' ip -j -4 address show dev "${interface##*/}" up scope global',
|
||||
'done',
|
||||
].join('\n'), 15_000)) : []
|
||||
if (allowCameraLan && !lanAddresses.length) throw new Error('카메라 내부망 통신에 사용할 활성 Ethernet/Wi-Fi IPv4 주소가 없습니다.')
|
||||
const configPath = `${runtimeRoot}/docker/fastdds-peer-${peerAddress.replace(/[^0-9]/g, '-')}-d${parseRosDomainId(rosDomainId)}${allowCameraLan ? '-camera-lan' : ''}.xml`
|
||||
await runSsh(target, [
|
||||
'set -e',
|
||||
`CONFIG_PATH=${quoteShellSingle(configPath)}`,
|
||||
...expandRemotePathVar('CONFIG_PATH'),
|
||||
'mkdir -p "$(dirname \"$CONFIG_PATH\")"',
|
||||
`printf %s ${quoteShellSingle(encodeBase64(buildFastDdsPeerConfig(peerAddress, localAddress, rosDomainId)))} | base64 -d > "$CONFIG_PATH"`,
|
||||
`printf %s ${quoteShellSingle(encodeBase64(buildFastDdsPeerConfig(peerAddress, localAddress, rosDomainId, lanAddresses)))} | base64 -d > "$CONFIG_PATH"`,
|
||||
].join('\n'), 15_000)
|
||||
return configPath
|
||||
}
|
||||
@@ -5337,7 +5451,8 @@ async function startRemoteWorkspaceModule(
|
||||
const runtimeRoot = nav2Module
|
||||
? await ensureRemoteNav2RuntimeAssets(target)
|
||||
: await ensureRemoteStudioRuntimeAssets(target)
|
||||
const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(target, runtimeRoot, rosNetwork, rosDomainId)
|
||||
const allowCameraLan = cameraLanEnabled(targetFolderName)
|
||||
const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(target, runtimeRoot, rosNetwork, rosDomainId, allowCameraLan)
|
||||
await prepareRemoteRuntimeImages(target, targetFolderName.includes('lms-nav2-camera-test')
|
||||
? (runtimeImages ?? []).filter((item) => item.image?.trim() === runtimeImage)
|
||||
: yahboomRuntimeArtifacts(targetFolderName, runtimeImage, runtimeImages))
|
||||
@@ -5390,7 +5505,7 @@ async function startRemoteWorkspaceModule(
|
||||
...expandRemotePathVar('ROOT'),
|
||||
`PARAMS=${quoteShellSingle(remoteRosParams)}`,
|
||||
`FASTDDS_CONFIG_PATH=${quoteShellSingle(fastDdsConfigPath ?? '')}`,
|
||||
`ROS_LOCALHOST_ONLY=${rosNetworkEnabled(rosNetwork) ? '0' : '1'}`,
|
||||
`ROS_LOCALHOST_ONLY=${rosNetworkEnabled(rosNetwork) || allowCameraLan ? '0' : '1'}`,
|
||||
...expandRemotePathVar('FASTDDS_CONFIG_PATH'),
|
||||
'read -r -a EXTRA_ARGS <<< "$PARAMS"',
|
||||
'export FASTDDS_CONFIG_PATH',
|
||||
@@ -6342,7 +6457,15 @@ function registerLocalWorkspaceHandlers(): void {
|
||||
closeSplashAndShowMain()
|
||||
})
|
||||
ipcMain.handle('local-workspace:modules:list', (_event, workspaceRoot?: string | null, courseModules?: CourseModuleInput[] | null) => collectAvailableModules(workspaceRoot, courseModules))
|
||||
ipcMain.handle('local-workspace:modules:docker-images', () => listLocalDockerImages())
|
||||
ipcMain.handle('local-workspace:modules:docker-images', () => listWorkspaceDockerImages())
|
||||
ipcMain.handle('local-workspace:modules:git-sources', (_event, root: string) => {
|
||||
const workspace = expandUserPath(root)
|
||||
if (!workspace) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.')
|
||||
return listLocalGitModules(workspace)
|
||||
})
|
||||
ipcMain.handle('local-workspace:modules:git-source', (_event, root: string, folder: string) => inspectLocalGitModule(root, folder))
|
||||
ipcMain.handle('remote-workspace:modules:import-git', (_event, target: RemoteWorkspaceTarget, root: string, input: GitModuleImportInput & { folderName: string }) => importRemoteGitModule(target, root, input))
|
||||
ipcMain.handle('remote-workspace:modules:docker-images', (_event, target: RemoteWorkspaceTarget) => listWorkspaceDockerImages(target))
|
||||
ipcMain.handle('local-workspace:docker:status', () => checkDockerStatus())
|
||||
ipcMain.handle('local-workspace:docker:guide', () => shell.openExternal('https://docs.docker.com/engine/install/'))
|
||||
ipcMain.handle('local-workspace:ros-graph', () => collectLocalRosGraph())
|
||||
|
||||
@@ -218,6 +218,8 @@ contextBridge.exposeInMainWorld('localWorkspace', {
|
||||
modules: {
|
||||
list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('local-workspace:modules:list', workspaceRoot, modules),
|
||||
listDockerImages: () => ipcRenderer.invoke('local-workspace:modules:docker-images'),
|
||||
listGitSources: (root: string) => ipcRenderer.invoke('local-workspace:modules:git-sources', root),
|
||||
inspectGitSource: (root: string, folder: string) => ipcRenderer.invoke('local-workspace:modules:git-source', root, folder),
|
||||
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('local-workspace:modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
|
||||
createCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => ipcRenderer.invoke('local-workspace:modules:create-custom', workspaceRoot, input),
|
||||
selectImportSource: () => ipcRenderer.invoke('local-workspace:modules:select-import-source'),
|
||||
@@ -307,6 +309,8 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
|
||||
modules: {
|
||||
list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:list', target, modules),
|
||||
install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('remote-workspace:modules:install', target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
|
||||
listDockerImages: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:modules:docker-images', target),
|
||||
importGit: (target: RemoteWorkspaceTarget, root: string, input: import('../shared/gitModuleImport').GitModuleImportInput & { folderName: string }) => ipcRenderer.invoke('remote-workspace:modules:import-git', target, root, input),
|
||||
createCustom: (target: RemoteWorkspaceTarget, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => ipcRenderer.invoke('remote-workspace:modules:create-custom', target, input),
|
||||
transfer: (target: RemoteWorkspaceTarget, sourceWorkspaceRoot: string | undefined, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:transfer', target, sourceWorkspaceRoot, moduleName, folderName),
|
||||
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('remote-workspace:modules:start', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork),
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 961 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 961 KiB After Width: | Height: | Size: 628 KiB |
@@ -9,7 +9,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #07111f;
|
||||
background: #ffffff;
|
||||
font-family: NanumSquare, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: radial-gradient(circle at 50% 35%, #11243e 0%, #07111f 54%, #03070d 100%);
|
||||
background: #ffffff;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
@@ -31,22 +31,53 @@
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.fallback-title {
|
||||
.brand-logo {
|
||||
position: absolute;
|
||||
top: 45%;
|
||||
top: 116px;
|
||||
left: calc(50% - 3px);
|
||||
z-index: 1;
|
||||
width: 80px;
|
||||
height: 70px;
|
||||
transform: translateX(-50%);
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
position: absolute;
|
||||
top: 187px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #4d5d76;
|
||||
text-shadow: 0 1px 12px rgba(255, 255, 255, 0.28);
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
z-index: 1;
|
||||
width: 290px;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
color: #252d39;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 32px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
letter-spacing: 6px;
|
||||
text-indent: 6px;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
margin-top: 9px;
|
||||
color: #7a8495;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
right: 26px;
|
||||
bottom: 20px;
|
||||
z-index: 2;
|
||||
color: #64738a;
|
||||
text-shadow: 0 1px 10px rgba(255, 255, 255, 0.24);
|
||||
font-size: 12px;
|
||||
@@ -58,6 +89,7 @@
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
z-index: 2;
|
||||
color: #7a8495;
|
||||
text-shadow: 0 1px 8px rgba(255, 255, 255, 0.2);
|
||||
font-size: 11px;
|
||||
@@ -68,15 +100,17 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="splash">
|
||||
<div class="fallback-title">LMS</div>
|
||||
<img class="splash-image" id="splash-image" alt="" />
|
||||
<img class="splash-image" src="./assets/splash.png" alt="" />
|
||||
<img class="brand-logo" src="./assets/splash-logo.png" alt="" />
|
||||
<div class="brand-copy">
|
||||
<div class="brand-title">LMS</div>
|
||||
<div class="brand-subtitle">Learning Management System</div>
|
||||
</div>
|
||||
<div class="copyright">© 2026 Autonomy Studio. All rights reserved.</div>
|
||||
<div class="loading" id="loading-text">학습 환경 준비 중...</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('splash-image').src = './assets/splash.png'
|
||||
|
||||
const el = document.getElementById('loading-text')
|
||||
const translations = {
|
||||
'학습 환경 준비 중...': 'Preparing your learning environment...',
|
||||
|
||||
+55
-10
@@ -1,4 +1,5 @@
|
||||
import { t, useLocaleRender } from './i18n'
|
||||
import type { GitModuleImportInput } from '../../shared/gitModuleImport'
|
||||
import { useStudioTheme } from './features/workspace/studioTheme'
|
||||
import './features/workspace/studioPalette.css'
|
||||
import { type BeforeMount, type OnMount } from '@monaco-editor/react'
|
||||
@@ -987,7 +988,9 @@ function LocalPcWorkspaceMockup({
|
||||
: sourceSettingsDraft.workspaceRoot
|
||||
const currentSelectedTreePath = exampleActive ? selectedExampleTreePath : selectedTreePath
|
||||
const surface = dark ? 'bg-[#202020] text-[#e6e6e6]' : 'bg-[#eef1f6] text-[#151827]'
|
||||
const topbar = dark ? 'border-white/10 bg-[#303030]' : 'border-[#bec9d8] bg-[#dce4ee]'
|
||||
const topbar = dark
|
||||
? 'border-white/10 bg-[#303030]'
|
||||
: 'border-[#cbbdde] bg-[linear-gradient(90deg,#e6ddf1_0%,#e5def1_52%,#dce9f5_100%)]'
|
||||
const panel = dark ? 'border-white/10 bg-[#252525]' : 'border-[#cfd6e4] bg-[#e9edf4]'
|
||||
const textStrong = dark ? 'text-white' : 'text-[#111520]'
|
||||
const textMid = dark ? 'text-white/70' : 'text-[#4f596b]'
|
||||
@@ -1918,14 +1921,14 @@ function LocalPcWorkspaceMockup({
|
||||
} catch (error) {
|
||||
console.error('[git] status before submit failed', error)
|
||||
setGitError(error instanceof Error ? error.message : 'Git 상태를 불러오지 못했습니다.')
|
||||
onTitlebarNotice('제출에 실패했습니다', 'error')
|
||||
onTitlebarNotice('커밋 또는 푸시에 실패했습니다', 'error')
|
||||
setGitSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const changedModules = installedGitModules.filter((module) => statusMap[module.name]?.files.length)
|
||||
if (!changedModules.length) {
|
||||
onTitlebarNotice('제출할 변경사항이 없습니다', 'warning')
|
||||
onTitlebarNotice('커밋할 변경사항이 없습니다', 'warning')
|
||||
setGitSubmitting(false)
|
||||
return
|
||||
}
|
||||
@@ -1961,7 +1964,7 @@ function LocalPcWorkspaceMockup({
|
||||
getRemoteWorkspaceTarget(),
|
||||
module.name,
|
||||
module.folderName ?? undefined,
|
||||
`${weekNumber}주차 ${module.name} 제출`,
|
||||
`${weekNumber}주차 ${module.name} 커밋`,
|
||||
undefined,
|
||||
undefined,
|
||||
submissionId,
|
||||
@@ -1971,7 +1974,7 @@ function LocalPcWorkspaceMockup({
|
||||
module.name,
|
||||
clonePathPreview,
|
||||
module.folderName,
|
||||
`${weekNumber}주차 ${module.name} 제출`,
|
||||
`${weekNumber}주차 ${module.name} 커밋`,
|
||||
undefined,
|
||||
undefined,
|
||||
submissionId,
|
||||
@@ -1981,13 +1984,13 @@ function LocalPcWorkspaceMockup({
|
||||
if (result.commit) submittedCommitCount += 1
|
||||
}
|
||||
|
||||
onTitlebarNotice(submittedCommitCount > 0 ? '변경사항을 제출했습니다' : '변경사항을 반영했습니다', 'info')
|
||||
onTitlebarNotice(submittedCommitCount > 0 ? '변경사항을 커밋하고 푸시했습니다' : '변경사항을 반영했습니다', 'info')
|
||||
await refreshGitStatuses(null)
|
||||
await refreshGitSubmissions()
|
||||
} catch (error) {
|
||||
console.error('[git] submission failed', error)
|
||||
setGitError(error instanceof Error ? error.message : '제출에 실패했습니다.')
|
||||
onTitlebarNotice('제출에 실패했습니다', 'error')
|
||||
setGitError(error instanceof Error ? error.message : '커밋 또는 푸시에 실패했습니다')
|
||||
onTitlebarNotice('커밋 또는 푸시에 실패했습니다', 'error')
|
||||
} finally {
|
||||
setGitSubmitting(false)
|
||||
}
|
||||
@@ -2433,6 +2436,21 @@ function LocalPcWorkspaceMockup({
|
||||
const updateSourceSetting = <Key extends keyof StudioSourceSettings>(key: Key, value: StudioSourceSettings[Key]): void => {
|
||||
setSourceSettingsDraft((settings) => ({ ...settings, [key]: value }))
|
||||
}
|
||||
const resetSourceSettingsDraft = (): void => {
|
||||
setSourceSettingsDraft((settings) => normalizeStudioSourceSettings({
|
||||
...settings,
|
||||
workspaceRoot: platformSourceSettingsDefaults.workspaceRoot,
|
||||
gpuEnabled: platformSourceSettingsDefaults.gpuEnabled,
|
||||
allowExternalRos: platformSourceSettingsDefaults.allowExternalRos,
|
||||
editorWordWrap: platformSourceSettingsDefaults.editorWordWrap,
|
||||
editorFontSize: platformSourceSettingsDefaults.editorFontSize,
|
||||
editorTabSize: platformSourceSettingsDefaults.editorTabSize,
|
||||
editorMinimap: platformSourceSettingsDefaults.editorMinimap,
|
||||
rosDomainId: platformSourceSettingsDefaults.rosDomainId,
|
||||
rosLinkedPlatformId: platformSourceSettingsDefaults.rosLinkedPlatformId,
|
||||
visualizationBridgeLocation: platformSourceSettingsDefaults.visualizationBridgeLocation,
|
||||
}))
|
||||
}
|
||||
const applySourceSettings = async (normalizedSettings: StudioSourceSettings, stopRunningModules: boolean): Promise<void> => {
|
||||
const workspaceRootChanged = normalizedSettings.workspaceRoot !== sourceSettings.workspaceRoot
|
||||
const runningModules = availableModules.filter((module) => module.state === 'running')
|
||||
@@ -3424,6 +3442,27 @@ function LocalPcWorkspaceMockup({
|
||||
addCustomModuleToList(customModule)
|
||||
onTitlebarNotice('커스텀 모듈이 저장되었습니다.', 'info')
|
||||
}
|
||||
const importGitModule = async (input: GitModuleImportInput): Promise<void> => {
|
||||
if (!remoteWorkspace) throw new Error('원격 워크스페이스에서만 사용할 수 있습니다.')
|
||||
const source = await window.localWorkspace.modules.inspectGitSource(localVisualizationWorkspaceRoot, input.sourceFolder)
|
||||
if (source.dirty || !source.pushed) throw new Error('로컬 변경 사항을 커밋하고 푸시한 뒤 다시 시도하세요.')
|
||||
if (!source.branches.some(branch => branch.name === input.branch && branch.commit === input.commit)) {
|
||||
throw new Error('브랜치가 변경되었습니다. 저장소 정보를 새로고침하세요.')
|
||||
}
|
||||
const customModule = await registerCustomModule({ name: input.name, category: input.category, packageName: input.packageName, launchFile: input.launchFile, dockerImage: input.dockerImage })
|
||||
try {
|
||||
await window.remoteWorkspace.modules.importGit(getRemoteWorkspaceTarget(), localVisualizationWorkspaceRoot, { ...input, folderName: customModule.folderName! })
|
||||
} catch (error) {
|
||||
const rollback = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/custom-modules/${encodeURIComponent(customModule.id)}?courseId=${encodeURIComponent(courseKey)}`, { method: 'DELETE' }).catch(() => null)
|
||||
if (!rollback?.ok) {
|
||||
addCustomModuleToList(customModule)
|
||||
throw new Error(t('가져오기에 실패했고 모듈 등록을 취소하지 못했습니다. 목록에서 해당 모듈을 확인하세요.'))
|
||||
}
|
||||
throw error
|
||||
}
|
||||
addCustomModuleToList(customModule)
|
||||
onTitlebarNotice(t('Git 모듈을 가져왔습니다. 차량에서 빌드한 뒤 실행하세요.'), 'info')
|
||||
}
|
||||
const importCustomModule = async ({ sourcePath, ...input }: CustomModuleImportInput): Promise<void> => {
|
||||
if (remoteWorkspace) throw new Error('모듈 불러오기는 로컬 워크스페이스에서만 사용할 수 있습니다.')
|
||||
const customModule = await registerCustomModule(input)
|
||||
@@ -3794,6 +3833,7 @@ function LocalPcWorkspaceMockup({
|
||||
saveDisabled={!sourceSettingsDirty}
|
||||
remotePlatforms={platformOptions.filter((platform) => platform.platformKey !== 'futuredrive-local-pc')}
|
||||
onUpdateSourceSetting={updateSourceSetting}
|
||||
onResetSourceSettings={resetSourceSettingsDraft}
|
||||
onSaveSourceSettings={saveSourceSettings}
|
||||
/>
|
||||
) : gitActive ? (
|
||||
@@ -4002,7 +4042,12 @@ function LocalPcWorkspaceMockup({
|
||||
selectedMap={selectedWorldMap}
|
||||
showSelectedMapPreview={Boolean(curriculumWeek.maps?.some((map) => map.platformKey === 'NAV2'))}
|
||||
allowRepositoryDeletion={!remoteWorkspace && !publicGuideCourse}
|
||||
allowCustomModuleCreation={!remoteWorkspace}
|
||||
allowCustomModuleCreation={true}
|
||||
allowCustomModuleImport={true}
|
||||
remoteGitImport={remoteWorkspace ? { workspaceRoot: localVisualizationWorkspaceRoot, onImport: importGitModule } : undefined}
|
||||
loadDockerImages={() => remoteWorkspace
|
||||
? window.remoteWorkspace.modules.listDockerImages(getRemoteWorkspaceTarget())
|
||||
: window.localWorkspace.modules.listDockerImages()}
|
||||
onAddModuleTab={addModuleTabOnly}
|
||||
onOpenSource={openModuleSource}
|
||||
onInstallModule={(module) => void installModule(module)}
|
||||
@@ -4173,7 +4218,7 @@ export default function App(): React.JSX.Element {
|
||||
const [workspaceVisualizationOpen, setWorkspaceVisualizationOpen] = useState(false)
|
||||
const [workspaceVisualizationMinimized, setWorkspaceVisualizationMinimized] = useState(false)
|
||||
const [workspaceVisualizationLarge, setWorkspaceVisualizationLarge] = useState(false)
|
||||
const [workspaceVisualizationPosition, setWorkspaceVisualizationPosition] = useState({ x: 1040, y: 72 })
|
||||
const [workspaceVisualizationPosition, setWorkspaceVisualizationPosition] = useState({ x: 1040, y: 86 })
|
||||
const [workspaceVisualizationSize, setWorkspaceVisualizationSize] = useState({ width: 600, height: 600 })
|
||||
const workspaceVisualizationHostRef = useRef<WorkspaceVisualizationHost | null>(null)
|
||||
const [workspaceVisualizationHost, setWorkspaceVisualizationHost] = useState<WorkspaceVisualizationHost | null>(null)
|
||||
|
||||
@@ -526,7 +526,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className="relative z-[10020] flex h-[38px] shrink-0 items-center justify-between rounded-t-[18px] bg-[#111520] px-3 pl-5"
|
||||
className="relative z-[10020] flex h-[38px] shrink-0 items-center justify-between bg-[#111520] px-3 pl-5"
|
||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||
onContextMenuCapture={suppressTitlebarContextMenu}
|
||||
onContextMenu={suppressTitlebarContextMenu}
|
||||
|
||||
Vendored
+4
@@ -111,6 +111,8 @@ interface Window {
|
||||
}>
|
||||
}>
|
||||
listDockerImages: () => Promise<{ images: string[]; message?: string }>
|
||||
listGitSources: (root: string) => Promise<import('../../shared/gitModuleImport').LocalGitModule[]>
|
||||
inspectGitSource: (root: string, folder: string) => Promise<import('../../shared/gitModuleImport').GitModuleSource>
|
||||
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => Promise<{
|
||||
ok: boolean
|
||||
module: {
|
||||
@@ -333,6 +335,8 @@ interface Window {
|
||||
runtime: string
|
||||
}
|
||||
}>
|
||||
listDockerImages: (target: RemoteWorkspaceTarget) => Promise<{ images: string[]; message?: string }>
|
||||
importGit: (target: RemoteWorkspaceTarget, root: string, input: import('../../shared/gitModuleImport').GitModuleImportInput & { folderName: string }) => Promise<{ ok: boolean; workspacePath: string }>
|
||||
createCustom: (target: RemoteWorkspaceTarget, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => Promise<{ ok: boolean; workspacePath: string }>
|
||||
transfer: (target: RemoteWorkspaceTarget, sourceWorkspaceRoot: string | undefined, moduleName: string, folderName?: string) => Promise<{ ok: boolean; workspacePath: string }>
|
||||
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; message: string }>
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
}
|
||||
|
||||
[data-ai-guide-theme='light'] [class~='bg-[#1d2230]'] {
|
||||
background: #fff;
|
||||
background: #faf8ff;
|
||||
}
|
||||
|
||||
[data-ai-guide-theme='light'] [class~='bg-[#5b4aa8]'] {
|
||||
@@ -111,6 +111,7 @@
|
||||
|
||||
[data-ai-guide-theme='light'] [class~='bg-[#1d2230]'] {
|
||||
border-color: #c2cfe6;
|
||||
background: #faf8ff;
|
||||
padding: 22px 24px;
|
||||
box-shadow: 0 5px 18px rgb(49 81 170 / 5%);
|
||||
}
|
||||
@@ -129,3 +130,21 @@
|
||||
border-color: #b8c8e8;
|
||||
box-shadow: 0 4px 16px rgb(49 81 170 / 6%);
|
||||
}
|
||||
|
||||
[data-ai-guide-theme='light'] [data-help='ai-guide-context'] button:not([title]):not([class*='bg-cyan-400/10']),
|
||||
[data-ai-guide-theme='light'] [data-help='ai-guide-context'] div.group:not([class*='bg-cyan-400/10']),
|
||||
[data-ai-guide-theme='light'] [data-help='ai-guide-questions'] button:not([class*='bg-cyan-400/10']) {
|
||||
border: 1px solid #d5ddef;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
[data-ai-guide-theme='light'] [data-help='ai-guide-context'] button:not([title]):not([class*='bg-cyan-400/10']):hover,
|
||||
[data-ai-guide-theme='light'] [data-help='ai-guide-context'] div.group:not([class*='bg-cyan-400/10']):hover,
|
||||
[data-ai-guide-theme='light'] [data-help='ai-guide-questions'] button:not([class*='bg-cyan-400/10']):hover {
|
||||
background: #f2f5fb;
|
||||
}
|
||||
|
||||
[data-ai-guide-theme='light'] [data-help='ai-guide-context'] div.group > button:first-child {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function PlatformSettingsPanel({
|
||||
<div className="mt-4">
|
||||
<StudioSettingsSwitch
|
||||
label={t("GPU 가속")}
|
||||
description={t("ON이면 Docker 실행 시 NVIDIA GPU를 사용합니다.")}
|
||||
description={t("모듈 Docker 실행 시 NVIDIA GPU를 사용합니다.")}
|
||||
checked={draft.gpuEnabled}
|
||||
onChange={(checked) => onDraftChange((settings) => ({ ...settings, gpuEnabled: checked }))}
|
||||
dark={false}
|
||||
|
||||
@@ -8,7 +8,7 @@ type ModuleListHeaderProps = {
|
||||
export default function ModuleListHeader({ dark }: ModuleListHeaderProps): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
return (
|
||||
<div className={['grid h-9 grid-cols-[78px_100px_minmax(150px,1fr)_minmax(104px,0.45fr)_minmax(104px,0.45fr)_84px_104px_62px] items-center border-b px-3 text-[11px] font-bold', dark ? 'border-white/10 bg-[#2b2b2b] text-white/45' : 'border-[#c4b5df] bg-[#e4d8f5] text-[#38205b]'].join(' ')}>
|
||||
<div className={['grid h-9 grid-cols-[78px_100px_minmax(150px,1fr)_minmax(104px,0.45fr)_minmax(104px,0.45fr)_84px_104px_62px] items-center border-b px-3 text-[11px] font-bold', dark ? 'border-white/10 bg-[#2b2b2b] text-white/45' : 'border-[#c4b5df] bg-[#e2e8f0] text-[#38205b]'].join(' ')}>
|
||||
<span className="text-center">{t("상태")}</span>
|
||||
<span className="text-center">{t("분류")}</span>
|
||||
<span>{t("모듈명")}</span>
|
||||
|
||||
@@ -62,18 +62,18 @@ function actionButtonClass(dark: boolean, tone: ModuleActionTone, busy: boolean)
|
||||
: tone === 'start'
|
||||
? dark
|
||||
? 'border-[#2fbda0]/35 bg-[#2fbda0]/10 text-[#80ddc7] hover:bg-[#2fbda0]/18'
|
||||
: 'border-[#2fbda0]/28 bg-[#effbf7] text-[#177553] hover:bg-[#e1f7ee]'
|
||||
: 'border-[#439b7b] bg-[#effbf7] text-[#177553] hover:border-[#177553] hover:bg-[#e1f7ee]'
|
||||
: tone === 'warning'
|
||||
? dark
|
||||
? 'border-[#f59e0b]/25 bg-[#f59e0b]/8 text-[#e8b76b] hover:bg-[#f59e0b]/14'
|
||||
: 'border-[#f59e0b]/22 bg-[#fff8e8] text-[#9a6814] hover:bg-[#fff1cf]'
|
||||
: 'border-[#b8872e] bg-[#fff8e8] text-[#9a6814] hover:border-[#9a6814] hover:bg-[#fff1cf]'
|
||||
: tone === 'delete'
|
||||
? dark
|
||||
? 'border-[#ef4444]/20 bg-white/[0.03] text-[#e87979] hover:border-[#ef4444]/40 hover:bg-[#ef4444]/10 hover:text-[#ffb4b4]'
|
||||
: 'border-[#ef4444]/18 bg-white text-[#c25a5a] hover:border-[#ef4444]/32 hover:bg-[#fff4f4] hover:text-[#b42323]'
|
||||
: 'border-[#cc6868] bg-[#fff7f7] text-[#b42323] hover:border-[#b42323] hover:bg-[#ffeded] hover:text-[#b42323]'
|
||||
: dark
|
||||
? 'border-white/15 bg-white/[0.04] text-white/60 hover:bg-white/10 hover:text-white'
|
||||
: 'border-[#cfd6e4] bg-white text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
: 'border-[#919db1] bg-white text-[#526078] hover:border-[#64748b] hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
@@ -192,8 +192,8 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
useEffect(() => { if (!busy) setProgress('') }, [busy])
|
||||
const stateStyle: Record<ModuleListRowItem['state'], string> = {
|
||||
running: dark ? 'bg-[#12372d] text-[#72e2b5]' : 'bg-[#dff8ed] text-[#177553]',
|
||||
stopped: dark ? 'bg-white/8 text-white/45' : 'bg-[#edf0f5] text-[#687284]',
|
||||
uninstalled: dark ? 'bg-[#2d2d2d] text-white/45' : 'bg-[#edf0f5] text-[#687284]'
|
||||
stopped: dark ? 'bg-white/8 text-white/45' : 'bg-[#dce2eb] text-[#3e4c62]',
|
||||
uninstalled: dark ? 'bg-[#2d2d2d] text-white/45' : 'bg-[#e7deef] text-[#60486f]'
|
||||
}
|
||||
const controlActions: Array<{
|
||||
label: string
|
||||
@@ -249,16 +249,17 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
className={[
|
||||
'grid h-[58px] grid-cols-[78px_100px_minmax(150px,1fr)_minmax(104px,0.45fr)_minmax(104px,0.45fr)_84px_104px_62px] items-center px-3 text-[12px] transition',
|
||||
'cursor-default',
|
||||
!dark ? 'last:border-b last:border-[#c9bfd8] hover:bg-[#ece6f4]' : '',
|
||||
dragging ? 'opacity-45' : '',
|
||||
dropTarget ? (dark ? 'bg-[#2563eb]/14 outline outline-1 outline-[#3b82f6]/50' : 'bg-[#edf6ff] outline outline-1 outline-[#2563eb]/35') : '',
|
||||
selectable
|
||||
? dark ? 'text-white/70 hover:bg-white/[0.045]' : 'text-[#4f596b] hover:bg-[#f5f7fb]'
|
||||
? dark ? 'text-white/70 hover:bg-white/[0.045]' : 'text-[#4f596b]'
|
||||
: dark ? 'text-white/42' : 'text-[#475569]'
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-center">
|
||||
<span className={['inline-flex h-6 items-center rounded px-2 text-[11px] font-bold', stateStyle[module.state]].join(' ')}>
|
||||
{t(stateLabel[module.state])}
|
||||
<span className="relative top-px leading-none">{t(stateLabel[module.state])}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className={['text-center', dark ? 'text-white/52' : 'font-medium text-[#334155]'].join(' ')}>{module.category}</span>
|
||||
@@ -299,7 +300,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
<span className={['w-10 text-right font-mono text-[10px]', dark ? 'text-white/58' : 'text-[#687284]'].join(' ')}>
|
||||
{module.cpu.toFixed(module.cpu >= 10 ? 0 : 1)}%
|
||||
</span>
|
||||
<span className={['h-1.5 w-[42px] overflow-hidden rounded-full', dark ? 'bg-white/10' : 'bg-[#e2e7f0]'].join(' ')}>
|
||||
<span className={['h-1.5 w-[42px] overflow-hidden rounded-full', dark ? 'bg-white/10' : 'bg-[#aeb9cb]'].join(' ')}>
|
||||
<span className="block h-full rounded-full bg-[#2f8dff]" style={{ width: `${Math.min(100, module.cpu)}%` }} />
|
||||
</span>
|
||||
</>
|
||||
@@ -313,7 +314,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
<span className={['w-10 text-right font-mono text-[10px]', dark ? 'text-white/58' : 'text-[#687284]'].join(' ')}>
|
||||
{module.memory.toFixed(module.memory >= 10 ? 0 : 1)}%
|
||||
</span>
|
||||
<span className={['h-1.5 w-[42px] overflow-hidden rounded-full', dark ? 'bg-white/10' : 'bg-[#e2e7f0]'].join(' ')}>
|
||||
<span className={['h-1.5 w-[42px] overflow-hidden rounded-full', dark ? 'bg-white/10' : 'bg-[#aeb9cb]'].join(' ')}>
|
||||
<span className="block h-full rounded-full bg-[#2fbda0]" style={{ width: `${Math.min(100, module.memory)}%` }} />
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -46,9 +46,11 @@ export default function ModuleListToolbar({
|
||||
const stoppingAll = installingModuleName === '__stop_all__'
|
||||
|
||||
const bulkActions = [
|
||||
{ label: '맵설정', Icon: MapPinned, busy: false, disabled: false, onClick: onOpenWorldEditor },
|
||||
{ label: '맵설정', Icon: MapPinned, busy: false, disabled: false, onClick: onOpenWorldEditor,
|
||||
lightClass: 'border-[#b59bd8] bg-[#e9def7] text-[#553080] enabled:hover:border-[#9370bd] enabled:hover:bg-[#ddccef]' },
|
||||
{
|
||||
label: '전체 설치',
|
||||
lightClass: 'border-[#a99bc9] bg-[#e8e2f3] text-[#59417f] enabled:hover:border-[#8b77b4] enabled:hover:bg-[#dcd2ee]',
|
||||
Icon: Download,
|
||||
busy: installingAll,
|
||||
disabled: loading || installingModuleName !== null || uninstalledCount === 0,
|
||||
@@ -56,6 +58,7 @@ export default function ModuleListToolbar({
|
||||
},
|
||||
{
|
||||
label: '전체 실행',
|
||||
lightClass: 'border-[#78ab94] bg-[#e0f1e8] text-[#216545] enabled:hover:border-[#439b7b] enabled:hover:bg-[#cde8da]',
|
||||
Icon: Play,
|
||||
busy: startingAll,
|
||||
disabled: loading || installingModuleName !== null || stoppedCount === 0,
|
||||
@@ -63,6 +66,7 @@ export default function ModuleListToolbar({
|
||||
},
|
||||
{
|
||||
label: '전체 중지',
|
||||
lightClass: 'border-[#b8872e] bg-[#fff0d0] text-[#855b13] enabled:hover:border-[#9a6814] enabled:hover:bg-[#ffe5ae]',
|
||||
Icon: Square,
|
||||
busy: stoppingAll,
|
||||
disabled: loading || installingModuleName !== null || runningCount === 0,
|
||||
@@ -86,7 +90,7 @@ export default function ModuleListToolbar({
|
||||
active
|
||||
? dark
|
||||
? 'border-white/10 bg-[#2b2b2b] text-white'
|
||||
: 'border-[#c4b5df] bg-[#e4d8f5] text-[#38205b]'
|
||||
: 'border-[#c4b5df] bg-[#e2e8f0] text-[#38205b]'
|
||||
: dark
|
||||
? 'border-white/8 bg-[#202020] text-white/45 hover:text-white/70'
|
||||
: 'border-[#d4c9e4] bg-[#eee8f6] text-[#475569] hover:bg-[#e8def3] hover:text-[#38205b]'
|
||||
@@ -98,7 +102,7 @@ export default function ModuleListToolbar({
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pb-2">
|
||||
{bulkActions.map(({ label, Icon, busy, disabled, onClick }) => (
|
||||
{bulkActions.map(({ label, Icon, busy, disabled, onClick, lightClass }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
@@ -109,10 +113,10 @@ export default function ModuleListToolbar({
|
||||
}}
|
||||
className={[
|
||||
'flex h-7 whitespace-nowrap items-center gap-1.5 rounded-md border px-3 text-[12px] font-bold transition',
|
||||
disabled ? 'cursor-default opacity-45' : 'cursor-pointer',
|
||||
disabled ? (dark ? 'cursor-default opacity-45' : 'cursor-default') : 'cursor-pointer',
|
||||
dark
|
||||
? 'border-white/15 bg-white/[0.04] text-white/70 hover:border-white/25 hover:bg-white/10 hover:text-white'
|
||||
: 'border-[#cfd6e4] bg-white text-[#4f596b] hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
: disabled ? 'border-[#d2ccdc] bg-[#e9e6ee] text-[#8b8498]' : lightClass
|
||||
].join(' ')}
|
||||
>
|
||||
{busy ? <RefreshCw size={14} className="animate-spin" /> : <Icon size={14} />}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { t, useLocaleRender } from '../../i18n'
|
||||
import RemoteModuleImportDialog from './RemoteModuleImportDialog'
|
||||
import type { GitModuleImportInput } from '../../../../shared/gitModuleImport'
|
||||
import { BookOpen, ChevronLeft, ChevronRight, FolderOpen, PackagePlus, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type React from 'react'
|
||||
@@ -28,6 +30,9 @@ type ModuleListViewProps = {
|
||||
showSelectedMapPreview?: boolean
|
||||
allowRepositoryDeletion?: boolean
|
||||
allowCustomModuleCreation?: boolean
|
||||
allowCustomModuleImport?: boolean
|
||||
remoteGitImport?: { workspaceRoot: string; onImport: (input: GitModuleImportInput) => Promise<void> }
|
||||
loadDockerImages: () => Promise<{ images: string[]; message?: string }>
|
||||
onAddModuleTab: (module: ModuleRow) => void
|
||||
onOpenSource: (module: ModuleRow) => void
|
||||
onInstallModule: (module: ModuleRow) => void
|
||||
@@ -64,6 +69,9 @@ export default function ModuleListView({
|
||||
showSelectedMapPreview = true,
|
||||
allowRepositoryDeletion = true,
|
||||
allowCustomModuleCreation = true,
|
||||
allowCustomModuleImport = allowCustomModuleCreation,
|
||||
remoteGitImport,
|
||||
loadDockerImages,
|
||||
onAddModuleTab,
|
||||
onOpenSource,
|
||||
onInstallModule,
|
||||
@@ -409,14 +417,14 @@ export default function ModuleListView({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("모듈 불러오기")}
|
||||
disabled={!allowCustomModuleCreation}
|
||||
disabled={!allowCustomModuleImport}
|
||||
onClick={() => {
|
||||
setModuleImportDialogOpen(true)
|
||||
setFloatingActionMenuOpen(false)
|
||||
}}
|
||||
className={[
|
||||
'absolute bottom-[124px] left-1/2 grid h-12 w-12 -translate-x-1/2 place-items-center rounded-full transition-all duration-200 ease-out',
|
||||
allowCustomModuleCreation
|
||||
allowCustomModuleImport
|
||||
? 'cursor-pointer bg-[#2563eb] text-white shadow-[0_8px_20px_rgba(20,54,104,0.24)] hover:scale-105 hover:bg-[#1d4ed8]'
|
||||
: 'cursor-not-allowed bg-[#3b4250] text-white/35 shadow-none',
|
||||
floatingActionMenuOpen ? 'pointer-events-auto translate-y-0 scale-100 opacity-100 delay-75' : 'pointer-events-none translate-y-4 scale-50 opacity-0'
|
||||
@@ -425,7 +433,7 @@ export default function ModuleListView({
|
||||
<FolderOpen size={18} />
|
||||
<span className={[
|
||||
'pointer-events-none absolute left-[calc(100%+8px)] whitespace-nowrap rounded-md px-2 py-1 text-[11px] font-bold shadow-[0_6px_18px_rgba(20,54,104,0.2)]',
|
||||
allowCustomModuleCreation ? 'bg-[#2563eb] text-white' : 'bg-[#3b4250] text-white/35 shadow-none'
|
||||
allowCustomModuleImport ? 'bg-[#2563eb] text-white' : 'bg-[#3b4250] text-white/35 shadow-none'
|
||||
].join(' ')}>{t("모듈 불러오기")}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -457,6 +465,7 @@ export default function ModuleListView({
|
||||
{allowCustomModuleCreation && moduleAddDialogOpen && (
|
||||
<ModuleAddDialog
|
||||
dark={dark}
|
||||
loadDockerImages={loadDockerImages}
|
||||
onClose={() => setModuleAddDialogOpen(false)}
|
||||
onCreate={async (input) => {
|
||||
await onCreateCustomModule(input)
|
||||
@@ -464,8 +473,14 @@ export default function ModuleListView({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{allowCustomModuleCreation && moduleImportDialogOpen && (
|
||||
<ModuleImportDialog
|
||||
{allowCustomModuleImport && moduleImportDialogOpen && (
|
||||
remoteGitImport ? <RemoteModuleImportDialog
|
||||
dark={dark}
|
||||
workspaceRoot={remoteGitImport.workspaceRoot}
|
||||
loadDockerImages={loadDockerImages}
|
||||
onImport={remoteGitImport.onImport}
|
||||
onClose={() => setModuleImportDialogOpen(false)}
|
||||
/> : <ModuleImportDialog
|
||||
dark={dark}
|
||||
onClose={() => setModuleImportDialogOpen(false)}
|
||||
onImport={async (input) => {
|
||||
@@ -667,10 +682,12 @@ export type CustomModuleImportInput = CustomModuleInput & {
|
||||
|
||||
function ModuleAddDialog({
|
||||
dark,
|
||||
loadDockerImages,
|
||||
onClose,
|
||||
onCreate
|
||||
}: {
|
||||
dark: boolean
|
||||
loadDockerImages: () => Promise<{ images: string[]; message?: string }>
|
||||
onClose: () => void
|
||||
onCreate: (input: CustomModuleInput) => Promise<void>
|
||||
}): React.JSX.Element {
|
||||
@@ -695,7 +712,7 @@ function ModuleAddDialog({
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void window.localWorkspace.modules.listDockerImages()
|
||||
void loadDockerImages()
|
||||
.then(({ images, message }) => {
|
||||
if (cancelled) return
|
||||
setDockerImages(images)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { RefreshCw, X } from 'lucide-react'
|
||||
import { t, useLocaleRender } from '../../i18n'
|
||||
import type { GitModuleImportInput, GitModuleSource, LocalGitModule } from '../../../../shared/gitModuleImport'
|
||||
|
||||
export default function RemoteModuleImportDialog({ dark, workspaceRoot, loadDockerImages, onImport, onClose }: {
|
||||
dark: boolean
|
||||
workspaceRoot: string
|
||||
loadDockerImages: () => Promise<{ images: string[]; message?: string }>
|
||||
onImport: (input: GitModuleImportInput) => Promise<void>
|
||||
onClose: () => void
|
||||
}): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
const [loadImages] = useState(() => loadDockerImages)
|
||||
const [modules, setModules] = useState<LocalGitModule[]>([])
|
||||
const [images, setImages] = useState<string[]>([])
|
||||
const [folder, setFolder] = useState('')
|
||||
const [source, setSource] = useState<GitModuleSource | null>(null)
|
||||
const [branch, setBranch] = useState('')
|
||||
const [image, setImage] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [revision, setRevision] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [inspecting, setInspecting] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const selected = modules.find(module => module.folderName === folder)
|
||||
const selectedBranch = source?.branches.find(item => item.name === branch)
|
||||
const ready = selected && source && !source.dirty && source.pushed && selectedBranch && image.trim() && name.trim()
|
||||
const inputClass = ['h-9 w-full rounded-md border px-2 text-[12px] disabled:opacity-60', dark ? 'border-white/20 bg-[#202020] text-white' : 'border-[#aebace] bg-white text-[#334155]'].join(' ')
|
||||
const buttonClass = ['h-8 cursor-pointer rounded-md border px-3 text-[12px] disabled:cursor-default disabled:opacity-50', dark ? 'border-white/20 hover:bg-white/10' : 'border-[#aebace] hover:bg-[#eee8f6]'].join(' ')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
void Promise.all([window.localWorkspace.modules.listGitSources(workspaceRoot), loadImages()])
|
||||
.then(([list, result]) => {
|
||||
if (cancelled) return
|
||||
setModules(list)
|
||||
setImages(result.images)
|
||||
if (result.message) setError(result.message)
|
||||
}).catch(reason => { if (!cancelled) setError(String(reason.message || reason)) })
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [workspaceRoot, loadImages])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setSource(null)
|
||||
setBranch('')
|
||||
if (!folder) {
|
||||
setInspecting(false)
|
||||
return
|
||||
}
|
||||
setInspecting(true)
|
||||
setError(null)
|
||||
void window.localWorkspace.modules.inspectGitSource(workspaceRoot, folder)
|
||||
.then(result => {
|
||||
if (cancelled) return
|
||||
setSource(result)
|
||||
setBranch(result.branches.some(item => item.name === result.currentBranch) ? result.currentBranch : result.branches[0]?.name ?? '')
|
||||
}).catch(reason => { if (!cancelled) setError(String(reason.message || reason)) })
|
||||
.finally(() => { if (!cancelled) setInspecting(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [workspaceRoot, folder, revision])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] grid place-items-center bg-black/36 p-4" role="dialog" aria-modal="true" aria-label={t('로컬 모듈 Git 가져오기')}>
|
||||
<section className={['w-full max-w-[500px] rounded-lg border shadow-xl', dark ? 'border-white/20 bg-[#252525] text-white' : 'border-[#aebace] bg-white text-[#1e293b]'].join(' ')}>
|
||||
<header className="flex items-center justify-between border-b border-current/15 p-4">
|
||||
<h2 className="text-[14px] font-bold">{t('로컬 모듈 Git 가져오기')}</h2>
|
||||
<button type="button" disabled={submitting} onClick={onClose} aria-label={t('닫기')} className="cursor-pointer rounded p-1 disabled:opacity-50"><X size={16} /></button>
|
||||
</header>
|
||||
<form className="grid gap-3 p-4" onSubmit={event => {
|
||||
event.preventDefault()
|
||||
if (!ready || submitting || inspecting || !selected || !selectedBranch) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
void onImport({ sourceFolder: folder, branch, commit: selectedBranch.commit, name: name.trim(), category: selected.category, packageName: selected.packageName, launchFile: selected.launchFile, dockerImage: image.trim() })
|
||||
.then(onClose)
|
||||
.catch(reason => setError(String(reason.message || reason)))
|
||||
.finally(() => setSubmitting(false))
|
||||
}}>
|
||||
<label className="grid gap-1 text-[12px] font-semibold">{t('로컬 사용자 모듈')}
|
||||
<select className={inputClass + ' cursor-pointer'} value={folder} disabled={loading || submitting} onChange={event => {
|
||||
setFolder(event.target.value)
|
||||
setSource(null)
|
||||
setBranch('')
|
||||
setName(modules.find(module => module.folderName === event.target.value)?.name ?? '')
|
||||
}}>
|
||||
<option value="">{loading ? t('조회 중...') : t('선택')}</option>
|
||||
{modules.map(module => <option key={module.folderName} value={module.folderName}>{module.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{!loading && !modules.length && <p className="text-[12px]">{t('현재 강좌·주차의 로컬 사용자 모듈이 없습니다.')}</p>}
|
||||
<label className="grid gap-1 text-[12px] font-semibold">{t('모듈 이름')}<input required className={inputClass} value={name} disabled={submitting} onChange={event => setName(event.target.value)} /></label>
|
||||
<div className="flex items-end gap-2">
|
||||
<label className="grid min-w-0 flex-1 gap-1 text-[12px] font-semibold">{t('브랜치')}
|
||||
<select className={inputClass + ' cursor-pointer'} value={branch} disabled={inspecting || submitting || !source} onChange={event => setBranch(event.target.value)}>
|
||||
<option value="">{inspecting ? t('조회 중...') : t('선택')}</option>
|
||||
{source?.branches.map(item => <option key={item.name} value={item.name}>{item.name} ({item.commit.slice(0, 8)})</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className={buttonClass} disabled={!folder || inspecting || submitting} onClick={() => setRevision(value => value + 1)} aria-label={t('새로고침')}><RefreshCw size={14} className={inspecting ? 'animate-spin' : ''} /></button>
|
||||
</div>
|
||||
{source && <p className="break-all text-[11px]">{source.repository}</p>}
|
||||
{source && (source.dirty || !source.pushed) && <p className={dark ? 'text-[12px] text-[#facc15]' : 'text-[12px] text-[#855b13]'}>{t('로컬 변경 사항을 커밋하고 푸시한 뒤 다시 시도하세요.')}</p>}
|
||||
<label className="grid gap-1 text-[12px] font-semibold">{t('실행 이미지')}
|
||||
<input required list="remote-git-module-images" className={inputClass} value={image} disabled={submitting} onChange={event => setImage(event.target.value)} placeholder="future-nav2:humble-yahboom" />
|
||||
<datalist id="remote-git-module-images">{images.map(value => <option key={value} value={value} />)}</datalist>
|
||||
</label>
|
||||
{error && <p className={['break-words text-[12px]', dark ? 'text-[#ff9c9c]' : 'text-[#b42323]'].join(' ')}>{t(error)}</p>}
|
||||
<footer className="mt-1 flex justify-end gap-2">
|
||||
<button type="button" className={buttonClass} disabled={submitting} onClick={onClose}>{t('취소')}</button>
|
||||
<button type="submit" disabled={!ready || submitting || inspecting} className="flex h-8 cursor-pointer items-center gap-2 rounded-md bg-[#7c3aed] px-3 text-[12px] font-semibold text-white disabled:cursor-default disabled:opacity-50">{submitting && <RefreshCw size={14} className="animate-spin" />}{t('가져오기')}</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export function StudioSettingsField({
|
||||
<label className="block">
|
||||
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{label}</span>
|
||||
{description && (
|
||||
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>{description}</span>
|
||||
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#687284]'].join(' ')}>{description}</span>
|
||||
)}
|
||||
<input
|
||||
value={value}
|
||||
@@ -62,7 +62,7 @@ export function StudioSettingsSwitch({
|
||||
<div className="min-w-0">
|
||||
<p className={['text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{label}</p>
|
||||
{description && (
|
||||
<p className={['mt-1 text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>{description}</p>
|
||||
<p className={['mt-1 text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#687284]'].join(' ')}>{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -24,7 +24,7 @@ function getHeaderCopy(
|
||||
return {
|
||||
title: '소스 제어',
|
||||
subtitle: gitPanelTab === 'history'
|
||||
? t('제출 내역 {{count}}개', { count: gitSubmissionCount })
|
||||
? t('커밋 내역 {{count}}개', { count: gitSubmissionCount })
|
||||
: t('모듈별 변경 파일 조회 · 변경 {{count}}개', { count: gitChangedFileCount })
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export default function WorkspaceActivityHeader({
|
||||
<div className="flex h-full items-center px-3">
|
||||
<div>
|
||||
<strong className={['block text-[13px] font-bold', textStrong].join(' ')}>{t(copy.title)}</strong>
|
||||
<span className={['mt-0.5 block text-[11px] font-medium', dark ? 'text-white/40' : 'text-[#727b8c]'].join(' ')}>
|
||||
<span className={['mt-0.5 block text-[11px] font-medium', dark ? 'text-white/40' : 'text-[#526078]'].join(' ')}>
|
||||
{t(copy.subtitle)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function WorkspaceCameraPanel({ camera, dark, onClose, containerR
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [large, setLarge] = useState(false)
|
||||
const [position, setPosition] = useState({ x: 32, y: 90 })
|
||||
const [position, setPosition] = useState({ x: 96, y: 90 })
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const [container, setContainer] = useState<HTMLDivElement | null>(null)
|
||||
const [bounds, setBounds] = useState({ width: 0, height: 0 })
|
||||
@@ -81,7 +81,7 @@ export default function WorkspaceCameraPanel({ camera, dark, onClose, containerR
|
||||
onPointerDownCapture={bringToFront} onFocusCapture={bringToFront}
|
||||
className={['absolute flex flex-col overflow-hidden rounded-md border shadow-xl', dark ? 'border-white/20 bg-[#242424] text-white' : 'border-gray-300 bg-white text-gray-800'].join(' ')}
|
||||
style={large ? { left: 0, right: 0, top: 40, bottom: 0, zIndex } : { zIndex, left: position.x, top: position.y, width: 660, height: 420, minWidth: Math.min(280, maxWidth), minHeight: Math.min(220, maxHeight), maxWidth, maxHeight, resize: 'both' }}>
|
||||
<div className="flex h-10 shrink-0 touch-none items-center gap-2 border-b border-gray-500/25 px-3 select-none"
|
||||
<div className={['flex h-10 shrink-0 touch-none items-center gap-2 border-b px-3 select-none', dark ? 'border-white/10 bg-[#252525]' : 'border-[#bec9d8] bg-[#e2e8f0]'].join(' ')}
|
||||
style={{ cursor: large ? 'default' : 'move' }}
|
||||
onPointerDown={(event) => {
|
||||
if (large || event.button !== 0 || (event.target as HTMLElement).closest('button')) return
|
||||
|
||||
@@ -65,7 +65,7 @@ function WorkspaceEditorTabs({
|
||||
activeFile === file
|
||||
? dark
|
||||
? 'border-white/10 bg-[#1f1f1f] text-white'
|
||||
: 'border-[#c4b5df] bg-[#e4d8f5] text-[#38205b]'
|
||||
: 'border-[#c4b5df] bg-[#e2e8f0] text-[#38205b]'
|
||||
: dark
|
||||
? 'border-white/10 bg-[#2b2b2b] text-white/45 hover:text-white/70'
|
||||
: 'border-[#d4c9e4] bg-[#eee8f6] text-[#475569] hover:bg-[#e8def3] hover:text-[#38205b]'
|
||||
|
||||
@@ -173,8 +173,8 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
|
||||
<div className={['flex gap-2.5', expanded ? 'items-start' : 'items-center'].join(' ')}>
|
||||
<span className={[
|
||||
'grid h-6 w-6 shrink-0 place-items-center rounded-md text-[11px] font-bold',
|
||||
active ? dark ? 'bg-[#2563eb]/18 text-[#9dbdff]' : 'bg-[#dbeafe] text-[#1e40af]' : (previous || allowFutureMaterialSteps) ? dark ? 'bg-white/12 text-white/70' : 'bg-[#e2e8f0] text-[#334155]' : dark ? 'bg-white/8 text-white/38' : 'bg-[#e2e8f0] text-[#475569]'
|
||||
].join(' ')}>{index + 1}</span>
|
||||
active ? dark ? 'bg-[#2563eb]/18 text-[#9dbdff]' : 'bg-[#ddd0fa] text-[#5b21b6]' : (previous || allowFutureMaterialSteps) ? dark ? 'bg-white/12 text-white/70' : 'bg-[#cbd5e1] text-[#334155]' : dark ? 'bg-white/8 text-white/38' : 'bg-[#d5dbe5] text-[#64748b]'
|
||||
].join(' ')}><span className="relative top-px leading-none">{index + 1}</span></span>
|
||||
<div className={['min-w-0 flex-1', expanded ? 'pt-0.5' : ''].join(' ')}>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<strong title={item.title} className={['block min-w-0 truncate text-[13px] font-bold', active || previous || allowFutureMaterialSteps ? textStrong : textDim].join(' ')}>{item.title}</strong>
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function WorkspaceModuleTabs({
|
||||
className={[
|
||||
'group flex h-full min-w-[170px] max-w-[210px] shrink-0 cursor-pointer items-center gap-2 rounded-none border-r px-3 text-[12px] font-bold transition',
|
||||
active
|
||||
? dark ? 'border-white/10 bg-[#1f1f1f] text-white/90' : 'border-[#c4b5df] bg-[#e4d8f5] text-[#38205b]'
|
||||
? dark ? 'border-white/10 bg-[#1f1f1f] text-white/90' : 'border-[#c4b5df] bg-[#e2e8f0] text-[#38205b]'
|
||||
: dark ? 'border-white/10 bg-[#252525] text-white/45 hover:bg-[#2b2b2b] hover:text-white/75' : 'border-[#d4c9e4] bg-[#eee8f6] text-[#475569] hover:bg-[#e8def3] hover:text-[#38205b]'
|
||||
].join(' ')}
|
||||
>
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function WorkspaceNav({
|
||||
return (
|
||||
<nav
|
||||
data-help="workspace-nav"
|
||||
className={['relative z-30 flex flex-col items-center gap-3 overflow-visible border-r py-3', dark ? 'border-white/10 bg-[#303030] text-white/65' : 'border-[#bec9d8] bg-[#e2e8f0] text-[#475569]'].join(' ')}
|
||||
className={['relative z-30 flex flex-col items-center gap-3 overflow-visible border-r py-3', dark ? 'border-white/10 bg-[#303030] text-white/65' : 'border-[#cbbdde] bg-[linear-gradient(180deg,#e6ddf1_0%,#e8e1f2_55%,#e4eef7_100%)] text-[#475569]'].join(' ')}
|
||||
style={workspaceActive ? { gridColumn: 3, gridRow: '1 / span 2' } : { gridColumn: 3, gridRow: 1 }}
|
||||
>
|
||||
{WORKSPACE_NAV_ITEMS.map(({ id, Icon, label }) => {
|
||||
|
||||
@@ -291,7 +291,7 @@ function AiMessages({
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#2563eb] text-white">
|
||||
<Bot size={15} />
|
||||
</span>
|
||||
<div className={['min-w-0 max-w-[calc(100%-38px)] overflow-hidden rounded-lg px-3 py-2 text-[12px] font-medium leading-relaxed', dark ? 'bg-[#303030] text-white/75' : 'bg-white text-[#4f596b]'].join(' ')}>
|
||||
<div className={['min-w-0 max-w-[calc(100%-38px)] overflow-hidden rounded-lg border px-3 py-2 text-[12px] font-medium leading-relaxed', dark ? 'border-transparent bg-[#303030] text-white/75' : 'border-[#b9a5e8] bg-[#faf7ff] text-[#334155]'].join(' ')}>
|
||||
<ExpandableMarkdownMessage
|
||||
collapseOnSignal={index < messages.length - 1}
|
||||
collapseSignal={collapseSignal}
|
||||
@@ -311,7 +311,7 @@ function AiMessages({
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#2563eb] text-white">
|
||||
<Bot size={15} />
|
||||
</span>
|
||||
<div className={['inline-flex items-center gap-2 rounded-lg px-3 py-2 text-[12px] font-medium', dark ? 'bg-[#303030] text-white/55' : 'bg-white text-[#727b8c]'].join(' ')}>
|
||||
<div className={['inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-[12px] font-medium', dark ? 'border-transparent bg-[#303030] text-white/55' : 'border-[#b9a5e8] bg-[#faf7ff] text-[#475569]'].join(' ')}>
|
||||
<span>{t("답변을 작성 중입니다...")}</span>
|
||||
<span className={['h-3 w-3 shrink-0 animate-spin rounded-full border-2 border-t-transparent', dark ? 'border-white/45' : 'border-[#94a3b8]'].join(' ')} />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t, useLocaleRender } from '../../i18n'
|
||||
import { Activity, ArrowRight, CircleDot, Radio, RefreshCw, Send, Waypoints, X } from 'lucide-react'
|
||||
import { Activity, ArrowRight, Check, CircleDot, Radio, RefreshCw, Send, Waypoints, X } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type React from 'react'
|
||||
import type { ModuleRow } from './types'
|
||||
@@ -80,8 +80,8 @@ function RuntimeLane({
|
||||
}): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
return (
|
||||
<section className={['grid min-h-0 min-w-[200px] grid-rows-[auto_minmax(0,1fr)] overflow-hidden border-r last:border-r-0', dark ? 'border-white/10 bg-[#222222]' : 'border-[#dbe1eb] bg-[#f8f9fc]'].join(' ')}>
|
||||
<header className={['sticky top-0 z-10 flex items-center justify-between gap-2 border-b px-4 py-3', dark ? 'border-white/10 bg-[#252525]' : 'border-[#dbe1eb] bg-white'].join(' ')}>
|
||||
<section className={['grid min-h-0 min-w-[200px] grid-rows-[auto_minmax(0,1fr)] overflow-hidden border-r last:border-r-0', dark ? 'border-white/10 bg-[#222222]' : 'border-[#aebace] bg-[#f8f9fc]'].join(' ')}>
|
||||
<header className={['sticky top-0 z-10 flex items-center justify-between gap-2 border-b px-4 py-3', dark ? 'border-white/10 bg-[#252525]' : 'border-[#aebace] bg-white'].join(' ')}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={['grid h-6 w-6 place-items-center rounded-md', dark ? 'bg-[#0f766e]/25 text-[#7ee7d3]' : 'bg-[#e7f8f5] text-[#0f766e]'].join(' ')}>
|
||||
<Radio size={14} />
|
||||
@@ -99,7 +99,7 @@ function RuntimeLane({
|
||||
key={moduleKey}
|
||||
className={[
|
||||
'w-full rounded-md border p-3 text-left shadow-sm',
|
||||
dark ? 'border-white/10 bg-[#2a2a2a]' : 'border-[#dbe1eb] bg-white'
|
||||
dark ? 'border-white/10 bg-[#2a2a2a]' : 'border-[#aebace] bg-white'
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
@@ -111,7 +111,7 @@ function RuntimeLane({
|
||||
</div>
|
||||
)
|
||||
}) : (
|
||||
<div className={['rounded-md border border-dashed px-3 py-8 text-center text-[11px]', dark ? 'border-white/12 text-white/38' : 'border-[#cdd6e4] text-[#7b8799]'].join(' ')}>{t("실행 중인 모듈이 없습니다.")}</div>
|
||||
<div className={['rounded-md border border-dashed px-3 py-8 text-center text-[11px]', dark ? 'border-white/12 text-white/38' : 'border-[#aebace] text-[#526078]'].join(' ')}>{t("실행 중인 모듈이 없습니다.")}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -133,7 +133,7 @@ function TopicDetailDrawer({
|
||||
}): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
return (
|
||||
<section className={['relative grid min-h-0 grid-rows-[auto_minmax(0,1fr)] overflow-hidden border-t', dark ? 'border-white/12 bg-[#242424]' : 'border-[#cfd8e6] bg-white'].join(' ')}>
|
||||
<section className={['relative grid min-h-0 grid-rows-[auto_minmax(0,1fr)] overflow-hidden border-t', dark ? 'border-white/12 bg-[#242424]' : 'border-[#98a8bf] bg-white'].join(' ')}>
|
||||
<div
|
||||
role="separator"
|
||||
aria-label={t("토픽 상세 패널 크기 조절")}
|
||||
@@ -142,7 +142,7 @@ function TopicDetailDrawer({
|
||||
>
|
||||
<span className={['mt-0.5 h-px w-10 rounded-full transition', dark ? 'bg-white/22 group-hover:bg-white/55' : 'bg-[#9daabc] group-hover:bg-[#64748b]'].join(' ')} />
|
||||
</div>
|
||||
<header className={['flex min-h-12 items-center justify-between border-b px-4 py-2', dark ? 'border-white/10' : 'border-[#e0e6ee]'].join(' ')}>
|
||||
<header className={['flex min-h-12 items-center justify-between border-b px-4 py-2', dark ? 'border-white/10' : 'border-[#b0bdd0]'].join(' ')}>
|
||||
<div className="flex min-w-0 items-center gap-2 self-center">
|
||||
<span className={['grid h-7 w-7 shrink-0 place-items-center rounded-md', dark ? 'bg-[#2563eb]/18 text-[#9fc6ff]' : 'bg-[#edf4ff] text-[#2563a8]'].join(' ')}><Send size={14} /></span>
|
||||
<div className="min-w-0">
|
||||
@@ -153,7 +153,7 @@ function TopicDetailDrawer({
|
||||
type="button"
|
||||
aria-label={t("토픽 상세 닫기")}
|
||||
onClick={onClose}
|
||||
className={['grid h-7 w-7 shrink-0 cursor-pointer place-items-center self-center rounded-md transition', dark ? 'text-white/55 hover:bg-white/10 hover:text-white' : 'text-[#718096] hover:bg-[#edf2f8] hover:text-[#1e293b]'].join(' ')}
|
||||
className={['grid h-7 w-7 shrink-0 cursor-pointer place-items-center self-center rounded-md transition', dark ? 'text-white/55 hover:bg-white/10 hover:text-white' : 'text-[#475569] hover:bg-[#edf2f8] hover:text-[#1e293b]'].join(' ')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
@@ -161,38 +161,38 @@ function TopicDetailDrawer({
|
||||
<div className="thin-y-scrollbar min-h-0 overflow-auto p-4">
|
||||
<div className="grid min-w-[660px] grid-cols-[minmax(230px,0.8fr)_minmax(250px,1fr)_minmax(250px,1fr)] gap-4">
|
||||
<div>
|
||||
<div className={['rounded-md border p-3', dark ? 'border-white/10 bg-[#1f1f1f]' : 'border-[#e0e6ee] bg-[#f8fafc]'].join(' ')}>
|
||||
<div className={['rounded-md border p-3', dark ? 'border-white/10 bg-[#1f1f1f]' : 'border-[#b0bdd0] bg-[#f8fafc]'].join(' ')}>
|
||||
<dl className={['grid grid-cols-2 gap-x-3 gap-y-2 text-[10px]', dark ? 'text-white/62' : 'text-[#4f5d74]'].join(' ')}>
|
||||
<div className="col-span-2"><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>{t("토픽")}</dt><dd className="mt-0.5"><code className={['block break-all font-semibold', dark ? 'text-[#9fc6ff]' : 'text-[#1d4ed8]'].join(' ')}>{connection.topic.name}</code></dd></div>
|
||||
<div className="col-span-2"><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>{t("메시지 타입")}</dt><dd className="mt-0.5"><code className="block break-all font-semibold">{connection.topic.type}</code></dd></div>
|
||||
<div className="col-span-2"><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>{t("상태")}</dt><dd className={['mt-0.5 inline-flex items-center gap-1 font-semibold', connection.active ? 'text-[#22a06b]' : dark ? 'text-white/40' : 'text-[#8a95a8]'].join(' ')}><span className={['h-1.5 w-1.5 rounded-full', connection.active ? 'bg-[#31c48d]' : 'bg-[#a9b3c2]'].join(' ')} />{connection.active ? t("발행·구독 연결됨") : t("연결 대기")}</dd></div>
|
||||
<div><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>{t("발행자")}</dt><dd className="mt-0.5 font-semibold">{connection.publishers.length}</dd></div>
|
||||
<div><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>{t("구독자")}</dt><dd className="mt-0.5 font-semibold">{connection.subscribers.length}</dd></div>
|
||||
<div><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>QoS</dt><dd className="mt-0.5 font-semibold">{t("기본값")}</dd></div>
|
||||
<div><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>Domain</dt><dd className="mt-0.5 font-semibold">{rosDomainId}</dd></div>
|
||||
<div className="col-span-2"><dt className={dark ? 'text-white/35' : 'text-[#526078]'}>{t("토픽")}</dt><dd className="mt-0.5"><code className={['block break-all font-semibold', dark ? 'text-[#9fc6ff]' : 'text-[#1d4ed8]'].join(' ')}>{connection.topic.name}</code></dd></div>
|
||||
<div className="col-span-2"><dt className={dark ? 'text-white/35' : 'text-[#526078]'}>{t("메시지 타입")}</dt><dd className="mt-0.5"><code className="block break-all font-semibold">{connection.topic.type}</code></dd></div>
|
||||
<div className="col-span-2"><dt className={dark ? 'text-white/35' : 'text-[#526078]'}>{t("상태")}</dt><dd className={['mt-0.5 inline-flex items-center gap-1 font-semibold', connection.active ? (dark ? 'text-[#22a06b]' : 'text-[#177553]') : dark ? 'text-white/40' : 'text-[#526078]'].join(' ')}><span className={['h-1.5 w-1.5 rounded-full', connection.active ? 'bg-[#31c48d]' : 'bg-[#a9b3c2]'].join(' ')} />{connection.active ? t("발행·구독 연결됨") : t("연결 대기")}</dd></div>
|
||||
<div><dt className={dark ? 'text-white/35' : 'text-[#526078]'}>{t("발행자")}</dt><dd className="mt-0.5 font-semibold">{connection.publishers.length}</dd></div>
|
||||
<div><dt className={dark ? 'text-white/35' : 'text-[#526078]'}>{t("구독자")}</dt><dd className="mt-0.5 font-semibold">{connection.subscribers.length}</dd></div>
|
||||
<div><dt className={dark ? 'text-white/35' : 'text-[#526078]'}>QoS</dt><dd className="mt-0.5 font-semibold">{t("기본값")}</dd></div>
|
||||
<div><dt className={dark ? 'text-white/35' : 'text-[#526078]'}>Domain</dt><dd className="mt-0.5 font-semibold">{rosDomainId}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={['text-[10px] font-bold', dark ? 'text-white/46' : 'text-[#718096]'].join(' ')}>{t("발행 노드")}</h3>
|
||||
<h3 className={['text-[10px] font-bold', dark ? 'text-white/46' : 'text-[#475569]'].join(' ')}>{t("발행 노드")}</h3>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{connection.publishers.length ? connection.publishers.map((endpoint) => (
|
||||
<div key={endpoint.node} className={['rounded-md border px-2.5 py-2 text-[11px]', dark ? 'border-white/10 bg-white/[0.03] text-white/72' : 'border-[#e0e6ee] bg-[#fbfcfe] text-[#42516a]'].join(' ')}>
|
||||
<div key={endpoint.node} className={['rounded-md border px-2.5 py-2 text-[11px]', dark ? 'border-white/10 bg-white/[0.03] text-white/72' : 'border-[#b0bdd0] bg-[#fbfcfe] text-[#42516a]'].join(' ')}>
|
||||
<strong className="block truncate font-semibold">{endpoint.node}</strong>
|
||||
<code className={['mt-0.5 block truncate text-[10px]', dark ? 'text-white/38' : 'text-[#8995a8]'].join(' ')}>{endpoint.type || connection.topic.type}</code>
|
||||
<code className={['mt-0.5 block truncate text-[10px]', dark ? 'text-white/38' : 'text-[#526078]'].join(' ')}>{endpoint.type || connection.topic.type}</code>
|
||||
</div>
|
||||
)) : <p className={['text-[11px]', dark ? 'text-white/38' : 'text-[#8792a5]'].join(' ')}>{t("발행 노드 없음")}</p>}
|
||||
)) : <p className={['text-[11px]', dark ? 'text-white/38' : 'text-[#526078]'].join(' ')}>{t("발행 노드 없음")}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={['text-[10px] font-bold', dark ? 'text-white/46' : 'text-[#718096]'].join(' ')}>{t("구독 노드")}</h3>
|
||||
<h3 className={['text-[10px] font-bold', dark ? 'text-white/46' : 'text-[#475569]'].join(' ')}>{t("구독 노드")}</h3>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{connection.subscribers.length ? connection.subscribers.map((endpoint) => (
|
||||
<div key={endpoint.node} className={['rounded-md border px-2.5 py-2 text-[11px]', dark ? 'border-white/10 bg-white/[0.03] text-white/72' : 'border-[#e0e6ee] bg-[#fbfcfe] text-[#42516a]'].join(' ')}>
|
||||
<div key={endpoint.node} className={['rounded-md border px-2.5 py-2 text-[11px]', dark ? 'border-white/10 bg-white/[0.03] text-white/72' : 'border-[#b0bdd0] bg-[#fbfcfe] text-[#42516a]'].join(' ')}>
|
||||
<strong className="block truncate font-semibold">{endpoint.node}</strong>
|
||||
<code className={['mt-0.5 block truncate text-[10px]', dark ? 'text-white/38' : 'text-[#8995a8]'].join(' ')}>{endpoint.type || connection.topic.type}</code>
|
||||
<code className={['mt-0.5 block truncate text-[10px]', dark ? 'text-white/38' : 'text-[#526078]'].join(' ')}>{endpoint.type || connection.topic.type}</code>
|
||||
</div>
|
||||
)) : <p className={['text-[11px]', dark ? 'text-white/38' : 'text-[#8792a5]'].join(' ')}>{t("구독 노드 없음")}</p>}
|
||||
)) : <p className={['text-[11px]', dark ? 'text-white/38' : 'text-[#526078]'].join(' ')}>{t("구독 노드 없음")}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,10 +215,12 @@ export default function WorkspaceRosConnectionsView({
|
||||
const [rosGraphLoading, setRosGraphLoading] = useState(false)
|
||||
|
||||
const connections = useMemo(() => rosGraphConnections(rosGraph), [rosGraph])
|
||||
const [activeOnly, setActiveOnly] = useState(false)
|
||||
const visibleConnections = useMemo(() => connections.filter((connection) => !activeOnly || connection.active), [connections, activeOnly])
|
||||
const [selectedTopicName, setSelectedTopicName] = useState<string | null>(null)
|
||||
const [topicDetailOpen, setTopicDetailOpen] = useState(false)
|
||||
const [topicDetailHeight, setTopicDetailHeight] = useState(320)
|
||||
const selectedConnection = connections.find((connection) => connection.topic.name === selectedTopicName) ?? null
|
||||
const selectedConnection = visibleConnections.find((connection) => connection.topic.name === selectedTopicName) ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTopicName) return
|
||||
@@ -268,23 +270,34 @@ export default function WorkspaceRosConnectionsView({
|
||||
className={['grid min-h-0 min-w-0 grid-rows-[auto_minmax(0,1fr)] overflow-hidden', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}
|
||||
style={{ gridColumn: '4 / span 3', gridRow: 2 }}
|
||||
>
|
||||
<div className={['flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}>
|
||||
<div className={['flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3', dark ? 'border-white/10 bg-[#242424]' : 'border-[#a6b4c9] bg-white'].join(' ')}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={['inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold', dark ? 'border-[#31c48d]/25 bg-[#31c48d]/10 text-[#8fe8bf]' : 'border-[#bcebd5] bg-[#effcf5] text-[#168254]'].join(' ')}>
|
||||
<span className={['inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold', dark ? 'border-[#31c48d]/25 bg-[#31c48d]/10 text-[#8fe8bf]' : 'border-[#79b99a] bg-[#effcf5] text-[#168254]'].join(' ')}>
|
||||
<Activity size={13} />{t("실행 모듈")}{runningModules}
|
||||
</span>
|
||||
<span className={['inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold', dark ? 'border-white/10 bg-white/5 text-white/70' : 'border-[#dbe1eb] bg-[#f6f8fb] text-[#536075]'].join(' ')}>
|
||||
<span className={['inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold', dark ? 'border-white/10 bg-white/5 text-white/70' : 'border-[#aebace] bg-[#f6f8fb] text-[#536075]'].join(' ')}>
|
||||
<Waypoints size={13} />{t("토픽")}{connections.length}
|
||||
</span>
|
||||
<span className={['inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold', dark ? 'border-white/10 bg-white/5 text-white/70' : 'border-[#dbe1eb] bg-[#f6f8fb] text-[#536075]'].join(' ')}>
|
||||
<CircleDot size={13} />{t("활성 연결")}{activeConnections}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={activeOnly}
|
||||
title={t("활성 연결만 보기")}
|
||||
onClick={() => setActiveOnly((value) => !value)}
|
||||
className={[
|
||||
'inline-flex cursor-pointer items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#8b5cf6]',
|
||||
activeOnly
|
||||
? dark ? 'border-[#a78bfa] bg-[#6d28d9] text-white hover:bg-[#7c3aed]' : 'border-[#b59bd8] bg-[#e7daf5] text-[#5b21a3] hover:bg-[#dac5ee]'
|
||||
: dark ? 'border-white/10 bg-white/5 text-white/70 hover:bg-white/10' : 'border-[#aebace] bg-[#f6f8fb] text-[#536075] hover:bg-[#ece6f4]'
|
||||
].join(' ')}
|
||||
>
|
||||
{activeOnly ? <Check size={13} /> : <CircleDot size={13} />}{t("활성 연결")} {activeConnections}
|
||||
</button>
|
||||
{rosGraph.message && (
|
||||
<span className={['max-w-[260px] truncate text-[10px] font-medium', dark ? 'text-[#fbbf24]' : 'text-[#a16207]'].join(' ')} title={rosGraph.message}>
|
||||
{rosGraph.message}
|
||||
</span>
|
||||
)}
|
||||
<span className={['inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold', dark ? 'border-white/10 bg-white/5 text-white/62' : 'border-[#dbe1eb] bg-[#f6f8fb] text-[#657187]'].join(' ')}>
|
||||
<span className={['inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold', dark ? 'border-white/10 bg-white/5 text-white/62' : 'border-[#aebace] bg-[#f6f8fb] text-[#657187]'].join(' ')}>
|
||||
ROS Domain {rosDomainId}
|
||||
</span>
|
||||
</div>
|
||||
@@ -292,7 +305,7 @@ export default function WorkspaceRosConnectionsView({
|
||||
type="button"
|
||||
onClick={() => void refreshRosGraph(true)}
|
||||
disabled={rosGraphLoading}
|
||||
className={['inline-flex h-7 items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-semibold transition', rosGraphLoading ? 'cursor-wait opacity-60' : 'cursor-pointer', dark ? 'border-white/12 text-white/72 hover:bg-white/10 hover:text-white' : 'border-[#d5dce8] text-[#526078] hover:bg-[#eef3f9] hover:text-[#1e293b]'].join(' ')}
|
||||
className={['inline-flex h-7 items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-semibold transition', rosGraphLoading ? 'cursor-wait opacity-60' : 'cursor-pointer', dark ? 'border-white/12 text-white/72 hover:bg-white/10 hover:text-white' : 'border-[#a6b4c9] text-[#526078] hover:bg-[#eef3f9] hover:text-[#1e293b]'].join(' ')}
|
||||
>
|
||||
<RefreshCw size={13} className={rosGraphLoading ? 'animate-spin' : ''} />{t("새로고침")}</button>
|
||||
</div>
|
||||
@@ -309,16 +322,16 @@ export default function WorkspaceRosConnectionsView({
|
||||
modules={runningRosModules}
|
||||
/>
|
||||
|
||||
<section className={['grid min-h-0 min-w-0 grid-rows-[auto_minmax(0,1fr)] overflow-hidden border-r p-3', dark ? 'border-white/10 bg-[#1f1f1f]' : 'border-[#dbe1eb] bg-[#f7f9fc]'].join(' ')}>
|
||||
<section className={['grid min-h-0 min-w-0 grid-rows-[auto_minmax(0,1fr)] overflow-hidden border-r p-3', dark ? 'border-white/10 bg-[#1f1f1f]' : 'border-[#aebace] bg-[#f7f9fc]'].join(' ')}>
|
||||
<div className="mb-3 flex items-center justify-between px-1">
|
||||
<div>
|
||||
<strong className={['block text-[12px]', dark ? 'text-white/88' : 'text-[#1f2937]'].join(' ')}>{t("토픽 연결")}</strong>
|
||||
<span className={['mt-0.5 block text-[10px]', dark ? 'text-white/42' : 'text-[#758196]'].join(' ')}>{t("발행 노드에서 구독 노드로 전달")}</span>
|
||||
<span className={['mt-0.5 block text-[10px]', dark ? 'text-white/42' : 'text-[#526078]'].join(' ')}>{t("발행 노드에서 구독 노드로 전달")}</span>
|
||||
</div>
|
||||
<span className={['text-[10px] font-semibold', dark ? 'text-white/42' : 'text-[#7a8597]'].join(' ')}>{activeConnections}/{connections.length}{t("활성")}</span>
|
||||
<span className={['text-[10px] font-semibold', dark ? 'text-white/42' : 'text-[#526078]'].join(' ')}>{activeConnections}/{connections.length}{t("활성")}</span>
|
||||
</div>
|
||||
<div className="thin-y-scrollbar min-h-0 space-y-2 overflow-y-auto overscroll-contain">
|
||||
{connections.length ? connections.map((connection) => {
|
||||
{visibleConnections.length ? visibleConnections.map((connection) => {
|
||||
const selected = topicDetailOpen && selectedConnection?.topic.name === connection.topic.name
|
||||
return (
|
||||
<button
|
||||
@@ -335,26 +348,26 @@ export default function WorkspaceRosConnectionsView({
|
||||
className={[
|
||||
'grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_28px_minmax(0,1fr)] items-center gap-2 rounded-md border p-2.5 text-left transition',
|
||||
selected
|
||||
? dark ? 'border-[#60a5fa]/55 bg-[#2563eb]/14' : 'border-[#82b3ff] bg-[#edf5ff]'
|
||||
: dark ? 'border-white/10 bg-[#282828] hover:border-white/22' : 'border-[#dbe2ec] bg-white hover:border-[#b8c9e5]'
|
||||
? dark ? 'border-[#60a5fa]/55 bg-[#2563eb]/14' : 'border-[#5289dc] bg-[#edf5ff]'
|
||||
: dark ? 'border-white/10 bg-[#282828] hover:border-white/22' : 'border-[#aebace] bg-white hover:border-[#7f99bd]'
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<span className={['block truncate text-[10px] font-semibold', dark ? 'text-[#7ee7d3]' : 'text-[#087f6a]'].join(' ')}>{t("발행")}{connection.publishers.length}</span>
|
||||
<span className={['mt-0.5 block truncate text-[11px]', dark ? 'text-white/72' : 'text-[#4b5870]'].join(' ')} title={connection.publishers.map((endpoint) => endpoint.node).join(', ')}>{summarizeEndpoints(connection.publishers)}</span>
|
||||
</div>
|
||||
<span className={['grid h-7 w-7 place-items-center rounded-full border', connection.active ? (dark ? 'border-[#31c48d]/40 bg-[#31c48d]/12 text-[#7ee7b5]' : 'border-[#9ae4bf] bg-[#ebfff4] text-[#1d9b62]') : (dark ? 'border-white/12 text-white/32' : 'border-[#dce3ed] text-[#98a3b4]')].join(' ')}>
|
||||
<span className={['grid h-7 w-7 place-items-center rounded-full border', connection.active ? (dark ? 'border-[#31c48d]/40 bg-[#31c48d]/12 text-[#7ee7b5]' : 'border-[#79b99a] bg-[#ebfff4] text-[#1d9b62]') : (dark ? 'border-white/12 text-white/32' : 'border-[#aebace] text-[#98a3b4]')].join(' ')}>
|
||||
<ArrowRight size={14} />
|
||||
</span>
|
||||
<div className="min-w-0 text-right">
|
||||
<span className={['block truncate text-[10px] font-semibold', dark ? 'text-[#9fc6ff]' : 'text-[#2563a8]'].join(' ')}>{t("구독")}{connection.subscribers.length}</span>
|
||||
<span className={['mt-0.5 block truncate text-[11px]', dark ? 'text-white/72' : 'text-[#4b5870]'].join(' ')} title={connection.subscribers.map((endpoint) => endpoint.node).join(', ')}>{summarizeEndpoints(connection.subscribers)}</span>
|
||||
</div>
|
||||
<code className={['col-span-3 mt-1 block truncate text-center text-[10px]', dark ? 'text-white/44' : 'text-[#7e899b]'].join(' ')}>{connection.topic.name}</code>
|
||||
<code className={['col-span-3 mt-1 block truncate text-center text-[10px]', dark ? 'text-white/44' : 'text-[#475569]'].join(' ')}>{connection.topic.name}</code>
|
||||
</button>
|
||||
)
|
||||
}) : (
|
||||
<div className={['rounded-md border border-dashed px-3 py-8 text-center text-[11px]', dark ? 'border-white/12 text-white/38' : 'border-[#cdd6e4] text-[#7b8799]'].join(' ')}>{rosGraphLoading ? t("조회 중...") : rosGraph.message ? t("ROS 그래프 조회에 실패했습니다.") : t("조회된 ROS 토픽이 없습니다.")}</div>
|
||||
<div className={['rounded-md border border-dashed px-3 py-8 text-center text-[11px]', dark ? 'border-white/12 text-white/38' : 'border-[#aebace] text-[#526078]'].join(' ')}>{rosGraphLoading ? t("조회 중...") : rosGraph.message ? t("ROS 그래프 조회에 실패했습니다.") : activeOnly ? t("활성 연결이 없습니다.") : t("조회된 ROS 토픽이 없습니다.")}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -17,6 +17,7 @@ type Props = {
|
||||
saveDisabled: boolean
|
||||
remotePlatforms: PlatformOption[]
|
||||
onUpdateSourceSetting: <Key extends keyof StudioSourceSettings>(key: Key, value: StudioSourceSettings[Key]) => void
|
||||
onResetSourceSettings: () => void
|
||||
onSaveSourceSettings: () => void
|
||||
}
|
||||
|
||||
@@ -31,6 +32,7 @@ function WorkspaceSettingsView({
|
||||
saveDisabled,
|
||||
remotePlatforms,
|
||||
onUpdateSourceSetting,
|
||||
onResetSourceSettings,
|
||||
onSaveSourceSettings
|
||||
}: Props): React.JSX.Element {
|
||||
const { language, setLanguage } = useLanguage()
|
||||
@@ -43,6 +45,8 @@ function WorkspaceSettingsView({
|
||||
const canSave = languageDirty || themeDirty || sourceSettingsDirty
|
||||
const [saveAcknowledged, setSaveAcknowledged] = useState(false)
|
||||
const saveAcknowledgedTimerRef = useRef<number | null>(null)
|
||||
const saveButtonClass = 'h-9 cursor-pointer rounded-md border border-[#2563eb] bg-[#2563eb] px-4 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8] disabled:cursor-default disabled:border-[#64748b] disabled:bg-[#64748b] disabled:text-white/70 disabled:hover:bg-[#64748b]'
|
||||
const resetButtonClass = ['h-9 cursor-pointer rounded-md border px-4 text-[12px] font-bold transition disabled:cursor-default disabled:opacity-50', dark ? 'border-white/15 bg-white/[0.04] text-white/75 hover:bg-white/[0.08]' : 'border-[#cbd5e1] bg-white text-[#475569] hover:bg-[#f1f5f9]'].join(' ')
|
||||
|
||||
useEffect(() => { setLanguageDraft(language) }, [language])
|
||||
useEffect(() => { setThemeDraft(theme) }, [theme])
|
||||
@@ -65,6 +69,13 @@ function WorkspaceSettingsView({
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const handleReset = (): void => {
|
||||
if (saveAcknowledged) return
|
||||
setLanguageDraft('ko')
|
||||
setThemeDraft('dark')
|
||||
if (!remoteWorkspace) onResetSourceSettings()
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className={['min-w-0 overflow-hidden', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}
|
||||
@@ -74,7 +85,27 @@ function WorkspaceSettingsView({
|
||||
<div className="mx-auto max-w-[760px]">
|
||||
<div className={['rounded-lg border p-5', dark ? 'border-white/10 bg-[#252525]' : 'border-[#d8dee9] bg-white'].join(' ')}>
|
||||
<div className={['mb-5 border-b pb-5', dark ? 'border-white/10' : 'border-[#e1e6ef]'].join(' ')}>
|
||||
<h3 className={['mb-5 text-[16px] font-black', textStrong].join(' ')}>{t('공통')}</h3>
|
||||
<div className="relative mb-5">
|
||||
<h3 className={['pr-40 text-[16px] font-black', textStrong].join(' ')}>{t('공통')}</h3>
|
||||
<div className="absolute right-0 top-1/2 flex -translate-y-1/2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saveAcknowledged}
|
||||
onClick={handleReset}
|
||||
className={resetButtonClass}
|
||||
>
|
||||
{t("초기화")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSave || saveAcknowledged}
|
||||
onClick={handleSave}
|
||||
className={saveButtonClass}
|
||||
>
|
||||
{saveAcknowledged ? t("저장됨") : t("저장")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LanguageSelect dark={dark} value={languageDraft} onChange={setLanguageDraft} />
|
||||
<label className="block">
|
||||
@@ -129,14 +160,14 @@ function WorkspaceSettingsView({
|
||||
</div>
|
||||
<StudioSettingsSwitch
|
||||
label={t("외부 ROS 통신")}
|
||||
description={t("같은 네트워크의 기체와 통신을 허용합니다. 사용자별 ROS Domain ID를 구분하고, 변경 후 모듈과 시각화를 재실행하세요. 플랫폼 연동 통신은 별도 설정을 따릅니다.")}
|
||||
description={t("같은 네트워크의 기체와 통신을 허용합니다. 플랫폼 연동 통신은 별도 설정을 따릅니다.")}
|
||||
checked={sourceSettingsDraft.allowExternalRos === true}
|
||||
onChange={(checked) => onUpdateSourceSetting('allowExternalRos', checked)}
|
||||
dark={dark}
|
||||
/>
|
||||
<StudioSettingsSwitch
|
||||
label={t("GPU 가속")}
|
||||
description={t("ON이면 Docker 실행 시 NVIDIA GPU를 사용합니다.")}
|
||||
description={t("모듈 Docker 실행 시 NVIDIA GPU를 사용합니다.")}
|
||||
checked={sourceSettingsDraft.gpuEnabled}
|
||||
onChange={(checked) => onUpdateSourceSetting('gpuEnabled', checked)}
|
||||
dark={dark}
|
||||
@@ -157,7 +188,7 @@ function WorkspaceSettingsView({
|
||||
<div className="mt-5 grid gap-4 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{t("연동 대상 플랫폼")}</span>
|
||||
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>{t("선택한 플랫폼과 같은 ROS Domain으로 모듈을 실행합니다.")}</span>
|
||||
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#687284]'].join(' ')}>{t("선택한 플랫폼과 같은 ROS Domain으로 모듈을 실행합니다.")}</span>
|
||||
<select
|
||||
value={sourceSettingsDraft.rosLinkedPlatformId ?? ''}
|
||||
onChange={(event) => onUpdateSourceSetting('rosLinkedPlatformId', event.target.value || null)}
|
||||
@@ -172,7 +203,7 @@ function WorkspaceSettingsView({
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{t("시각화 브리지 실행 위치")}</span>
|
||||
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>{t("로컬과 플랫폼 모듈을 함께 쓸 때 실행 PC 브리지를 사용하세요.")}</span>
|
||||
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#687284]'].join(' ')}>{t("로컬과 플랫폼 모듈을 함께 쓸 때 실행 PC 브리지를 사용하세요.")}</span>
|
||||
<select
|
||||
value={sourceSettingsDraft.visualizationBridgeLocation ?? 'local'}
|
||||
onChange={(event) => onUpdateSourceSetting('visualizationBridgeLocation', event.target.value === 'platform' ? 'platform' : 'local')}
|
||||
@@ -186,12 +217,12 @@ function WorkspaceSettingsView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={['mt-5 border-t pt-5', dark ? 'border-white/[0.03]' : 'border-[#f6f8fb]'].join(' ')}>
|
||||
<div className={['mt-5 border-y py-5', dark ? 'border-white/10' : 'border-[#e1e6ef]'].join(' ')}>
|
||||
<h3 className={['text-[16px] font-black', textStrong].join(' ')}>{t("에디터")}</h3>
|
||||
<div className="mt-5 grid gap-4 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{t("글꼴 크기")}</span>
|
||||
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>{t("코드 편집기에 표시할 글자 크기입니다.")}</span>
|
||||
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#687284]'].join(' ')}>{t("코드 편집기에 표시할 글자 크기입니다.")}</span>
|
||||
<select
|
||||
value={sourceSettingsDraft.editorFontSize}
|
||||
onChange={(event) => onUpdateSourceSetting('editorFontSize', Number(event.target.value))}
|
||||
@@ -205,7 +236,7 @@ function WorkspaceSettingsView({
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{t("탭 크기")}</span>
|
||||
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>{t("탭 입력 시 사용할 공백 칸 수입니다.")}</span>
|
||||
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#687284]'].join(' ')}>{t("탭 입력 시 사용할 공백 칸 수입니다.")}</span>
|
||||
<select
|
||||
value={sourceSettingsDraft.editorTabSize}
|
||||
onChange={(event) => onUpdateSourceSetting('editorTabSize', Number(event.target.value))}
|
||||
@@ -245,7 +276,7 @@ function WorkspaceSettingsView({
|
||||
type="button"
|
||||
disabled={!canSave || saveAcknowledged}
|
||||
onClick={handleSave}
|
||||
className="h-9 cursor-pointer rounded-md border border-[#2563eb] bg-[#2563eb] px-4 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8] disabled:cursor-default disabled:border-[#64748b] disabled:bg-[#64748b] disabled:text-white/70 disabled:hover:bg-[#64748b]"
|
||||
className={saveButtonClass}
|
||||
>
|
||||
{saveAcknowledged ? t("저장됨") : t("저장")}
|
||||
</button>
|
||||
|
||||
@@ -152,7 +152,7 @@ function WorkspaceSourceControlView({
|
||||
<div className="flex items-end">
|
||||
{[
|
||||
{ key: 'source' as const, label: t("소스 제어") },
|
||||
{ key: 'history' as const, label: teamProject ? t("팀 변경 내역") : t("제출 내역") }
|
||||
{ key: 'history' as const, label: teamProject ? t("팀 변경 내역") : t("커밋 내역") }
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
@@ -163,7 +163,7 @@ function WorkspaceSourceControlView({
|
||||
gitPanelTab === tab.key
|
||||
? dark
|
||||
? 'border-white/10 bg-[#2b2b2b] text-white'
|
||||
: 'border-[#c4b5df] bg-[#e4d8f5] text-[#38205b]'
|
||||
: 'border-[#c4b5df] bg-[#e2e8f0] text-[#38205b]'
|
||||
: dark
|
||||
? 'border-white/8 bg-[#202020] text-white/45 hover:text-white/70'
|
||||
: 'border-[#d4c9e4] bg-[#eee8f6] text-[#475569] hover:bg-[#e8def3] hover:text-[#38205b]'
|
||||
@@ -184,10 +184,10 @@ function WorkspaceSourceControlView({
|
||||
learningSubmitted || gitSubmitting || !hasInstalledGitModules || gitChangedFileCount === 0 ? 'cursor-default opacity-45' : 'cursor-pointer',
|
||||
dark
|
||||
? 'border-[#60a5fa]/45 bg-[#2563eb]/15 text-[#bfdbfe] hover:bg-[#2563eb]/26 hover:text-white'
|
||||
: 'border-[#93c5fd] bg-[#eff6ff] text-[#1d5fae] hover:bg-[#dbeafe] hover:text-[#174a87]'
|
||||
: 'border-[#b59bd8] bg-[#f1eafa] text-[#7138bf] hover:bg-[#e7daf5] hover:text-[#5b21a3]'
|
||||
].join(' ')}
|
||||
>
|
||||
{gitSubmitting ? <RefreshCw size={14} className="animate-spin" /> : <Upload size={14} />}{t("제출")}</button>
|
||||
{gitSubmitting ? <RefreshCw size={14} className="animate-spin" /> : <Upload size={14} />}{t("커밋")}</button>
|
||||
)}
|
||||
{teamProject && (
|
||||
<>
|
||||
@@ -239,7 +239,7 @@ function WorkspaceSourceControlView({
|
||||
{gitPanelTab === 'source' ? (
|
||||
<div className={['grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)]', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
|
||||
<aside className={['flex min-h-0 flex-col border-r', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}>
|
||||
<div className={['flex h-11 items-center justify-between border-b px-4', borderSoft, dark ? 'bg-[#2b2b2b]' : 'bg-[#f3f5f9]'].join(' ')}>
|
||||
<div className={['flex h-11 items-center justify-between border-y px-4', borderSoft, dark ? 'bg-[#2b2b2b]' : 'bg-[#f3f5f9]'].join(' ')}>
|
||||
<span className={['truncate text-[12px] font-bold', textMid].join(' ')}>{teamProject ? t("내 변경 파일") : t("변경 파일")}</span>
|
||||
<span className={['text-[11px] font-bold', textDim].join(' ')}>{gitChangedFileCount}{t("개")}</span>
|
||||
</div>
|
||||
@@ -344,8 +344,8 @@ function WorkspaceSourceControlView({
|
||||
) : (
|
||||
<div className={['grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)]', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
|
||||
<aside className={['flex min-h-0 flex-col border-r', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}>
|
||||
<div className={['flex h-11 items-center justify-between border-b px-4', borderSoft, dark ? 'bg-[#2b2b2b]' : 'bg-[#f3f5f9]'].join(' ')}>
|
||||
<span className={['text-[12px] font-bold', textMid].join(' ')}>{teamProject ? t("팀 변경 내역") : t("제출 목록")}</span>
|
||||
<div className={['flex h-11 items-center justify-between border-y px-4', borderSoft, dark ? 'bg-[#2b2b2b]' : 'bg-[#f3f5f9]'].join(' ')}>
|
||||
<span className={['text-[12px] font-bold', textMid].join(' ')}>{teamProject ? t("팀 변경 내역") : t("커밋 목록")}</span>
|
||||
<span className={['text-[11px] font-bold', textDim].join(' ')}>{gitSubmissionGroups.length}{t("개")}</span>
|
||||
</div>
|
||||
<div className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
|
||||
@@ -356,18 +356,18 @@ function WorkspaceSourceControlView({
|
||||
)}
|
||||
{historyLoading && !gitSubmissions.length ? (
|
||||
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
|
||||
{teamProject ? t("팀 변경 내역 불러오는 중") : t("제출 내역 불러오는 중")}
|
||||
{teamProject ? t("팀 변경 내역 불러오는 중") : t("커밋 내역 불러오는 중")}
|
||||
</div>
|
||||
) : !gitSubmissionGroups.length ? (
|
||||
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
|
||||
{teamProject ? t("팀 변경 내역 없음") : t("제출 내역 없음")}
|
||||
{teamProject ? t("팀 변경 내역 없음") : t("커밋 내역 없음")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{gitSubmissionGroups.map((group, groupIndex) => (
|
||||
<div key={group.id} className={['overflow-hidden rounded-lg border', dark ? 'border-white/10' : 'border-[#e1e6ef]'].join(' ')}>
|
||||
<div className={['px-3 py-2', dark ? 'bg-[#1f1f1f]' : 'bg-[#f8fafc]'].join(' ')}>
|
||||
<p className={['text-[12px] font-black', textStrong].join(' ')}>{teamProject ? t("팀 반영") : t("제출 {{v0}}", { v0: gitSubmissionGroups.length - groupIndex })}</p>
|
||||
<p className={['text-[12px] font-black', textStrong].join(' ')}>{teamProject ? t("팀 반영") : t("커밋 {{v0}}", { v0: gitSubmissionGroups.length - groupIndex })}</p>
|
||||
<p className={['mt-0.5 text-[10px] font-bold', textDim].join(' ')}>
|
||||
{formatGitCommitDate(group.submittedAt)} · {teamProject ? group.submissions[0]?.commit.author : t("{{v0}}개 모듈", { v0: group.submissions.length })}
|
||||
</p>
|
||||
|
||||
@@ -48,7 +48,7 @@ function WorkspaceTerminalPanel({
|
||||
aria-pressed={terminalMode === mode}
|
||||
onClick={() => onModeChange(mode)}
|
||||
className={['h-6 cursor-pointer rounded px-2 text-[12px] font-bold transition-colors', terminalMode === mode
|
||||
? dark ? 'bg-white/15 text-white' : 'bg-[#e4d8f5] text-[#38205b]'
|
||||
? dark ? 'bg-white/15 text-white' : 'bg-[#e2e8f0] text-[#38205b]'
|
||||
: `${textMid} ${dark ? 'hover:bg-white/5' : 'hover:bg-black/5'}`].join(' ')}
|
||||
>
|
||||
{mode === 'host' ? t("호스트") : t("컨테이너")}
|
||||
|
||||
@@ -104,7 +104,7 @@ export default function WorkspaceVisualizationPanel({
|
||||
className={[
|
||||
'flex h-9 select-none touch-none items-center justify-between border-b px-3',
|
||||
large ? 'cursor-default' : 'cursor-move',
|
||||
dark ? 'border-white/10 bg-[#252525]' : 'border-[#d8dee9] bg-[#f3f5f9]'
|
||||
dark ? 'border-white/10 bg-[#252525]' : 'border-[#bec9d8] bg-[#e2e8f0]'
|
||||
].join(' ')}
|
||||
>
|
||||
<strong className={['flex items-center gap-2 text-[12px] font-bold', textStrong].join(' ')}>
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
{
|
||||
"로컬 모듈 Git 가져오기": "Import local module from Git",
|
||||
"로컬 사용자 모듈": "Local custom module",
|
||||
"현재 강좌·주차의 로컬 사용자 모듈이 없습니다.": "No local custom modules for this course and week.",
|
||||
"브랜치": "Branch",
|
||||
"가져오기": "Import",
|
||||
"로컬 변경 사항을 커밋하고 푸시한 뒤 다시 시도하세요.": "Commit and push your local changes, then try again.",
|
||||
"브랜치가 변경되었습니다. 저장소 정보를 새로고침하세요.": "The branch has changed. Refresh the repository information.",
|
||||
"원격 워크스페이스에서만 사용할 수 있습니다.": "Only available in a remote workspace.",
|
||||
"가져오기에 실패했고 모듈 등록을 취소하지 못했습니다. 목록에서 해당 모듈을 확인하세요.": "Import failed and registration could not be rolled back. Check the module in the list.",
|
||||
"Git 모듈을 가져왔습니다. 차량에서 빌드한 뒤 실행하세요.": "Git module imported. Build it on the platform before running.",
|
||||
"모듈 자체의 Git 저장소가 필요합니다.": "The module needs its own Git repository.",
|
||||
"Git 저장소를 조회하지 못했습니다. 접근 권한과 네트워크를 확인하세요.": "Could not query the Git repository. Check access and connectivity.",
|
||||
"Git 모듈 복제에 실패했습니다. 원격 저장소 접근, 기존 폴더, 소스 및 빌드 산출물 포함 여부를 확인하세요.": "Git import failed. Check platform repository access, existing folders, source files, and tracked build artifacts.",
|
||||
"조회 중...": "Loading...",
|
||||
"ROS 그래프 조회에 실패했습니다.": "Failed to retrieve the ROS graph.",
|
||||
"조회된 ROS 토픽이 없습니다.": "No ROS topics found.",
|
||||
@@ -6,6 +19,16 @@
|
||||
"완료한 주차": "Completed weeks",
|
||||
"학습 시간": "Learning time",
|
||||
"커밋 수": "Commits",
|
||||
"커밋": "Commit",
|
||||
"커밋 {{v0}}": "Commit {{v0}}",
|
||||
"커밋 내역": "Commit history",
|
||||
"커밋 목록": "Commits",
|
||||
"커밋 내역 불러오는 중": "Loading commits",
|
||||
"커밋 내역 없음": "No commits",
|
||||
"커밋 내역 {{count}}개": "Commit history: {{count}}",
|
||||
"커밋할 변경사항이 없습니다": "No changes to commit",
|
||||
"커밋 또는 푸시에 실패했습니다": "Commit or push failed",
|
||||
"변경사항을 커밋하고 푸시했습니다": "Changes committed and pushed",
|
||||
"평균 점수": "Average score",
|
||||
"[{{name}}] 맵이 선택되었습니다.": "Map selected: [{{name}}].",
|
||||
"언어": "Language",
|
||||
@@ -197,7 +220,7 @@
|
||||
"Git 상태 불러오는 중": "Loading Git status",
|
||||
"Launch 파일": "Launch file",
|
||||
"NVIDIA Container Toolkit과 Docker GPU 런타임이 준비된 PC에서만 실행됩니다. 준비되지 않은 경우 GPU 가속을 끄세요.": "Requires NVIDIA Container Toolkit and a configured Docker GPU runtime. Otherwise, turn off GPU acceleration.",
|
||||
"ON이면 Docker 실행 시 NVIDIA GPU를 사용합니다.": "Use the NVIDIA GPU when running Docker.",
|
||||
"모듈 Docker 실행 시 NVIDIA GPU를 사용합니다.": "Use the NVIDIA GPU when running module Docker containers.",
|
||||
"ROS 연결 초기화": "Reset ROS connections",
|
||||
"ROS2 패키지 소스를 검증한 뒤 현재 워크스페이스로 복사합니다.": "Validate ROS2 package sources and copy them into the current workspace.",
|
||||
"{{v0}} 개발 스튜디오 연결만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.": "Disconnect from the {{v0}} studio only. Platform control is retained.",
|
||||
@@ -232,7 +255,7 @@
|
||||
"강좌 선택 후 이동 가능": "Select a course first",
|
||||
"강좌를 선택하면 학습 내용에 맞춰 안내합니다.": "Select a course for guidance tailored to its content.",
|
||||
"강좌명 또는 강사명 검색": "Search courses or instructors",
|
||||
"같은 네트워크의 기체와 통신을 허용합니다. 사용자별 ROS Domain ID를 구분하고, 변경 후 모듈과 시각화를 재실행하세요. 플랫폼 연동 통신은 별도 설정을 따릅니다.": "Allow communication with robots on the same network. Use a separate ROS Domain ID for each user. Restart modules and visualization after changing this setting. Linked platforms use separate communication settings.",
|
||||
"같은 네트워크의 기체와 통신을 허용합니다. 플랫폼 연동 통신은 별도 설정을 따릅니다.": "Allow communication with robots on the same network. Linked platform communication uses separate settings.",
|
||||
"같은 번호의 ROS 모듈끼리 토픽과 서비스를 공유합니다.": "ROS modules with the same domain ID share topics and services.",
|
||||
"개": " items",
|
||||
"개념, 실습 순서, 오류 원인처럼 학습에 필요한 내용을 질문하세요.": "Ask about concepts, practical steps, or troubleshooting.",
|
||||
@@ -630,5 +653,7 @@
|
||||
"확인": "OK",
|
||||
"확인 중": "Checking",
|
||||
"활성": "Active",
|
||||
"활성 연결": "Active connections"
|
||||
"활성 연결": "Active connections",
|
||||
"활성 연결만 보기": "Show active connections only",
|
||||
"활성 연결이 없습니다.": "No active connections."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export type LocalGitModule = {
|
||||
name: string
|
||||
category: string
|
||||
folderName: string
|
||||
packageName: string
|
||||
launchFile: string
|
||||
}
|
||||
|
||||
export type GitModuleSource = {
|
||||
repository: string
|
||||
currentBranch: string
|
||||
branches: Array<{ name: string; commit: string }>
|
||||
dirty: boolean
|
||||
pushed: boolean
|
||||
}
|
||||
|
||||
export type GitModuleImportInput = {
|
||||
sourceFolder: string
|
||||
branch: string
|
||||
commit: string
|
||||
name: string
|
||||
category: string
|
||||
packageName: string
|
||||
launchFile: string
|
||||
dockerImage: string
|
||||
}
|
||||
Reference in New Issue
Block a user