🐛 fix(vscode): 调用树弹框路径太长时不再挤乱标题、溢出卡片:文件名在前、目录淡色省略

This commit is contained in:
cruldra committed 2026-10-13 10:54:16 +11:00
1 parent 4d29b6aab8
commit 6cf053dcd0
3 files changed
+119 -19

No files matched your search

@@ -8,7 +8,7 @@ import { act, useState } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockLevel } from './BlockTree' import { BlockLevel } from './BlockTree'
import { CallTreeDialog } from './CallTreeDialog' import { CallTreeDialog, splitWhere } from './CallTreeDialog'
vi.mock('./useOccurrences', async () => { vi.mock('./useOccurrences', async () => {
const { createContext } = await import('react') const { createContext } = await import('react')
@@ -155,7 +155,7 @@ describe('调用树弹框', () => {
expect(dialog).not.toBeNull() expect(dialog).not.toBeNull()
expect(dialog!.textContent).toContain('调用树') expect(dialog!.textContent).toContain('调用树')
expect(dialog!.textContent).toContain('mask_phone') expect(dialog!.textContent).toContain('mask_phone')
expect(dialog!.textContent).toContain('utils/phone.py:28') expect(dialog!.querySelector('.cr-call-title [data-tooltip="utils/phone.py:28"]')).not.toBeNull()
expect(dialog!.textContent).not.toContain('读取用户资料') expect(dialog!.textContent).not.toContain('读取用户资料')
expect(dialog!.querySelector('[role="tablist"]')).toBeNull() expect(dialog!.querySelector('[role="tablist"]')).toBeNull()
@@ -274,4 +274,39 @@ describe('调用树弹框', () => {
await act(async () => rows[1].click()) await act(async () => rows[1].click())
expect(jumps).toEqual([{ uri: 'file:///web/src/lib/agents.ts', line: 10, character: 20 }]) expect(jumps).toEqual([{ uri: 'file:///web/src/lib/agents.ts', line: 10, character: 20 }])
}) })
it('标题和卡片的位置拆成「文件名:行号」和淡色的所在目录,悬停看完整路径;卡片第一行放名字和 detail,第二行只放位置;每层缩进 16px', async () => {
const where = 'backend/src/luffy_agent/agent/runtime/runner.py:1325'
const run = node('run', {
detail: 'class SessionRunner (runner.py)',
where,
children: [node('mask_phone', { current: true, where: 'phone.py:28' })],
})
await render(<CallTreeDialog kind="function" name="run" where={where} status="ready" trees={[{ id: 'run', name: 'run', hits: 1, root: run }]} onClose={() => {}} onJump={() => {}} />)
const title = document.body.querySelector('.cr-call-title')!
const place = title.querySelector<HTMLElement>('[data-tooltip]')!
expect(place.dataset.tooltip).toBe(where)
expect(place.querySelector('.cr-call-file')!.textContent).toBe('runner.py:1325')
expect(place.querySelector('.cr-call-dir')!.textContent).toBe('backend/src/luffy_agent/agent/runtime')
const [outer, inner] = [...document.body.querySelectorAll('button.cr-call-card')]
const lines = (card: Element) => [...card.children].map(line => [...line.querySelectorAll('.cr-call-name, .cr-call-detail, .cr-call-pill, .cr-call-file, .cr-call-dir')].map(part => part.textContent))
expect(lines(outer)).toEqual([['run', 'class SessionRunner (runner.py)'], ['runner.py:1325', 'backend/src/luffy_agent/agent/runtime']])
expect(outer.children[1].getAttribute('data-tooltip')).toBe(where)
expect(lines(inner)).toEqual([['mask_phone', '当前'], ['phone.py:28']])
const rows = [...document.body.querySelectorAll<HTMLElement>('.cr-call-row')]
expect(rows.map(row => row.style.paddingLeft)).toEqual(['0px', '16px'])
})
})
describe('位置拆成文件名和所在目录', () => {
it('按最后一个 / 拆:「文件名:行号」和它所在的目录', () => {
expect(splitWhere('backend/src/luffy_agent/agent/runtime/runner.py:1325')).toEqual({ file: 'runner.py:1325', dir: 'backend/src/luffy_agent/agent/runtime' })
expect(splitWhere('web/src/lib/agents.ts:11')).toEqual({ file: 'agents.ts:11', dir: 'web/src/lib' })
})
it('没有目录时只有文件名', () => {
expect(splitWhere('runner.py:12')).toEqual({ file: 'runner.py:12', dir: '' })
})
}) })
@@ -61,7 +61,7 @@ export function CallTreeDialog({ kind, badge, name, where, status, trees, contra
<div className="cr-call-title"> <div className="cr-call-title">
<span className="cr-call-heading">调用树</span> <span className="cr-call-heading">调用树</span>
{name && <span className="cr-call-symbol">{name}</span>} {name && <span className="cr-call-symbol">{name}</span>}
{where && <span className="cr-call-loc">{where}</span>} {where && <CallWhere where={where} />}
<button type="button" className="cr-tool cr-call-close" aria-label="关闭" onClick={close}> <button type="button" className="cr-tool cr-call-close" aria-label="关闭" onClick={close}>
<X size={16} strokeWidth={1.8} /> <X size={16} strokeWidth={1.8} />
</button> </button>
@@ -117,30 +117,30 @@ function ContractNote({ miss }: { miss: ContractMiss }): ReactElement {
} }
const NODE_ICONS: Record<string, typeof Braces> = { class: Box, request: Send } const NODE_ICONS: Record<string, typeof Braces> = { class: Box, request: Send }
/** 每深一层往右缩这么多:六七层深时卡片还够宽 */
const INDENT_PX = 16
function CallNodes({ node, depth, path, onJump }: { node: CallTreeNode, depth: number, path: string, onJump: (jump: CallJump) => void }): ReactElement { function CallNodes({ node, depth, path, onJump }: { node: CallTreeNode, depth: number, path: string, onJump: (jump: CallJump) => void }): ReactElement {
const Icon = NODE_ICONS[node.kind] ?? Braces const Icon = NODE_ICONS[node.kind] ?? Braces
return ( return (
<> <>
<div className="cr-call-row" style={{ paddingLeft: depth * 22 }}> <div className="cr-call-row" style={{ paddingLeft: depth * INDENT_PX }}>
<span className="cr-call-guide" /> <span className="cr-call-guide" />
<button type="button" className="cr-call-card" data-current={node.current ? '' : undefined} onClick={() => onJump(node.jump)}> <button type="button" className="cr-call-card" data-current={node.current ? '' : undefined} onClick={() => onJump(node.jump)}>
<span className="cr-call-line"> <span className="cr-call-line">
<Icon size={14} strokeWidth={1.8} aria-hidden /> <Icon size={14} strokeWidth={1.8} aria-hidden />
<span className="cr-call-name">{node.name}</span> <span className="cr-call-name">{node.name}</span>
{node.detail && <span className="cr-call-detail">{node.detail}</span>}
{node.current && <span className="cr-call-pill">当前</span>} {node.current && <span className="cr-call-pill">当前</span>}
</span> </span>
<span className="cr-call-sub"> <CallWhere where={node.where} />
{node.detail && <span>{node.detail}</span>}
<span className="cr-call-loc">{node.where}</span>
</span>
</button> </button>
</div> </div>
{node.children.map((child, index) => ( {node.children.map((child, index) => (
<CallNodes key={`${path}/${index}`} node={child} depth={depth + 1} path={`${path}/${index}`} onJump={onJump} /> <CallNodes key={`${path}/${index}`} node={child} depth={depth + 1} path={`${path}/${index}`} onJump={onJump} />
))} ))}
{node.otherCalls !== undefined && node.otherCalls > 0 && ( {node.otherCalls !== undefined && node.otherCalls > 0 && (
<div className="cr-call-row" style={{ paddingLeft: (depth + 1) * 22 }}> <div className="cr-call-row" style={{ paddingLeft: (depth + 1) * INDENT_PX }}>
<span className="cr-call-guide" data-folded="" /> <span className="cr-call-guide" data-folded="" />
<span className="cr-call-folded">{`另外 ${node.otherCalls} 处调用`}</span> <span className="cr-call-folded">{`另外 ${node.otherCalls} 处调用`}</span>
</div> </div>
@@ -148,3 +148,20 @@ function CallNodes({ node, depth, path, onJump }: { node: CallTreeNode, depth: n
</> </>
) )
} }
/** 位置照 VS Code 快速打开的样子摆:「文件名:行号」在前,所在目录淡色跟在后面、放不下末尾省略,悬停看完整路径。 */
function CallWhere({ where }: { where: string }): ReactElement {
const { file, dir } = splitWhere(where)
return (
<span className="cr-call-where" data-tooltip={where}>
<span className="cr-call-file">{file}</span>
{dir && <span className="cr-call-dir">{dir}</span>}
</span>
)
}
/** `目录/文件名:行号` 按最后一个 `/` 拆成文件名(带行号)和所在目录;没有目录时 dir 是空的。 */
export function splitWhere(where: string): { file: string, dir: string } {
const slash = where.lastIndexOf('/')
return { file: where.slice(slash + 1), dir: where.slice(0, Math.max(slash, 0)) }
}
+58 -10
View File
@@ -1312,7 +1312,7 @@ del.cr-diff-word {
.cr-call-dialog { .cr-call-dialog {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: min(640px, 100%); width: min(880px, 100%);
max-height: 100%; max-height: 100%;
overflow: hidden; overflow: hidden;
border: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border, transparent)); border: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border, transparent));
@@ -1321,23 +1321,56 @@ del.cr-diff-word {
color: var(--vscode-editorWidget-foreground, var(--vscode-foreground)); color: var(--vscode-editorWidget-foreground, var(--vscode-foreground));
box-shadow: 0 8px 28px var(--vscode-widget-shadow); box-shadow: 0 8px 28px var(--vscode-widget-shadow);
} }
/* 标题一行:「调用树」和符号名整个留着,位置占剩下的宽度 */
.cr-call-title { .cr-call-title {
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 0;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
height: 44px; min-height: 44px;
padding: 0 14px; padding: 8px 14px;
border-bottom: 1px solid var(--vscode-widget-border, var(--vscode-panel-border, transparent)); border-bottom: 1px solid var(--vscode-widget-border, var(--vscode-panel-border, transparent));
} }
.cr-call-heading { .cr-call-heading {
flex-shrink: 0;
font-weight: 600; font-weight: 600;
white-space: nowrap;
} }
.cr-call-symbol, .cr-call-symbol,
.cr-call-name { .cr-call-name {
color: var(--kind); color: var(--kind);
font-family: var(--vscode-editor-font-family, monospace); font-family: var(--vscode-editor-font-family, monospace);
font-weight: 600; font-weight: 600;
white-space: nowrap;
}
.cr-call-symbol {
flex-shrink: 0;
}
/* 位置:文件名先放全,目录拿剩下的宽度、放不下末尾省略;文件名自己也放不下时才省略 */
.cr-call-where {
display: flex;
flex: 1;
align-items: baseline;
gap: 8px;
min-width: 0;
font-family: var(--vscode-editor-font-family, monospace);
font-size: 11px;
}
.cr-call-file,
.cr-call-dir {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.cr-call-file {
flex: 0 1 auto;
min-width: 0;
color: var(--vscode-foreground);
}
.cr-call-dir {
flex: 1 1 0;
min-width: 0;
color: var(--vscode-disabledForeground, var(--vscode-descriptionForeground));
} }
.cr-call-loc { .cr-call-loc {
color: var(--vscode-disabledForeground, var(--vscode-descriptionForeground)); color: var(--vscode-disabledForeground, var(--vscode-descriptionForeground));
@@ -1349,6 +1382,7 @@ del.cr-diff-word {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 24px; width: 24px;
flex-shrink: 0;
height: 24px; height: 24px;
margin-left: auto; margin-left: auto;
border: 0; border: 0;
@@ -1447,19 +1481,33 @@ del.cr-diff-word {
border-color: var(--kind); border-color: var(--kind);
background: color-mix(in srgb, var(--kind) 14%, transparent); background: color-mix(in srgb, var(--kind) 14%, transparent);
} }
.cr-call-line, /* 卡片第一行:图标、名字、detail 占名字后面剩下的宽度,「当前」靠右;第二行只放位置 */
.cr-call-sub { .cr-call-line {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
}
.cr-call-line {
color: var(--kind); color: var(--kind);
} }
.cr-call-sub { .cr-call-line > svg {
margin-top: 2px; flex-shrink: 0;
}
.cr-call-name {
overflow: hidden;
flex: 0 1 auto;
min-width: 0;
text-overflow: ellipsis;
}
.cr-call-detail {
overflow: hidden;
flex: 1 1 0;
min-width: 0;
color: var(--vscode-descriptionForeground); color: var(--vscode-descriptionForeground);
font-size: 12px; font-size: 11px;
white-space: nowrap;
text-overflow: ellipsis;
}
.cr-call-card .cr-call-where {
margin-top: 2px;
} }
.cr-call-pill { .cr-call-pill {
display: flex; display: flex;