fix: simplify code navigation and support Gazebo XML files

Remove redundant code-view cancellation and align button labels. Restore module scrollbar thickness while preserving workspace colors, and support config, SDF and world text files locally and remotely.
This commit is contained in:
2026-10-19 17:29:02 +09:00
parent b7870cffa2
commit 00a83ce4ac
12 changed files with 126 additions and 46 deletions
+25 -15
View File
@@ -71,7 +71,7 @@ test('does not decorate another file, a hidden/diff editor, stale scope or unrea
const hookSource = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
const hook = hookSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent')
const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && ['openCodeReference', 'cancelCodeReference'].includes(node.declarationList.declarations[0].name.getText(hookSource)))
const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && node.declarationList.declarations[0].name.getText(hookSource) === 'openCodeReference')
function consentHarness() {
const session = {}
const messageStateRef = { current: { session, messages: [{ id: 'answer', role: 'assistant', plan: { codeReferences: [reference] } }] } }
@@ -79,19 +79,23 @@ function consentHarness() {
return { ...compile(functions.map(node => `export ${node.getText(hookSource)}`).join('\n'), globals), globals, message: () => messageStateRef.current.messages[0] }
}
test('opening requires explicit approval, cancellation never opens or applies anything', async () => {
test('code navigation opens only when the user selects View in editor', async () => {
const h = consentHarness()
let opened = 0
const open = async () => { opened++ }
assert.equal(opened, 0)
h.cancelCodeReference('answer', 'location')
await h.openCodeReference('answer', 'location', open)
assert.equal(opened, 0)
assert.equal(h.message().codeReferenceStates.location.status, 'cancelled')
const approved = consentHarness()
await approved.openCodeReference('answer', 'location', async ref => { assert.equal(ref.path, reference.path); opened++ })
assert.equal(opened, 1)
assert.equal(approved.message().codeReferenceStates.location.status, 'opened')
assert.equal(h.message().codeReferenceStates.location.status, 'opened')
})
test('legacy cancelled references can be opened with the same read-only navigation action', async () => {
const h = consentHarness()
h.message().codeReferenceStates = { location: { status: 'cancelled' } }
let opened = 0
await h.openCodeReference('answer', 'location', async ref => { assert.equal(ref.path, reference.path); opened++ })
assert.equal(opened, 1)
assert.equal(h.message().codeReferenceStates.location.status, 'opened')
})
test('duplicate clicks, invalid IDs and busy tasks do not open more files', async () => {
@@ -99,8 +103,6 @@ test('duplicate clicks, invalid IDs and busy tasks do not open more files', asyn
let finish, opened = 0
const first = h.openCodeReference('answer', 'location', () => { opened++; return new Promise(resolve => { finish = resolve }) })
assert.equal(h.message().codeReferenceStates.location.status, 'opening')
h.cancelCodeReference('answer', 'location')
assert.equal(h.message().codeReferenceStates.location.status, 'opening')
await h.openCodeReference('answer', 'location', async () => { opened++ })
finish()
await first
@@ -158,15 +160,23 @@ function text(tree) {
return typeof tree === 'string' ? tree : React.isValidElement(tree) ? React.Children.toArray(tree.props.children).map(text).join('') : ''
}
const render = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentCodeReferences.tsx'), 'utf8'), {}, name => name.endsWith('/i18n') ? { t: key => key, useLocaleRender() {} } : require(name)).AgentCodeReferences
for (const dark of [true, false]) test(`consent controls use cancel-left/open-right and preserve states (${dark})`, () => {
for (const dark of [true, false]) test(`code navigation has only a vertically aligned View in editor button (${dark})`, () => {
const clicked = []
const props = { dark, references: [reference], onOpen: id => clicked.push(`open:${id}`), onCancel: id => clicked.push(`cancel:${id}`) }
const props = { dark, references: [reference], onOpen: id => clicked.push(`open:${id}`) }
const buttons = elements(render(props), element => element.type === 'button')
assert.equal(clicked.length, 0)
assert.deepEqual(buttons.map(text), ['취소', '코드에서 보기'])
buttons[1].props.onClick()
assert.deepEqual(buttons.map(text), ['코드에서 보기'])
const label = elements(buttons[0], element => element.type === 'span')[0]
assert.match(buttons[0].props.className, /items-center.*leading-none/)
assert.match(label.props.className, /top-px leading-none/)
buttons[0].props.onClick()
assert.deepEqual(clicked, ['open:location'])
for (const button of elements(render({ ...props, busy: true }), element => element.type === 'button')) assert.equal(button.props.disabled, true)
assert.equal(elements(render({ ...props, states: { location: { status: 'cancelled' } } }), element => element.type === 'button').length, 0)
for (const status of ['cancelled', 'opened', 'error']) {
assert.deepEqual(elements(render({ ...props, states: { location: { status } } }), element => element.type === 'button').map(text), ['코드에서 보기'])
}
const opening = elements(render({ ...props, states: { location: { status: 'opening' } } }), element => element.type === 'button')[0]
assert.equal(opening.props.disabled, true)
assert.equal(text(opening), '파일 여는 중...')
assert.equal(elements(render({ ...props, states: { location: { status: 'error', error: 'missing' } } }), element => element.props.role === 'alert').length, 1)
})
+5 -2
View File
@@ -47,7 +47,7 @@ test('theme synchronizes across windows without writing runtime settings', () =>
assert.equal(api.useStudioTheme(), 'dark')
})
test('module content horizontal scrollbar uses the shared workspace scrollbar style', () => {
test('module content scrollbar uses workspace colors without thinner scrollbar dimensions', () => {
const source = ts.createSourceFile('ModuleListView.tsx', fs.readFileSync(path.join(root, 'ModuleListView.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const scrollClasses = []
function visit(node) {
@@ -59,5 +59,8 @@ test('module content horizontal scrollbar uses the shared workspace scrollbar st
}
visit(source)
assert.ok(scrollClasses.length > 0)
for (const classes of scrollClasses) assert.ok(classes.includes('thin-x-scrollbar'))
for (const classes of scrollClasses) {
assert.ok(classes.includes('module-content-scrollbar'))
assert.ok(!classes.includes('thin-x-scrollbar'))
}
})
+78
View File
@@ -0,0 +1,78 @@
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, '..')
const placeholder = 'Binary or large file preview is not available.'
const xml = '<?xml version="1.0"?>\n<model><name>mkmini_gazebo</name><description>주행 모델</description></model>\n'
function compile(source, context = {}) {
const exports = {}
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, { exports, require, Buffer, ...context })
return exports
}
async function fixture(run) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-xml-editor-'))
try {
for (const name of ['model.config', 'model.sdf', 'simulation.world', 'MODEL.CONFIG']) await fs.writeFile(path.join(dir, name), xml)
await fs.writeFile(path.join(dir, 'large.config'), 'x'.repeat(1024 * 1024 + 1))
await fs.writeFile(path.join(dir, 'model.glb'), Buffer.from([0, 1, 2, 3]))
await run(dir)
} finally { await fs.rm(dir, { recursive: true, force: true }) }
}
test('local module and root collectors read small Gazebo XML files without changing binary and size exclusions', async () => {
const source = ts.createSourceFile('index.ts', await fs.readFile(path.join(root, 'src/main/index.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
const functions = source.statements.filter(node => ts.isFunctionDeclaration(node) && ['collectModuleFiles', 'collectRootFiles'].includes(node.name?.text))
const pattern = source.statements.find(node => ts.isVariableStatement(node) && node.declarationList.declarations[0].name.getText(source) === 'textFilePattern')
assert.equal(functions.length, 2)
assert.ok(pattern)
await fixture(async dir => {
const api = compile([pattern.getText(source), ...functions.map(node => `export ${node.getText(source)}`)].join('\n'), {
readdir: fs.readdir, stat: fs.stat, readFile: fs.readFile,
resolve: path.resolve, basename: path.basename,
skippedCacheDirs: new Set(['.git', 'build', 'install', 'log', 'node_modules', 'docker']),
findModuleRoot: async () => dir,
toRendererModulePath: (name, file, base) => `modules/${name}/${path.relative(base, file)}`,
toRendererRootPath: (name, file, base) => `${name}/${path.relative(base, file)}`
})
for (const result of [await api.collectModuleFiles('NAV2-MKMini'), await api.collectRootFiles(dir, 'examples')]) {
const files = Object.fromEntries(Object.entries(result.files).map(([file, content]) => [path.basename(file), content]))
for (const name of ['model.config', 'model.sdf', 'simulation.world', 'MODEL.CONFIG']) assert.equal(files[name], xml)
for (const name of ['large.config', 'model.glb']) assert.equal(files[name], placeholder)
}
})
})
test('remote listing, content and search include Gazebo XML while preserving size, NUL and path protections', async () => {
const api = compile(await fs.readFile(path.join(root, 'src/main/remoteModuleFiles.ts'), 'utf8'))
await fixture(async dir => {
const request = async input => JSON.parse((await exec('bash', ['-c', api.remoteModuleFilesScript(dir, input)], { timeout: 10000 })).stdout)
const listing = await request({ action: 'list' })
assert.ok(listing.filePaths.includes('model.config'))
for (const file of ['model.config', 'model.sdf', 'simulation.world', 'MODEL.CONFIG']) assert.equal((await request({ action: 'content', path: file })).content, xml)
for (const file of ['large.config', 'model.glb']) assert.equal((await request({ action: 'content', path: file })).content, placeholder)
await fs.writeFile(path.join(dir, 'binary.config'), Buffer.from('text\0binary'))
assert.equal((await request({ action: 'content', path: 'binary.config' })).content, placeholder)
const matches = await request({ action: 'search', query: 'mkmini_gazebo' })
assert.deepEqual(matches.map(match => match.file).sort(), ['MODEL.CONFIG', 'model.config', 'model.sdf', 'simulation.world'].sort())
await assert.rejects(request({ action: 'content', path: '../outside.config' }), /Invalid module path/)
await fs.symlink(path.join(dir, '..'), path.join(dir, 'escape'))
await assert.rejects(request({ action: 'content', path: 'escape/outside.config' }), /Invalid module path/)
})
})
test('Gazebo model.config, SDF and world files use XML highlighting without treating arbitrary config formats as XML', async () => {
const api = compile(await fs.readFile(path.join(root, 'src/renderer/src/features/workspace/workspaceFiles.ts'), 'utf8'))
for (const file of ['model.config', 'models/mkmini/model.config', 'models/MODEL.CONFIG', 'model.sdf', 'simulation.world', 'WORLD.SDF', 'package.xml', 'robot.urdf', 'robot.xacro']) assert.equal(api.getEditorLanguage(file), 'xml', file)
assert.equal(api.getEditorLanguage('app.config'), 'plaintext')
assert.equal(api.getEditorLanguage('control.yaml'), 'yaml')
assert.equal(api.getEditorLanguage('model.glb'), 'plaintext')
})