D-6: 비교 결과 텍스트 내보내기

결과를 남에게 보내거나 결재에 붙일 때 화면을 캡처하는 것 말고는 방법이 없었다.
캡처는 검색도 안 되고 줄 번호를 짚어 말하기도 어렵다.

Services/CompareReport.cs 신설. 파일 비교와 폴더 비교 양쪽에서 쓴다. 글을
만드는 일만 여기서 하고, 어디에 저장할지 묻는 것은 화면 쪽 일이다. 그래야
화면 없이 검사한다.

파일 쪽은 요약과 다른 줄 목록을 적는다. 줄 번호는 양쪽을 다 적는다. 한쪽에만
있는 줄이 있어 번호가 어긋나므로 하나만 적으면 파일에서 그 줄을 찾을 수 없다.
폴더 쪽은 상태와 상대 경로를 줄마다 적는다. 폴더 자체는 적지 않는다. 그 안의
파일이 이미 다 적혀 있다.

곁들여 요약에 주석만 다른 수를 덧붙였다. "다름 3(주석만 1)". D-1 로 새로 생긴
갈래가 요약에는 안 보였다.

확인: 두 화면에서 각각 뽑아 메모장으로 열었다. ReportTest 23건 신설.
전체 451건 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
김승준 2026-09-09 14:49:02 +09:00
parent 32c0146b48
commit ee5d34c2a6
7 changed files with 317 additions and 2 deletions

View File

