feat: support simultaneous Tailscale and LAN communication for Yahboom cameras

This commit is contained in:
2026-09-30 15:37:17 +09:00
parent 9387ade537
commit 08f74575c2
38 changed files with 917 additions and 142 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "lms",
"version": "1.2.4",
"version": "1.2.5",
"description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js",
"author": {
+81
View File
@@ -0,0 +1,81 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const ts = require('typescript')
const source = ts.createSourceFile('index.ts', fs.readFileSync(path.join(__dirname, '../src/main/index.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
const names = ['buildFastDdsPeerConfig', 'parseCameraLanAddresses', 'cameraLanEnabled', 'rosNetworkEnabled', 'prepareRemoteRosNetworkConfig']
const code = source.statements.filter(node => ts.isFunctionDeclaration(node) && names.includes(node.name?.text)).map(node => node.getText(source)).join('\n')
function load(overrides = {}) {
const dependencies = {
parseRosDomainId: value => Number(value ?? 0),
readLocalTailscaleIpv4: async () => '100.64.0.10',
readRemoteTailscaleIpv4: async () => '100.64.0.20',
runSshOutput: async () => JSON.stringify([{ addr_info: [{ family: 'inet', scope: 'global', local: '192.168.1.20' }] }]),
runSsh: async () => {},
quoteShellSingle: value => "'" + value.replaceAll("'", "'\\''") + "'",
expandRemotePathVar: () => [],
encodeBase64: value => Buffer.from(value).toString('base64'),
...overrides,
}
return new Function(...Object.keys(dependencies), ts.transpile(code, { target: ts.ScriptTarget.ES2022 }) + `;return {${names.join(',')}}`)(...Object.values(dependencies))
}
test('LAN discovery adds physical addresses and multicast while keeping Tailscale peers', () => {
const { buildFastDdsPeerConfig } = load()
const xml = buildFastDdsPeerConfig('100.64.0.10', '100.64.0.20', '77', ['192.168.1.20'])
const whitelist = xml.match(/<interfaceWhiteList>(.*?)<\/interfaceWhiteList>/s)[1]
for (const address of ['127.0.0.1', '100.64.0.20', '192.168.1.20']) assert.ok(whitelist.includes(address))
assert.ok(xml.includes('<address>100.64.0.10</address>'))
assert.ok(xml.includes('<address>239.255.0.1</address><port>26650</port>'))
assert.ok(xml.includes('<metatrafficMulticastLocatorList>'))
const original = buildFastDdsPeerConfig('100.64.0.10', '100.64.0.20', '77')
assert.ok(!original.includes('192.168.1.20'))
assert.ok(!original.includes('239.255.0.1'))
})
test('only the Yahboom camera opts in', () => {
const { cameraLanEnabled } = load()
assert.equal(cameraLanEnabled('[lms-nav2-yahboom-camera]yahboom_camera_v1.0.0_ws'), true)
for (const role of ['vehicle', 'agent', 'control', 'slam', 'planning']) {
assert.equal(cameraLanEnabled(`[lms-nav2-yahboom-${role}]yahboom_${role}_v1.0.0_ws`), false)
}
assert.equal(cameraLanEnabled('[lms-nav2-custom]camera_v1.0.0_ws'), false)
})
test('physical interface responses are parsed, validated and deduplicated', () => {
const { parseCameraLanAddresses } = load()
const output = JSON.stringify([{ addr_info: [
{ family: 'inet', scope: 'global', local: '192.168.1.20' },
{ family: 'inet', scope: 'global', local: '999.1.1.1' },
{ family: 'inet6', scope: 'global', local: 'fe80::1' },
] }])
assert.deepEqual(parseCameraLanAddresses(output + '\n' + output), ['192.168.1.20'])
})
test('camera config uses its own path and LAN works without ROS platform linking', async () => {
let script = ''
const api = load({
readLocalTailscaleIpv4: async () => { throw new Error('Must not require local Tailscale') },
readRemoteTailscaleIpv4: async () => { throw new Error('Must not require remote Tailscale') },
runSsh: async (_target, command) => { script = command },
})
assert.equal(await api.prepareRemoteRosNetworkConfig({}, '/runtime', { enabled: false }, '77'), null)
const result = await api.prepareRemoteRosNetworkConfig({}, '/runtime', { enabled: false }, '77', true)
assert.ok(result.endsWith('-camera-lan.xml'))
const encoded = script.match(/printf %s '([^']+)'/)[1]
const xml = Buffer.from(encoded, 'base64').toString()
assert.ok(xml.includes('192.168.1.20'))
assert.ok(xml.includes('239.255.0.1'))
})
test('linked camera retains both networks and does not overwrite restricted config', async () => {
const api = load()
const restricted = await api.prepareRemoteRosNetworkConfig({}, '/runtime', { enabled: true }, '77')
const camera = await api.prepareRemoteRosNetworkConfig({}, '/runtime', { enabled: true }, '77', true)
assert.notEqual(camera, restricted)
assert.ok(camera.includes('100-64-0-10'))
const noLan = load({ runSshOutput: async () => '' })
await assert.rejects(noLan.prepareRemoteRosNetworkConfig({}, '/runtime', { enabled: true }, '77', true), /IPv4/)
})
+30
View File
@@ -0,0 +1,30 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const root = path.resolve(__dirname, '../src')
const app = fs.readFileSync(path.join(root, 'renderer/src/App.tsx'), 'utf8')
const view = fs.readFileSync(path.join(root, 'renderer/src/features/workspace/ModuleListView.tsx'), 'utf8')
test('remote creation and Git import are enabled while raw folder import remains local', () => {
assert.match(app, /allowCustomModuleCreation=\{true\}/)
assert.match(app, /allowCustomModuleImport=\{true\}/)
assert.match(app, /remoteGitImport=\{remoteWorkspace \? \{ workspaceRoot: localVisualizationWorkspaceRoot, onImport: importGitModule \} : undefined\}/)
assert.match(view, /remoteGitImport \? <RemoteModuleImportDialog/)
assert.match(app, /const importCustomModule[\s\S]*?if \(remoteWorkspace\) throw new Error/)
const importButton = view.slice(view.indexOf('aria-label={t("모듈 불러오기")}'), view.indexOf('{deleteConfirmModule &&'))
assert.match(importButton, /disabled=\{!allowCustomModuleImport\}/)
assert.doesNotMatch(importButton, /allowCustomModuleCreation/)
assert.match(view, /allowCustomModuleImport && moduleImportDialogOpen/)
})
test('new module dialog requests images from the current workspace', () => {
assert.match(app, /loadDockerImages=\{\(\) => remoteWorkspace\s*\? window\.remoteWorkspace\.modules\.listDockerImages\(getRemoteWorkspaceTarget\(\)\)/)
const dialog = view.slice(view.indexOf('function ModuleAddDialog('), view.indexOf('function ModuleImportDialog('))
assert.match(dialog, /void loadDockerImages\(\)/)
assert.doesNotMatch(dialog, /window\.localWorkspace/)
const preload = fs.readFileSync(path.join(root, 'preload/index.ts'), 'utf8')
const main = fs.readFileSync(path.join(root, 'main/index.ts'), 'utf8')
assert.match(preload, /invoke\('remote-workspace:modules:docker-images', target\)/)
assert.match(main, /handle\('remote-workspace:modules:docker-images'/)
})
+109
View File
@@ -0,0 +1,109 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs/promises')
const path = require('node:path')
const os = require('node:os')
const vm = require('node:vm')
const ts = require('typescript')
const { execFile } = require('node:child_process')
const { promisify } = require('node:util')
const exec = promisify(execFile)
const root = path.resolve(__dirname, '..')
async function loadHelpers() {
const exports = {}
const code = ts.transpileModule(await fs.readFile(path.join(root, 'src/main/gitModuleImport.ts'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
vm.runInNewContext(code, { exports, require, URL })
return exports
}
test('repository addresses strip credentials and reject local/helper protocols', async () => {
const api = await loadHelpers()
assert.equal(api.repositoryAddress('https://user:secret@example.com/team/module.git?token=secret'), 'https://example.com/team/module.git')
assert.equal(api.repositoryAddress('git@example.com:team/module.git'), 'ssh://git@example.com/team/module.git')
for (const url of ['file:///tmp/repo', 'ext::command', '/tmp/repo']) assert.throws(() => api.repositoryAddress(url))
assert.equal(api.remoteBranches('a'.repeat(40) + '\trefs/heads/main\ninvalid')[0].name, 'main')
})
test('local module discovery ignores malformed folders and rejects symlink escapes', async () => {
const api = await loadHelpers()
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-git-list-'))
try {
const workspace = path.join(dir, 'workspace')
const folder = '[lms-nav2-custom]demo_v1.0.0_ws'
await fs.mkdir(path.join(workspace, folder), { recursive: true })
await fs.writeFile(path.join(workspace, folder, '.autonomy-studio-module.json'), JSON.stringify({ sourceType: 'CUSTOM', name: 'Demo', category: 'Control', packageName: 'demo', launchFile: 'demo.launch.py' }))
assert.equal((await api.listLocalGitModules(workspace))[0].name, 'Demo')
await fs.symlink(dir, path.join(workspace, '[lms-nav2-custom]escape_v1.0.0_ws'))
await assert.rejects(api.localGitModulePath(workspace, '[lms-nav2-custom]escape_v1.0.0_ws'), /outside/)
await assert.rejects(api.localGitModulePath(workspace, '../escape'), /Invalid/)
assert.equal((await api.listLocalGitModules(path.join(dir, 'missing'))).length, 0)
} finally { await fs.rm(dir, { recursive: true, force: true }) }
})
test('Git clone imports only the selected commit, refuses overwrite and cleans failed staging', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-git-import-'))
const repo = path.join(dir, 'source')
const workspace = path.join(dir, 'remote')
const folderName = '[lms-nav2-custom]demo_v1.0.0_ws'
const git = (...args) => exec('git', args, { cwd: repo })
try {
await fs.mkdir(path.join(repo, 'src/demo/launch'), { recursive: true })
await fs.writeFile(path.join(repo, 'src/demo/package.xml'), '<package><name>demo</name></package>')
await fs.writeFile(path.join(repo, 'src/demo/launch/demo.launch.py'), '# launch')
for (const file of ['build.sh', 'run.sh']) await fs.writeFile(path.join(repo, file), '#!/bin/bash\nexit 0\n')
await git('init', '-b', 'main')
await git('add', '.')
await git('-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '-m', 'initial')
const commit = (await git('rev-parse', 'HEAD')).stdout.trim()
await fs.writeFile(path.join(repo, 'uncommitted.txt'), 'must not transfer')
let dirty = false
let pushed = true
let requestedCommit = commit
const index = await fs.readFile(path.join(root, 'src/main/index.ts'), 'utf8')
const fn = index.slice(index.indexOf('async function importRemoteGitModule('), index.indexOf('async function transferLocalWorkspaceModuleToRemote('))
const quote = value => "'" + value.replaceAll("'", "'\\''") + "'"
const context = {
URL, Buffer,
normalizeRemoteTarget: target => target,
validateCustomModuleScaffold: input => input,
inspectLocalGitModule: async () => ({ repository: 'https://example.com/module.git', dirty, pushed, branches: [{ name: 'main', commit: requestedCommit }] }),
remoteRepositoryUrl: () => 'file://' + repo,
withGitCredential: value => value,
customModuleConfigContent: input => JSON.stringify(input),
encodeBase64: value => Buffer.from(value).toString('base64'),
quoteShellSingle: quote,
expandRemotePathVar: () => [],
runSshOutput: async (_target, script) => (await exec('bash', ['-c', script], { timeout: 15000 })).stdout,
}
vm.runInNewContext(ts.transpileModule(fn, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText, context)
const input = { sourceFolder: folderName, folderName, branch: 'main', commit, name: 'Remote demo', category: 'Control', packageName: 'demo', launchFile: 'demo.launch.py', dockerImage: 'vehicle:arm64' }
const run = () => context.importRemoteGitModule({ workspaceRoot: workspace }, repo, input)
dirty = true
await assert.rejects(run(), /커밋/)
dirty = false
pushed = false
await assert.rejects(run(), /푸시/)
pushed = true
assert.equal((await run()).ok, true)
const target = path.join(workspace, folderName)
assert.equal((await exec('git', ['rev-parse', 'HEAD'], { cwd: target })).stdout.trim(), commit)
await assert.rejects(fs.stat(path.join(target, 'uncommitted.txt')), { code: 'ENOENT' })
assert.equal(JSON.parse(await fs.readFile(path.join(target, '.autonomy-studio-module.json'), 'utf8')).dockerImage, 'vehicle:arm64')
await fs.writeFile(path.join(target, 'keep.txt'), 'keep')
await assert.rejects(run(), /복제에 실패/)
assert.equal(await fs.readFile(path.join(target, 'keep.txt'), 'utf8'), 'keep')
await fs.rm(target, { recursive: true })
requestedCommit = 'a'.repeat(40)
input.commit = requestedCommit
await assert.rejects(run(), /복제에 실패/)
assert.equal((await fs.readdir(workspace)).length, 0)
await fs.mkdir(path.join(repo, 'install'))
await fs.writeFile(path.join(repo, 'install/binary'), 'not portable')
await git('add', 'install')
await git('-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '-m', 'bad artifacts')
input.commit = requestedCommit = (await git('rev-parse', 'HEAD')).stdout.trim()
await assert.rejects(run(), /복제에 실패/)
assert.equal((await fs.readdir(workspace)).length, 0)
} finally { await fs.rm(dir, { recursive: true, force: true }) }
})
+40
View File
@@ -45,3 +45,43 @@ test('keeps discovered topics without endpoints and deduplicates node entries',
test('module selection and guessed topic mappings are removed from the view', () => {
assert.doesNotMatch(source, /selectedModuleKeys|moduleTopics|fallbackPublishers|fallbackSubscribers|onToggleModule|ListChecks|ListX/)
})
test('active toggle filters only topics, preserves totals and hides filtered details', () => {
const graph = { topics: [
{ name: '/active', types: [], publishers: [endpoint('/pub')], subscribers: [endpoint('/sub')] },
{ name: '/waiting', types: [], publishers: [endpoint('/pub')], subscribers: [] },
] }
function render(activeOnly) {
const states = [graph, false, activeOnly, '/waiting', true, 320]
let index = 0
const api = {}
const jsx = (type, props, key) => ({ type, props, key })
vm.runInNewContext(ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
}).outputText, { exports: api, require: (name) => {
if (name === 'react') return { useState: () => [states[index++], () => {}], useMemo: fn => fn(), useEffect: () => {} }
if (name === 'react/jsx-runtime') return { jsx, jsxs: jsx }
if (name === '../../i18n') return { t: value => value, useLocaleRender: () => {} }
return {}
} })
const tree = api.default({ dark: false, modules: [{ name: 'Running module', state: 'running' }], remoteWorkspace: false, rosDomainId: '77' })
const nodes = []
function visit(node) {
if (Array.isArray(node)) return node.forEach(visit)
if (!node || typeof node !== 'object') return
nodes.push(node)
visit(node.props?.children)
}
visit(tree)
return nodes
}
for (const activeOnly of [false, true]) {
const nodes = render(activeOnly)
assert.equal(nodes.filter(node => node.type === 'button' && node.key?.startsWith('/')).length, activeOnly ? 1 : 2)
const toggle = nodes.find(node => node.props?.title === '활성 연결만 보기')
assert.equal(toggle.props['aria-pressed'], activeOnly)
assert.equal(toggle.props.children.at(-1), 1)
assert.equal(nodes.find(node => node.type?.name === 'RuntimeLane').props.modules.length, 1)
assert.equal(nodes.some(node => node.type?.name === 'TopicDetailDrawer'), !activeOnly)
}
})
+48
View File
@@ -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
View File
@@ -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())
+4
View File
@@ -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

+47 -13
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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}
+4
View File
@@ -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 }>
+20 -1
View File
@@ -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(' ')}>
+28 -3
View File
@@ -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."
}
+26
View File
@@ -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
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/shared/**/*"],
"compilerOptions": {
"noEmit": true,
"moduleResolution": "bundler",