From 328924f5784d512e45ec4d932fa721930d40f3aa Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 21 Nov 2025 01:52:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(vscode-ide-companion):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20DiffDisplay=20=E7=BB=84=E4=BB=B6=E5=92=8C=20diff=20?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增强 DiffDisplay 组件,支持更丰富的差异展示 - 新增 diffStats.ts 工具,提供差异统计功能 - 新增样式文件 DiffDisplay.css 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../toolcalls/shared/DiffDisplay.css | 315 ++++++++++++++++++ .../toolcalls/shared/DiffDisplay.tsx | 168 +++++++--- .../src/webview/utils/diffStats.ts | 160 +++++++++ 3 files changed, 604 insertions(+), 39 deletions(-) create mode 100644 packages/vscode-ide-companion/src/webview/components/toolcalls/shared/DiffDisplay.css create mode 100644 packages/vscode-ide-companion/src/webview/utils/diffStats.ts diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/DiffDisplay.css b/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/DiffDisplay.css new file mode 100644 index 00000000..f2df1b6a --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/DiffDisplay.css @@ -0,0 +1,315 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * DiffDisplay 组件样式 + */ + +/* ======================================== + 容器样式 + ======================================== */ + +.diff-display-container { + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); + width: 100%; +} + +/* ======================================== + 紧凑视图样式 - 超简洁版本 + ======================================== */ + +.diff-compact-view { + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + background: var(--vscode-editor-background); + overflow: hidden; +} + +.diff-compact-clickable { + padding: 6px 10px; + cursor: pointer; + user-select: none; + transition: all 0.15s ease-in-out; +} + +.diff-compact-clickable:hover { + background: var(--vscode-list-hoverBackground); +} + +.diff-compact-clickable:active { + opacity: 0.9; +} + +.diff-compact-clickable:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.diff-compact-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.diff-file-info { + flex: 1; + min-width: 0; /* 允许文字截断 */ + font-weight: 500; + font-size: 0.95em; +} + +.diff-file-info .file-link { + color: var(--vscode-foreground); +} + +.diff-stats { + display: flex; + gap: 8px; + align-items: center; + font-family: var(--vscode-editor-font-family, 'Menlo', 'Monaco', 'Courier New', monospace); + font-size: 0.85em; + flex-shrink: 0; +} + +.diff-stats > span { + font-weight: 600; + white-space: nowrap; +} + +.stat-added { + color: var(--vscode-gitDecoration-addedResourceForeground, #4ec9b0); +} + +.stat-removed { + color: var(--vscode-gitDecoration-deletedResourceForeground, #f48771); +} + +.stat-changed { + color: var(--vscode-gitDecoration-modifiedResourceForeground, #e5c07b); +} + +.stat-no-change { + color: var(--vscode-descriptionForeground); + opacity: 0.7; +} + +.diff-compact-actions { + padding: 4px 10px 6px; + border-top: 1px solid var(--vscode-panel-border); + background: var(--vscode-editorGroupHeader-tabsBackground); + display: flex; + justify-content: flex-end; +} + +/* ======================================== + 完整视图样式 + ======================================== */ + +.diff-full-view { + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + overflow: hidden; +} + +.diff-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px; + background: var(--vscode-editorGroupHeader-tabsBackground); + border-bottom: 1px solid var(--vscode-panel-border); +} + +.diff-file-path { + font-weight: 500; + flex: 1; +} + +.diff-header-actions { + display: flex; + gap: 8px; +} + +.diff-stats-line { + padding: 8px 12px; + background: var(--vscode-editor-background); + color: var(--vscode-descriptionForeground); + font-size: 0.9em; + border-bottom: 1px solid var(--vscode-panel-border); +} + +.diff-section { + padding: 12px; + background: var(--vscode-editor-background); +} + +.diff-section + .diff-section { + border-top: 1px solid var(--vscode-panel-border); +} + +.diff-label { + font-size: 0.85em; + color: var(--vscode-descriptionForeground); + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 600; +} + +/* ======================================== + 按钮样式 + ======================================== */ + +.diff-action-button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: none; + border-radius: 4px; + font-size: 0.9em; + font-family: var(--vscode-font-family); + cursor: pointer; + transition: all 0.15s ease-in-out; + white-space: nowrap; +} + +.diff-action-button svg { + flex-shrink: 0; +} + +.diff-action-button.primary { + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} + +.diff-action-button.primary:hover { + background: var(--vscode-button-hoverBackground); +} + +.diff-action-button.primary:active { + opacity: 0.9; +} + +.diff-action-button.secondary { + background: transparent; + color: var(--vscode-textLink-foreground); + padding: 4px 8px; + font-size: 0.85em; +} + +.diff-action-button.secondary:hover { + background: var(--vscode-button-secondaryHoverBackground); + text-decoration: underline; +} + +.diff-action-button:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; +} + +.diff-action-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ======================================== + 代码块样式 + ======================================== */ + +.diff-section .code-block { + margin: 0; + padding: 12px; + background: var(--vscode-textCodeBlock-background, rgba(0, 0, 0, 0.1)); + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + overflow-x: auto; + font-family: var(--vscode-editor-font-family, 'Menlo', 'Monaco', 'Courier New', monospace); + font-size: 0.9em; + line-height: 1.5; +} + +.diff-section .code-content { + white-space: pre; + color: var(--vscode-editor-foreground); +} + +/* ======================================== + 响应式调整 + ======================================== */ + +@media (max-width: 600px) { + .diff-compact-header { + flex-direction: column; + align-items: flex-start; + } + + .diff-stats { + align-self: flex-start; + } + + .diff-header { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .diff-header-actions { + align-self: flex-end; + } +} + +/* ======================================== + 高对比度模式支持 + ======================================== */ + +@media (prefers-contrast: high) { + .diff-compact-view, + .diff-full-view { + border-width: 2px; + } + + .diff-stats > span { + font-weight: 700; + border: 1px solid currentColor; + } + + .diff-action-button { + border: 1px solid currentColor; + } +} + +/* ======================================== + 深色主题优化 + ======================================== */ + +@media (prefers-color-scheme: dark) { + .diff-compact-view:hover { + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); + } + + .stat-added { + background: rgba(78, 201, 176, 0.2); + } + + .stat-removed { + background: rgba(244, 135, 113, 0.2); + } + + .stat-changed { + background: rgba(229, 192, 123, 0.2); + } +} + +/* ======================================== + LocationsList 样式(用于 FileLink 列表) + ======================================== */ + +.locations-list { + display: flex; + flex-direction: column; + gap: 6px; +} diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/DiffDisplay.tsx b/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/DiffDisplay.tsx index 928d5077..4ce2c059 100644 --- a/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/DiffDisplay.tsx +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/DiffDisplay.tsx @@ -7,6 +7,13 @@ */ import type React from 'react'; +import { useState, useMemo } from 'react'; +import { FileLink } from '../../shared/FileLink.js'; +import { + calculateDiffStats, + formatDiffStatsDetailed, +} from '../../../utils/diffStats.js'; +import './DiffDisplay.css'; /** * Props for DiffDisplay @@ -16,57 +23,140 @@ interface DiffDisplayProps { oldText?: string | null; newText?: string; onOpenDiff?: () => void; + /** 默认显示模式:'compact' | 'full' */ + defaultMode?: 'compact' | 'full'; } /** - * Display diff with before/after sections and option to open in VSCode diff viewer + * Display diff with compact stats or full before/after sections + * Supports toggling between compact and full view modes */ export const DiffDisplay: React.FC = ({ path, oldText, newText, onOpenDiff, -}) => ( -
-
-
- {path || 'Unknown file'} + defaultMode = 'compact', +}) => { + // 视图模式状态:紧凑或完整 + const [viewMode, setViewMode] = useState<'compact' | 'full'>(defaultMode); + + // 计算 diff 统计信息(仅在文本变化时重新计算) + const stats = useMemo( + () => calculateDiffStats(oldText, newText), + [oldText, newText], + ); + + // 渲染紧凑视图 + const renderCompactView = () => ( +
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onOpenDiff?.(); + } + }} + > +
+ {path && ( +
+ +
+ )} +
+ {stats.added > 0 && ( + +{stats.added} + )} + {stats.removed > 0 && ( + -{stats.removed} + )} + {stats.changed > 0 && ( + ~{stats.changed} + )} + {stats.total === 0 && ( + No changes + )} +
+
- {onOpenDiff && ( +
+
+
+ ); + + // 渲染完整视图 + const renderFullView = () => ( +
+
+
+ {path && } +
+
+ {onOpenDiff && ( + + )} + +
+
+
{formatDiffStatsDetailed(stats)}
+ {oldText !== undefined && ( +
+
Before:
+
+            
{oldText || '(empty)'}
+
+
+ )} + {newText !== undefined && ( +
+
After:
+
+            
{newText}
+
+
)}
- {oldText !== undefined && ( -
-
Before:
-
-          
{oldText || '(empty)'}
-
-
- )} - {newText !== undefined && ( -
-
After:
-
-          
{newText}
-
-
- )} -
-); + ); + + return ( +
+ {viewMode === 'compact' ? renderCompactView() : renderFullView()} +
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/utils/diffStats.ts b/packages/vscode-ide-companion/src/webview/utils/diffStats.ts new file mode 100644 index 00000000..ba752311 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/diffStats.ts @@ -0,0 +1,160 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Diff 统计计算工具 + */ + +/** + * Diff 统计信息 + */ +export interface DiffStats { + /** 新增行数 */ + added: number; + /** 删除行数 */ + removed: number; + /** 修改行数(估算值) */ + changed: number; + /** 总变更行数 */ + total: number; +} + +/** + * 计算两个文本之间的 diff 统计信息 + * + * 使用简单的行对比算法(避免引入重量级 diff 库) + * 算法说明: + * 1. 将文本按行分割 + * 2. 比较行的集合差异 + * 3. 估算修改行数(同时出现在新增和删除中的行数) + * + * @param oldText 旧文本内容 + * @param newText 新文本内容 + * @returns diff 统计信息 + * + * @example + * ```typescript + * const stats = calculateDiffStats( + * "line1\nline2\nline3", + * "line1\nline2-modified\nline4" + * ); + * // { added: 2, removed: 2, changed: 1, total: 3 } + * ``` + */ +export function calculateDiffStats( + oldText: string | null | undefined, + newText: string | undefined, +): DiffStats { + // 处理空值情况 + const oldContent = oldText || ''; + const newContent = newText || ''; + + // 按行分割 + const oldLines = oldContent.split('\n').filter((line) => line.trim() !== ''); + const newLines = newContent.split('\n').filter((line) => line.trim() !== ''); + + // 如果其中一个为空,直接计算 + if (oldLines.length === 0) { + return { + added: newLines.length, + removed: 0, + changed: 0, + total: newLines.length, + }; + } + + if (newLines.length === 0) { + return { + added: 0, + removed: oldLines.length, + changed: 0, + total: oldLines.length, + }; + } + + // 使用 Set 进行快速查找 + const oldSet = new Set(oldLines); + const newSet = new Set(newLines); + + // 计算新增:在 new 中但不在 old 中的行 + const addedLines = newLines.filter((line) => !oldSet.has(line)); + + // 计算删除:在 old 中但不在 new 中的行 + const removedLines = oldLines.filter((line) => !newSet.has(line)); + + // 估算修改:取较小值(因为修改的行既被删除又被添加) + // 这是一个简化的估算,实际的 diff 算法会更精确 + const estimatedChanged = Math.min(addedLines.length, removedLines.length); + + const added = addedLines.length - estimatedChanged; + const removed = removedLines.length - estimatedChanged; + const changed = estimatedChanged; + + return { + added, + removed, + changed, + total: added + removed + changed, + }; +} + +/** + * 格式化 diff 统计信息为人类可读的文本 + * + * @param stats diff 统计信息 + * @returns 格式化后的文本,例如 "+5 -3 ~2" + * + * @example + * ```typescript + * formatDiffStats({ added: 5, removed: 3, changed: 2, total: 10 }); + * // "+5 -3 ~2" + * ``` + */ +export function formatDiffStats(stats: DiffStats): string { + const parts: string[] = []; + + if (stats.added > 0) { + parts.push(`+${stats.added}`); + } + + if (stats.removed > 0) { + parts.push(`-${stats.removed}`); + } + + if (stats.changed > 0) { + parts.push(`~${stats.changed}`); + } + + return parts.join(' ') || 'No changes'; +} + +/** + * 格式化详细的 diff 统计信息 + * + * @param stats diff 统计信息 + * @returns 详细的描述文本 + * + * @example + * ```typescript + * formatDiffStatsDetailed({ added: 5, removed: 3, changed: 2, total: 10 }); + * // "+5 lines, -3 lines, ~2 lines" + * ``` + */ +export function formatDiffStatsDetailed(stats: DiffStats): string { + const parts: string[] = []; + + if (stats.added > 0) { + parts.push(`+${stats.added} ${stats.added === 1 ? 'line' : 'lines'}`); + } + + if (stats.removed > 0) { + parts.push(`-${stats.removed} ${stats.removed === 1 ? 'line' : 'lines'}`); + } + + if (stats.changed > 0) { + parts.push(`~${stats.changed} ${stats.changed === 1 ? 'line' : 'lines'}`); + } + + return parts.join(', ') || 'No changes'; +}