Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### [Unreleased]

#### Added

- **Copy old/new compared-file paths from HTML reports** — Added the same two-file copy button used for IL details to `TextMatch` / `TextMismatch` labels in `diff_report.html`. The button copies the quoted absolute paths of the original old/new files.

### [1.23.0] - 2026-07-29

#### Added
Expand Down Expand Up @@ -1713,6 +1717,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### [Unreleased]

#### 追加

- **HTML レポートから比較対象ファイルの新旧パスをコピー** — `diff_report.html` の `TextMatch` / `TextMismatch` ラベルに、IL 明細と同じ2ファイル用コピーボタンを追加しました。元の新旧ファイルの引用符付き絶対パスをコピーできます。

### [1.23.0] - 2026-07-29

#### 追加
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,12 @@ public void GenerateDiffReportHtml_ILRows_HaveDistinctOldNewIlPathCopyButtons()
_resultLists.RecordDiffDetail("match.dll", FileDiffResultLists.DiffDetailResult.ILMatch);
_resultLists.AddModifiedFileRelativePath("mismatch.dll");
_resultLists.RecordDiffDetail("mismatch.dll", FileDiffResultLists.DiffDetailResult.ILMismatch);
_resultLists.RecordNewFileTimestampOlderThanOldWarning(
"mismatch.dll", "2026-03-15 10:00:00", "2026-03-15 09:00:00");

var builder = CreateConfigBuilder();
builder.ShouldOutputILText = true;
builder.ShouldWarnWhenNewFileTimestampIsOlderThanOldFileTimestamp = true;
var config = builder.Build();
_service.GenerateDiffReportHtml(CreateReportContext(oldDir, newDir, reportDir, config));

Expand All @@ -141,7 +144,9 @@ public void GenerateDiffReportHtml_ILRows_HaveDistinctOldNewIlPathCopyButtons()
Assert.Contains("<code>ILMismatch</code><span class=\"btn-tooltip-wrap il-copy-tooltip-wrap\">", html);
Assert.Contains("data-il-file=\"match.dll_IL.txt\"", html);
Assert.Contains("data-il-file=\"mismatch.dll_IL.txt\"", html);
Assert.Contains("class=\"copy-icon il-path-pair-icon\"", html);
Assert.Equal(1, html.Split("data-il-file=\"match.dll_IL.txt\"", StringSplitOptions.None).Length - 1);
Assert.Equal(2, html.Split("data-il-file=\"mismatch.dll_IL.txt\"", StringSplitOptions.None).Length - 1);
Assert.Contains("class=\"copy-icon path-pair-icon\"", html);
Assert.Contains("onclick=\"copyIlPaths(this)\"", html);
Assert.Contains("Copy the quoted old/new absolute IL text paths for use with a text-based diff tool.", html);
Assert.Contains("background: color-mix(in srgb, var(--color-surface) 60%, transparent);", html);
Expand Down Expand Up @@ -175,6 +180,42 @@ public void GenerateDiffReportHtml_ShouldOutputIlTextFalse_OmitsIlPathMetadataAn
Assert.DoesNotContain("data-il-file=\"", html);
}

[Fact]
public void GenerateDiffReportHtml_TextRows_HaveDistinctOldNewComparedFilePathCopyButtons()
{
var (oldDir, newDir, reportDir) = MakeDirs("text-path-copy-btn");

const string matchPath = "config/match.json";
const string mismatchPath = "config/mismatch.json";
_resultLists.AddUnchangedFileRelativePath(matchPath);
_resultLists.RecordDiffDetail(matchPath, FileDiffResultLists.DiffDetailResult.TextMatch);
_resultLists.AddModifiedFileRelativePath(mismatchPath);
_resultLists.RecordDiffDetail(mismatchPath, FileDiffResultLists.DiffDetailResult.TextMismatch);
_resultLists.RecordNewFileTimestampOlderThanOldWarning(
mismatchPath, "2026-03-15 10:00:00", "2026-03-15 09:00:00");

var builder = CreateConfigBuilder();
builder.ShouldWarnWhenNewFileTimestampIsOlderThanOldFileTimestamp = true;
var config = builder.Build();
_service.GenerateDiffReportHtml(CreateReportContext(oldDir, newDir, reportDir, config));

var html = File.ReadAllText(Path.Combine(reportDir, HtmlReportGenerateService.DIFF_REPORT_HTML_FILE_NAME));

Assert.Contains("<code>TextMatch</code><span class=\"btn-tooltip-wrap text-copy-tooltip-wrap\">", html);
Assert.Contains("<code>TextMismatch</code><span class=\"btn-tooltip-wrap text-copy-tooltip-wrap\">", html);
Assert.Contains($"data-text-old-prefix=\"{Path.GetFullPath(oldDir)}{Path.DirectorySeparatorChar}\"", html);
Assert.Contains($"data-text-new-prefix=\"{Path.GetFullPath(newDir)}{Path.DirectorySeparatorChar}\"", html);
string platformMatchPath = matchPath.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
string platformMismatchPath = mismatchPath.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
string matchFileAttribute = $"data-text-file=\"{platformMatchPath}\"";
string mismatchFileAttribute = $"data-text-file=\"{platformMismatchPath}\"";
Assert.Equal(1, html.Split(matchFileAttribute, StringSplitOptions.None).Length - 1);
Assert.Equal(2, html.Split(mismatchFileAttribute, StringSplitOptions.None).Length - 1);
Assert.Contains("class=\"btn-copy-path btn-copy-text-path\"", html);
Assert.Contains("onclick=\"copyTextPaths(this)\"", html);
Assert.Contains("Copy the quoted old/new absolute file paths for use with a text-based diff tool.", html);
}

