diff --git a/CHANGELOG.md b/CHANGELOG.md index 94ec0adf..11bfa624 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 #### 追加 diff --git a/FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.SortAndResources.cs b/FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.SortAndResources.cs index 92790dcc..17435afd 100644 --- a/FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.SortAndResources.cs +++ b/FolderDiffIL4DotNet.Tests/Services/HtmlReportGenerateServiceTests.SortAndResources.cs @@ -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)); @@ -141,7 +144,9 @@ public void GenerateDiffReportHtml_ILRows_HaveDistinctOldNewIlPathCopyButtons() Assert.Contains("ILMismatch", 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); @@ -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("TextMatch", html); + Assert.Contains("TextMismatch", 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] diff --git a/JsTests/diff_report.test.js b/JsTests/diff_report.test.js index b00c265f..4912363c 100644 --- a/JsTests/diff_report.test.js +++ b/JsTests/diff_report.test.js @@ -1380,6 +1380,134 @@ describe('copyIlPaths', () => { }); }); +// ─── copyTextPaths ────────────────────────────────────────────────────────── +// テキスト比較対象ファイルの新旧絶対パスコピーのテスト +describe('copyTextPaths', () => { + function loadTextPathButton() { + loadScript({ + bodyHtml: ` + + + `, + }); + 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', () => { diff --git a/Services/HtmlReport/HtmlReportGenerateService.Helpers.cs b/Services/HtmlReport/HtmlReportGenerateService.Helpers.cs index 971742e2..125aae1f 100644 --- a/Services/HtmlReport/HtmlReportGenerateService.Helpers.cs +++ b/Services/HtmlReport/HtmlReportGenerateService.Helpers.cs @@ -14,8 +14,9 @@ namespace FolderDiffIL4DotNet.Services public sealed partial class HtmlReportGenerateService { private const string COPY_BUTTON_CONTENT = ""; - private const string IL_PATH_COPY_BUTTON_CONTENT = ""; + private const string PATH_PAIR_COPY_BUTTON_CONTENT = ""; 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 ──────────────────────────────────────────────────── @@ -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 += $"{HtmlEncode(IL_PATH_COPY_TOOLTIP)}"; + col6Cell += $"{HtmlEncode(IL_PATH_COPY_TOOLTIP)}"; + } + 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 += $"{HtmlEncode(TEXT_PATH_COPY_TOOLTIP)}"; } if (!string.IsNullOrEmpty(importance)) col6Cell += $" {HtmlEncode(importance)}"; @@ -119,6 +127,41 @@ private static void AppendFileRow( writer.WriteLine(""); } + // 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 diffLines) { var dsb = new StringBuilder(); diff --git a/Services/HtmlReport/diff_report.css b/Services/HtmlReport/diff_report.css index c88e8bb4..74d01226 100644 --- a/Services/HtmlReport/diff_report.css +++ b/Services/HtmlReport/diff_report.css @@ -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); } diff --git a/Services/HtmlReport/js/diff_report_filter.js b/Services/HtmlReport/js/diff_report_filter.js index d599de5a..413d0fd5 100644 --- a/Services/HtmlReport/js/diff_report_filter.js +++ b/Services/HtmlReport/js/diff_report_filter.js @@ -255,6 +255,20 @@ return copyText(btn, text); } + /** Copy quoted old/new absolute compared-file paths for a TextMatch or TextMismatch row. / TextMatch・TextMismatch 行の比較対象ファイルの新旧絶対パスを引用符付きでコピー。 */ + function copyTextPaths(btn) { + var body = document.body; + var fileName = btn.getAttribute('data-text-file') || ''; + var oldPrefix = body ? body.getAttribute('data-text-old-prefix') || '' : ''; + var newPrefix = body ? body.getAttribute('data-text-new-prefix') || '' : ''; + if (!fileName || !oldPrefix || !newPrefix) { + showCopyFailure(btn, 'Compared text file paths are unavailable.'); + return Promise.resolve(false); + } + var text = quoteCommandPath(oldPrefix + fileName) + ' ' + quoteCommandPath(newPrefix + fileName); + return copyText(btn, text); + } + /* Export functions for Node.js/Jest testing (no-op in browser) */ /* Node.js/Jest テスト用に関数をエクスポート(ブラウザでは無効) */ - if (typeof module !== 'undefined' && module.exports) { module.exports = { applyFilters: applyFilters, applyFiltersDebounced: applyFiltersDebounced, resetFilters: resetFilters, copyPath: copyPath, copyIlPaths: copyIlPaths, copyText: copyText, quoteCommandPath: quoteCommandPath }; } + if (typeof module !== 'undefined' && module.exports) { module.exports = { applyFilters: applyFilters, applyFiltersDebounced: applyFiltersDebounced, resetFilters: resetFilters, copyPath: copyPath, copyIlPaths: copyIlPaths, copyTextPaths: copyTextPaths, copyText: copyText, quoteCommandPath: quoteCommandPath }; } diff --git a/Services/HtmlReportGenerateService.cs b/Services/HtmlReportGenerateService.cs index 51a212d2..839f3a92 100644 --- a/Services/HtmlReportGenerateService.cs +++ b/Services/HtmlReportGenerateService.cs @@ -123,16 +123,24 @@ private void WriteHtml( string reportDate = DateTime.Now.ToString("yyyyMMdd"); AppendHtmlHead(writer); + string textOldPrefix = BuildAbsoluteDirectoryPrefix(oldFolderAbsolutePath); + string textNewPrefix = BuildAbsoluteDirectoryPrefix(newFolderAbsolutePath); + string textPathDataAttributes = + $"data-text-old-prefix=\"{HtmlEncode(textOldPrefix)}\" " + + $"data-text-new-prefix=\"{HtmlEncode(textNewPrefix)}\""; + string bodyDataAttributes = textPathDataAttributes; if (config.ShouldOutputILText) { - string ilOldPrefix = Path.Combine(reportsFolderAbsolutePath, Constants.LABEL_IL, "old") + Path.DirectorySeparatorChar; - string ilNewPrefix = Path.Combine(reportsFolderAbsolutePath, Constants.LABEL_IL, "new") + Path.DirectorySeparatorChar; - writer.WriteLine($""); - } - else - { - writer.WriteLine(""); + string ilOldPrefix = BuildAbsoluteDirectoryPrefix( + Path.Combine(reportsFolderAbsolutePath, Constants.LABEL_IL, "old")); + string ilNewPrefix = BuildAbsoluteDirectoryPrefix( + Path.Combine(reportsFolderAbsolutePath, Constants.LABEL_IL, "new")); + string ilPathDataAttributes = + $"data-il-old-prefix=\"{HtmlEncode(ilOldPrefix)}\" " + + $"data-il-new-prefix=\"{HtmlEncode(ilNewPrefix)}\""; + bodyDataAttributes = $"{textPathDataAttributes} {ilPathDataAttributes}"; } + writer.WriteLine($""); // Skip link for keyboard navigation / キーボードナビゲーション用スキップリンク writer.WriteLine("Skip to main content"); diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 22003792..1e802414 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -373,6 +373,8 @@ See [doc/samples/diff_report.html](doc/samples/diff_report.html) for a live samp ☑ check the checkbox, type the OK reason, add notes if needed. For ILMatch / ILMismatch, use the two-file button beside the Diff Detail label to copy the quoted absolute old/new *_IL.txt paths into a text-based diff tool. + For TextMatch / TextMismatch, the same button copies the quoted absolute + old/new paths of the original compared files into a text-based diff tool. A successful copy briefly shows a check mark on the button; a failed copy shows a red exclamation mark and a browser alert. 3. State is auto-saved to the browser's localStorage as you type @@ -1264,6 +1266,8 @@ HTML レポートはブラウザで開くだけで動く自己完結ファイル ☑ チェックを入れ、Justification(根拠)を入力し、必要なら備考も追記。 ILMatch / ILMismatch では Diff Detail ラベル横の2ファイル用ボタンから、 テキストベース差分ツール向けの引用符付き新旧 *_IL.txt 絶対パスをコピー可能。 + TextMatch / TextMismatch では同じボタンから、テキストベース差分ツール向けの + 元の比較対象ファイルの引用符付き新旧絶対パスをコピー可能。 コピー成功時はボタンに短時間チェックマークを表示し、失敗時は赤い感嘆符と ブラウザー警告を表示。 3. 入力のたびにブラウザの localStorage へ自動保存される diff --git a/doc/samples/diff_report.html b/doc/samples/diff_report.html index e22ae6d9..871e5d18 100644 --- a/doc/samples/diff_report.html +++ b/doc/samples/diff_report.html @@ -563,11 +563,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); } @@ -849,7 +852,7 @@ } - +