@ -131,7 +131,7 @@ CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘
| 대기 | D-3 폴더 안 전체 검색 | | 대기 | D-3 폴더 안 전체 검색 |
| ~~취소~~ | ~~D-4 비교 창 여러 개~~ (2026-09-09 사용자 판단으로 뺌) | | ~~취소~~ | ~~D-4 비교 창 여러 개~~ (2026-09-09 사용자 판단으로 뺌) |
| **완료** | D-5 덩어리 단위 반영 (3-Way 에서 BASE 를 가져오던 것을 REMOTE 로) | | **완료** | D-5 덩어리 단위 반영 (3-Way 에서 BASE 를 가져오던 것을 REMOTE 로) |
| 대기 | D-6 비교 결과 텍스트 내보내기 | | **완료** | D-6 비교 결과 텍스트 내보내기 |
| 대기 | D-7 드래그 앤 드롭으로 열기 | | 대기 | D-7 드래그 앤 드롭으로 열기 |
### E. 추가 요청 (2026-09-09 오후) ### E. 추가 요청 (2026-09-09 오후)
@ -160,6 +160,45 @@ BASE 머리글에 그렇게 적었다 - `BASE (merge base) · 이 파일 없음
조상에 파일이 있는 경우는 지금까지처럼 파일 이름이 그대로 나온다. 조상에 파일이 있는 경우는 지금까지처럼 파일 이름이 그대로 나온다.
--- ---
### D-6. 비교 결과 텍스트 내보내기 — **완료** (2026-09-09)
**왜 필요했나** - 결과를 남에게 보내거나 결재에 붙일 때 <b>화면을 캡처하는 것 말고는 방법이 없었다.</b>
캡처는 검색도 안 되고 줄 번호를 짚어 말하기도 어렵다.
`Services/CompareReport.cs` 신설. 파일 비교와 폴더 비교 양쪽에서 쓴다.
<b>글을 만드는 일만</b> 여기서 하고, 어디에 저장할지 묻는 것은 화면 쪽 일이다 - 그래야 화면 없이 검사한다.
파일 비교 결과는 이렇게 나온다.
```
MergeHub 파일 비교 결과
만든 때: 2026-09-09 14:46:23
왼쪽 : ...\WeldController_기준.cs
오른쪽: ...\WeldController_내작업.cs
----------------------------------------------------------------
요약: 총 43 줄 · 다름 5 · 오른쪽에만 5 · 왼쪽에만 0
다른 줄
----------------------------------------------------------------
[8/8] 다름
- private const int PowerWatt = 1800;
+ private const int PowerWatt = 2000;
[-/28] 오른쪽에만
+ {
```
**줄 번호를 양쪽 다 적는다.** 한쪽에만 있는 줄이 있어 번호가 어긋나므로, 하나만 적으면
파일에서 그 줄을 찾을 수 없다. 없는 쪽은 `-` 로 둔다.
폴더 비교 결과는 상태와 경로를 줄마다 적는다. 하위 폴더 파일도 상대 경로로 들어가고,
폴더 자체는 줄로 적지 않는다 - 그 안의 파일이 이미 다 적혀 있다.
**곁들여** 요약에 주석만 다른 수를 덧붙였다 - `다름 3(주석만 1)`.
D-1 로 새로 생긴 갈래가 요약에는 안 보였다.
**확인**: 두 화면에서 각각 뽑아 메모장으로 열었다. `ReportTest` 23건 신설. 전체 **451건**.
---
### D-1. 주석만 다른 파일을 다른 색으로 — **완료** (2026-09-09) ### D-1. 주석만 다른 파일을 다른 색으로 — **완료** (2026-09-09)
폴더 비교 결과에 `주석만 다름` 상태를 새로 두었다. 코드는 같고 주석·공백만 다른 파일이다. 폴더 비교 결과에 `주석만 다름` 상태를 새로 두었다. 코드는 같고 주석·공백만 다른 파일이다.

View File

@ -0,0 +1,147 @@
using System;
using System.Collections.Generic;
using System.Text;
using MergeHub.Models;
namespace MergeHub.Services
{
/// <summary>
/// 비교 결과를 <b>메모장으로 열어 읽을 수 있는 글</b>로 만든다.
///
/// 왜 글로 뽑나 - 결과를 남에게 보내거나 결재에 붙일 때 화면을 캡처하는 것 말고는 방법이 없었다.
/// 캡처는 검색도 안 되고 줄 번호를 짚어 말하기도 어렵다.
///
/// 화면 없이 검사할 수 있어야 하므로 <b>글을 만드는 일만</b> 여기서 한다.
/// 어디에 저장할지 묻는 것은 화면 쪽 일이다.
/// </summary>
public static class CompareReport
{
private const string Rule = "----------------------------------------------------------------";
/// <summary>파일 비교 결과.</summary>
public static string ForFile(string leftLabel, string rightLabel,
IReadOnlyList<MergeLine> lines, DateTime when)
{
var text = new StringBuilder();
Head(text, "파일 비교 결과", leftLabel, rightLabel, when);
var changed = 0;
var added = 0;
var removed = 0;
foreach (var line in lines)
{
if (line.IsMergeNote || line.IsIgnored) continue;
switch (line.State)
{
case MergeLineState.Changed: changed++; break;
case MergeLineState.LocalOnly: added++; break;
case MergeLineState.BaseOnly: removed++; break;
}
}
text.AppendLine($"요약: 총 {lines.Count} 줄 · 다름 {changed} · 오른쪽에만 {added} · 왼쪽에만 {removed}");
text.AppendLine();
if (changed + added + removed == 0)
{
text.AppendLine("다른 줄이 없습니다.");
return text.ToString();
}
text.AppendLine("다른 줄");
text.AppendLine(Rule);
foreach (var line in lines)
{
if (line.IsMergeNote || line.IsIgnored) continue;
if (line.State == MergeLineState.Same) continue;
// 줄 번호는 <b>양쪽을 다 적는다.</b> 한쪽에만 있는 줄이 있어 번호가 어긋나므로,
// 하나만 적으면 파일에서 그 줄을 찾을 수 없다.
var number = $"{Number(line.BaseNumber)}/{Number(line.LocalNumber)}";
text.AppendLine($"[{number}] {StateName(line.State)}");
if (line.HasBase) text.AppendLine(" - " + line.BaseText);
if (line.HasLocal) text.AppendLine(" + " + line.LocalText);
}
return text.ToString();
}
/// <summary>폴더 비교 결과.</summary>
public static string ForFolder(string leftFolder, string rightFolder,
FolderCompareResult result, DateTime when)
{
var text = new StringBuilder();
Head(text, "폴더 비교 결과", leftFolder, rightFolder, when);
text.AppendLine("요약: " + result.Summary);
if (result.Truncated) text.AppendLine("(항목이 너무 많아 도중에 멈춘 결과입니다)");
text.AppendLine();
var rows = new List<(string Status, string Path)>();
Collect(result.Items, rows);
if (rows.Count == 0)
{
text.AppendLine("다른 항목이 없습니다.");
return text.ToString();
}
text.AppendLine("다른 항목");
text.AppendLine(Rule);
foreach (var row in rows) text.AppendLine($"{row.Status,-12}{row.Path}");
return text.ToString();
}
/// <summary>폴더 트리를 훑어 "같음"이 아닌 <b>파일만</b> 모은다.</summary>
private static void Collect(IEnumerable<FolderCompareItem> items, List<(string, string)> rows)
{
foreach (var item in items)
{
if (item.IsDirectory)
{
Collect(item.Children, rows);
continue;
}
if (item.Status == FolderEntryStatus.Same) continue;
rows.Add((StatusName(item.Status), item.RelativePath));
}
}
private static void Head(StringBuilder text, string title, string left, string right, DateTime when)
{
text.AppendLine($"MergeHub {title}");
text.AppendLine($"만든 때: {when:yyyy-MM-dd HH:mm:ss}");
text.AppendLine($"왼쪽 : {left}");
text.AppendLine($"오른쪽: {right}");
text.AppendLine(Rule);
}
private static string Number(int? value) => value is int n ? n.ToString() : "-";
private static string StateName(MergeLineState state) => state switch
{
MergeLineState.Changed => "다름",
MergeLineState.LocalOnly => "오른쪽에만",
MergeLineState.BaseOnly => "왼쪽에만",
_ => "같음"
};
private static string StatusName(FolderEntryStatus status) => status switch
{
FolderEntryStatus.Different => "다름",
FolderEntryStatus.CommentOnly => "주석만 다름",
FolderEntryStatus.Binary => "다름(이진)",
FolderEntryStatus.LeftOnly => "왼쪽에만",
FolderEntryStatus.RightOnly => "오른쪽에만",
_ => "같음"
};
}
}

View File

@ -101,11 +101,16 @@ namespace MergeHub.Services
public int LeftOnlyCount { get; set; } public int LeftOnlyCount { get; set; }
public int RightOnlyCount { get; set; } public int RightOnlyCount { get; set; }
/// <summary>그중 코드는 같고 주석만 다른 것. "다름" 안에 든 수다.</summary>
public int CommentOnlyCount { get; set; }
/// <summary>항목 상한에 걸려 도중에 멈췄는지.</summary> /// <summary>항목 상한에 걸려 도중에 멈췄는지.</summary>
public bool Truncated { get; set; } public bool Truncated { get; set; }
public string Summary => public string Summary =>
$"전체 {TotalFiles}개 · 다름 {DifferentCount} · 왼쪽만 {LeftOnlyCount} · 오른쪽만 {RightOnlyCount} · 같음 {SameCount}" $"전체 {TotalFiles}개 · 다름 {DifferentCount}"
+ (CommentOnlyCount > 0 ? $"(주석만 {CommentOnlyCount})" : string.Empty)
+ $" · 왼쪽만 {LeftOnlyCount} · 오른쪽만 {RightOnlyCount} · 같음 {SameCount}"
+ (Truncated ? " (너무 많아 일부만 봄)" : string.Empty); + (Truncated ? " (너무 많아 일부만 봄)" : string.Empty);
} }
@ -198,6 +203,11 @@ namespace MergeHub.Services
switch (item.Status) switch (item.Status)
{ {
case FolderEntryStatus.Same: result.SameCount++; break; case FolderEntryStatus.Same: result.SameCount++; break;
// "주석만 다름"도 다름의 한 갈래다. 둘 다 센다 - 요약이 "다름 3(주석만 1)" 로 읽힌다.
case FolderEntryStatus.CommentOnly:
result.DifferentCount++;
result.CommentOnlyCount++;
break;
case FolderEntryStatus.LeftOnly: result.LeftOnlyCount++; break; case FolderEntryStatus.LeftOnly: result.LeftOnlyCount++; break;
case FolderEntryStatus.RightOnly: result.RightOnlyCount++; break; case FolderEntryStatus.RightOnly: result.RightOnlyCount++; break;
default: result.DifferentCount++; break; default: result.DifferentCount++; break;

View File

@ -208,6 +208,7 @@ namespace MergeHub.ViewModels
CopyToLeftCommand.NotifyCanExecuteChanged(); CopyToLeftCommand.NotifyCanExecuteChanged();
DeleteLeftCommand.NotifyCanExecuteChanged(); DeleteLeftCommand.NotifyCanExecuteChanged();
DeleteRightCommand.NotifyCanExecuteChanged(); DeleteRightCommand.NotifyCanExecuteChanged();
ExportReportCommand.NotifyCanExecuteChanged();
} }
[RelayCommand] [RelayCommand]
@ -222,6 +223,57 @@ namespace MergeHub.ViewModels
if (AskForFolder("오른쪽(비교 대상) 폴더 고르기", RightFolder) is { } picked) RightFolder = picked; if (AskForFolder("오른쪽(비교 대상) 폴더 고르기", RightFolder) is { } picked) RightFolder = picked;
} }
// ================= 결과 내보내기 =================
private bool CanExportReport() => _lastResult != null;
/// <summary>
/// 비교 결과를 글로 뽑아 파일에 쓴다.
///
/// 화면을 캡처하는 것 말고는 결과를 남에게 보낼 방법이 없었다. 캡처는 검색도 안 되고
/// 줄 번호를 짚어 말하기도 어렵다. 글이면 메일에 붙이든 결재에 넣든 그대로 쓴다.
/// </summary>
[RelayCommand(CanExecute = nameof(CanExportReport))]
private void ExportReport()
{
if (_lastResult is not { } result) return;
var dialog = new Microsoft.Win32.SaveFileDialog
{
Title = "폴더 비교 결과 저장",
Filter = "텍스트 파일 (*.txt)|*.txt|모든 파일 (*.*)|*.*",
DefaultExt = ".txt",
FileName = $"폴더비교_{DateTime.Now:yyyyMMdd_HHmm}.txt"
};
var start = PathUtil.FullFolderPath(LeftFolder);
if (start.Length > 0) dialog.InitialDirectory = start;
bool ok;
try
{
ok = dialog.ShowDialog() == true;
}
catch (Exception)
{
dialog.InitialDirectory = string.Empty;
ok = dialog.ShowDialog() == true;
}
if (!ok) return;
try
{
var report = CompareReport.ForFolder(LeftFolder, RightFolder, result, DateTime.Now);
TextFile.Write(dialog.FileName, report);
MessageUtil.ShowSuccessToast($"저장했습니다 · {Path.GetFileName(dialog.FileName)}");
}
catch (Exception ex)
{
MessageUtil.ShowErrorToast($"저장하지 못했습니다: {ex.Message}");
}
}
private static string? AskForFolder(string title, string current) private static string? AskForFolder(string title, string current)
=> FolderPicker.Pick(title, current); => FolderPicker.Pick(title, current);
private bool CanCompare() => !IsBusy; private bool CanCompare() => !IsBusy;

View File

@ -471,6 +471,7 @@ namespace MergeHub.ViewModels
ReloadCommand.NotifyCanExecuteChanged(); ReloadCommand.NotifyCanExecuteChanged();
SaveAndCommitCommand.NotifyCanExecuteChanged(); SaveAndCommitCommand.NotifyCanExecuteChanged();
SavePlainCommand.NotifyCanExecuteChanged(); SavePlainCommand.NotifyCanExecuteChanged();
ExportReportCommand.NotifyCanExecuteChanged();
AutoMergeCommand.NotifyCanExecuteChanged(); AutoMergeCommand.NotifyCanExecuteChanged();
AcceptBlockCommand.NotifyCanExecuteChanged(); AcceptBlockCommand.NotifyCanExecuteChanged();
@ -1443,6 +1444,64 @@ namespace MergeHub.ViewModels
} }
} }
// ================= 결과 내보내기 =================
private bool CanExportReport() => Lines.Count > 0;
/// <summary>
/// 비교 결과를 글로 뽑아 파일에 쓴다.
///
/// 화면을 캡처하는 것 말고는 결과를 남에게 보낼 방법이 없었다. 캡처는 검색도 안 되고
/// 줄 번호를 짚어 말하기도 어렵다.
/// </summary>
[RelayCommand(CanExecute = nameof(CanExportReport))]
private void ExportReport()
{
var dialog = new Microsoft.Win32.SaveFileDialog
{
Title = "파일 비교 결과 저장",
Filter = "텍스트 파일 (*.txt)|*.txt|모든 파일 (*.*)|*.*",
DefaultExt = ".txt",
FileName = $"파일비교_{DateTime.Now:yyyyMMdd_HHmm}.txt"
};
var start = PathUtil.FolderOfFile(RightFile.Length > 0 ? RightFile : LeftFile);
if (start.Length > 0) dialog.InitialDirectory = start;
bool ok;
try
{
ok = dialog.ShowDialog() == true;
}
catch (Exception)
{
dialog.InitialDirectory = string.Empty;
ok = dialog.ShowDialog() == true;
}
if (!ok) return;
try
{
var report = CompareReport.ForFile(ReportLabel(BaseHeader, LeftFile),
ReportLabel(LocalHeader, RightFile),
Lines, DateTime.Now);
TextFile.Write(dialog.FileName, report);
MessageUtil.ShowSuccessToast($"저장했습니다 · {Path.GetFileName(dialog.FileName)}");
}
catch (Exception ex)
{
MessageUtil.ShowErrorToast($"저장하지 못했습니다: {ex.Message}");
}
}
/// <summary>
/// 보고서에 적을 쪽 이름. Git 모드면 머리글(브랜치·커밋)이 더 쓸모 있고,
/// 파일 비교 모드면 경로가 있어야 어느 파일인지 알 수 있다.
/// </summary>
private string ReportLabel(string header, string path) =>
IsGitMode || path.Length == 0 ? header : path;
private static string? AskForFile(string title, string current) private static string? AskForFile(string title, string current)
{ {
var dialog = new Microsoft.Win32.OpenFileDialog var dialog = new Microsoft.Win32.OpenFileDialog

View File

@ -255,6 +255,9 @@
</StackPanel> </StackPanel>
<WrapPanel Grid.Column="1" Orientation="Horizontal"> <WrapPanel Grid.Column="1" Orientation="Horizontal">
<Button Content="결과 내보내기" Command="{Binding ExportReportCommand}"
Height="28" Padding="10,2" FontSize="11" Margin="0,0,14,0"
ToolTip="비교 결과를 글로 뽑아 파일에 저장합니다. 메일이나 결재에 그대로 붙일 수 있습니다"/>
<Button Content="줄 단위 비교" Command="{Binding OpenInDiffCommand}" <Button Content="줄 단위 비교" Command="{Binding OpenInDiffCommand}"
Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"/> Height="28" Padding="10,2" FontSize="11" Margin="0,0,6,0"/>
<Button Content="→ 오른쪽으로 복사" Command="{Binding CopyToRightCommand}" <Button Content="→ 오른쪽으로 복사" Command="{Binding CopyToRightCommand}"

View File

@ -354,6 +354,11 @@
Height="26" MinHeight="26" Padding="10,2" FontSize="11" Margin="10,0,0,0" Height="26" MinHeight="26" Padding="10,2" FontSize="11" Margin="10,0,0,0"
VerticalAlignment="Top" VerticalAlignment="Top"
ToolTip="단축키 목록을 봅니다 (F1)"/> ToolTip="단축키 목록을 봅니다 (F1)"/>
<Button DockPanel.Dock="Right" Content="결과 내보내기" Command="{Binding ExportReportCommand}"
Height="26" MinHeight="26" Padding="10,2" FontSize="11" Margin="10,0,0,0"
VerticalAlignment="Top"
ToolTip="비교 결과를 글로 뽑아 파일에 저장합니다. 메일이나 결재에 그대로 붙일 수 있습니다"/>
<WrapPanel Orientation="Horizontal"> <WrapPanel Orientation="Horizontal">
<!-- 지금 어느 모드인지 늘 보이게 한다 - 커밋 이력이 없는 이유를 알 수 있어야 한다. --> <!-- 지금 어느 모드인지 늘 보이게 한다 - 커밋 이력이 없는 이유를 알 수 있어야 한다. -->