diff --git a/scripts/test-agent-code-navigation.cjs b/scripts/test-agent-code-navigation.cjs index 7aefa14..67146c8 100644 --- a/scripts/test-agent-code-navigation.cjs +++ b/scripts/test-agent-code-navigation.cjs @@ -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) }) diff --git a/scripts/test-studio-theme.cjs b/scripts/test-studio-theme.cjs index 2d9c8a3..1d3cf15 100644 --- a/scripts/test-studio-theme.cjs +++ b/scripts/test-studio-theme.cjs @@ -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')) + } }) diff --git a/scripts/test-workspace-xml-files.cjs b/scripts/test-workspace-xml-files.cjs new file mode 100644 index 0000000..8e9637d --- /dev/null +++ b/scripts/test-workspace-xml-files.cjs @@ -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 = '\nmkmini_gazebo주행 모델\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') +}) diff --git a/src/main/index.ts b/src/main/index.ts index 9de3cdc..bfd78cf 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -78,7 +78,7 @@ async function readAppDiagnostics(): Promise<{ path: string; content: string }> } } const skippedCacheDirs = new Set(['.git', 'build', 'install', 'log', 'node_modules', 'docker']) -const textFilePattern = /\.(c|cc|cpp|cxx|h|hpp|hh|py|xml|urdf|xacro|yaml|yml|json|txt|md|cmake|launch|sh|bash|toml|ini|cfg|rviz)$/i +const textFilePattern = /\.(c|cc|cpp|cxx|h|hpp|hh|py|xml|urdf|xacro|sdf|world|config|yaml|yml|json|txt|md|cmake|launch|sh|bash|toml|ini|cfg|rviz)$/i const imageFilePattern = /\.(png|jpe?g|webp|gif|svg)$/i const maxImagePreviewBytes = 10 * 1024 * 1024 const explicitModuleFolders: Record = { diff --git a/src/main/remoteModuleFiles.ts b/src/main/remoteModuleFiles.ts index c660eb9..034174c 100644 --- a/src/main/remoteModuleFiles.ts +++ b/src/main/remoteModuleFiles.ts @@ -7,7 +7,7 @@ import base64, json, os, stat, sys args = json.loads(base64.b64decode(sys.argv[1])) root = os.path.realpath(args['root']) skip = {'.git', 'build', 'install', 'log', 'node_modules', 'docker'} -extensions = {'.c', '.cc', '.cpp', '.cxx', '.h', '.hpp', '.hh', '.py', '.xml', '.urdf', '.xacro', '.yaml', '.yml', '.json', '.txt', '.md', '.cmake', '.launch', '.sh', '.bash', '.toml', '.ini', '.cfg', '.rviz'} +extensions = {'.c', '.cc', '.cpp', '.cxx', '.h', '.hpp', '.hh', '.py', '.xml', '.urdf', '.xacro', '.sdf', '.world', '.config', '.yaml', '.yml', '.json', '.txt', '.md', '.cmake', '.launch', '.sh', '.bash', '.toml', '.ini', '.cfg', '.rviz'} placeholder = 'Binary or large file preview is not available.' def content(relative): if os.path.isabs(relative) or '..' in relative.split('/'): diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ac14090..c556068 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -642,7 +642,6 @@ function LocalPcWorkspaceMockup({ cancelPlan: cancelAgentPlan, updateCommandExecution: updateAgentCommandExecution, openCodeReference: openAgentCodeReference, - cancelCodeReference: cancelAgentCodeReference, stop: stopAgent, clear: clearAgentConversation } = useAiAgent({ @@ -4792,7 +4791,6 @@ function LocalPcWorkspaceMockup({ if (cancelAgentPlan(messageId)) closeAgentDiffPreviewsForMessage(messageId) }} onOpenAgentCodeReference={(messageId, referenceId) => void openAgentCodeReference(messageId, referenceId, openWorkspaceAgentCodeReference)} - onCancelAgentCodeReference={cancelAgentCodeReference} onApplyAgentPlan={(messageId, plan) => void applyAgentPlan(messageId, async () => { await applyWorkspaceAgentPlan(plan) closeAgentDiffPreviewsForMessage(messageId) diff --git a/src/renderer/src/components/AgentCodeReferences.tsx b/src/renderer/src/components/AgentCodeReferences.tsx index c9130a5..9cd86b9 100644 --- a/src/renderer/src/components/AgentCodeReferences.tsx +++ b/src/renderer/src/components/AgentCodeReferences.tsx @@ -1,21 +1,19 @@ -import { FileCode2, Loader2, X } from 'lucide-react' +import { FileCode2, Loader2 } from 'lucide-react' import { t, useLocaleRender } from '../i18n' import type { AgentCodeReference, AgentCodeReferenceState } from '../features/workspace/useAiAgent' -export function AgentCodeReferences({ references, states, dark, busy, onOpen, onCancel }: { +export function AgentCodeReferences({ references, states, dark, busy, onOpen }: { references: AgentCodeReference[] states?: Record dark: boolean busy?: boolean onOpen?: (id: string) => void - onCancel?: (id: string) => void }): React.JSX.Element { useLocaleRender() return {references.map(reference => { const state = states?.[reference.id] const opening = state?.status === 'opening' - const cancelled = state?.status === 'cancelled' return @@ -27,10 +25,7 @@ export function AgentCodeReferences({ references, states, dark, busy, onOpen, on {state?.error && {t(state.error)}} - {cancelled ? {t('취소됨')} : <> - {state?.status !== 'opened' && onCancel?.(reference.id)} className={['inline-flex h-8 shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-md border px-3 text-[11px] font-bold disabled:cursor-not-allowed disabled:opacity-40', dark ? 'border-white/15 hover:bg-white/8' : 'border-[#d8dee9] hover:bg-[#e2e8f0]'].join(' ')}>{t('취소')}} - onOpen?.(reference.id)} className="inline-flex h-8 min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-md bg-[#7c3aed] px-3 text-[11px] font-bold text-white hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:opacity-40">{opening ? : }{opening ? t('파일 여는 중...') : t('코드에서 보기')} - >} + onOpen?.(reference.id)} className="inline-flex h-8 min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-md bg-[#7c3aed] px-3 text-[11px] font-bold leading-none text-white hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:opacity-40">{opening ? : }{opening ? t('파일 여는 중...') : t('코드에서 보기')} })} diff --git a/src/renderer/src/features/workspace/ModuleListView.tsx b/src/renderer/src/features/workspace/ModuleListView.tsx index 3ab2704..626c935 100644 --- a/src/renderer/src/features/workspace/ModuleListView.tsx +++ b/src/renderer/src/features/workspace/ModuleListView.tsx @@ -275,7 +275,7 @@ export default function ModuleListView({ )} - + void onRejectAgentCommand: (messageId: string, command: AgentCommand) => void onOpenAgentCodeReference?: (messageId: string, referenceId: string) => void - onCancelAgentCodeReference?: (messageId: string, referenceId: string) => void } function MessageCopyButton({ content, dark }: { content: string; dark: boolean }): React.JSX.Element { @@ -146,7 +145,6 @@ export default function WorkspaceQaPanel({ onApplyAgentPlan, onCancelAgentPlan, onOpenAgentCodeReference, - onCancelAgentCodeReference, onOpenAgentChange, onRunAgentCommand, onRejectAgentCommand @@ -325,7 +323,6 @@ export default function WorkspaceQaPanel({ onApply={onApplyAgentPlan} onCancel={onCancelAgentPlan} onOpenCodeReference={onOpenAgentCodeReference} - onCancelCodeReference={onCancelAgentCodeReference} onOpenChange={onOpenAgentChange} onRunCommand={onRunAgentCommand} onRejectCommand={onRejectAgentCommand} @@ -388,8 +385,7 @@ function AgentMessages({ onOpenChange, onRunCommand, onRejectCommand, - onOpenCodeReference, - onCancelCodeReference + onOpenCodeReference }: { dark: boolean enabled: boolean @@ -406,7 +402,6 @@ function AgentMessages({ onRunCommand: (messageId: string, command: AgentCommand) => void onRejectCommand: (messageId: string, command: AgentCommand) => void onOpenCodeReference?: (messageId: string, referenceId: string) => void - onCancelCodeReference?: (messageId: string, referenceId: string) => void }): React.JSX.Element { useLocaleRender() if (statusLoading) { @@ -452,7 +447,7 @@ function AgentMessages({ }> {message.plan && ( <> - {!!message.plan.codeReferences?.length && onOpenCodeReference(message.id, id) : undefined} onCancel={onCancelCodeReference ? id => onCancelCodeReference(message.id, id) : undefined} />} + {!!message.plan.codeReferences?.length && onOpenCodeReference(message.id, id) : undefined} />} {!!message.plan.evidence?.length && ( {t("조회 근거")} · {message.plan.evidence.length} diff --git a/src/renderer/src/features/workspace/useAiAgent.ts b/src/renderer/src/features/workspace/useAiAgent.ts index 4f83766..e08914d 100644 --- a/src/renderer/src/features/workspace/useAiAgent.ts +++ b/src/renderer/src/features/workspace/useAiAgent.ts @@ -459,17 +459,11 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap : message)) } - const cancelCodeReference = (messageId: string, referenceId: string): void => { - updateMessages(current => current.map(message => message.id === messageId && message.plan?.codeReferences?.some(reference => reference.id === referenceId) && message.codeReferenceStates?.[referenceId]?.status !== 'opening' - ? { ...message, codeReferenceStates: { ...message.codeReferenceStates, [referenceId]: { status: 'cancelled' } } } - : message)) - } - const openCodeReference = async (messageId: string, referenceId: string, open: (reference: AgentCodeReference, isCurrent: () => boolean) => Promise): Promise => { if (navigatingRef.current || applyingRef.current || abortControllerRef.current || messageStateRef.current.session !== session) return const message = messageStateRef.current.messages.find(message => message.id === messageId) const reference = message?.plan?.codeReferences?.find(reference => reference.id === referenceId) - if (!reference || message?.codeReferenceStates?.[referenceId]?.status === 'cancelled') return + if (!reference) return const latestMessageId = messageStateRef.current.messages.at(-1)?.id const isCurrent = (): boolean => messageStateRef.current.session === session && messageStateRef.current.messages.some(message => message.id === messageId) const update = (state: AgentCodeReferenceState): void => { @@ -522,7 +516,6 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap cancelPlan, updateCommandExecution, openCodeReference, - cancelCodeReference, stop, clear } diff --git a/src/renderer/src/features/workspace/workspaceFiles.ts b/src/renderer/src/features/workspace/workspaceFiles.ts index d96aea2..73f3d93 100644 --- a/src/renderer/src/features/workspace/workspaceFiles.ts +++ b/src/renderer/src/features/workspace/workspaceFiles.ts @@ -10,7 +10,7 @@ export function getEditorLanguage(file: string): string { if (file.endsWith('.tsx') || file.endsWith('.ts')) return 'typescript' if (/\.(c|cc|cpp|cxx|h|hh|hpp|hxx)$/i.test(file)) return 'cpp' if (file.endsWith('.json')) return 'json' - if (file.endsWith('.xml') || file.endsWith('.urdf') || file.endsWith('.xacro')) return 'xml' + if (/\.(xml|urdf|xacro|sdf|world)$/i.test(file) || /(^|\/)model\.config$/i.test(file)) return 'xml' if (file.endsWith('.html')) return 'html' if (file.endsWith('.css')) return 'css' if (file.endsWith('.py')) return 'python' diff --git a/src/renderer/src/index.css b/src/renderer/src/index.css index f901eed..f47e995 100644 --- a/src/renderer/src/index.css +++ b/src/renderer/src/index.css @@ -294,4 +294,12 @@ background: rgba(148, 163, 184, 0.95); background-clip: content-box; } + + .module-content-scrollbar::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.65); + } + + .module-content-scrollbar::-webkit-scrollbar-thumb:hover { + background: rgba(148, 163, 184, 0.95); + } }
{t(state.error)}