feat(vscode-ide-companion): 新增共享 UI 组件 FileLink

- 新增 FileLink 组件用于显示文件链接
- 更新 LayoutComponents 增加通用布局组件
- 新增 utils.ts 提供工具函数

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
yiliang114
2025-11-21 01:51:50 +08:00
parent 9ba99177b9
commit 1eedd36542
4 changed files with 299 additions and 9 deletions

View File

@@ -0,0 +1,128 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* FileLink 组件样式
*/
/**
* 文件链接基础样式
*/
.file-link {
/* 使用 VSCode 主题的链接颜色 */
color: var(--vscode-textLink-foreground);
cursor: pointer;
text-decoration: none;
/* 使用编辑器字体保持一致性 */
font-family: var(--vscode-editor-font-family, 'Menlo', 'Monaco', 'Courier New', monospace);
font-size: inherit;
/* 行内显示 */
display: inline-flex;
align-items: baseline;
gap: 0;
/* 平滑过渡效果 */
transition: color 0.1s ease-in-out, text-decoration 0.1s ease-in-out;
}
/**
* 悬停状态
*/
.file-link:hover {
/* 悬停时显示下划线 */
text-decoration: underline;
/* 使用激活状态的链接颜色 */
color: var(--vscode-textLink-activeForeground);
}
/**
* 聚焦状态(键盘导航)
*/
.file-link:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 2px;
border-radius: 2px;
}
/**
* 激活状态(点击时)
*/
.file-link:active {
opacity: 0.8;
}
/**
* 文件路径部分
*/
.file-link-path {
font-weight: 500;
/* 继承父元素的颜色 */
color: inherit;
}
/**
* 位置信息部分(行号和列号)
*/
.file-link-location {
opacity: 0.7;
font-size: 0.9em;
/* 继承父元素的颜色 */
color: inherit;
font-weight: normal;
}
/**
* 在深色主题下增强可读性
*/
@media (prefers-color-scheme: dark) {
.file-link-location {
opacity: 0.6;
}
}
/**
* 高对比度模式支持
*/
@media (prefers-contrast: high) {
.file-link {
text-decoration: underline;
font-weight: 600;
}
.file-link-location {
opacity: 1;
}
}
/**
* 禁用点击时的样式(当父元素处理点击时)
*/
.file-link-disabled {
cursor: inherit;
pointer-events: none;
}
.file-link-disabled:hover {
text-decoration: none;
color: inherit;
}
/**
* 在工具调用卡片中的样式调整
*/
.tool-call-card .file-link {
/* 在工具调用中略微缩小字体 */
font-size: 0.95em;
}
/**
* 在代码块中的样式调整
*/
.code-block .file-link {
/* 在代码块中保持等宽字体 */
font-family: var(--vscode-editor-font-family, 'Menlo', 'Monaco', 'Courier New', monospace);
}

View File

