diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 9ed7b33..704ee06 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -10,7 +10,7 @@ body:
id: version
attributes:
label: ZCompare version
- placeholder: 0.1.1
+ placeholder: 0.1.2
validations:
required: true
- type: dropdown
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 72b249f..138d262 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,18 @@ All notable changes to ZCompare are documented here. The project follows [Semant
## [Unreleased]
+## [0.1.2] - 2026-08-20
+
+### Added
+
+- Added complete raw/display-value viewing with horizontal scrolling for long cell contents.
+- Highlighted changed character spans in both the worksheet grid and cell-detail window while keeping copied text unmodified.
+
+### Fixed
+
+- Fixed a crash when double-clicking highlighted text inside a differing cell.
+- Removed synthetic `⟦ ⟧` difference markers from cell-detail text and preserved real brackets, whitespace, and trailing spaces when copying.
+
## [0.1.1] - 2026-08-12
### Fixed
@@ -21,6 +33,7 @@ All notable changes to ZCompare are documented here. The project follows [Semant
- Safe handling for stale formula caches, changed source files, unsupported ISO Strict files, and unreadable containers.
- Per-user Windows installer, portable developer package, release checksums, and non-intrusive update notification.
-[Unreleased]: https://github.com/Zcube7/ZCompare/compare/v0.1.1...HEAD
+[Unreleased]: https://github.com/Zcube7/ZCompare/compare/v0.1.2...HEAD
+[0.1.2]: https://github.com/Zcube7/ZCompare/compare/v0.1.1...v0.1.2
[0.1.1]: https://github.com/Zcube7/ZCompare/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/Zcube7/ZCompare/releases/tag/v0.1.0
diff --git a/Directory.Build.props b/Directory.Build.props
index 6c84d41..0153b07 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -5,10 +5,10 @@
enable
true
true
- 0.1.1
- 0.1.1.0
- 0.1.1.0
- 0.1.1
+ 0.1.2
+ 0.1.2.0
+ 0.1.2.0
+ 0.1.2
ZCompare
ZCompare
Zcube7
diff --git a/README.md b/README.md
index 0c936dc..83e53b1 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
ZCompare is a read-only Windows tool for people who maintain XLSX files across long-lived versions. It compares two workbooks or two folders, aligns inserted/deleted rows conservatively, and presents the result in synchronized spreadsheet views.
-> Public preview: **v0.1.1** supports Windows 10/11 x64 and genuine OOXML `.xlsx` files only.
+> Public preview: **v0.1.2** supports Windows 10/11 x64 and genuine OOXML `.xlsx` files only.

@@ -21,11 +21,11 @@ ZCompare is a read-only Windows tool for people who maintain XLSX files across l
Download assets only from the official [ZCompare Releases](https://github.com/Zcube7/ZCompare/releases) page.
-- **Most users:** `ZCompare-0.1.1-win-x64-setup.exe`. Per-user install; no administrator rights or preinstalled .NET required.
-- **Developers / advanced users:** `ZCompare-0.1.1-win-x64-portable-fdd.zip`. Requires the .NET 10 Desktop Runtime x64 and includes both GUI and CLI.
+- **Most users:** `ZCompare-0.1.2-win-x64-setup.exe`. Per-user install; no administrator rights or preinstalled .NET required.
+- **Developers / advanced users:** `ZCompare-0.1.2-win-x64-portable-fdd.zip`. Requires the .NET 10 Desktop Runtime x64 and includes both GUI and CLI.
- Verify downloads with `SHA256SUMS.txt`.
-The first release is not code-signed. Windows SmartScreen may show a warning. Verify the GitHub repository, filename, and SHA-256 before choosing **More info → Run anyway**. Do not disable SmartScreen.
+The current release is not code-signed. Windows SmartScreen may show a warning. Verify the GitHub repository, filename, and SHA-256 before choosing **More info → Run anyway**. Do not disable SmartScreen.
Detailed instructions: [English](docs/INSTALL.md) · [简体中文](docs/INSTALL.zh-CN.md)
@@ -79,7 +79,7 @@ After the window loads, ZCompare performs a non-blocking update check against Gi
- No telemetry and no workbook/report upload.
- The update request sends only standard HTTP headers and the app version to GitHub.
- Recent paths and named profiles are stored locally under `%LocalAppData%\ZCompare`.
-- Error dialogs stay local; v0.1.1 does not create or upload diagnostic logs.
+- Error dialogs stay local; v0.1.2 does not create or upload diagnostic logs.
- Source files are hashed before and after comparison and are never written by ZCompare.
See the [installation guide](docs/INSTALL.md) for the full privacy and AI-assisted installation checklist.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 38b9810..f7be4d9 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -4,7 +4,7 @@
ZCompare 是面向长期跨版本管理、维护 XLSX 文件用户的只读 Windows 对比工具。它支持双工作簿与双文件夹对比,能够保守对齐插入/删除行,并在同步滚动的左右表格中清晰展示结果。
-> 公开预览版:**v0.1.1**,支持 Windows 10/11 x64,仅支持真实 OOXML `.xlsx` 文件。
+> 公开预览版:**v0.1.2**,支持 Windows 10/11 x64,仅支持真实 OOXML `.xlsx` 文件。

@@ -21,11 +21,11 @@ ZCompare 是面向长期跨版本管理、维护 XLSX 文件用户的只读 Wind
只从官方 [ZCompare Releases](https://github.com/Zcube7/ZCompare/releases) 页面下载。
-- **普通用户推荐:** `ZCompare-0.1.1-win-x64-setup.exe`。当前用户安装,无需管理员权限,也无需预装 .NET。
-- **开发者/高级用户:** `ZCompare-0.1.1-win-x64-portable-fdd.zip`。需要 .NET 10 Desktop Runtime x64,包含 GUI 与 CLI。
+- **普通用户推荐:** `ZCompare-0.1.2-win-x64-setup.exe`。当前用户安装,无需管理员权限,也无需预装 .NET。
+- **开发者/高级用户:** `ZCompare-0.1.2-win-x64-portable-fdd.zip`。需要 .NET 10 Desktop Runtime x64,包含 GUI 与 CLI。
- 使用 `SHA256SUMS.txt` 校验下载文件。
-首版暂未购买代码签名,Windows SmartScreen 可能显示风险提示。请先核对 GitHub 官方仓库、文件名和 SHA-256,再按正常流程选择 **“更多信息”→“仍要运行”**;不要关闭 SmartScreen。
+当前版本暂未购买代码签名,Windows SmartScreen 可能显示风险提示。请先核对 GitHub 官方仓库、文件名和 SHA-256,再按正常流程选择 **“更多信息”→“仍要运行”**;不要关闭 SmartScreen。
完整指南:[普通安装、便携版、源码与 AI 辅助安装](docs/INSTALL.zh-CN.md)
@@ -79,7 +79,7 @@ dotnet run --project src/ZCompare.App/ZCompare.App.csproj
- 无遥测,不上传工作簿或报告。
- 更新请求只向 GitHub 发送标准 HTTP 头和程序版本。
- 最近路径与命名配置只保存在 `%LocalAppData%\ZCompare`。
-- 错误提示仅在本机显示;v0.1.1 不生成或上传诊断日志。
+- 错误提示仅在本机显示;v0.1.2 不生成或上传诊断日志。
- 对比前后复核源文件 SHA-256,ZCompare 不写入源文件。
完整隐私和 AI 辅助安装注意事项见[安装指南](docs/INSTALL.zh-CN.md)。
diff --git a/docs/INSTALL.md b/docs/INSTALL.md
index a6cd83d..f325c8a 100644
--- a/docs/INSTALL.md
+++ b/docs/INSTALL.md
@@ -5,24 +5,24 @@
## Option A: installer (recommended)
1. Open the official [ZCompare Releases](https://github.com/Zcube7/ZCompare/releases) page.
-2. Download `ZCompare-0.1.1-win-x64-setup.exe` and `SHA256SUMS.txt`.
+2. Download `ZCompare-0.1.2-win-x64-setup.exe` and `SHA256SUMS.txt`.
3. Verify the SHA-256 value:
```powershell
- Get-FileHash .\ZCompare-0.1.1-win-x64-setup.exe -Algorithm SHA256
+ Get-FileHash .\ZCompare-0.1.2-win-x64-setup.exe -Algorithm SHA256
Get-Content .\SHA256SUMS.txt
```
4. Run the installer. It installs for the current user under `%LocalAppData%\Programs\ZCompare` and does not require administrator rights or a separate .NET installation.
-The installer is not code-signed in v0.1.1. SmartScreen may warn about an unrecognized app. Continue through **More info → Run anyway** only after verifying the official source and checksum. Never disable SmartScreen for installation.
+The installer is not code-signed in v0.1.2. SmartScreen may warn about an unrecognized app. Continue through **More info → Run anyway** only after verifying the official source and checksum. Never disable SmartScreen for installation.
Future installers use the same AppId and upgrade in place. Uninstalling the app does not remove recent comparisons and profiles from `%LocalAppData%\ZCompare`.
## Option B: portable developer package
1. Install the [.NET 10 Desktop Runtime x64](https://dotnet.microsoft.com/download/dotnet/10.0).
-2. Download and verify `ZCompare-0.1.1-win-x64-portable-fdd.zip`.
+2. Download and verify `ZCompare-0.1.2-win-x64-portable-fdd.zip`.
3. Extract the archive into a new folder.
4. Run `ZCompare.App.exe`, or open a terminal in that folder and run `zcompare.exe --help`.
@@ -54,4 +54,4 @@ ZCompare checks the latest stable GitHub Release in the background at most once
## Network and privacy
-The application does not send workbooks, paths, reports, or settings to a server. The optional update check contacts `api.github.com` and sends only standard HTTP headers plus the installed version. Recent paths and update-cache metadata stay under `%LocalAppData%\ZCompare`. No diagnostic log file is created in v0.1.1.
+The application does not send workbooks, paths, reports, or settings to a server. The optional update check contacts `api.github.com` and sends only standard HTTP headers plus the installed version. Recent paths and update-cache metadata stay under `%LocalAppData%\ZCompare`. No diagnostic log file is created in v0.1.2.
diff --git a/docs/INSTALL.zh-CN.md b/docs/INSTALL.zh-CN.md
index 2c8607c..80cf80a 100644
--- a/docs/INSTALL.zh-CN.md
+++ b/docs/INSTALL.zh-CN.md
@@ -5,24 +5,24 @@
## 方式 A:安装包(推荐)
1. 打开官方 [ZCompare Releases](https://github.com/Zcube7/ZCompare/releases) 页面。
-2. 下载 `ZCompare-0.1.1-win-x64-setup.exe` 和 `SHA256SUMS.txt`。
+2. 下载 `ZCompare-0.1.2-win-x64-setup.exe` 和 `SHA256SUMS.txt`。
3. 校验 SHA-256:
```powershell
- Get-FileHash .\ZCompare-0.1.1-win-x64-setup.exe -Algorithm SHA256
+ Get-FileHash .\ZCompare-0.1.2-win-x64-setup.exe -Algorithm SHA256
Get-Content .\SHA256SUMS.txt
```
4. 运行安装包。程序会安装到当前用户的 `%LocalAppData%\Programs\ZCompare`,不需要管理员权限,也不需要另装 .NET。
-v0.1.1 暂未购买代码签名,因此 SmartScreen 可能提示“无法识别的应用”。只有在确认来源是官方仓库且 SHA-256 一致后,才按 **“更多信息”→“仍要运行”**;不要关闭 SmartScreen。
+v0.1.2 暂未购买代码签名,因此 SmartScreen 可能提示“无法识别的应用”。只有在确认来源是官方仓库且 SHA-256 一致后,才按 **“更多信息”→“仍要运行”**;不要关闭 SmartScreen。
以后版本使用同一个 AppId,会原位覆盖升级。卸载程序默认不会删除 `%LocalAppData%\ZCompare` 中的最近记录和命名配置。
## 方式 B:便携开发者包
1. 安装 [.NET 10 Desktop Runtime x64](https://dotnet.microsoft.com/download/dotnet/10.0)。
-2. 下载并校验 `ZCompare-0.1.1-win-x64-portable-fdd.zip`。
+2. 下载并校验 `ZCompare-0.1.2-win-x64-portable-fdd.zip`。
3. 解压到一个新文件夹。
4. 运行 `ZCompare.App.exe`;需要 CLI 时,在该目录打开终端并运行 `zcompare.exe --help`。
@@ -54,4 +54,4 @@ ZCompare 最多每 24 小时在后台查询一次 GitHub 最新稳定 Release。
## 网络与隐私
-程序不会把工作簿、路径、报告或设置发送到服务器。更新检查只访问 `api.github.com`,发送标准 HTTP 头和已安装版本。最近路径与更新缓存保存在 `%LocalAppData%\ZCompare`。v0.1.1 不生成诊断日志文件。
+程序不会把工作簿、路径、报告或设置发送到服务器。更新检查只访问 `api.github.com`,发送标准 HTTP 头和已安装版本。最近路径与更新缓存保存在 `%LocalAppData%\ZCompare`。v0.1.2 不生成诊断日志文件。
diff --git a/installer/ZCompare.iss b/installer/ZCompare.iss
index f791e21..bfefd62 100644
--- a/installer/ZCompare.iss
+++ b/installer/ZCompare.iss
@@ -1,5 +1,5 @@
#ifndef MyAppVersion
- #define MyAppVersion "0.1.1"
+ #define MyAppVersion "0.1.2"
#endif
#ifndef SourceDir
#define SourceDir "..\artifacts\release-staging\installer-payload"
diff --git a/src/ZCompare.App/Controls/DifferenceTextColumn.cs b/src/ZCompare.App/Controls/DifferenceTextColumn.cs
new file mode 100644
index 0000000..a292ac7
--- /dev/null
+++ b/src/ZCompare.App/Controls/DifferenceTextColumn.cs
@@ -0,0 +1,76 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using System.Windows.Documents;
+using System.Windows.Media;
+using ZCompare.App.ViewModels;
+
+namespace ZCompare.App.Controls;
+
+internal sealed class DifferenceTextBlock : TextBlock
+{
+ private static readonly Brush DifferenceBrush = CreateDifferenceBrush();
+
+ public static readonly DependencyProperty SegmentsProperty = DependencyProperty.Register(
+ nameof(Segments),
+ typeof(IReadOnlyList),
+ typeof(DifferenceTextBlock),
+ new PropertyMetadata(null, SegmentsChanged));
+
+ public IReadOnlyList? Segments
+ {
+ get => (IReadOnlyList?)GetValue(SegmentsProperty);
+ set => SetValue(SegmentsProperty, value);
+ }
+
+ private static void SegmentsChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs eventArgs)
+ {
+ var textBlock = (DifferenceTextBlock)dependencyObject;
+ textBlock.Inlines.Clear();
+ foreach (var segment in eventArgs.NewValue as IReadOnlyList ?? [])
+ {
+ var run = new Run(segment.Text);
+ if (segment.IsDifferent)
+ {
+ run.Foreground = DifferenceBrush;
+ run.FontWeight = FontWeights.Bold;
+ }
+
+ textBlock.Inlines.Add(run);
+ }
+ }
+
+ private static Brush CreateDifferenceBrush()
+ {
+ var brush = new SolidColorBrush(Color.FromRgb(153, 27, 27));
+ brush.Freeze();
+ return brush;
+ }
+}
+
+internal sealed class DifferenceTextColumn : DataGridBoundColumn
+{
+ public BindingBase? SegmentsBinding { get; init; }
+
+ protected override FrameworkElement GenerateElement(DataGridCell cell, object dataItem) =>
+ CreateElement();
+
+ protected override FrameworkElement GenerateEditingElement(DataGridCell cell, object dataItem) =>
+ CreateElement();
+
+ private FrameworkElement CreateElement()
+ {
+ var element = new DifferenceTextBlock();
+ if (ElementStyle is not null)
+ {
+ element.Style = ElementStyle;
+ }
+
+ if (SegmentsBinding is not null)
+ {
+ BindingOperations.SetBinding(element, DifferenceTextBlock.SegmentsProperty, SegmentsBinding);
+ }
+
+ return element;
+ }
+}
diff --git a/src/ZCompare.App/DetailsWindow.xaml b/src/ZCompare.App/DetailsWindow.xaml
index d544b66..ae54331 100644
--- a/src/ZCompare.App/DetailsWindow.xaml
+++ b/src/ZCompare.App/DetailsWindow.xaml
@@ -13,17 +13,17 @@
-
+
+
+
+
+
diff --git a/src/ZCompare.App/DetailsWindow.xaml.cs b/src/ZCompare.App/DetailsWindow.xaml.cs
index 11e85c9..2a9302d 100644
--- a/src/ZCompare.App/DetailsWindow.xaml.cs
+++ b/src/ZCompare.App/DetailsWindow.xaml.cs
@@ -1,23 +1,49 @@
using System.Windows;
+using System.Windows.Documents;
+using System.Windows.Media;
+using ZCompare.App.ViewModels;
namespace ZCompare.App;
public partial class DetailsWindow : Window
{
+ private static readonly Brush DifferenceForeground = CreateBrush(153, 27, 27);
+ private static readonly Brush DifferenceBackground = CreateBrush(254, 202, 202);
+ private readonly string _clipboardText;
+
public DetailsWindow(string title, string details)
+ : this(title, new CellDetailsContent(
+ details,
+ [new DetailTextSegment(details, details, false)]))
+ {
+ }
+
+ internal DetailsWindow(string title, CellDetailsContent details)
{
InitializeComponent();
Title = title;
- DetailsTextBox.Text = details;
+ _clipboardText = details.ClipboardText;
+ foreach (var segment in details.Segments)
+ {
+ var run = new Run(segment.DisplayText);
+ if (segment.IsDifferent)
+ {
+ run.Foreground = DifferenceForeground;
+ run.Background = DifferenceBackground;
+ run.FontWeight = FontWeights.SemiBold;
+ }
+
+ DetailsTextBlock.Inlines.Add(run);
+ }
}
private void CopyButton_OnClick(object sender, RoutedEventArgs eventArgs)
{
- if (!string.IsNullOrEmpty(DetailsTextBox.Text))
+ if (!string.IsNullOrEmpty(_clipboardText))
{
try
{
- Clipboard.SetText(DetailsTextBox.Text);
+ Clipboard.SetText(_clipboardText);
}
catch (Exception exception)
{
@@ -25,4 +51,11 @@ private void CopyButton_OnClick(object sender, RoutedEventArgs eventArgs)
}
}
}
+
+ private static Brush CreateBrush(byte red, byte green, byte blue)
+ {
+ var brush = new SolidColorBrush(Color.FromRgb(red, green, blue));
+ brush.Freeze();
+ return brush;
+ }
}
diff --git a/src/ZCompare.App/MainWindow.xaml b/src/ZCompare.App/MainWindow.xaml
index 6b71f46..c15151a 100644
--- a/src/ZCompare.App/MainWindow.xaml
+++ b/src/ZCompare.App/MainWindow.xaml
@@ -9,7 +9,6 @@
Height="900"
MinWidth="1100"
MinHeight="700"
- Icon="pack://application:,,,/Assets/zcompare.ico"
WindowStartupLocation="CenterScreen">
@@ -426,7 +425,7 @@
-
+
@@ -464,6 +463,11 @@
Content="其他详情"
Style="{StaticResource ToolbarButtonStyle}"
Visibility="{Binding HasOtherDetails, Converter={StaticResource BoolToVisibility}}" />
+
@@ -543,16 +547,44 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ZCompare.App/MainWindow.xaml.cs b/src/ZCompare.App/MainWindow.xaml.cs
index deea4a0..b2efb24 100644
--- a/src/ZCompare.App/MainWindow.xaml.cs
+++ b/src/ZCompare.App/MainWindow.xaml.cs
@@ -5,10 +5,13 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
+using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
+using System.Windows.Media.Media3D;
using System.Windows.Threading;
using Microsoft.Win32;
+using ZCompare.App.Controls;
using ZCompare.App.Services;
using ZCompare.App.ViewModels;
using ZCompare.Core;
@@ -115,13 +118,13 @@ private DataGridColumn CreateSpreadsheetColumn(int index)
var elementStyle = new Style(typeof(TextBlock));
elementStyle.Setters.Add(new Setter(TextBlock.TextTrimmingProperty, TextTrimming.CharacterEllipsis));
elementStyle.Setters.Add(new Setter(TextBlock.VerticalAlignmentProperty, VerticalAlignment.Center));
- elementStyle.Setters.Add(new Setter(TextBlock.ForegroundProperty, new Binding($"Cells[{index}].Foreground")));
- elementStyle.Setters.Add(new Setter(TextBlock.FontFamilyProperty, new Binding($"Cells[{index}].FontFamily")));
- elementStyle.Setters.Add(new Setter(TextBlock.FontSizeProperty, new Binding($"Cells[{index}].FontSize")));
- elementStyle.Setters.Add(new Setter(TextBlock.FontWeightProperty, new Binding($"Cells[{index}].FontWeight")));
- elementStyle.Setters.Add(new Setter(TextBlock.FontStyleProperty, new Binding($"Cells[{index}].FontStyle")));
- elementStyle.Setters.Add(new Setter(TextBlock.TextAlignmentProperty, new Binding($"Cells[{index}].TextAlignment")));
- elementStyle.Setters.Add(new Setter(TextBlock.TextWrappingProperty, new Binding($"Cells[{index}].TextWrapping")));
+ elementStyle.Setters.Add(new Setter(TextBlock.ForegroundProperty, CreateCellTagBinding(nameof(GridCellViewModel.Foreground))));
+ elementStyle.Setters.Add(new Setter(TextBlock.FontFamilyProperty, CreateCellTagBinding(nameof(GridCellViewModel.FontFamily))));
+ elementStyle.Setters.Add(new Setter(TextBlock.FontSizeProperty, CreateCellTagBinding(nameof(GridCellViewModel.FontSize))));
+ elementStyle.Setters.Add(new Setter(TextBlock.FontWeightProperty, CreateCellTagBinding(nameof(GridCellViewModel.FontWeight))));
+ elementStyle.Setters.Add(new Setter(TextBlock.FontStyleProperty, CreateCellTagBinding(nameof(GridCellViewModel.FontStyle))));
+ elementStyle.Setters.Add(new Setter(TextBlock.TextAlignmentProperty, CreateCellTagBinding(nameof(GridCellViewModel.TextAlignment))));
+ elementStyle.Setters.Add(new Setter(TextBlock.TextWrappingProperty, CreateCellTagBinding(nameof(GridCellViewModel.TextWrapping))));
var baseCellStyle = (Style)FindResource("SpreadsheetCellStyle");
var cellStyle = new Style(typeof(DataGridCell), baseCellStyle);
@@ -129,15 +132,18 @@ private DataGridColumn CreateSpreadsheetColumn(int index)
FrameworkElement.TagProperty,
new Binding($"Cells[{index}]") { Mode = BindingMode.OneWay }));
- return new DataGridTextColumn
+ var displayBinding = new Binding($"Cells[{index}].DisplayValue")
+ {
+ Mode = BindingMode.OneWay,
+ FallbackValue = string.Empty,
+ TargetNullValue = string.Empty,
+ };
+ return new DifferenceTextColumn
{
Header = GetColumnName(index + 1),
- Binding = new Binding($"Cells[{index}].DisplayValue")
- {
- Mode = BindingMode.OneWay,
- FallbackValue = string.Empty,
- TargetNullValue = string.Empty,
- },
+ Binding = displayBinding,
+ ClipboardContentBinding = displayBinding,
+ SegmentsBinding = CreateCellTagBinding(nameof(GridCellViewModel.DisplaySegments)),
CellStyle = cellStyle,
ElementStyle = elementStyle,
IsReadOnly = true,
@@ -147,6 +153,12 @@ private DataGridColumn CreateSpreadsheetColumn(int index)
};
}
+ private static Binding CreateCellTagBinding(string propertyName) => new($"Tag.{propertyName}")
+ {
+ Mode = BindingMode.OneWay,
+ RelativeSource = new RelativeSource(RelativeSourceMode.FindAncestor, typeof(DataGridCell), 1),
+ };
+
private static string GetColumnName(int column)
{
Span buffer = stackalloc char[8];
@@ -334,8 +346,8 @@ private void ShowCurrentCellDetails(DataGrid grid)
}
var displayRowIndex = grid.Items.IndexOf(row);
- var details = _viewModel.GetCellDialogDetails(displayRowIndex, grid.CurrentCell.Column.DisplayIndex);
- if (!string.IsNullOrWhiteSpace(details))
+ var details = _viewModel.GetCellDetailsContent(displayRowIndex, grid.CurrentCell.Column.DisplayIndex);
+ if (details is not null)
{
ShowDetailsWindow($"{row.RowHeader} 行单元格差异详情", details);
}
@@ -349,6 +361,9 @@ private void OtherDetailsButton_OnClick(object sender, RoutedEventArgs eventArgs
}
}
+ private void SelectedCellDetailsButton_OnClick(object sender, RoutedEventArgs eventArgs) =>
+ ShowCurrentCellDetails(LeftGrid);
+
private void SpreadsheetGrid_OnLoaded(object sender, RoutedEventArgs eventArgs)
{
if (sender == LeftGrid)
@@ -477,7 +492,7 @@ private void SynchronizeCurrentCell(DataGrid target, int rowIndex, int columnInd
private static T? FindVisualAncestor(DependencyObject source) where T : DependencyObject
{
- for (var current = source; current is not null; current = VisualTreeHelper.GetParent(current))
+ for (var current = source; current is not null; current = GetParent(current))
{
if (current is T match)
{
@@ -487,10 +502,25 @@ private void SynchronizeCurrentCell(DataGrid target, int rowIndex, int columnInd
return null;
}
+ private static DependencyObject? GetParent(DependencyObject current) => current switch
+ {
+ FrameworkContentElement contentElement =>
+ ContentOperations.GetParent(contentElement) ?? contentElement.Parent,
+ ContentElement contentElement => ContentOperations.GetParent(contentElement),
+ Visual or Visual3D => VisualTreeHelper.GetParent(current),
+ _ => LogicalTreeHelper.GetParent(current),
+ };
+
private void ShowDetailsWindow(string title, string details)
{
var window = new DetailsWindow(title, details) { Owner = this };
window.ShowDialog();
}
+ private void ShowDetailsWindow(string title, CellDetailsContent details)
+ {
+ var window = new DetailsWindow(title, details) { Owner = this };
+ window.ShowDialog();
+ }
+
}
diff --git a/src/ZCompare.App/ViewModels/MainWindowViewModel.cs b/src/ZCompare.App/ViewModels/MainWindowViewModel.cs
index 5847e79..6b41f76 100644
--- a/src/ZCompare.App/ViewModels/MainWindowViewModel.cs
+++ b/src/ZCompare.App/ViewModels/MainWindowViewModel.cs
@@ -4,6 +4,7 @@
using System.Diagnostics;
using System.IO;
using System.Security.Cryptography;
+using System.Text;
using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Threading;
@@ -656,9 +657,17 @@ public string CurrentRightFile
public string SelectedAddress
{
get => _selectedAddress;
- private set => SetProperty(ref _selectedAddress, value);
+ private set
+ {
+ if (SetProperty(ref _selectedAddress, value))
+ {
+ OnPropertyChanged(nameof(HasSelectedCell));
+ }
+ }
}
+ public bool HasSelectedCell => !string.IsNullOrWhiteSpace(SelectedAddress) && SelectedAddress != "—";
+
public string LeftSelectedRaw
{
get => _leftSelectedRaw;
@@ -895,17 +904,133 @@ public void SelectGridCell(int displayRowIndex, int columnIndex)
RightSelectedFormula = right?.Formula ?? string.Empty;
}
- public string GetCellDialogDetails(int displayRowIndex, int columnIndex)
+ public string GetCellDialogDetails(int displayRowIndex, int columnIndex) =>
+ GetCellDetailsContent(displayRowIndex, columnIndex)?.ClipboardText ?? string.Empty;
+
+ internal CellDetailsContent? GetCellDetailsContent(int displayRowIndex, int columnIndex)
{
+ var left = GridViewport.GetCell(CompareSide.Left, displayRowIndex, columnIndex);
+ var right = GridViewport.GetCell(CompareSide.Right, displayRowIndex, columnIndex);
+ if (left is null && right is null)
+ {
+ return null;
+ }
+
+ var valueDifference = left?.IsValueDifferent == true || right?.IsValueDifferent == true;
+ var segments = new List();
+ AppendCellValueDetails(segments, "左侧", left, right, valueDifference);
+ AppendDetailText(segments, Environment.NewLine + Environment.NewLine);
+ AppendCellValueDetails(segments, "右侧", right, left, valueDifference);
+
var details = new[]
{
- GridViewport.GetCell(CompareSide.Left, displayRowIndex, columnIndex)?.AdvancedDifferenceDetails,
- GridViewport.GetCell(CompareSide.Right, displayRowIndex, columnIndex)?.AdvancedDifferenceDetails,
+ left?.DifferenceDetails,
+ right?.DifferenceDetails,
+ left?.AdvancedDifferenceDetails,
+ right?.AdvancedDifferenceDetails,
}
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.ToArray();
- return string.Join(Environment.NewLine + Environment.NewLine, details!);
+ if (details.Length > 0)
+ {
+ AppendDetailText(
+ segments,
+ Environment.NewLine + Environment.NewLine + "【差异说明】" + Environment.NewLine);
+ AppendDetailText(
+ segments,
+ string.Join(Environment.NewLine + Environment.NewLine, details!));
+ }
+
+ return new CellDetailsContent(
+ string.Concat(segments.Select(static segment => segment.ClipboardText)),
+ segments);
+ }
+
+ private static void AppendCellValueDetails(
+ List segments,
+ string sideName,
+ GridCellViewModel? cell,
+ GridCellViewModel? oppositeCell,
+ bool valueDifference)
+ {
+ AppendDetailText(
+ segments,
+ $"【{sideName}】地址:{cell?.Address ?? "—"}{Environment.NewLine}" +
+ $"原始值:{Environment.NewLine}");
+ AppendSelectedValue(segments, cell?.RawValue, oppositeCell?.RawValue, valueDifference);
+ AppendDetailText(segments, Environment.NewLine + "显示值:" + Environment.NewLine);
+ AppendSelectedValue(segments, cell?.DisplayValue, oppositeCell?.DisplayValue, valueDifference);
+ if (!string.IsNullOrEmpty(cell?.Formula))
+ {
+ AppendDetailText(segments, Environment.NewLine + "公式:" + Environment.NewLine + cell.Formula);
+ }
+ }
+
+ private static void AppendSelectedValue(
+ List segments,
+ string? value,
+ string? oppositeValue,
+ bool valueDifference)
+ {
+ value ??= string.Empty;
+ oppositeValue ??= string.Empty;
+ if (value.Length == 0)
+ {
+ AppendDetailText(
+ segments,
+ "(空值)",
+ string.Empty,
+ valueDifference && oppositeValue.Length > 0);
+ return;
+ }
+
+ var visualizeWhitespace = string.IsNullOrWhiteSpace(value);
+ foreach (var segment in TextDifferenceHighlighter.CreateSegments(value, oppositeValue, valueDifference))
+ {
+ AppendDetailText(
+ segments,
+ visualizeWhitespace ? VisualizeWhitespace(segment.Text) : segment.Text,
+ segment.Text,
+ segment.IsDifferent);
+ }
+ }
+
+ private static string VisualizeWhitespace(string value) => value
+ .Replace("\r", "␍", StringComparison.Ordinal)
+ .Replace("\n", "␊", StringComparison.Ordinal)
+ .Replace("\t", "⇥", StringComparison.Ordinal)
+ .Replace(" ", "␠", StringComparison.Ordinal);
+
+ private static void AppendDetailText(
+ List segments,
+ string text,
+ bool isDifferent = false) =>
+ AppendDetailText(segments, text, text, isDifferent);
+
+ private static void AppendDetailText(
+ List segments,
+ string displayText,
+ string clipboardText,
+ bool isDifferent)
+ {
+ if (displayText.Length == 0 && clipboardText.Length == 0)
+ {
+ return;
+ }
+
+ if (segments.Count > 0 && segments[^1].IsDifferent == isDifferent)
+ {
+ var previous = segments[^1];
+ segments[^1] = previous with
+ {
+ DisplayText = previous.DisplayText + displayText,
+ ClipboardText = previous.ClipboardText + clipboardText,
+ };
+ return;
+ }
+
+ segments.Add(new DetailTextSegment(displayText, clipboardText, isDifferent));
}
private void SetMode(CompareMode mode)
diff --git a/src/ZCompare.App/ViewModels/PresentationModels.cs b/src/ZCompare.App/ViewModels/PresentationModels.cs
index c8da4f3..13490eb 100644
--- a/src/ZCompare.App/ViewModels/PresentationModels.cs
+++ b/src/ZCompare.App/ViewModels/PresentationModels.cs
@@ -466,6 +466,8 @@ internal sealed class GridCellViewModel
public string DisplayValue { get; init; } = string.Empty;
+ public IReadOnlyList DisplaySegments { get; init; } = [];
+
public string RawValue { get; init; } = string.Empty;
public string Formula { get; init; } = string.Empty;
@@ -1152,7 +1154,11 @@ oppositeSourceColumn is { } otherColumn
var cell = reference is not null && _cells.TryGetValue(reference, out var localCell)
? localCell
: null;
- var oppositeExists = oppositeReference is not null && _oppositeCells.ContainsKey(oppositeReference);
+ var oppositeCell = oppositeReference is not null &&
+ _oppositeCells.TryGetValue(oppositeReference, out var matchedOppositeCell)
+ ? matchedOppositeCell
+ : null;
+ var oppositeExists = oppositeCell is not null;
_differences.TryGetValue(displayReference, out var differences);
var alignedOneSided = (_row is null) != (_oppositeRow is null);
var oneSidedValue = (_oneSidedWorksheet || alignedOneSided) && (cell is not null || oppositeExists);
@@ -1178,9 +1184,14 @@ oppositeSourceColumn is { } otherColumn
: _rowDifferent
? new SolidColorBrush(Color.FromRgb(255, 241, 242))
: ParseBrush(cell?.Format?.BackgroundArgb, Brushes.White);
- var foreground = isDifferent
+ var foreground = isDifferent && !valueDifferent
? new SolidColorBrush(Color.FromRgb(185, 28, 28))
: ParseBrush(cell?.Format?.ForegroundArgb, Brushes.Black);
+ var displayValue = cell?.DisplayValue ?? string.Empty;
+ var displaySegments = TextDifferenceHighlighter.CreateSegments(
+ displayValue,
+ oppositeCell?.DisplayValue ?? string.Empty,
+ valueDifferent);
var advancedDetails = dialogDifferences
.Select(static difference => DifferencePresentation.FormatDetail(difference))
@@ -1190,7 +1201,8 @@ oppositeSourceColumn is { } otherColumn
return new GridCellViewModel
{
Address = reference ?? "—",
- DisplayValue = cell?.DisplayValue ?? string.Empty,
+ DisplayValue = displayValue,
+ DisplaySegments = displaySegments,
RawValue = cell?.RawValue ?? string.Empty,
Formula = cell?.Formula ?? string.Empty,
IsDifferent = isDifferent,
diff --git a/src/ZCompare.App/ViewModels/TextDifferenceHighlighter.cs b/src/ZCompare.App/ViewModels/TextDifferenceHighlighter.cs
new file mode 100644
index 0000000..b141c78
--- /dev/null
+++ b/src/ZCompare.App/ViewModels/TextDifferenceHighlighter.cs
@@ -0,0 +1,279 @@
+using System.Text;
+
+namespace ZCompare.App.ViewModels;
+
+internal sealed record TextDifferenceSegment(string Text, bool IsDifferent);
+
+internal sealed record DetailTextSegment(
+ string DisplayText,
+ string ClipboardText,
+ bool IsDifferent);
+
+internal sealed record CellDetailsContent(
+ string ClipboardText,
+ IReadOnlyList Segments);
+
+internal static class TextDifferenceHighlighter
+{
+ private const int MaximumEditDistance = 256;
+ private const int MaximumDetailedLength = 20_000;
+
+ public static IReadOnlyList CreateSegments(
+ string text,
+ string oppositeText,
+ bool highlightDifferences)
+ {
+ if (text.Length == 0)
+ {
+ return [];
+ }
+
+ if (!highlightDifferences || string.Equals(text, oppositeText, StringComparison.Ordinal))
+ {
+ return [new TextDifferenceSegment(text, false)];
+ }
+
+ var prefixLength = CommonPrefixLength(text, oppositeText);
+ var suffixLength = CommonSuffixLength(text, oppositeText, prefixLength);
+ var textMiddle = text.AsSpan(prefixLength, text.Length - prefixLength - suffixLength);
+ var oppositeMiddle = oppositeText.AsSpan(
+ prefixLength,
+ oppositeText.Length - prefixLength - suffixLength);
+ var segments = new List(5);
+
+ AddSegment(segments, text[..prefixLength], false);
+ if (textMiddle.Length > 0)
+ {
+ if (textMiddle.Length + oppositeMiddle.Length <= MaximumDetailedLength &&
+ !ContainsSurrogate(textMiddle) &&
+ !ContainsSurrogate(oppositeMiddle) &&
+ TryCreateDetailedSegments(textMiddle.ToString(), oppositeMiddle.ToString(), out var detailed))
+ {
+ foreach (var segment in detailed)
+ {
+ AddSegment(segments, segment.Text, segment.IsDifferent);
+ }
+ }
+ else
+ {
+ AddSegment(segments, textMiddle.ToString(), true);
+ }
+ }
+
+ if (suffixLength > 0)
+ {
+ AddSegment(segments, text[^suffixLength..], false);
+ }
+
+ return segments;
+ }
+
+ private static bool TryCreateDetailedSegments(
+ string text,
+ string oppositeText,
+ out IReadOnlyList segments)
+ {
+ var sourceLength = text.Length;
+ var targetLength = oppositeText.Length;
+ var maximum = sourceLength + targetLength;
+ var previous = new Dictionary { [1] = 0 };
+ var trace = new List>(
+ Math.Min(maximum, MaximumEditDistance) + 1);
+
+ for (var distance = 0; distance <= Math.Min(maximum, MaximumEditDistance); distance++)
+ {
+ var current = new Dictionary((distance * 2) + 1);
+ for (var diagonal = -distance; diagonal <= distance; diagonal += 2)
+ {
+ var moveDown = diagonal == -distance ||
+ (diagonal != distance && Get(previous, diagonal - 1) < Get(previous, diagonal + 1));
+ var x = moveDown
+ ? Get(previous, diagonal + 1)
+ : Get(previous, diagonal - 1) + 1;
+ var y = x - diagonal;
+ while (x < sourceLength && y < targetLength && text[x] == oppositeText[y])
+ {
+ x++;
+ y++;
+ }
+
+ current[diagonal] = x;
+ if (x < sourceLength || y < targetLength)
+ {
+ continue;
+ }
+
+ trace.Add(current);
+ segments = Backtrack(text, oppositeText, trace);
+ return true;
+ }
+
+ trace.Add(current);
+ previous = current;
+ }
+
+ segments = [];
+ return false;
+ }
+
+ private static IReadOnlyList Backtrack(
+ string text,
+ string oppositeText,
+ IReadOnlyList> trace)
+ {
+ var edits = new List<(char Character, EditKind Kind)>(text.Length + oppositeText.Length);
+ var x = text.Length;
+ var y = oppositeText.Length;
+
+ for (var distance = trace.Count - 1; distance > 0; distance--)
+ {
+ var previous = trace[distance - 1];
+ var diagonal = x - y;
+ var moveDown = diagonal == -distance ||
+ (diagonal != distance && Get(previous, diagonal - 1) < Get(previous, diagonal + 1));
+ var previousDiagonal = moveDown ? diagonal + 1 : diagonal - 1;
+ var previousX = Get(previous, previousDiagonal);
+ var previousY = previousX - previousDiagonal;
+
+ while (x > previousX && y > previousY)
+ {
+ edits.Add((text[x - 1], EditKind.Equal));
+ x--;
+ y--;
+ }
+
+ if (moveDown)
+ {
+ edits.Add((oppositeText[y - 1], EditKind.Inserted));
+ y--;
+ }
+ else
+ {
+ edits.Add((text[x - 1], EditKind.Deleted));
+ x--;
+ }
+ }
+
+ while (x > 0 && y > 0)
+ {
+ edits.Add((text[x - 1], EditKind.Equal));
+ x--;
+ y--;
+ }
+
+ while (x > 0)
+ {
+ edits.Add((text[--x], EditKind.Deleted));
+ }
+
+ while (y > 0)
+ {
+ edits.Add((oppositeText[--y], EditKind.Inserted));
+ }
+
+ edits.Reverse();
+ var segments = new List();
+ var builder = new StringBuilder();
+ bool? currentDifference = null;
+ foreach (var edit in edits)
+ {
+ if (edit.Kind == EditKind.Inserted)
+ {
+ continue;
+ }
+
+ var isDifferent = edit.Kind == EditKind.Deleted;
+ if (currentDifference != isDifferent)
+ {
+ AddSegment(segments, builder.ToString(), currentDifference == true);
+ builder.Clear();
+ currentDifference = isDifferent;
+ }
+
+ builder.Append(edit.Character);
+ }
+
+ AddSegment(segments, builder.ToString(), currentDifference == true);
+ return segments;
+ }
+
+ private static int CommonPrefixLength(string left, string right)
+ {
+ var maximum = Math.Min(left.Length, right.Length);
+ var index = 0;
+ while (index < maximum && left[index] == right[index])
+ {
+ index++;
+ }
+
+ return index > 0 &&
+ index < left.Length &&
+ index < right.Length &&
+ char.IsHighSurrogate(left[index - 1])
+ ? index - 1
+ : index;
+ }
+
+ private static int CommonSuffixLength(string left, string right, int prefixLength)
+ {
+ var maximum = Math.Min(left.Length, right.Length) - prefixLength;
+ var length = 0;
+ while (length < maximum && left[^(length + 1)] == right[^(length + 1)])
+ {
+ length++;
+ }
+
+ var leftStart = left.Length - length;
+ var rightStart = right.Length - length;
+ return length > 0 &&
+ leftStart < left.Length &&
+ rightStart < right.Length &&
+ (char.IsLowSurrogate(left[leftStart]) || char.IsLowSurrogate(right[rightStart]))
+ ? length - 1
+ : length;
+ }
+
+ private static bool ContainsSurrogate(ReadOnlySpan text)
+ {
+ foreach (var character in text)
+ {
+ if (char.IsSurrogate(character))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static int Get(IReadOnlyDictionary values, int key) =>
+ values.TryGetValue(key, out var value) ? value : -1;
+
+ private static void AddSegment(
+ ICollection segments,
+ string text,
+ bool isDifferent)
+ {
+ if (text.Length == 0)
+ {
+ return;
+ }
+
+ if (segments is List list &&
+ list.Count > 0 &&
+ list[^1].IsDifferent == isDifferent)
+ {
+ list[^1] = list[^1] with { Text = list[^1].Text + text };
+ return;
+ }
+
+ segments.Add(new TextDifferenceSegment(text, isDifferent));
+ }
+
+ private enum EditKind
+ {
+ Equal,
+ Deleted,
+ Inserted,
+ }
+}
diff --git a/src/ZCompare.App/ZCompare.App.csproj b/src/ZCompare.App/ZCompare.App.csproj
index e191444..4f1558a 100644
--- a/src/ZCompare.App/ZCompare.App.csproj
+++ b/src/ZCompare.App/ZCompare.App.csproj
@@ -4,12 +4,6 @@
-
-
- Assets\zcompare.ico
-
-
-
WinExe
net10.0-windows
diff --git a/tests/ZCompare.App.Tests/DetailsWindowTests.cs b/tests/ZCompare.App.Tests/DetailsWindowTests.cs
new file mode 100644
index 0000000..29bc1fd
--- /dev/null
+++ b/tests/ZCompare.App.Tests/DetailsWindowTests.cs
@@ -0,0 +1,54 @@
+using System.Reflection;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Documents;
+using System.Windows.Media;
+using ZCompare.App.ViewModels;
+
+namespace ZCompare.App.Tests;
+
+public sealed class DetailsWindowTests
+{
+ [Fact]
+ public void DifferenceRunsUseHighlightStyleAndPlainRunsRemainUnstyled()
+ {
+ Exception? failure = null;
+ var thread = new Thread(() =>
+ {
+ try
+ {
+ var content = new CellDetailsContent(
+ "normal2",
+ [
+ new DetailTextSegment("normal", "normal", false),
+ new DetailTextSegment("2", "2", true),
+ ]);
+ var window = new DetailsWindow("差异详情", content);
+ var textBlock = Assert.IsType(window.FindName("DetailsTextBlock"));
+ var runs = textBlock.Inlines.OfType().ToArray();
+
+ Assert.Equal(2, runs.Length);
+ Assert.Equal(DependencyProperty.UnsetValue, runs[0].ReadLocalValue(TextElement.ForegroundProperty));
+ Assert.Equal(DependencyProperty.UnsetValue, runs[0].ReadLocalValue(TextElement.BackgroundProperty));
+ Assert.Equal(DependencyProperty.UnsetValue, runs[0].ReadLocalValue(TextElement.FontWeightProperty));
+ Assert.Equal(Color.FromRgb(153, 27, 27), Assert.IsType(runs[1].Foreground).Color);
+ Assert.Equal(Color.FromRgb(254, 202, 202), Assert.IsType(runs[1].Background).Color);
+ Assert.Equal(FontWeights.SemiBold, runs[1].FontWeight);
+
+ var clipboardField = typeof(DetailsWindow)
+ .GetField("_clipboardText", BindingFlags.Instance | BindingFlags.NonPublic);
+ Assert.NotNull(clipboardField);
+ Assert.Equal(content.ClipboardText, clipboardField.GetValue(window));
+ }
+ catch (Exception exception)
+ {
+ failure = exception;
+ }
+ });
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ Assert.True(thread.Join(TimeSpan.FromSeconds(10)), "DetailsWindow rendering timed out.");
+
+ Assert.Null(failure);
+ }
+}
diff --git a/tests/ZCompare.App.Tests/MainWindowSmokeTests.cs b/tests/ZCompare.App.Tests/MainWindowSmokeTests.cs
new file mode 100644
index 0000000..8f45b2e
--- /dev/null
+++ b/tests/ZCompare.App.Tests/MainWindowSmokeTests.cs
@@ -0,0 +1,77 @@
+using System.Reflection;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Documents;
+using ZCompare.App.Services;
+
+namespace ZCompare.App.Tests;
+
+public sealed class MainWindowSmokeTests
+{
+ [Fact]
+ public void SpreadsheetCellAncestorLookupHandlesInlineRun()
+ {
+ Exception? failure = null;
+ object? ancestor = null;
+ var thread = new Thread(() =>
+ {
+ try
+ {
+ var run = new Run("1002");
+ var textBlock = new TextBlock();
+ textBlock.Inlines.Add(run);
+ var cell = new DataGridCell { Content = textBlock };
+ cell.ApplyTemplate();
+ cell.Measure(new Size(200, 40));
+ cell.Arrange(new Rect(0, 0, 200, 40));
+ cell.UpdateLayout();
+
+ var method = typeof(MainWindow)
+ .GetMethod("FindVisualAncestor", BindingFlags.NonPublic | BindingFlags.Static)!
+ .MakeGenericMethod(typeof(DataGridCell));
+ ancestor = method.Invoke(null, [run]);
+ }
+ catch (TargetInvocationException exception)
+ {
+ failure = exception.InnerException ?? exception;
+ }
+ catch (Exception exception)
+ {
+ failure = exception;
+ }
+ });
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ Assert.True(thread.Join(TimeSpan.FromSeconds(5)), "Inline ancestor lookup timed out.");
+
+ Assert.Null(failure);
+ Assert.IsType(ancestor);
+ }
+
+ [Fact]
+ public void MainWindowCanBeConstructed()
+ {
+ Exception? failure = null;
+ var thread = new Thread(() =>
+ {
+ try
+ {
+ var application = new App();
+ application.InitializeComponent();
+ var window = new MainWindow();
+ Assert.Contains($"ZCompare {ProductInfo.VersionText}", window.Title, StringComparison.Ordinal);
+ window.Close();
+ application.Shutdown();
+ }
+ catch (Exception exception)
+ {
+ failure = exception;
+ }
+ });
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ Assert.True(thread.Join(TimeSpan.FromSeconds(10)), "MainWindow construction timed out.");
+
+ Assert.Null(failure);
+ }
+}
diff --git a/tests/ZCompare.App.Tests/PresentationModelsTests.cs b/tests/ZCompare.App.Tests/PresentationModelsTests.cs
index 914319b..33449a9 100644
--- a/tests/ZCompare.App.Tests/PresentationModelsTests.cs
+++ b/tests/ZCompare.App.Tests/PresentationModelsTests.cs
@@ -113,8 +113,8 @@ public void TwoSidedDifferenceColorsWholeRowAndOnlyExactCellText()
Assert.True(changedLeft.IsDifferent);
Assert.True(changedLeft.IsValueDifferent);
Assert.True(changedRight.IsDifferent);
- AssertBrushColor(changedLeft.Foreground, 185, 28, 28);
- AssertBrushColor(changedRight.Foreground, 185, 28, 28);
+ AssertBrushColor(changedLeft.Foreground, 0, 0, 0);
+ AssertBrushColor(changedRight.Foreground, 0, 0, 0);
}
[Fact]
@@ -141,8 +141,8 @@ public void OneSidedWorksheetColorsExistingAndMissingCellsAsDifferences()
AssertBrushColor(existing.Background, 252, 165, 165);
Assert.Equal(FontWeights.SemiBold, existing.FontWeight);
AssertBrushColor(missing.Background, 248, 250, 252);
- AssertBrushColor(existing.Foreground, 185, 28, 28);
- AssertBrushColor(missing.Foreground, 185, 28, 28);
+ AssertBrushColor(existing.Foreground, 0, 0, 0);
+ AssertBrushColor(missing.Foreground, 0, 0, 0);
}
[Fact]
diff --git a/tests/ZCompare.App.Tests/TextDifferenceHighlighterTests.cs b/tests/ZCompare.App.Tests/TextDifferenceHighlighterTests.cs
new file mode 100644
index 0000000..de2115b
--- /dev/null
+++ b/tests/ZCompare.App.Tests/TextDifferenceHighlighterTests.cs
@@ -0,0 +1,205 @@
+using ZCompare.App.ViewModels;
+using ZCompare.Core;
+
+namespace ZCompare.App.Tests;
+
+public sealed class TextDifferenceHighlighterTests
+{
+ [Fact]
+ public void HighlightsOnlyChangedCharacters()
+ {
+ var left = TextDifferenceHighlighter.CreateSegments("1002", "1004", true);
+ var right = TextDifferenceHighlighter.CreateSegments("1004", "1002", true);
+
+ AssertSegments(left, ("100", false), ("2", true));
+ AssertSegments(right, ("100", false), ("4", true));
+ }
+
+ [Fact]
+ public void KeepsSeparateChangedRegionsPrecise()
+ {
+ var left = TextDifferenceHighlighter.CreateSegments("abcXdefYghi", "abcZdefWghi", true);
+
+ AssertSegments(left,
+ ("abc", false),
+ ("X", true),
+ ("def", false),
+ ("Y", true),
+ ("ghi", false));
+ }
+
+ [Fact]
+ public void InsertionHighlightsOnlyTheSideContainingCharacters()
+ {
+ var left = TextDifferenceHighlighter.CreateSegments("abcd", "abXcd", true);
+ var right = TextDifferenceHighlighter.CreateSegments("abXcd", "abcd", true);
+
+ AssertSegments(left, ("abcd", false));
+ AssertSegments(right, ("ab", false), ("X", true), ("cd", false));
+ }
+
+ [Fact]
+ public void DisabledHighlightingDoesNotInventCaseDifferences()
+ {
+ var segments = TextDifferenceHighlighter.CreateSegments("Alpha", "alpha", false);
+
+ AssertSegments(segments, ("Alpha", false));
+ }
+
+ [Fact]
+ public void EmojiRemainValidTextWhenTheyDiffer()
+ {
+ var segments = TextDifferenceHighlighter.CreateSegments("a😀b", "a😁b", true);
+
+ Assert.Equal("a😀b", string.Concat(segments.Select(static segment => segment.Text)));
+ Assert.Contains(segments, static segment => segment.Text == "😀" && segment.IsDifferent);
+ }
+
+ [Fact]
+ public void LongValuesWithSparseChangesRemainPrecise()
+ {
+ var sharedMiddle = new string('m', 8_000);
+ var leftText = "start-A-" + sharedMiddle + "-B-end";
+ var rightText = "start-X-" + sharedMiddle + "-Y-end";
+
+ var segments = TextDifferenceHighlighter.CreateSegments(leftText, rightText, true);
+
+ Assert.Equal(leftText, string.Concat(segments.Select(static segment => segment.Text)));
+ Assert.Equal(["A", "B"], segments.Where(static segment => segment.IsDifferent).Select(static segment => segment.Text));
+ }
+
+ [Fact]
+ public void FullCellDetailsContainUnclippedPlainValuesWithoutMarkerCharacters()
+ {
+ var prefix = new string('x', 240);
+ var leftCell = TestViewModels.Cell("C7", prefix + "1002");
+ var rightCell = TestViewModels.Cell("C7", prefix + "1004");
+ var difference = new Difference(
+ DifferenceKind.Value,
+ "Sheet1",
+ "C7",
+ "Value differs.",
+ leftCell,
+ rightCell,
+ leftCell.RawValue,
+ rightCell.RawValue);
+ var viewModel = TestViewModels.CreateMainWindow();
+ viewModel.GridViewport.SetPreviews(
+ TestViewModels.Preview(leftCell),
+ TestViewModels.Preview(rightCell),
+ [difference],
+ differencesOnly: false);
+
+ viewModel.SelectGridCell(6, 2);
+ var details = viewModel.GetCellDialogDetails(6, 2);
+
+ Assert.True(viewModel.HasSelectedCell);
+ Assert.Contains(prefix + "1002", details, StringComparison.Ordinal);
+ Assert.Contains(prefix + "1004", details, StringComparison.Ordinal);
+ Assert.DoesNotContain("⟦", details, StringComparison.Ordinal);
+ Assert.DoesNotContain("⟧", details, StringComparison.Ordinal);
+ Assert.Contains("【差异说明】", details, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void RawAndDisplayValuesReconstructFromColoredSegmentsForBothSides()
+ {
+ AssertColoredPair("raw-1002", "raw-1004", "2", "4");
+ AssertColoredPair("¥1,002.00", "¥1,004.00", "2", "4");
+ }
+
+ [Fact]
+ public void CellDetailsKeepRealBracketsAndTrailingSpacesWithoutSyntheticMarkers()
+ {
+ const string leftRaw = "raw⟦1002⟧ ";
+ const string rightRaw = "raw⟦1004⟧ ";
+ const string leftDisplay = "display⟦1,002⟧ ";
+ const string rightDisplay = "display⟦1,004⟧ ";
+ var leftCell = TestViewModels.Cell("C7", leftRaw) with { DisplayValue = leftDisplay };
+ var rightCell = TestViewModels.Cell("C7", rightRaw) with { DisplayValue = rightDisplay };
+ var content = CreateDetailsContent(leftCell, rightCell);
+ var displayText = string.Concat(content.Segments.Select(static segment => segment.DisplayText));
+
+ Assert.Equal(
+ content.ClipboardText,
+ string.Concat(content.Segments.Select(static segment => segment.ClipboardText)));
+ Assert.Contains(leftRaw + Environment.NewLine + "显示值:", content.ClipboardText, StringComparison.Ordinal);
+ Assert.Contains(rightRaw + Environment.NewLine + "显示值:", content.ClipboardText, StringComparison.Ordinal);
+ Assert.Contains(leftDisplay, content.ClipboardText, StringComparison.Ordinal);
+ Assert.Contains(rightDisplay, content.ClipboardText, StringComparison.Ordinal);
+ Assert.Contains(leftRaw, displayText, StringComparison.Ordinal);
+ Assert.Contains(rightDisplay, displayText, StringComparison.Ordinal);
+ Assert.DoesNotContain("100⟦2⟧", content.ClipboardText, StringComparison.Ordinal);
+ Assert.DoesNotContain("100⟦4⟧", content.ClipboardText, StringComparison.Ordinal);
+ Assert.Contains(content.Segments, static segment => segment.IsDifferent && segment.ClipboardText == "2");
+ Assert.Contains(content.Segments, static segment => segment.IsDifferent && segment.ClipboardText == "4");
+ }
+
+ [Fact]
+ public void CellDetailsVisualizeWhitespaceWithoutChangingClipboardText()
+ {
+ var leftCell = TestViewModels.Cell("C7", " \t ");
+ var rightCell = TestViewModels.Cell("C7", " \t");
+ var content = CreateDetailsContent(leftCell, rightCell);
+ var displayText = string.Concat(content.Segments.Select(static segment => segment.DisplayText));
+ var lineBreak = Environment.NewLine;
+ var clipboardValueText = content.ClipboardText.Split("【差异说明】", StringSplitOptions.None)[0];
+
+ Assert.Contains($"原始值:{lineBreak} \t {lineBreak}显示值:{lineBreak} \t ", content.ClipboardText, StringComparison.Ordinal);
+ Assert.Contains($"原始值:{lineBreak}␠⇥␠{lineBreak}显示值:{lineBreak}␠⇥␠", displayText, StringComparison.Ordinal);
+ Assert.DoesNotContain("␠", clipboardValueText, StringComparison.Ordinal);
+ Assert.DoesNotContain("⇥", clipboardValueText, StringComparison.Ordinal);
+ Assert.Contains(content.Segments, static segment =>
+ segment.IsDifferent && segment.DisplayText == "␠" && segment.ClipboardText == " ");
+ }
+
+ private static void AssertSegments(
+ IReadOnlyList actual,
+ params (string Text, bool IsDifferent)[] expected)
+ {
+ Assert.Equal(expected.Length, actual.Count);
+ for (var index = 0; index < expected.Length; index++)
+ {
+ Assert.Equal(expected[index].Text, actual[index].Text);
+ Assert.Equal(expected[index].IsDifferent, actual[index].IsDifferent);
+ }
+ }
+
+ private static void AssertColoredPair(
+ string left,
+ string right,
+ string leftDifference,
+ string rightDifference)
+ {
+ var leftSegments = TextDifferenceHighlighter.CreateSegments(left, right, true);
+ var rightSegments = TextDifferenceHighlighter.CreateSegments(right, left, true);
+
+ Assert.Equal(left, string.Concat(leftSegments.Select(static segment => segment.Text)));
+ Assert.Equal(right, string.Concat(rightSegments.Select(static segment => segment.Text)));
+ Assert.Equal(leftDifference, string.Concat(
+ leftSegments.Where(static segment => segment.IsDifferent).Select(static segment => segment.Text)));
+ Assert.Equal(rightDifference, string.Concat(
+ rightSegments.Where(static segment => segment.IsDifferent).Select(static segment => segment.Text)));
+ }
+
+ private static CellDetailsContent CreateDetailsContent(CellSnapshot leftCell, CellSnapshot rightCell)
+ {
+ var difference = new Difference(
+ DifferenceKind.Value,
+ "Sheet1",
+ "C7",
+ "Value differs.",
+ leftCell,
+ rightCell,
+ leftCell.RawValue,
+ rightCell.RawValue);
+ var viewModel = TestViewModels.CreateMainWindow();
+ viewModel.GridViewport.SetPreviews(
+ TestViewModels.Preview(leftCell),
+ TestViewModels.Preview(rightCell),
+ [difference],
+ differencesOnly: false);
+
+ return Assert.IsType(viewModel.GetCellDetailsContent(6, 2));
+ }
+}
diff --git a/tests/ZCompare.Tests/CliIntegrationTests.cs b/tests/ZCompare.Tests/CliIntegrationTests.cs
index 73e21b2..91cd2b3 100644
--- a/tests/ZCompare.Tests/CliIntegrationTests.cs
+++ b/tests/ZCompare.Tests/CliIntegrationTests.cs
@@ -16,7 +16,7 @@ public async Task VersionReturnsProductVersion(string argument)
var result = await RunCliAsync(argument);
Assert.Equal(0, result.ExitCode);
- Assert.Equal("ZCompare 0.1.1", result.StandardOutput.Trim());
+ Assert.Equal("ZCompare 0.1.2", result.StandardOutput.Trim());
}
[Theory]
diff --git a/tests/ZCompare.Tests/ComparisonReportExporterTests.cs b/tests/ZCompare.Tests/ComparisonReportExporterTests.cs
index 93d6e91..0d73182 100644
--- a/tests/ZCompare.Tests/ComparisonReportExporterTests.cs
+++ b/tests/ZCompare.Tests/ComparisonReportExporterTests.cs
@@ -22,7 +22,7 @@ await ComparisonReportExporter.ExportAsync(
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(outputPath));
var root = document.RootElement;
- Assert.Equal("0.1.1", root.GetProperty("ZCompareVersion").GetString());
+ Assert.Equal("0.1.2", root.GetProperty("ZCompareVersion").GetString());
Assert.Equal("Different", root.GetProperty("Status").GetString());
Assert.Equal(1, root.GetProperty("DifferenceCount").GetInt32());
var worksheet = Assert.Single(root.GetProperty("Worksheets").EnumerateArray());