// ── Req8: Row hover highlight / 行ホバーハイライト ──────────────────────

[Fact]
Expand Down
128 changes: 128 additions & 0 deletions JsTests/diff_report.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1380,6 +1380,134 @@ describe('copyIlPaths', () => {
});
});

// ─── copyTextPaths ──────────────────────────────────────────────────────────
// テキスト比較対象ファイルの新旧絶対パスコピーのテスト
describe('copyTextPaths', () => {
function loadTextPathButton() {
loadScript({
bodyHtml: `
<button id="copy-text-btn" class="btn-copy-path btn-copy-text-path">
<svg class="copy-icon"></svg><span class="copy-result"></span>
</button>
<span id="save-status"></span>
`,
});
navigator.clipboard = {
writeText: (text) => {
clipboardText = text;
return Promise.resolve();
},
};
window.alert = jest.fn();
return document.getElementById('copy-text-btn');
}

let clipboardText;

beforeEach(() => {
clipboardText = '';
});

test('copies quoted old and new Windows drive paths', async () => {
const btn = loadTextPathButton();
btn.setAttribute('data-text-file', 'config\\app.config');
document.body.setAttribute('data-text-old-prefix', 'C:\\Old Folder\\');
document.body.setAttribute('data-text-new-prefix', 'D:\\New Folder\\');

const copied = await window.copyTextPaths(btn);

expect(copied).toBe(true);
expect(clipboardText).toBe('"C:\\Old Folder\\config\\app.config" "D:\\New Folder\\config\\app.config"');
expect(btn.classList.contains('is-copy-success')).toBe(true);
expect(window.alert).not.toHaveBeenCalled();
});

test('copies quoted old and new Windows UNC paths', async () => {
const btn = loadTextPathButton();
btn.setAttribute('data-text-file', 'config\\app.config');
document.body.setAttribute('data-text-old-prefix', '\\\\old-server\\share\\');
document.body.setAttribute('data-text-new-prefix', '\\\\new-server\\share\\');

await window.copyTextPaths(btn);

expect(clipboardText).toBe('"\\\\old-server\\share\\config\\app.config" "\\\\new-server\\share\\config\\app.config"');
expect(window.alert).not.toHaveBeenCalled();
});

test('copies shell-safe old and new macOS paths', async () => {
const btn = loadTextPathButton();
btn.setAttribute('data-text-file', 'config/app.config');
document.body.setAttribute('data-text-old-prefix', '/Users/test/Old Folder/');
document.body.setAttribute('data-text-new-prefix', '/Users/test/New Folder/');

await window.copyTextPaths(btn);

expect(clipboardText).toBe("'/Users/test/Old Folder/config/app.config' '/Users/test/New Folder/config/app.config'");
expect(window.alert).not.toHaveBeenCalled();
});

test('copies shell-safe old and new Linux paths including a single quote', async () => {
const btn = loadTextPathButton();
btn.setAttribute('data-text-file', "config/app's.conf");
document.body.setAttribute('data-text-old-prefix', '/srv/old release/');
document.body.setAttribute('data-text-new-prefix', '/srv/new release/');

await window.copyTextPaths(btn);

expect(clipboardText).toBe("'/srv/old release/config/app'\\''s.conf' '/srv/new release/config/app'\\''s.conf'");
expect(window.alert).not.toHaveBeenCalled();
});

test('shows button feedback and an alert when compared file path metadata is unavailable', async () => {
const btn = loadTextPathButton();
btn.setAttribute('data-text-file', 'config/app.config');
document.body.setAttribute('data-text-old-prefix', '/old/');

const copied = await window.copyTextPaths(btn);

expect(copied).toBe(false);
expect(btn.classList.contains('is-copy-error')).toBe(true);
expect(window.alert).toHaveBeenCalledWith('Compared text file paths are unavailable.');
});
});