@@ -0,0 +1,129 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* FileLink 组件 - 可点击的文件路径链接
* 支持点击打开文件并跳转到指定行号和列号
*/
import type React from 'react';
import { useVSCode } from '../../hooks/useVSCode.js';
import './FileLink.css';
/**
* Props for FileLink
*/
interface FileLinkProps {
/** 文件路径 */
path: string;
/** 可选的行号(从 1 开始) */
line?: number | null;
/** 可选的列号(从 1 开始) */
column?: number | null;
/** 是否显示完整路径,默认 false只显示文件名 */
showFullPath?: boolean;
/** 可选的自定义类名 */
className?: string;
/** 是否禁用点击行为(当父元素已经处理点击时使用) */
disableClick?: boolean;
}
/**
* 从完整路径中提取文件名
* @param path 文件路径
* @returns 文件名
*/
function getFileName(path: string): string {
const segments = path.split(/[/\\]/);
return segments[segments.length - 1] || path;
}
/**
* FileLink 组件 - 可点击的文件链接
*
* 功能:
* - 点击打开文件
* - 支持行号和列号跳转
* - 悬停显示完整路径
* - 可选显示模式(完整路径 vs 仅文件名)
*
* @example
* ```tsx
* <FileLink path="/src/App.tsx" line={42} />
* <FileLink path="/src/components/Button.tsx" line={10} column={5} showFullPath={true} />
* ```
*/
export const FileLink: React.FC<FileLinkProps> = ({
path,
line,
column,
showFullPath = false,
className = '',
disableClick = false,
}) => {
const vscode = useVSCode();
/**
* 处理点击事件 - 发送消息到 VSCode 打开文件
*/
const handleClick = (e: React.MouseEvent) => {
// 总是阻止默认行为(防止 <a> 标签的 # 跳转)
e.preventDefault();
if (disableClick) {
// 如果禁用点击,直接返回,不阻止冒泡
// 这样父元素可以处理点击事件
return;
}
// 如果启用点击,阻止事件冒泡
e.stopPropagation();
// 构建包含行号和列号的完整路径
let fullPath = path;
if (line !== null && line !== undefined) {
fullPath += `:${line}`;
if (column !== null && column !== undefined) {
fullPath += `:${column}`;
}
}
console.log('[FileLink] Opening file:', fullPath);
vscode.postMessage({
type: 'openFile',
data: { path: fullPath },
});
};
// 构建显示文本
const displayPath = showFullPath ? path : getFileName(path);
// 构建悬停提示(始终显示完整路径)
const fullDisplayText =
line !== null && line !== undefined
? column !== null && column !== undefined
? `${path}:${line}:${column}`
: `${path}:${line}`
: path;
return (
<a
href="#"
className={`file-link ${disableClick ? 'file-link-disabled' : ''} ${className}`}
onClick={handleClick}
title={fullDisplayText}
role="button"
aria-label={`Open file: ${fullDisplayText}`}
>
<span className="file-link-path">{displayPath}</span>
{line !== null && line !== undefined && (
<span className="file-link-location">
:{line}
{column !== null && column !== undefined && <>:{column}</>}
</span>
)}
</a>
);
};

View File

@@ -7,6 +7,7 @@
*/
import type React from 'react';
import { FileLink } from '../../shared/FileLink.js';
/**
* Props for ToolCallCard wrapper
@@ -20,11 +21,11 @@ interface ToolCallCardProps {
* Main card wrapper with icon
*/
export const ToolCallCard: React.FC<ToolCallCardProps> = ({
icon,
icon: _icon,
children,
}) => (
<div className="tool-call-card">
<div className="tool-call-icon">{icon}</div>
{/* <div className="tool-call-icon">{icon}</div> */}
<div className="tool-call-grid">{children}</div>
</div>
);
@@ -95,15 +96,12 @@ interface LocationsListProps {
}
/**
* List of file locations
* List of file locations with clickable links
*/
export const LocationsList: React.FC<LocationsListProps> = ({ locations }) => (
<>
<div className="locations-list">
{locations.map((loc, idx) => (
<div key={idx}>
{loc.path}
{loc.line !== null && loc.line !== undefined && `:${loc.line}`}
</div>
<FileLink key={idx} path={loc.path} line={loc.line} showFullPath={true} />
))}
</>
</div>
);

View File

@@ -76,6 +76,41 @@ export const getKindIcon = (kind: string): string => {
export const shouldShowToolCall = (kind: string): boolean =>
!kind.includes('internal');
/**
* Check if a tool call has actual output to display
* Returns false for tool calls that completed successfully but have no visible output
*/
export const hasToolCallOutput = (
toolCall: import('./types.js').ToolCallData,
): boolean => {
// Always show failed tool calls (even without content)
if (toolCall.status === 'failed') {
return true;
}
// Show if there are locations (file paths)
if (toolCall.locations && toolCall.locations.length > 0) {
return true;
}
// Show if there is content
if (toolCall.content && toolCall.content.length > 0) {
const grouped = groupContent(toolCall.content);
// Has any meaningful content?
if (
grouped.textOutputs.length > 0 ||
grouped.errors.length > 0 ||
grouped.diffs.length > 0 ||
grouped.otherData.length > 0
) {
return true;
}
}
// No output, don't show
return false;
};
/**
* Group tool call content by type to avoid duplicate labels
*/