feat: support simultaneous Tailscale and LAN communication for Yahboom cameras
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.2.4",
|
"version": "1.2.5",
|
||||||
"description": "Autonomy Studio LMS Electron app",
|
"description": "Autonomy Studio LMS Electron app",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -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/)
|
||||||
|
})
|
||||||
@@ -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'/)
|
||||||
|
})
|
||||||
@@ -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 }) }
|
||||||
|
})
|
||||||
@@ -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', () => {
|
test('module selection and guessed topic mappings are removed from the view', () => {
|
||||||
assert.doesNotMatch(source, /selectedModuleKeys|moduleTopics|fallbackPublishers|fallbackSubscribers|onToggleModule|ListChecks|ListX/)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { readdir, readFile, realpath } from 'fs/promises'
|
||||||
|
import { resolve, sep } from 'path'
|
||||||
|
import type { LocalGitModule } from '../shared/gitModuleImport'
|
||||||
|
|
||||||
|
export async function localGitModulePath(root: string, folder: string): Promise<string> {
|
||||||
|
if (!/^\[lms-nav2-custom\][a-z][a-z0-9_]*_v[^/\\]+_ws$/.test(folder)) throw new Error('Invalid custom module folder')
|
||||||
|
const base = await realpath(root)
|
||||||
|
const target = await realpath(resolve(base, folder))
|
||||||
|
if (!target.startsWith(base + sep)) throw new Error('Module is outside the local workspace')
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLocalGitModules(root: string): Promise<LocalGitModule[]> {
|
||||||
|
const entries = await readdir(root, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code === 'ENOENT') return []
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
const modules: LocalGitModule[] = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory() || !entry.name.startsWith('[lms-nav2-custom]')) continue
|
||||||
|
try {
|
||||||
|
const target = await localGitModulePath(root, entry.name)
|
||||||
|
const data = JSON.parse(await readFile(resolve(target, '.autonomy-studio-module.json'), 'utf8'))
|
||||||
|
if (data.sourceType !== 'CUSTOM' || !['name', 'category', 'packageName', 'launchFile'].every(key => typeof data[key] === 'string' && data[key])) continue
|
||||||
|
modules.push({ name: data.name, category: data.category, packageName: data.packageName, launchFile: data.launchFile, folderName: entry.name })
|
||||||
|
} catch { /* Ignore incomplete module folders. */ }
|
||||||
|
}
|
||||||
|
return modules.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function repositoryAddress(value: string): string {
|
||||||
|
// Do not allow local paths, Git remote helpers, or credentials in the UI.
|
||||||
|
const normalized = /^git@[^:]+:.+/.test(value) ? value.replace(/^git@([^:]+):(.+)$/, 'ssh://git@$1/$2') : value
|
||||||
|
const url = new URL(normalized)
|
||||||
|
if (!['https:', 'http:', 'ssh:'].includes(url.protocol)) throw new Error('Unsupported Git repository protocol')
|
||||||
|
url.password = ''
|
||||||
|
if (url.protocol !== 'ssh:') url.username = ''
|
||||||
|
url.search = ''
|
||||||
|
url.hash = ''
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remoteBranches(output: string): Array<{ name: string; commit: string }> {
|
||||||
|
return output.split('\n').flatMap(line => {
|
||||||
|
const match = line.trim().match(/^([a-f0-9]{40,64})\s+refs\/heads\/(.+)$/)
|
||||||
|
return match ? [{ name: match[2], commit: match[1] }] : []
|
||||||
|
}).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
+136
-13
@@ -5,6 +5,8 @@ import { cameraRuntimeArtifacts } from './cameraRuntime'
|
|||||||
import { remoteModuleFilesScript } from './remoteModuleFiles'
|
import { remoteModuleFilesScript } from './remoteModuleFiles'
|
||||||
import { resolveContainerTerminal } from './containerTerminal'
|
import { resolveContainerTerminal } from './containerTerminal'
|
||||||
import { remoteShellCommand, remoteShellEnvironment } from './remoteShell'
|
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 { yahboomDevicePreflight, yahboomModuleCases, yahboomRuntimeImage, yahboomRuntimeArtifacts, yahboomSimulation } from './yahboomRuntime'
|
||||||
import { visualizationProgress, reportVisualizationProgress } from './visualizationProgress'
|
import { visualizationProgress, reportVisualizationProgress } from './visualizationProgress'
|
||||||
import { moduleProgress, reportModuleProgress, createModuleProgressParser } from './moduleProgress'
|
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) }
|
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(
|
async function transferLocalWorkspaceModuleToRemote(
|
||||||
targetInput: RemoteWorkspaceTarget,
|
targetInput: RemoteWorkspaceTarget,
|
||||||
sourceWorkspaceRoot: string | null | undefined,
|
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)
|
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 {
|
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) => {
|
const images = Array.from(new Set(output.split('\n').flatMap((line) => {
|
||||||
if (!line.trim()) return []
|
if (!line.trim()) return []
|
||||||
try {
|
try {
|
||||||
@@ -1794,7 +1874,7 @@ async function listLocalDockerImages(): Promise<{ images: string[]; message?: st
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
images: [],
|
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
|
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 domainId = parseRosDomainId(rosDomainId)
|
||||||
const discoveryBasePort = 7400 + (250 * domainId)
|
const discoveryBasePort = 7400 + (250 * domainId)
|
||||||
// Fast DDS can bind the default discovery port itself (d0), while other
|
// Fast DDS can bind the default discovery port itself (d0), while other
|
||||||
@@ -4347,7 +4427,7 @@ function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDo
|
|||||||
discoveryBasePort,
|
discoveryBasePort,
|
||||||
...Array.from({ length: 121 }, (_, participantId) => discoveryBasePort + 10 + (2 * participantId))
|
...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]))
|
const peerAddresses = Array.from(new Set(['127.0.0.1', peerAddress]))
|
||||||
return [
|
return [
|
||||||
'<?xml version="1.0" encoding="UTF-8" ?>',
|
'<?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>',
|
' <transport_id>autonomy_studio_udp</transport_id>',
|
||||||
' </userTransports>',
|
' </userTransports>',
|
||||||
' <builtin>',
|
' <builtin>',
|
||||||
|
...(lanAddresses.length ? [
|
||||||
|
' <metatrafficMulticastLocatorList>',
|
||||||
|
` <locator><udpv4><address>239.255.0.1</address><port>${discoveryBasePort}</port></udpv4></locator>`,
|
||||||
|
' </metatrafficMulticastLocatorList>',
|
||||||
|
] : []),
|
||||||
' <metatrafficUnicastLocatorList>',
|
' <metatrafficUnicastLocatorList>',
|
||||||
...localAddresses.flatMap((address) => [
|
...localAddresses.flatMap((address) => [
|
||||||
' <locator>',
|
' <locator>',
|
||||||
@@ -4382,6 +4467,9 @@ function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDo
|
|||||||
]),
|
]),
|
||||||
' </metatrafficUnicastLocatorList>',
|
' </metatrafficUnicastLocatorList>',
|
||||||
' <initialPeersList>',
|
' <initialPeersList>',
|
||||||
|
...(lanAddresses.length ? [
|
||||||
|
` <locator><udpv4><address>239.255.0.1</address><port>${discoveryBasePort}</port></udpv4></locator>`,
|
||||||
|
] : []),
|
||||||
...peerAddresses.flatMap((address) => peers.flatMap((port) => [
|
...peerAddresses.flatMap((address) => peers.flatMap((port) => [
|
||||||
' <locator>',
|
' <locator>',
|
||||||
' <udpv4>',
|
' <udpv4>',
|
||||||
@@ -4467,25 +4555,51 @@ async function prepareLocalRosNetworkConfig(
|
|||||||
return configPath
|
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(
|
async function prepareRemoteRosNetworkConfig(
|
||||||
target: RemoteWorkspaceTarget,
|
target: RemoteWorkspaceTarget,
|
||||||
runtimeRoot: string,
|
runtimeRoot: string,
|
||||||
network?: RosNetworkConfig | null,
|
network?: RosNetworkConfig | null,
|
||||||
rosDomainId?: string | null,
|
rosDomainId?: string | null,
|
||||||
|
allowCameraLan = false,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
if (!rosNetworkEnabled(network)) return null
|
if (!rosNetworkEnabled(network) && !allowCameraLan) return null
|
||||||
|
|
||||||
const [peerAddress, localAddress] = await Promise.all([
|
const [peerAddress, localAddress] = await Promise.all([
|
||||||
readLocalTailscaleIpv4(),
|
rosNetworkEnabled(network) ? readLocalTailscaleIpv4() : Promise.resolve('127.0.0.1'),
|
||||||
readRemoteTailscaleIpv4(target),
|
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, [
|
await runSsh(target, [
|
||||||
'set -e',
|
'set -e',
|
||||||
`CONFIG_PATH=${quoteShellSingle(configPath)}`,
|
`CONFIG_PATH=${quoteShellSingle(configPath)}`,
|
||||||
...expandRemotePathVar('CONFIG_PATH'),
|
...expandRemotePathVar('CONFIG_PATH'),
|
||||||
'mkdir -p "$(dirname \"$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)
|
].join('\n'), 15_000)
|
||||||
return configPath
|
return configPath
|
||||||
}
|
}
|
||||||
@@ -5337,7 +5451,8 @@ async function startRemoteWorkspaceModule(
|
|||||||
const runtimeRoot = nav2Module
|
const runtimeRoot = nav2Module
|
||||||
? await ensureRemoteNav2RuntimeAssets(target)
|
? await ensureRemoteNav2RuntimeAssets(target)
|
||||||
: await ensureRemoteStudioRuntimeAssets(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')
|
await prepareRemoteRuntimeImages(target, targetFolderName.includes('lms-nav2-camera-test')
|
||||||
? (runtimeImages ?? []).filter((item) => item.image?.trim() === runtimeImage)
|
? (runtimeImages ?? []).filter((item) => item.image?.trim() === runtimeImage)
|
||||||
: yahboomRuntimeArtifacts(targetFolderName, runtimeImage, runtimeImages))
|
: yahboomRuntimeArtifacts(targetFolderName, runtimeImage, runtimeImages))
|
||||||
@@ -5390,7 +5505,7 @@ async function startRemoteWorkspaceModule(
|
|||||||
...expandRemotePathVar('ROOT'),
|
...expandRemotePathVar('ROOT'),
|
||||||
`PARAMS=${quoteShellSingle(remoteRosParams)}`,
|
`PARAMS=${quoteShellSingle(remoteRosParams)}`,
|
||||||
`FASTDDS_CONFIG_PATH=${quoteShellSingle(fastDdsConfigPath ?? '')}`,
|
`FASTDDS_CONFIG_PATH=${quoteShellSingle(fastDdsConfigPath ?? '')}`,
|
||||||
`ROS_LOCALHOST_ONLY=${rosNetworkEnabled(rosNetwork) ? '0' : '1'}`,
|
`ROS_LOCALHOST_ONLY=${rosNetworkEnabled(rosNetwork) || allowCameraLan ? '0' : '1'}`,
|
||||||
...expandRemotePathVar('FASTDDS_CONFIG_PATH'),
|
...expandRemotePathVar('FASTDDS_CONFIG_PATH'),
|
||||||
'read -r -a EXTRA_ARGS <<< "$PARAMS"',
|
'read -r -a EXTRA_ARGS <<< "$PARAMS"',
|
||||||
'export FASTDDS_CONFIG_PATH',
|
'export FASTDDS_CONFIG_PATH',
|
||||||
@@ -6342,7 +6457,15 @@ function registerLocalWorkspaceHandlers(): void {
|
|||||||
closeSplashAndShowMain()
|
closeSplashAndShowMain()
|
||||||
})
|
})
|
||||||
ipcMain.handle('local-workspace:modules:list', (_event, workspaceRoot?: string | null, courseModules?: CourseModuleInput[] | null) => collectAvailableModules(workspaceRoot, courseModules))
|
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:status', () => checkDockerStatus())
|
||||||
ipcMain.handle('local-workspace:docker:guide', () => shell.openExternal('https://docs.docker.com/engine/install/'))
|
ipcMain.handle('local-workspace:docker:guide', () => shell.openExternal('https://docs.docker.com/engine/install/'))
|
||||||
ipcMain.handle('local-workspace:ros-graph', () => collectLocalRosGraph())
|
ipcMain.handle('local-workspace:ros-graph', () => collectLocalRosGraph())
|
||||||
|
|||||||
@@ -218,6 +218,8 @@ contextBridge.exposeInMainWorld('localWorkspace', {
|
|||||||
modules: {
|
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),
|
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'),
|
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),
|
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),
|
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'),
|
selectImportSource: () => ipcRenderer.invoke('local-workspace:modules:select-import-source'),
|
||||||
@@ -307,6 +309,8 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
|
|||||||
modules: {
|
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),
|
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),
|
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),
|
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),
|
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),
|
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('remote-workspace:modules:start', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork),
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 961 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 961 KiB After Width: | Height: | Size: 628 KiB |
@@ -9,7 +9,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #07111f;
|
background: #ffffff;
|
||||||
font-family: NanumSquare, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
font-family: NanumSquare, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: radial-gradient(circle at 50% 35%, #11243e 0%, #07111f 54%, #03070d 100%);
|
background: #ffffff;
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,22 +31,53 @@
|
|||||||
-webkit-user-drag: none;
|
-webkit-user-drag: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fallback-title {
|
.brand-logo {
|
||||||
position: absolute;
|
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%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
z-index: 1;
|
||||||
color: #4d5d76;
|
width: 290px;
|
||||||
text-shadow: 0 1px 12px rgba(255, 255, 255, 0.28);
|
transform: translateX(-50%);
|
||||||
font-size: 28px;
|
text-align: center;
|
||||||
font-weight: 800;
|
}
|
||||||
|
|
||||||
|
.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;
|
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 {
|
.loading {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 26px;
|
right: 26px;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
|
z-index: 2;
|
||||||
color: #64738a;
|
color: #64738a;
|
||||||
text-shadow: 0 1px 10px rgba(255, 255, 255, 0.24);
|
text-shadow: 0 1px 10px rgba(255, 255, 255, 0.24);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -58,6 +89,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
left: 20px;
|
left: 20px;
|
||||||
|
z-index: 2;
|
||||||
color: #7a8495;
|
color: #7a8495;
|
||||||
text-shadow: 0 1px 8px rgba(255, 255, 255, 0.2);
|
text-shadow: 0 1px 8px rgba(255, 255, 255, 0.2);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -68,15 +100,17 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="splash">
|
<div class="splash">
|
||||||
<div class="fallback-title">LMS</div>
|
<img class="splash-image" src="./assets/splash.png" alt="" />
|
||||||
<img class="splash-image" id="splash-image" 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="copyright">© 2026 Autonomy Studio. All rights reserved.</div>
|
||||||
<div class="loading" id="loading-text">학습 환경 준비 중...</div>
|
<div class="loading" id="loading-text">학습 환경 준비 중...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('splash-image').src = './assets/splash.png'
|
|
||||||
|
|
||||||
const el = document.getElementById('loading-text')
|
const el = document.getElementById('loading-text')
|
||||||
const translations = {
|
const translations = {
|
||||||
'학습 환경 준비 중...': 'Preparing your learning environment...',
|
'학습 환경 준비 중...': 'Preparing your learning environment...',
|
||||||
|
|||||||
+55
-10
@@ -1,4 +1,5 @@
|
|||||||
import { t, useLocaleRender } from './i18n'
|
import { t, useLocaleRender } from './i18n'
|
||||||
|
import type { GitModuleImportInput } from '../../shared/gitModuleImport'
|
||||||
import { useStudioTheme } from './features/workspace/studioTheme'
|
import { useStudioTheme } from './features/workspace/studioTheme'
|
||||||
import './features/workspace/studioPalette.css'
|
import './features/workspace/studioPalette.css'
|
||||||
import { type BeforeMount, type OnMount } from '@monaco-editor/react'
|
import { type BeforeMount, type OnMount } from '@monaco-editor/react'
|
||||||
@@ -987,7 +988,9 @@ function LocalPcWorkspaceMockup({
|
|||||||
: sourceSettingsDraft.workspaceRoot
|
: sourceSettingsDraft.workspaceRoot
|
||||||
const currentSelectedTreePath = exampleActive ? selectedExampleTreePath : selectedTreePath
|
const currentSelectedTreePath = exampleActive ? selectedExampleTreePath : selectedTreePath
|
||||||
const surface = dark ? 'bg-[#202020] text-[#e6e6e6]' : 'bg-[#eef1f6] text-[#151827]'
|
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 panel = dark ? 'border-white/10 bg-[#252525]' : 'border-[#cfd6e4] bg-[#e9edf4]'
|
||||||
const textStrong = dark ? 'text-white' : 'text-[#111520]'
|
const textStrong = dark ? 'text-white' : 'text-[#111520]'
|
||||||
const textMid = dark ? 'text-white/70' : 'text-[#4f596b]'
|
const textMid = dark ? 'text-white/70' : 'text-[#4f596b]'
|
||||||
@@ -1918,14 +1921,14 @@ function LocalPcWorkspaceMockup({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[git] status before submit failed', error)
|
console.error('[git] status before submit failed', error)
|
||||||
setGitError(error instanceof Error ? error.message : 'Git 상태를 불러오지 못했습니다.')
|
setGitError(error instanceof Error ? error.message : 'Git 상태를 불러오지 못했습니다.')
|
||||||
onTitlebarNotice('제출에 실패했습니다', 'error')
|
onTitlebarNotice('커밋 또는 푸시에 실패했습니다', 'error')
|
||||||
setGitSubmitting(false)
|
setGitSubmitting(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const changedModules = installedGitModules.filter((module) => statusMap[module.name]?.files.length)
|
const changedModules = installedGitModules.filter((module) => statusMap[module.name]?.files.length)
|
||||||
if (!changedModules.length) {
|
if (!changedModules.length) {
|
||||||
onTitlebarNotice('제출할 변경사항이 없습니다', 'warning')
|
onTitlebarNotice('커밋할 변경사항이 없습니다', 'warning')
|
||||||
setGitSubmitting(false)
|
setGitSubmitting(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1961,7 +1964,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
getRemoteWorkspaceTarget(),
|
getRemoteWorkspaceTarget(),
|
||||||
module.name,
|
module.name,
|
||||||
module.folderName ?? undefined,
|
module.folderName ?? undefined,
|
||||||
`${weekNumber}주차 ${module.name} 제출`,
|
`${weekNumber}주차 ${module.name} 커밋`,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
submissionId,
|
submissionId,
|
||||||
@@ -1971,7 +1974,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
module.name,
|
module.name,
|
||||||
clonePathPreview,
|
clonePathPreview,
|
||||||
module.folderName,
|
module.folderName,
|
||||||
`${weekNumber}주차 ${module.name} 제출`,
|
`${weekNumber}주차 ${module.name} 커밋`,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
submissionId,
|
submissionId,
|
||||||
@@ -1981,13 +1984,13 @@ function LocalPcWorkspaceMockup({
|
|||||||
if (result.commit) submittedCommitCount += 1
|
if (result.commit) submittedCommitCount += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
onTitlebarNotice(submittedCommitCount > 0 ? '변경사항을 제출했습니다' : '변경사항을 반영했습니다', 'info')
|
onTitlebarNotice(submittedCommitCount > 0 ? '변경사항을 커밋하고 푸시했습니다' : '변경사항을 반영했습니다', 'info')
|
||||||
await refreshGitStatuses(null)
|
await refreshGitStatuses(null)
|
||||||
await refreshGitSubmissions()
|
await refreshGitSubmissions()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[git] submission failed', error)
|
console.error('[git] submission failed', error)
|
||||||
setGitError(error instanceof Error ? error.message : '제출에 실패했습니다.')
|
setGitError(error instanceof Error ? error.message : '커밋 또는 푸시에 실패했습니다')
|
||||||
onTitlebarNotice('제출에 실패했습니다', 'error')
|
onTitlebarNotice('커밋 또는 푸시에 실패했습니다', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setGitSubmitting(false)
|
setGitSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -2433,6 +2436,21 @@ function LocalPcWorkspaceMockup({
|
|||||||
const updateSourceSetting = <Key extends keyof StudioSourceSettings>(key: Key, value: StudioSourceSettings[Key]): void => {
|
const updateSourceSetting = <Key extends keyof StudioSourceSettings>(key: Key, value: StudioSourceSettings[Key]): void => {
|
||||||
setSourceSettingsDraft((settings) => ({ ...settings, [key]: value }))
|
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 applySourceSettings = async (normalizedSettings: StudioSourceSettings, stopRunningModules: boolean): Promise<void> => {
|
||||||
const workspaceRootChanged = normalizedSettings.workspaceRoot !== sourceSettings.workspaceRoot
|
const workspaceRootChanged = normalizedSettings.workspaceRoot !== sourceSettings.workspaceRoot
|
||||||
const runningModules = availableModules.filter((module) => module.state === 'running')
|
const runningModules = availableModules.filter((module) => module.state === 'running')
|
||||||
@@ -3424,6 +3442,27 @@ function LocalPcWorkspaceMockup({
|
|||||||
addCustomModuleToList(customModule)
|
addCustomModuleToList(customModule)
|
||||||
onTitlebarNotice('커스텀 모듈이 저장되었습니다.', 'info')
|
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> => {
|
const importCustomModule = async ({ sourcePath, ...input }: CustomModuleImportInput): Promise<void> => {
|
||||||
if (remoteWorkspace) throw new Error('모듈 불러오기는 로컬 워크스페이스에서만 사용할 수 있습니다.')
|
if (remoteWorkspace) throw new Error('모듈 불러오기는 로컬 워크스페이스에서만 사용할 수 있습니다.')
|
||||||
const customModule = await registerCustomModule(input)
|
const customModule = await registerCustomModule(input)
|
||||||
@@ -3794,6 +3833,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
saveDisabled={!sourceSettingsDirty}
|
saveDisabled={!sourceSettingsDirty}
|
||||||
remotePlatforms={platformOptions.filter((platform) => platform.platformKey !== 'futuredrive-local-pc')}
|
remotePlatforms={platformOptions.filter((platform) => platform.platformKey !== 'futuredrive-local-pc')}
|
||||||
onUpdateSourceSetting={updateSourceSetting}
|
onUpdateSourceSetting={updateSourceSetting}
|
||||||
|
onResetSourceSettings={resetSourceSettingsDraft}
|
||||||
onSaveSourceSettings={saveSourceSettings}
|
onSaveSourceSettings={saveSourceSettings}
|
||||||
/>
|
/>
|
||||||
) : gitActive ? (
|
) : gitActive ? (
|
||||||
@@ -4002,7 +4042,12 @@ function LocalPcWorkspaceMockup({
|
|||||||
selectedMap={selectedWorldMap}
|
selectedMap={selectedWorldMap}
|
||||||
showSelectedMapPreview={Boolean(curriculumWeek.maps?.some((map) => map.platformKey === 'NAV2'))}
|
showSelectedMapPreview={Boolean(curriculumWeek.maps?.some((map) => map.platformKey === 'NAV2'))}
|
||||||
allowRepositoryDeletion={!remoteWorkspace && !publicGuideCourse}
|
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}
|
onAddModuleTab={addModuleTabOnly}
|
||||||
onOpenSource={openModuleSource}
|
onOpenSource={openModuleSource}
|
||||||
onInstallModule={(module) => void installModule(module)}
|
onInstallModule={(module) => void installModule(module)}
|
||||||
@@ -4173,7 +4218,7 @@ export default function App(): React.JSX.Element {
|
|||||||
const [workspaceVisualizationOpen, setWorkspaceVisualizationOpen] = useState(false)
|
const [workspaceVisualizationOpen, setWorkspaceVisualizationOpen] = useState(false)
|
||||||
const [workspaceVisualizationMinimized, setWorkspaceVisualizationMinimized] = useState(false)
|
const [workspaceVisualizationMinimized, setWorkspaceVisualizationMinimized] = useState(false)
|
||||||
const [workspaceVisualizationLarge, setWorkspaceVisualizationLarge] = 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 [workspaceVisualizationSize, setWorkspaceVisualizationSize] = useState({ width: 600, height: 600 })
|
||||||
const workspaceVisualizationHostRef = useRef<WorkspaceVisualizationHost | null>(null)
|
const workspaceVisualizationHostRef = useRef<WorkspaceVisualizationHost | null>(null)
|
||||||
const [workspaceVisualizationHost, setWorkspaceVisualizationHost] = useState<WorkspaceVisualizationHost | null>(null)
|
const [workspaceVisualizationHost, setWorkspaceVisualizationHost] = useState<WorkspaceVisualizationHost | null>(null)
|
||||||
|
|||||||
@@ -526,7 +526,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header
|
<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}
|
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||||
onContextMenuCapture={suppressTitlebarContextMenu}
|
onContextMenuCapture={suppressTitlebarContextMenu}
|
||||||
onContextMenu={suppressTitlebarContextMenu}
|
onContextMenu={suppressTitlebarContextMenu}
|
||||||
|
|||||||
Vendored
+4
@@ -111,6 +111,8 @@ interface Window {
|
|||||||
}>
|
}>
|
||||||
}>
|
}>
|
||||||
listDockerImages: () => Promise<{ images: string[]; message?: string }>
|
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<{
|
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => Promise<{
|
||||||
ok: boolean
|
ok: boolean
|
||||||
module: {
|
module: {
|
||||||
@@ -333,6 +335,8 @@ interface Window {
|
|||||||
runtime: string
|
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 }>
|
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 }>
|
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 }>
|
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; message: string }>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-ai-guide-theme='light'] [class~='bg-[#1d2230]'] {
|
[data-ai-guide-theme='light'] [class~='bg-[#1d2230]'] {
|
||||||
background: #fff;
|
background: #faf8ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-ai-guide-theme='light'] [class~='bg-[#5b4aa8]'] {
|
[data-ai-guide-theme='light'] [class~='bg-[#5b4aa8]'] {
|
||||||
@@ -111,6 +111,7 @@
|
|||||||
|
|
||||||
[data-ai-guide-theme='light'] [class~='bg-[#1d2230]'] {
|
[data-ai-guide-theme='light'] [class~='bg-[#1d2230]'] {
|
||||||
border-color: #c2cfe6;
|
border-color: #c2cfe6;
|
||||||
|
background: #faf8ff;
|
||||||
padding: 22px 24px;
|
padding: 22px 24px;
|
||||||
box-shadow: 0 5px 18px rgb(49 81 170 / 5%);
|
box-shadow: 0 5px 18px rgb(49 81 170 / 5%);
|
||||||
}
|
}
|
||||||
@@ -129,3 +130,21 @@
|
|||||||
border-color: #b8c8e8;
|
border-color: #b8c8e8;
|
||||||
box-shadow: 0 4px 16px rgb(49 81 170 / 6%);
|
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">
|
<div className="mt-4">
|
||||||
<StudioSettingsSwitch
|
<StudioSettingsSwitch
|
||||||
label={t("GPU 가속")}
|
label={t("GPU 가속")}
|
||||||
description={t("ON이면 Docker 실행 시 NVIDIA GPU를 사용합니다.")}
|
description={t("모듈 Docker 실행 시 NVIDIA GPU를 사용합니다.")}
|
||||||
checked={draft.gpuEnabled}
|
checked={draft.gpuEnabled}
|
||||||
onChange={(checked) => onDraftChange((settings) => ({ ...settings, gpuEnabled: checked }))}
|
onChange={(checked) => onDraftChange((settings) => ({ ...settings, gpuEnabled: checked }))}
|
||||||
dark={false}
|
dark={false}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type ModuleListHeaderProps = {
|
|||||||
export default function ModuleListHeader({ dark }: ModuleListHeaderProps): React.JSX.Element {
|
export default function ModuleListHeader({ dark }: ModuleListHeaderProps): React.JSX.Element {
|
||||||
useLocaleRender()
|
useLocaleRender()
|
||||||
return (
|
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 className="text-center">{t("분류")}</span>
|
<span className="text-center">{t("분류")}</span>
|
||||||
<span>{t("모듈명")}</span>
|
<span>{t("모듈명")}</span>
|
||||||
|
|||||||
@@ -62,18 +62,18 @@ function actionButtonClass(dark: boolean, tone: ModuleActionTone, busy: boolean)
|
|||||||
: tone === 'start'
|
: tone === 'start'
|
||||||
? dark
|
? dark
|
||||||
? 'border-[#2fbda0]/35 bg-[#2fbda0]/10 text-[#80ddc7] hover:bg-[#2fbda0]/18'
|
? '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'
|
: tone === 'warning'
|
||||||
? dark
|
? dark
|
||||||
? 'border-[#f59e0b]/25 bg-[#f59e0b]/8 text-[#e8b76b] hover:bg-[#f59e0b]/14'
|
? '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'
|
: tone === 'delete'
|
||||||
? dark
|
? dark
|
||||||
? 'border-[#ef4444]/20 bg-white/[0.03] text-[#e87979] hover:border-[#ef4444]/40 hover:bg-[#ef4444]/10 hover:text-[#ffb4b4]'
|
? '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
|
: dark
|
||||||
? 'border-white/15 bg-white/[0.04] text-white/60 hover:bg-white/10 hover:text-white'
|
? '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(' ')
|
].join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,8 +192,8 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
|||||||
useEffect(() => { if (!busy) setProgress('') }, [busy])
|
useEffect(() => { if (!busy) setProgress('') }, [busy])
|
||||||
const stateStyle: Record<ModuleListRowItem['state'], string> = {
|
const stateStyle: Record<ModuleListRowItem['state'], string> = {
|
||||||
running: dark ? 'bg-[#12372d] text-[#72e2b5]' : 'bg-[#dff8ed] text-[#177553]',
|
running: dark ? 'bg-[#12372d] text-[#72e2b5]' : 'bg-[#dff8ed] text-[#177553]',
|
||||||
stopped: dark ? 'bg-white/8 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-[#edf0f5] text-[#687284]'
|
uninstalled: dark ? 'bg-[#2d2d2d] text-white/45' : 'bg-[#e7deef] text-[#60486f]'
|
||||||
}
|
}
|
||||||
const controlActions: Array<{
|
const controlActions: Array<{
|
||||||
label: string
|
label: string
|
||||||
@@ -249,16 +249,17 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
|||||||
className={[
|
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',
|
'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',
|
'cursor-default',
|
||||||
|
!dark ? 'last:border-b last:border-[#c9bfd8] hover:bg-[#ece6f4]' : '',
|
||||||
dragging ? 'opacity-45' : '',
|
dragging ? 'opacity-45' : '',
|
||||||
dropTarget ? (dark ? 'bg-[#2563eb]/14 outline outline-1 outline-[#3b82f6]/50' : 'bg-[#edf6ff] outline outline-1 outline-[#2563eb]/35') : '',
|
dropTarget ? (dark ? 'bg-[#2563eb]/14 outline outline-1 outline-[#3b82f6]/50' : 'bg-[#edf6ff] outline outline-1 outline-[#2563eb]/35') : '',
|
||||||
selectable
|
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]'
|
: dark ? 'text-white/42' : 'text-[#475569]'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<span className="text-center">
|
<span className="text-center">
|
||||||
<span className={['inline-flex h-6 items-center rounded px-2 text-[11px] font-bold', stateStyle[module.state]].join(' ')}>
|
<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>
|
</span>
|
||||||
<span className={['text-center', dark ? 'text-white/52' : 'font-medium text-[#334155]'].join(' ')}>{module.category}</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(' ')}>
|
<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)}%
|
{module.cpu.toFixed(module.cpu >= 10 ? 0 : 1)}%
|
||||||
</span>
|
</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 className="block h-full rounded-full bg-[#2f8dff]" style={{ width: `${Math.min(100, module.cpu)}%` }} />
|
||||||
</span>
|
</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(' ')}>
|
<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)}%
|
{module.memory.toFixed(module.memory >= 10 ? 0 : 1)}%
|
||||||
</span>
|
</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 className="block h-full rounded-full bg-[#2fbda0]" style={{ width: `${Math.min(100, module.memory)}%` }} />
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -46,9 +46,11 @@ export default function ModuleListToolbar({
|
|||||||
const stoppingAll = installingModuleName === '__stop_all__'
|
const stoppingAll = installingModuleName === '__stop_all__'
|
||||||
|
|
||||||
const bulkActions = [
|
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: '전체 설치',
|
label: '전체 설치',
|
||||||
|
lightClass: 'border-[#a99bc9] bg-[#e8e2f3] text-[#59417f] enabled:hover:border-[#8b77b4] enabled:hover:bg-[#dcd2ee]',
|
||||||
Icon: Download,
|
Icon: Download,
|
||||||
busy: installingAll,
|
busy: installingAll,
|
||||||
disabled: loading || installingModuleName !== null || uninstalledCount === 0,
|
disabled: loading || installingModuleName !== null || uninstalledCount === 0,
|
||||||
@@ -56,6 +58,7 @@ export default function ModuleListToolbar({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '전체 실행',
|
label: '전체 실행',
|
||||||
|
lightClass: 'border-[#78ab94] bg-[#e0f1e8] text-[#216545] enabled:hover:border-[#439b7b] enabled:hover:bg-[#cde8da]',
|
||||||
Icon: Play,
|
Icon: Play,
|
||||||
busy: startingAll,
|
busy: startingAll,
|
||||||
disabled: loading || installingModuleName !== null || stoppedCount === 0,
|
disabled: loading || installingModuleName !== null || stoppedCount === 0,
|
||||||
@@ -63,6 +66,7 @@ export default function ModuleListToolbar({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '전체 중지',
|
label: '전체 중지',
|
||||||
|
lightClass: 'border-[#b8872e] bg-[#fff0d0] text-[#855b13] enabled:hover:border-[#9a6814] enabled:hover:bg-[#ffe5ae]',
|
||||||
Icon: Square,
|
Icon: Square,
|
||||||
busy: stoppingAll,
|
busy: stoppingAll,
|
||||||
disabled: loading || installingModuleName !== null || runningCount === 0,
|
disabled: loading || installingModuleName !== null || runningCount === 0,
|
||||||
@@ -86,7 +90,7 @@ export default function ModuleListToolbar({
|
|||||||
active
|
active
|
||||||
? dark
|
? dark
|
||||||
? 'border-white/10 bg-[#2b2b2b] text-white'
|
? 'border-white/10 bg-[#2b2b2b] text-white'
|
||||||
: 'border-[#c4b5df] bg-[#e4d8f5] text-[#38205b]'
|
: 'border-[#c4b5df] bg-[#e2e8f0] text-[#38205b]'
|
||||||
: dark
|
: dark
|
||||||
? 'border-white/8 bg-[#202020] text-white/45 hover:text-white/70'
|
? 'border-white/8 bg-[#202020] text-white/45 hover:text-white/70'
|
||||||
: 'border-[#d4c9e4] bg-[#eee8f6] text-[#475569] hover:bg-[#e8def3] hover:text-[#38205b]'
|
: 'border-[#d4c9e4] bg-[#eee8f6] text-[#475569] hover:bg-[#e8def3] hover:text-[#38205b]'
|
||||||
@@ -98,7 +102,7 @@ export default function ModuleListToolbar({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 pb-2">
|
<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
|
<button
|
||||||
key={label}
|
key={label}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -109,10 +113,10 @@ export default function ModuleListToolbar({
|
|||||||
}}
|
}}
|
||||||
className={[
|
className={[
|
||||||
'flex h-7 whitespace-nowrap items-center gap-1.5 rounded-md border px-3 text-[12px] font-bold transition',
|
'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
|
dark
|
||||||
? 'border-white/15 bg-white/[0.04] text-white/70 hover:border-white/25 hover:bg-white/10 hover:text-white'
|
? '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(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{busy ? <RefreshCw size={14} className="animate-spin" /> : <Icon size={14} />}
|
{busy ? <RefreshCw size={14} className="animate-spin" /> : <Icon size={14} />}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { t, useLocaleRender } from '../../i18n'
|
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 { BookOpen, ChevronLeft, ChevronRight, FolderOpen, PackagePlus, X } from 'lucide-react'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
@@ -28,6 +30,9 @@ type ModuleListViewProps = {
|
|||||||
showSelectedMapPreview?: boolean
|
showSelectedMapPreview?: boolean
|
||||||
allowRepositoryDeletion?: boolean
|
allowRepositoryDeletion?: boolean
|
||||||
allowCustomModuleCreation?: boolean
|
allowCustomModuleCreation?: boolean
|
||||||
|
allowCustomModuleImport?: boolean
|
||||||
|
remoteGitImport?: { workspaceRoot: string; onImport: (input: GitModuleImportInput) => Promise<void> }
|
||||||
|
loadDockerImages: () => Promise<{ images: string[]; message?: string }>
|
||||||
onAddModuleTab: (module: ModuleRow) => void
|
onAddModuleTab: (module: ModuleRow) => void
|
||||||
onOpenSource: (module: ModuleRow) => void
|
onOpenSource: (module: ModuleRow) => void
|
||||||
onInstallModule: (module: ModuleRow) => void
|
onInstallModule: (module: ModuleRow) => void
|
||||||
@@ -64,6 +69,9 @@ export default function ModuleListView({
|
|||||||
showSelectedMapPreview = true,
|
showSelectedMapPreview = true,
|
||||||
allowRepositoryDeletion = true,
|
allowRepositoryDeletion = true,
|
||||||
allowCustomModuleCreation = true,
|
allowCustomModuleCreation = true,
|
||||||
|
allowCustomModuleImport = allowCustomModuleCreation,
|
||||||
|
remoteGitImport,
|
||||||
|
loadDockerImages,
|
||||||
onAddModuleTab,
|
onAddModuleTab,
|
||||||
onOpenSource,
|
onOpenSource,
|
||||||
onInstallModule,
|
onInstallModule,
|
||||||
@@ -409,14 +417,14 @@ export default function ModuleListView({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("모듈 불러오기")}
|
aria-label={t("모듈 불러오기")}
|
||||||
disabled={!allowCustomModuleCreation}
|
disabled={!allowCustomModuleImport}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setModuleImportDialogOpen(true)
|
setModuleImportDialogOpen(true)
|
||||||
setFloatingActionMenuOpen(false)
|
setFloatingActionMenuOpen(false)
|
||||||
}}
|
}}
|
||||||
className={[
|
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',
|
'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-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',
|
: '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'
|
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} />
|
<FolderOpen size={18} />
|
||||||
<span className={[
|
<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)]',
|
'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>
|
].join(' ')}>{t("모듈 불러오기")}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -457,6 +465,7 @@ export default function ModuleListView({
|
|||||||
{allowCustomModuleCreation && moduleAddDialogOpen && (
|
{allowCustomModuleCreation && moduleAddDialogOpen && (
|
||||||
<ModuleAddDialog
|
<ModuleAddDialog
|
||||||
dark={dark}
|
dark={dark}
|
||||||
|
loadDockerImages={loadDockerImages}
|
||||||
onClose={() => setModuleAddDialogOpen(false)}
|
onClose={() => setModuleAddDialogOpen(false)}
|
||||||
onCreate={async (input) => {
|
onCreate={async (input) => {
|
||||||
await onCreateCustomModule(input)
|
await onCreateCustomModule(input)
|
||||||
@@ -464,8 +473,14 @@ export default function ModuleListView({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{allowCustomModuleCreation && moduleImportDialogOpen && (
|
{allowCustomModuleImport && moduleImportDialogOpen && (
|
||||||
<ModuleImportDialog
|
remoteGitImport ? <RemoteModuleImportDialog
|
||||||
|
dark={dark}
|
||||||
|
workspaceRoot={remoteGitImport.workspaceRoot}
|
||||||
|
loadDockerImages={loadDockerImages}
|
||||||
|
onImport={remoteGitImport.onImport}
|
||||||
|
onClose={() => setModuleImportDialogOpen(false)}
|
||||||
|
/> : <ModuleImportDialog
|
||||||
dark={dark}
|
dark={dark}
|
||||||
onClose={() => setModuleImportDialogOpen(false)}
|
onClose={() => setModuleImportDialogOpen(false)}
|
||||||
onImport={async (input) => {
|
onImport={async (input) => {
|
||||||
@@ -667,10 +682,12 @@ export type CustomModuleImportInput = CustomModuleInput & {
|
|||||||
|
|
||||||
function ModuleAddDialog({
|
function ModuleAddDialog({
|
||||||
dark,
|
dark,
|
||||||
|
loadDockerImages,
|
||||||
onClose,
|
onClose,
|
||||||
onCreate
|
onCreate
|
||||||
}: {
|
}: {
|
||||||
dark: boolean
|
dark: boolean
|
||||||
|
loadDockerImages: () => Promise<{ images: string[]; message?: string }>
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onCreate: (input: CustomModuleInput) => Promise<void>
|
onCreate: (input: CustomModuleInput) => Promise<void>
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
@@ -695,7 +712,7 @@ function ModuleAddDialog({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void window.localWorkspace.modules.listDockerImages()
|
void loadDockerImages()
|
||||||
.then(({ images, message }) => {
|
.then(({ images, message }) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setDockerImages(images)
|
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">
|
<label className="block">
|
||||||
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{label}</span>
|
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{label}</span>
|
||||||
{description && (
|
{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
|
<input
|
||||||
value={value}
|
value={value}
|
||||||
@@ -62,7 +62,7 @@ export function StudioSettingsSwitch({
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className={['text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{label}</p>
|
<p className={['text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{label}</p>
|
||||||
{description && (
|
{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>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function getHeaderCopy(
|
|||||||
return {
|
return {
|
||||||
title: '소스 제어',
|
title: '소스 제어',
|
||||||
subtitle: gitPanelTab === 'history'
|
subtitle: gitPanelTab === 'history'
|
||||||
? t('제출 내역 {{count}}개', { count: gitSubmissionCount })
|
? t('커밋 내역 {{count}}개', { count: gitSubmissionCount })
|
||||||
: t('모듈별 변경 파일 조회 · 변경 {{count}}개', { count: gitChangedFileCount })
|
: t('모듈별 변경 파일 조회 · 변경 {{count}}개', { count: gitChangedFileCount })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@ export default function WorkspaceActivityHeader({
|
|||||||
<div className="flex h-full items-center px-3">
|
<div className="flex h-full items-center px-3">
|
||||||
<div>
|
<div>
|
||||||
<strong className={['block text-[13px] font-bold', textStrong].join(' ')}>{t(copy.title)}</strong>
|
<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)}
|
{t(copy.subtitle)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function WorkspaceCameraPanel({ camera, dark, onClose, containerR
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [large, setLarge] = useState(false)
|
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 panelRef = useRef<HTMLDivElement>(null)
|
||||||
const [container, setContainer] = useState<HTMLDivElement | null>(null)
|
const [container, setContainer] = useState<HTMLDivElement | null>(null)
|
||||||
const [bounds, setBounds] = useState({ width: 0, height: 0 })
|
const [bounds, setBounds] = useState({ width: 0, height: 0 })
|
||||||
@@ -81,7 +81,7 @@ export default function WorkspaceCameraPanel({ camera, dark, onClose, containerR
|
|||||||
onPointerDownCapture={bringToFront} onFocusCapture={bringToFront}
|
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(' ')}
|
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' }}>
|
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' }}
|
style={{ cursor: large ? 'default' : 'move' }}
|
||||||
onPointerDown={(event) => {
|
onPointerDown={(event) => {
|
||||||
if (large || event.button !== 0 || (event.target as HTMLElement).closest('button')) return
|
if (large || event.button !== 0 || (event.target as HTMLElement).closest('button')) return
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ function WorkspaceEditorTabs({
|
|||||||
activeFile === file
|
activeFile === file
|
||||||
? dark
|
? dark
|
||||||
? 'border-white/10 bg-[#1f1f1f] text-white'
|
? 'border-white/10 bg-[#1f1f1f] text-white'
|
||||||
: 'border-[#c4b5df] bg-[#e4d8f5] text-[#38205b]'
|
: 'border-[#c4b5df] bg-[#e2e8f0] text-[#38205b]'
|
||||||
: dark
|
: dark
|
||||||
? 'border-white/10 bg-[#2b2b2b] text-white/45 hover:text-white/70'
|
? 'border-white/10 bg-[#2b2b2b] text-white/45 hover:text-white/70'
|
||||||
: 'border-[#d4c9e4] bg-[#eee8f6] text-[#475569] hover:bg-[#e8def3] hover:text-[#38205b]'
|
: '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(' ')}>
|
<div className={['flex gap-2.5', expanded ? 'items-start' : 'items-center'].join(' ')}>
|
||||||
<span className={[
|
<span className={[
|
||||||
'grid h-6 w-6 shrink-0 place-items-center rounded-md text-[11px] font-bold',
|
'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]'
|
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(' ')}>{index + 1}</span>
|
].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={['min-w-0 flex-1', expanded ? 'pt-0.5' : ''].join(' ')}>
|
||||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
<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>
|
<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={[
|
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',
|
'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
|
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]'
|
: 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(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export default function WorkspaceNav({
|
|||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
data-help="workspace-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 }}
|
style={workspaceActive ? { gridColumn: 3, gridRow: '1 / span 2' } : { gridColumn: 3, gridRow: 1 }}
|
||||||
>
|
>
|
||||||
{WORKSPACE_NAV_ITEMS.map(({ id, Icon, label }) => {
|
{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">
|
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#2563eb] text-white">
|
||||||
<Bot size={15} />
|
<Bot size={15} />
|
||||||
</span>
|
</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
|
<ExpandableMarkdownMessage
|
||||||
collapseOnSignal={index < messages.length - 1}
|
collapseOnSignal={index < messages.length - 1}
|
||||||
collapseSignal={collapseSignal}
|
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">
|
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#2563eb] text-white">
|
||||||
<Bot size={15} />
|
<Bot size={15} />
|
||||||
</span>
|
</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>{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(' ')} />
|
<span className={['h-3 w-3 shrink-0 animate-spin rounded-full border-2 border-t-transparent', dark ? 'border-white/45' : 'border-[#94a3b8]'].join(' ')} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { t, useLocaleRender } from '../../i18n'
|
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 { useEffect, useMemo, useState } from 'react'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import type { ModuleRow } from './types'
|
import type { ModuleRow } from './types'
|
||||||
@@ -80,8 +80,8 @@ function RuntimeLane({
|
|||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
useLocaleRender()
|
useLocaleRender()
|
||||||
return (
|
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(' ')}>
|
<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-[#dbe1eb] bg-white'].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">
|
<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(' ')}>
|
<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} />
|
<Radio size={14} />
|
||||||
@@ -99,7 +99,7 @@ function RuntimeLane({
|
|||||||
key={moduleKey}
|
key={moduleKey}
|
||||||
className={[
|
className={[
|
||||||
'w-full rounded-md border p-3 text-left shadow-sm',
|
'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(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
@@ -111,7 +111,7 @@ function RuntimeLane({
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -133,7 +133,7 @@ function TopicDetailDrawer({
|
|||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
useLocaleRender()
|
useLocaleRender()
|
||||||
return (
|
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
|
<div
|
||||||
role="separator"
|
role="separator"
|
||||||
aria-label={t("토픽 상세 패널 크기 조절")}
|
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(' ')} />
|
<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>
|
</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">
|
<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>
|
<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">
|
<div className="min-w-0">
|
||||||
@@ -153,7 +153,7 @@ function TopicDetailDrawer({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={t("토픽 상세 닫기")}
|
aria-label={t("토픽 상세 닫기")}
|
||||||
onClick={onClose}
|
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} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -161,38 +161,38 @@ function TopicDetailDrawer({
|
|||||||
<div className="thin-y-scrollbar min-h-0 overflow-auto p-4">
|
<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 className="grid min-w-[660px] grid-cols-[minmax(230px,0.8fr)_minmax(250px,1fr)_minmax(250px,1fr)] gap-4">
|
||||||
<div>
|
<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(' ')}>
|
<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-[#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-[#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-[#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-[#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 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-[#8994a7]'}>{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.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-[#526078]'}>{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-[#526078]'}>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><dt className={dark ? 'text-white/35' : 'text-[#526078]'}>Domain</dt><dd className="mt-0.5 font-semibold">{rosDomainId}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div className="mt-2 space-y-1.5">
|
||||||
{connection.publishers.length ? connection.publishers.map((endpoint) => (
|
{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>
|
<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>
|
</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>
|
</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">
|
<div className="mt-2 space-y-1.5">
|
||||||
{connection.subscribers.length ? connection.subscribers.map((endpoint) => (
|
{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>
|
<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>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -215,10 +215,12 @@ export default function WorkspaceRosConnectionsView({
|
|||||||
const [rosGraphLoading, setRosGraphLoading] = useState(false)
|
const [rosGraphLoading, setRosGraphLoading] = useState(false)
|
||||||
|
|
||||||
const connections = useMemo(() => rosGraphConnections(rosGraph), [rosGraph])
|
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 [selectedTopicName, setSelectedTopicName] = useState<string | null>(null)
|
||||||
const [topicDetailOpen, setTopicDetailOpen] = useState(false)
|
const [topicDetailOpen, setTopicDetailOpen] = useState(false)
|
||||||
const [topicDetailHeight, setTopicDetailHeight] = useState(320)
|
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(() => {
|
useEffect(() => {
|
||||||
if (!selectedTopicName) return
|
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(' ')}
|
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 }}
|
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">
|
<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}
|
<Activity size={13} />{t("실행 모듈")}{runningModules}
|
||||||
</span>
|
</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}
|
<Waypoints size={13} />{t("토픽")}{connections.length}
|
||||||
</span>
|
</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(' ')}>
|
<button
|
||||||
<CircleDot size={13} />{t("활성 연결")}{activeConnections}
|
type="button"
|
||||||
</span>
|
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 && (
|
{rosGraph.message && (
|
||||||
<span className={['max-w-[260px] truncate text-[10px] font-medium', dark ? 'text-[#fbbf24]' : 'text-[#a16207]'].join(' ')} title={rosGraph.message}>
|
<span className={['max-w-[260px] truncate text-[10px] font-medium', dark ? 'text-[#fbbf24]' : 'text-[#a16207]'].join(' ')} title={rosGraph.message}>
|
||||||
{rosGraph.message}
|
{rosGraph.message}
|
||||||
</span>
|
</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}
|
ROS Domain {rosDomainId}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -292,7 +305,7 @@ export default function WorkspaceRosConnectionsView({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => void refreshRosGraph(true)}
|
onClick={() => void refreshRosGraph(true)}
|
||||||
disabled={rosGraphLoading}
|
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>
|
<RefreshCw size={13} className={rosGraphLoading ? 'animate-spin' : ''} />{t("새로고침")}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -309,16 +322,16 @@ export default function WorkspaceRosConnectionsView({
|
|||||||
modules={runningRosModules}
|
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 className="mb-3 flex items-center justify-between px-1">
|
||||||
<div>
|
<div>
|
||||||
<strong className={['block text-[12px]', dark ? 'text-white/88' : 'text-[#1f2937]'].join(' ')}>{t("토픽 연결")}</strong>
|
<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>
|
</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>
|
||||||
<div className="thin-y-scrollbar min-h-0 space-y-2 overflow-y-auto overscroll-contain">
|
<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
|
const selected = topicDetailOpen && selectedConnection?.topic.name === connection.topic.name
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -335,26 +348,26 @@ export default function WorkspaceRosConnectionsView({
|
|||||||
className={[
|
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',
|
'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
|
selected
|
||||||
? dark ? 'border-[#60a5fa]/55 bg-[#2563eb]/14' : 'border-[#82b3ff] bg-[#edf5ff]'
|
? dark ? 'border-[#60a5fa]/55 bg-[#2563eb]/14' : 'border-[#5289dc] bg-[#edf5ff]'
|
||||||
: dark ? 'border-white/10 bg-[#282828] hover:border-white/22' : 'border-[#dbe2ec] bg-white hover:border-[#b8c9e5]'
|
: dark ? 'border-white/10 bg-[#282828] hover:border-white/22' : 'border-[#aebace] bg-white hover:border-[#7f99bd]'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<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={['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>
|
<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>
|
</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} />
|
<ArrowRight size={14} />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 text-right">
|
<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={['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>
|
<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>
|
</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>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type Props = {
|
|||||||
saveDisabled: boolean
|
saveDisabled: boolean
|
||||||
remotePlatforms: PlatformOption[]
|
remotePlatforms: PlatformOption[]
|
||||||
onUpdateSourceSetting: <Key extends keyof StudioSourceSettings>(key: Key, value: StudioSourceSettings[Key]) => void
|
onUpdateSourceSetting: <Key extends keyof StudioSourceSettings>(key: Key, value: StudioSourceSettings[Key]) => void
|
||||||
|
onResetSourceSettings: () => void
|
||||||
onSaveSourceSettings: () => void
|
onSaveSourceSettings: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ function WorkspaceSettingsView({
|
|||||||
saveDisabled,
|
saveDisabled,
|
||||||
remotePlatforms,
|
remotePlatforms,
|
||||||
onUpdateSourceSetting,
|
onUpdateSourceSetting,
|
||||||
|
onResetSourceSettings,
|
||||||
onSaveSourceSettings
|
onSaveSourceSettings
|
||||||
}: Props): React.JSX.Element {
|
}: Props): React.JSX.Element {
|
||||||
const { language, setLanguage } = useLanguage()
|
const { language, setLanguage } = useLanguage()
|
||||||
@@ -43,6 +45,8 @@ function WorkspaceSettingsView({
|
|||||||
const canSave = languageDirty || themeDirty || sourceSettingsDirty
|
const canSave = languageDirty || themeDirty || sourceSettingsDirty
|
||||||
const [saveAcknowledged, setSaveAcknowledged] = useState(false)
|
const [saveAcknowledged, setSaveAcknowledged] = useState(false)
|
||||||
const saveAcknowledgedTimerRef = useRef<number | null>(null)
|
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(() => { setLanguageDraft(language) }, [language])
|
||||||
useEffect(() => { setThemeDraft(theme) }, [theme])
|
useEffect(() => { setThemeDraft(theme) }, [theme])
|
||||||
@@ -65,6 +69,13 @@ function WorkspaceSettingsView({
|
|||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleReset = (): void => {
|
||||||
|
if (saveAcknowledged) return
|
||||||
|
setLanguageDraft('ko')
|
||||||
|
setThemeDraft('dark')
|
||||||
|
if (!remoteWorkspace) onResetSourceSettings()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={['min-w-0 overflow-hidden', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}
|
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="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={['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(' ')}>
|
<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">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<LanguageSelect dark={dark} value={languageDraft} onChange={setLanguageDraft} />
|
<LanguageSelect dark={dark} value={languageDraft} onChange={setLanguageDraft} />
|
||||||
<label className="block">
|
<label className="block">
|
||||||
@@ -129,14 +160,14 @@ function WorkspaceSettingsView({
|
|||||||
</div>
|
</div>
|
||||||
<StudioSettingsSwitch
|
<StudioSettingsSwitch
|
||||||
label={t("외부 ROS 통신")}
|
label={t("외부 ROS 통신")}
|
||||||
description={t("같은 네트워크의 기체와 통신을 허용합니다. 사용자별 ROS Domain ID를 구분하고, 변경 후 모듈과 시각화를 재실행하세요. 플랫폼 연동 통신은 별도 설정을 따릅니다.")}
|
description={t("같은 네트워크의 기체와 통신을 허용합니다. 플랫폼 연동 통신은 별도 설정을 따릅니다.")}
|
||||||
checked={sourceSettingsDraft.allowExternalRos === true}
|
checked={sourceSettingsDraft.allowExternalRos === true}
|
||||||
onChange={(checked) => onUpdateSourceSetting('allowExternalRos', checked)}
|
onChange={(checked) => onUpdateSourceSetting('allowExternalRos', checked)}
|
||||||
dark={dark}
|
dark={dark}
|
||||||
/>
|
/>
|
||||||
<StudioSettingsSwitch
|
<StudioSettingsSwitch
|
||||||
label={t("GPU 가속")}
|
label={t("GPU 가속")}
|
||||||
description={t("ON이면 Docker 실행 시 NVIDIA GPU를 사용합니다.")}
|
description={t("모듈 Docker 실행 시 NVIDIA GPU를 사용합니다.")}
|
||||||
checked={sourceSettingsDraft.gpuEnabled}
|
checked={sourceSettingsDraft.gpuEnabled}
|
||||||
onChange={(checked) => onUpdateSourceSetting('gpuEnabled', checked)}
|
onChange={(checked) => onUpdateSourceSetting('gpuEnabled', checked)}
|
||||||
dark={dark}
|
dark={dark}
|
||||||
@@ -157,7 +188,7 @@ function WorkspaceSettingsView({
|
|||||||
<div className="mt-5 grid gap-4 sm:grid-cols-2">
|
<div className="mt-5 grid gap-4 sm:grid-cols-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{t("연동 대상 플랫폼")}</span>
|
<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
|
<select
|
||||||
value={sourceSettingsDraft.rosLinkedPlatformId ?? ''}
|
value={sourceSettingsDraft.rosLinkedPlatformId ?? ''}
|
||||||
onChange={(event) => onUpdateSourceSetting('rosLinkedPlatformId', event.target.value || null)}
|
onChange={(event) => onUpdateSourceSetting('rosLinkedPlatformId', event.target.value || null)}
|
||||||
@@ -172,7 +203,7 @@ function WorkspaceSettingsView({
|
|||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{t("시각화 브리지 실행 위치")}</span>
|
<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
|
<select
|
||||||
value={sourceSettingsDraft.visualizationBridgeLocation ?? 'local'}
|
value={sourceSettingsDraft.visualizationBridgeLocation ?? 'local'}
|
||||||
onChange={(event) => onUpdateSourceSetting('visualizationBridgeLocation', event.target.value === 'platform' ? 'platform' : 'local')}
|
onChange={(event) => onUpdateSourceSetting('visualizationBridgeLocation', event.target.value === 'platform' ? 'platform' : 'local')}
|
||||||
@@ -186,12 +217,12 @@ function WorkspaceSettingsView({
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<h3 className={['text-[16px] font-black', textStrong].join(' ')}>{t("에디터")}</h3>
|
||||||
<div className="mt-5 grid gap-4 sm:grid-cols-2">
|
<div className="mt-5 grid gap-4 sm:grid-cols-2">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{t("글꼴 크기")}</span>
|
<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
|
<select
|
||||||
value={sourceSettingsDraft.editorFontSize}
|
value={sourceSettingsDraft.editorFontSize}
|
||||||
onChange={(event) => onUpdateSourceSetting('editorFontSize', Number(event.target.value))}
|
onChange={(event) => onUpdateSourceSetting('editorFontSize', Number(event.target.value))}
|
||||||
@@ -205,7 +236,7 @@ function WorkspaceSettingsView({
|
|||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{t("탭 크기")}</span>
|
<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
|
<select
|
||||||
value={sourceSettingsDraft.editorTabSize}
|
value={sourceSettingsDraft.editorTabSize}
|
||||||
onChange={(event) => onUpdateSourceSetting('editorTabSize', Number(event.target.value))}
|
onChange={(event) => onUpdateSourceSetting('editorTabSize', Number(event.target.value))}
|
||||||
@@ -245,7 +276,7 @@ function WorkspaceSettingsView({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={!canSave || saveAcknowledged}
|
disabled={!canSave || saveAcknowledged}
|
||||||
onClick={handleSave}
|
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("저장")}
|
{saveAcknowledged ? t("저장됨") : t("저장")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ function WorkspaceSourceControlView({
|
|||||||
<div className="flex items-end">
|
<div className="flex items-end">
|
||||||
{[
|
{[
|
||||||
{ key: 'source' as const, label: t("소스 제어") },
|
{ key: 'source' as const, label: t("소스 제어") },
|
||||||
{ key: 'history' as const, label: teamProject ? t("팀 변경 내역") : t("제출 내역") }
|
{ key: 'history' as const, label: teamProject ? t("팀 변경 내역") : t("커밋 내역") }
|
||||||
].map((tab) => (
|
].map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
@@ -163,7 +163,7 @@ function WorkspaceSourceControlView({
|
|||||||
gitPanelTab === tab.key
|
gitPanelTab === tab.key
|
||||||
? dark
|
? dark
|
||||||
? 'border-white/10 bg-[#2b2b2b] text-white'
|
? 'border-white/10 bg-[#2b2b2b] text-white'
|
||||||
: 'border-[#c4b5df] bg-[#e4d8f5] text-[#38205b]'
|
: 'border-[#c4b5df] bg-[#e2e8f0] text-[#38205b]'
|
||||||
: dark
|
: dark
|
||||||
? 'border-white/8 bg-[#202020] text-white/45 hover:text-white/70'
|
? 'border-white/8 bg-[#202020] text-white/45 hover:text-white/70'
|
||||||
: 'border-[#d4c9e4] bg-[#eee8f6] text-[#475569] hover:bg-[#e8def3] hover:text-[#38205b]'
|
: 'border-[#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',
|
learningSubmitted || gitSubmitting || !hasInstalledGitModules || gitChangedFileCount === 0 ? 'cursor-default opacity-45' : 'cursor-pointer',
|
||||||
dark
|
dark
|
||||||
? 'border-[#60a5fa]/45 bg-[#2563eb]/15 text-[#bfdbfe] hover:bg-[#2563eb]/26 hover:text-white'
|
? '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(' ')}
|
].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 && (
|
{teamProject && (
|
||||||
<>
|
<>
|
||||||
@@ -239,7 +239,7 @@ function WorkspaceSourceControlView({
|
|||||||
{gitPanelTab === 'source' ? (
|
{gitPanelTab === 'source' ? (
|
||||||
<div className={['grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)]', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
|
<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(' ')}>
|
<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={['truncate text-[12px] font-bold', textMid].join(' ')}>{teamProject ? t("내 변경 파일") : t("변경 파일")}</span>
|
||||||
<span className={['text-[11px] font-bold', textDim].join(' ')}>{gitChangedFileCount}{t("개")}</span>
|
<span className={['text-[11px] font-bold', textDim].join(' ')}>{gitChangedFileCount}{t("개")}</span>
|
||||||
</div>
|
</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(' ')}>
|
<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(' ')}>
|
<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={['text-[12px] font-bold', textMid].join(' ')}>{teamProject ? t("팀 변경 내역") : t("제출 목록")}</span>
|
<span className={['text-[12px] font-bold', textMid].join(' ')}>{teamProject ? t("팀 변경 내역") : t("커밋 목록")}</span>
|
||||||
<span className={['text-[11px] font-bold', textDim].join(' ')}>{gitSubmissionGroups.length}{t("개")}</span>
|
<span className={['text-[11px] font-bold', textDim].join(' ')}>{gitSubmissionGroups.length}{t("개")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
|
<div className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
@@ -356,18 +356,18 @@ function WorkspaceSourceControlView({
|
|||||||
)}
|
)}
|
||||||
{historyLoading && !gitSubmissions.length ? (
|
{historyLoading && !gitSubmissions.length ? (
|
||||||
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
|
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
|
||||||
{teamProject ? t("팀 변경 내역 불러오는 중") : t("제출 내역 불러오는 중")}
|
{teamProject ? t("팀 변경 내역 불러오는 중") : t("커밋 내역 불러오는 중")}
|
||||||
</div>
|
</div>
|
||||||
) : !gitSubmissionGroups.length ? (
|
) : !gitSubmissionGroups.length ? (
|
||||||
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
|
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
|
||||||
{teamProject ? t("팀 변경 내역 없음") : t("제출 내역 없음")}
|
{teamProject ? t("팀 변경 내역 없음") : t("커밋 내역 없음")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{gitSubmissionGroups.map((group, groupIndex) => (
|
{gitSubmissionGroups.map((group, groupIndex) => (
|
||||||
<div key={group.id} className={['overflow-hidden rounded-lg border', dark ? 'border-white/10' : 'border-[#e1e6ef]'].join(' ')}>
|
<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(' ')}>
|
<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(' ')}>
|
<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 })}
|
{formatGitCommitDate(group.submittedAt)} · {teamProject ? group.submissions[0]?.commit.author : t("{{v0}}개 모듈", { v0: group.submissions.length })}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ function WorkspaceTerminalPanel({
|
|||||||
aria-pressed={terminalMode === mode}
|
aria-pressed={terminalMode === mode}
|
||||||
onClick={() => onModeChange(mode)}
|
onClick={() => onModeChange(mode)}
|
||||||
className={['h-6 cursor-pointer rounded px-2 text-[12px] font-bold transition-colors', terminalMode === 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(' ')}
|
: `${textMid} ${dark ? 'hover:bg-white/5' : 'hover:bg-black/5'}`].join(' ')}
|
||||||
>
|
>
|
||||||
{mode === 'host' ? t("호스트") : t("컨테이너")}
|
{mode === 'host' ? t("호스트") : t("컨테이너")}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export default function WorkspaceVisualizationPanel({
|
|||||||
className={[
|
className={[
|
||||||
'flex h-9 select-none touch-none items-center justify-between border-b px-3',
|
'flex h-9 select-none touch-none items-center justify-between border-b px-3',
|
||||||
large ? 'cursor-default' : 'cursor-move',
|
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(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<strong className={['flex items-center gap-2 text-[12px] font-bold', textStrong].join(' ')}>
|
<strong className={['flex items-center gap-2 text-[12px] font-bold', textStrong].join(' ')}>
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
{
|
{
|
||||||
|
"로컬 모듈 Git 가져오기": "Import local module from Git",
|
||||||
|
"로컬 사용자 모듈": "Local custom module",
|
||||||
|
"현재 강좌·주차의 로컬 사용자 모듈이 없습니다.": "No local custom modules for this course and week.",
|
||||||
|
"브랜치": "Branch",
|
||||||
|
"가져오기": "Import",
|
||||||
|
"로컬 변경 사항을 커밋하고 푸시한 뒤 다시 시도하세요.": "Commit and push your local changes, then try again.",
|
||||||
|
"브랜치가 변경되었습니다. 저장소 정보를 새로고침하세요.": "The branch has changed. Refresh the repository information.",
|
||||||
|
"원격 워크스페이스에서만 사용할 수 있습니다.": "Only available in a remote workspace.",
|
||||||
|
"가져오기에 실패했고 모듈 등록을 취소하지 못했습니다. 목록에서 해당 모듈을 확인하세요.": "Import failed and registration could not be rolled back. Check the module in the list.",
|
||||||
|
"Git 모듈을 가져왔습니다. 차량에서 빌드한 뒤 실행하세요.": "Git module imported. Build it on the platform before running.",
|
||||||
|
"모듈 자체의 Git 저장소가 필요합니다.": "The module needs its own Git repository.",
|
||||||
|
"Git 저장소를 조회하지 못했습니다. 접근 권한과 네트워크를 확인하세요.": "Could not query the Git repository. Check access and connectivity.",
|
||||||
|
"Git 모듈 복제에 실패했습니다. 원격 저장소 접근, 기존 폴더, 소스 및 빌드 산출물 포함 여부를 확인하세요.": "Git import failed. Check platform repository access, existing folders, source files, and tracked build artifacts.",
|
||||||
"조회 중...": "Loading...",
|
"조회 중...": "Loading...",
|
||||||
"ROS 그래프 조회에 실패했습니다.": "Failed to retrieve the ROS graph.",
|
"ROS 그래프 조회에 실패했습니다.": "Failed to retrieve the ROS graph.",
|
||||||
"조회된 ROS 토픽이 없습니다.": "No ROS topics found.",
|
"조회된 ROS 토픽이 없습니다.": "No ROS topics found.",
|
||||||
@@ -6,6 +19,16 @@
|
|||||||
"완료한 주차": "Completed weeks",
|
"완료한 주차": "Completed weeks",
|
||||||
"학습 시간": "Learning time",
|
"학습 시간": "Learning time",
|
||||||
"커밋 수": "Commits",
|
"커밋 수": "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",
|
"평균 점수": "Average score",
|
||||||
"[{{name}}] 맵이 선택되었습니다.": "Map selected: [{{name}}].",
|
"[{{name}}] 맵이 선택되었습니다.": "Map selected: [{{name}}].",
|
||||||
"언어": "Language",
|
"언어": "Language",
|
||||||
@@ -197,7 +220,7 @@
|
|||||||
"Git 상태 불러오는 중": "Loading Git status",
|
"Git 상태 불러오는 중": "Loading Git status",
|
||||||
"Launch 파일": "Launch file",
|
"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.",
|
"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",
|
"ROS 연결 초기화": "Reset ROS connections",
|
||||||
"ROS2 패키지 소스를 검증한 뒤 현재 워크스페이스로 복사합니다.": "Validate ROS2 package sources and copy them into the current workspace.",
|
"ROS2 패키지 소스를 검증한 뒤 현재 워크스페이스로 복사합니다.": "Validate ROS2 package sources and copy them into the current workspace.",
|
||||||
"{{v0}} 개발 스튜디오 연결만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.": "Disconnect from the {{v0}} studio only. Platform control is retained.",
|
"{{v0}} 개발 스튜디오 연결만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.": "Disconnect from the {{v0}} studio only. Platform control is retained.",
|
||||||
@@ -232,7 +255,7 @@
|
|||||||
"강좌 선택 후 이동 가능": "Select a course first",
|
"강좌 선택 후 이동 가능": "Select a course first",
|
||||||
"강좌를 선택하면 학습 내용에 맞춰 안내합니다.": "Select a course for guidance tailored to its content.",
|
"강좌를 선택하면 학습 내용에 맞춰 안내합니다.": "Select a course for guidance tailored to its content.",
|
||||||
"강좌명 또는 강사명 검색": "Search courses or instructors",
|
"강좌명 또는 강사명 검색": "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.",
|
"같은 번호의 ROS 모듈끼리 토픽과 서비스를 공유합니다.": "ROS modules with the same domain ID share topics and services.",
|
||||||
"개": " items",
|
"개": " items",
|
||||||
"개념, 실습 순서, 오류 원인처럼 학습에 필요한 내용을 질문하세요.": "Ask about concepts, practical steps, or troubleshooting.",
|
"개념, 실습 순서, 오류 원인처럼 학습에 필요한 내용을 질문하세요.": "Ask about concepts, practical steps, or troubleshooting.",
|
||||||
@@ -630,5 +653,7 @@
|
|||||||
"확인": "OK",
|
"확인": "OK",
|
||||||
"확인 중": "Checking",
|
"확인 중": "Checking",
|
||||||
"활성": "Active",
|
"활성": "Active",
|
||||||
"활성 연결": "Active connections"
|
"활성 연결": "Active connections",
|
||||||
|
"활성 연결만 보기": "Show active connections only",
|
||||||
|
"활성 연결이 없습니다.": "No active connections."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export type LocalGitModule = {
|
||||||
|
name: string
|
||||||
|
category: string
|
||||||
|
folderName: string
|
||||||
|
packageName: string
|
||||||
|
launchFile: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GitModuleSource = {
|
||||||
|
repository: string
|
||||||
|
currentBranch: string
|
||||||
|
branches: Array<{ name: string; commit: string }>
|
||||||
|
dirty: boolean
|
||||||
|
pushed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GitModuleImportInput = {
|
||||||
|
sourceFolder: string
|
||||||
|
branch: string
|
||||||
|
commit: string
|
||||||
|
name: string
|
||||||
|
category: string
|
||||||
|
packageName: string
|
||||||
|
launchFile: string
|
||||||
|
dockerImage: string
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
"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": {
|
"compilerOptions": {
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
|||||||
Reference in New Issue
Block a user