// ─── Text path copy sample consistency ──────────────────────────────────────
// テキストパスコピーのサンプル整合性
describe('text path copy sample consistency', () => {
test('every TextMatch and TextMismatch sample row has matching old/new absolute paths', () => {
const sampleHtml = fs.readFileSync(
path.join(__dirname, '..', 'doc', 'samples', 'diff_report.html'),
'utf-8'
);
const sampleDocument = new DOMParser().parseFromString(sampleHtml, 'text/html');
const textRows = sampleDocument.querySelectorAll(
'tr[data-section][data-diff="TextMatch"], tr[data-section][data-diff="TextMismatch"]'
);

expect(textRows).toHaveLength(10);
expect(sampleDocument.body.getAttribute('data-text-old-prefix')).toBe('/Users/UserA/workspace/old/');
expect(sampleDocument.body.getAttribute('data-text-new-prefix')).toBe('/Users/UserA/workspace/new/');
textRows.forEach((row) => {
const relativePath = row.querySelector('.path-text').textContent;
const button = row.querySelector('.btn-copy-text-path');
expect(button).not.toBeNull();
expect(button.getAttribute('data-text-file')).toBe(relativePath);
expect(button.getAttribute('onclick')).toBe('copyTextPaths(this)');

const tooltipId = button.getAttribute('aria-describedby');
const tooltip = sampleDocument.getElementById(tooltipId);
expect(tooltip).not.toBeNull();
expect(tooltip.textContent).toBe(
'Copy the quoted old/new absolute file paths for use with a text-based diff tool.'
);
});

expect(sampleHtml).toContain('.text-copy-tooltip-wrap');
expect(sampleHtml).toContain('.btn-copy-text-path');
expect(sampleHtml).toContain('function copyTextPaths(btn)');
});
});

// ─── setupLazySection ───────────────────────────────────────────────────────
// 遅延セクションレンダリングのテスト
describe('setupLazySection', () => {
Expand Down
47 changes: 45 additions & 2 deletions Services/HtmlReport/HtmlReportGenerateService.Helpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ namespace FolderDiffIL4DotNet.Services
public sealed partial class HtmlReportGenerateService
{
private const string COPY_BUTTON_CONTENT = "<svg class=\"copy-icon\" aria-hidden=\"true\" width=\"12\" height=\"12\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><rect x=\"5.5\" y=\"5.5\" width=\"9\" height=\"9\" rx=\"1.5\"/><path d=\"M5 10.5H2.5A1.5 1.5 0 011 9V2.5A1.5 1.5 0 012.5 1H9A1.5 1.5 0 0110.5 2.5V5\"/></svg><span class=\"copy-result\" aria-hidden=\"true\"></span>";
private const string IL_PATH_COPY_BUTTON_CONTENT = "<svg class=\"copy-icon il-path-pair-icon\" aria-hidden=\"true\" width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.35\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"1\" y=\"2\" width=\"4.5\" height=\"12\" rx=\"1\"/><rect x=\"10.5\" y=\"2\" width=\"4.5\" height=\"12\" rx=\"1\"/><path d=\"M6.75 5.5h2.5M8.25 4.5l1 1-1 1M9.25 10.5h-2.5M7.75 9.5l-1 1 1 1\"/></svg><span class=\"copy-result\" aria-hidden=\"true\"></span>";
private const string PATH_PAIR_COPY_BUTTON_CONTENT = "<svg class=\"copy-icon path-pair-icon\" aria-hidden=\"true\" width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.35\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"1\" y=\"2\" width=\"4.5\" height=\"12\" rx=\"1\"/><rect x=\"10.5\" y=\"2\" width=\"4.5\" height=\"12\" rx=\"1\"/><path d=\"M6.75 5.5h2.5M8.25 4.5l1 1-1 1M9.25 10.5h-2.5M7.75 9.5l-1 1 1 1\"/></svg><span class=\"copy-result\" aria-hidden=\"true\"></span>";
private const string IL_PATH_COPY_TOOLTIP = "Copy the quoted old/new absolute IL text paths for use with a text-based diff tool.";
private const string TEXT_PATH_COPY_TOOLTIP = "Copy the quoted old/new absolute file paths for use with a text-based diff tool.";

// ── Table helpers ────────────────────────────────────────────────────

Expand Down Expand Up @@ -103,7 +104,14 @@ private static void AppendFileRow(
string ilFileName = TextSanitizer.Sanitize(path) + "_" + Constants.LABEL_IL + ".txt";
string ariaLabel = $"Copy old and new IL text paths for {path}";
string tooltipId = $"il_copy_tip_{sectionPrefix}_{idx}";
col6Cell += $"<span class=\"btn-tooltip-wrap il-copy-tooltip-wrap\"><button type=\"button\" class=\"btn-copy-path btn-copy-il-path\" data-il-file=\"{HtmlEncode(ilFileName)}\" onclick=\"copyIlPaths(this)\" aria-label=\"{HtmlEncode(ariaLabel)}\" aria-describedby=\"{tooltipId}\">{IL_PATH_COPY_BUTTON_CONTENT}</button><span id=\"{tooltipId}\" class=\"btn-tooltip il-copy-tooltip\" role=\"tooltip\">{HtmlEncode(IL_PATH_COPY_TOOLTIP)}</span></span>";
col6Cell += $"<span class=\"btn-tooltip-wrap il-copy-tooltip-wrap\"><button type=\"button\" class=\"btn-copy-path btn-copy-il-path\" data-il-file=\"{HtmlEncode(ilFileName)}\" onclick=\"copyIlPaths(this)\" aria-label=\"{HtmlEncode(ariaLabel)}\" aria-describedby=\"{tooltipId}\">{PATH_PAIR_COPY_BUTTON_CONTENT}</button><span id=\"{tooltipId}\" class=\"btn-tooltip il-copy-tooltip\" role=\"tooltip\">{HtmlEncode(IL_PATH_COPY_TOOLTIP)}</span></span>";
}
if ((diffCat == "TextMatch" || diffCat == "TextMismatch") &&
TryBuildComparedFileRelativePath(path, out string comparedFileRelativePath))
{
string ariaLabel = $"Copy old and new compared file paths for {path}";
string tooltipId = $"text_copy_tip_{sectionPrefix}_{idx}";
col6Cell += $"<span class=\"btn-tooltip-wrap text-copy-tooltip-wrap\"><button type=\"button\" class=\"btn-copy-path btn-copy-text-path\" data-text-file=\"{HtmlEncode(comparedFileRelativePath)}\" onclick=\"copyTextPaths(this)\" aria-label=\"{HtmlEncode(ariaLabel)}\" aria-describedby=\"{tooltipId}\">{PATH_PAIR_COPY_BUTTON_CONTENT}</button><span id=\"{tooltipId}\" class=\"btn-tooltip text-copy-tooltip\" role=\"tooltip\">{HtmlEncode(TEXT_PATH_COPY_TOOLTIP)}</span></span>";
}
if (!string.IsNullOrEmpty(importance))
col6Cell += $" <code>{HtmlEncode(importance)}</code>";
Expand All @@ -119,6 +127,41 @@ private static void AppendFileRow(
writer.WriteLine("</tr>");
}

// Normalize compared-file paths with the report generator's native directory separator.
// 比較対象ファイルのパスを、レポート生成環境のネイティブなディレクトリ区切りで正規化する。
private static bool TryBuildComparedFileRelativePath(
string fileRelativePath,
out string normalizedRelativePath)
{
try
{
normalizedRelativePath = Path.DirectorySeparatorChar == Path.AltDirectorySeparatorChar
? fileRelativePath
: fileRelativePath.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
if (Path.IsPathRooted(normalizedRelativePath))
{
normalizedRelativePath = "";
return false;
}
_ = Path.GetFullPath(normalizedRelativePath);
return true;
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
{
normalizedRelativePath = "";
return false;
}
}

private static string BuildAbsoluteDirectoryPrefix(string folderAbsolutePath)
{
string fullPath = Path.GetFullPath(folderAbsolutePath);
string trimmedPath = fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return string.IsNullOrEmpty(trimmedPath)
? Path.DirectorySeparatorChar.ToString()
: trimmedPath + Path.DirectorySeparatorChar;
}

private static string BuildDiffViewHtml(IReadOnlyList<TextDiffer.DiffLine> diffLines)
{
var dsb = new StringBuilder();
Expand Down
9 changes: 6 additions & 3 deletions Services/HtmlReport/diff_report.css
Original file line number Diff line number Diff line change
Expand Up @@ -707,11 +707,14 @@
.btn-copy-path.is-copy-error .copy-result { display: inline; animation: copy-result-pop 0.18s ease-out; }
.btn-copy-path.is-copy-success { color: var(--color-added); }
.btn-copy-path.is-copy-error { color: var(--color-removed); }
.il-copy-tooltip-wrap {
.il-copy-tooltip-wrap,
.text-copy-tooltip-wrap {
display: inline-flex; align-items: center; vertical-align: middle; margin-left: 0.35em;
}
.btn-copy-il-path { width: 24px; height: 24px; margin-left: 0; }
.btn-copy-il-path .il-path-pair-icon { width: 14px; height: 14px; }
.btn-copy-il-path,
.btn-copy-text-path { width: 24px; height: 24px; margin-left: 0; }
.btn-copy-il-path .path-pair-icon,
.btn-copy-text-path .path-pair-icon { width: 14px; height: 14px; }
@keyframes copy-result-pop {
from { opacity: 0; transform: scale(0.7); }
to { opacity: 1; transform: scale(1); }
Expand Down
Loading
Loading