D-1: 주석만 다른 파일을 다른 색으로

폴더 비교에 "주석만 다름" 상태를 새로 두었다. 코드는 같고 주석/공백만 다른
파일이다. 봐야 할 성격이 다르다. 주석만 손댄 파일은 눈으로 훑고 넘길 수
있는데 다른 것과 같은 색으로 섞여 있으면 전부 열어 봐야 한다.

판단은 주석 토큰을 걷어내고 공백을 지운 뒤 견주는 것이다. 주석 기호는 언어마다
다르므로 확장자로 고른 언어표를 따르고, 언어를 모르는 파일은 하지 않는다.
양쪽 확장자가 다르면 같은 언어로 볼 근거가 없으니 역시 하지 않는다.
줄 구분은 코드가 있는 줄에만 남긴다. 큰 파일은 건너뛴다.

곁들여 색 뜻풀이 줄을 목록 위에 붙이고, 보기 필터에 "코드가 다른 것만"을
더했다. 주석만 다른 것을 빼고 보는 쪽이 실제로 필요한 동작이다.

확인: 주석만 고친 .cs 는 회색 "주석만 다름", 값이 바뀐 .cs 와 평문 .txt 는
노란 "다름". "코드가 다른 것만"을 고르면 앞의 것이 빠진다.
FolderTest 4건 추가. 전체 428건 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
김승준 2026-09-09 14:33:25 +09:00
parent 5c8c942ba5
commit 451b6245cb
5 changed files with 198 additions and 16 deletions

View File

@ -126,7 +126,7 @@ CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘
| 상태 | 항목 | | 상태 | 항목 |
|---|---| |---|---|
| 대기 | D-1 주석만 다른 파일을 다른 색으로 | | **완료** | D-1 주석만 다른 파일을 다른 색으로 |
| **완료** | D-2 C++ 문법 강조 (이미 되고 있었음 · 전처리 지시문만 추가) | | **완료** | D-2 C++ 문법 강조 (이미 되고 있었음 · 전처리 지시문만 추가) |
| 대기 | D-3 폴더 안 전체 검색 | | 대기 | D-3 폴더 안 전체 검색 |
| ~~취소~~ | ~~D-4 비교 창 여러 개~~ (2026-09-09 사용자 판단으로 뺌) | | ~~취소~~ | ~~D-4 비교 창 여러 개~~ (2026-09-09 사용자 판단으로 뺌) |
@ -147,6 +147,35 @@ CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘
--- ---
### D-1. 주석만 다른 파일을 다른 색으로 — **완료** (2026-09-09)
폴더 비교 결과에 `주석만 다름` 상태를 새로 두었다. 코드는 같고 주석·공백만 다른 파일이다.
**왜 나누나** - 봐야 할 성격이 다르다. 주석만 손댄 파일은 눈으로 훑고 넘길 수 있는데
다른 것과 같은 색으로 섞여 있으면 전부 열어 봐야 한다.
**판단하는 법**: 주석 토큰을 걷어내고 공백을 지운 뒤 견준다. 주석 기호는 언어마다 다르므로
확장자로 고른 언어표를 따르고, 언어를 모르는 파일(평문·표)은 이 판단을 하지 않는다 -
주석이라는 것이 없다. 양쪽 확장자가 다르면 같은 언어로 볼 근거가 없으니 역시 하지 않는다.
줄 구분은 <b>코드가 있는 줄에만</b> 남긴다. 그래야 주석 한 줄을 통째로 지운 경우도 같다고 보고,
반대로 코드를 다른 줄로 옮긴 것은 다르다고 본다.
큰 파일은 이 판단을 건너뛴다. 전체 비교에서 이미 쓰는 한도(8MB)를 그대로 따른다 -
폴더 하나에 수천 개가 있을 때 이 판단만으로 몇 분이 더 들면 안 된다.
**곁들여 둘**
- <b>색 뜻풀이 줄</b>을 목록 위에 붙였다. 색만 칠해 두면 무슨 뜻인지 아무도 모른다 -
파일 비교 화면의 "색 구분" 줄과 같은 성격이다.
- 보기 필터에 <b>코드가 다른 것만</b>을 더했다. 주석만 다른 것을 빼고 보는 쪽이
실제로 필요한 동작이다.
**확인**: 주석만 고친 `.cs` 는 회색 `주석만 다름`, 값이 바뀐 `.cs` 와 평문 `.txt` 는 노란 `다름`.
`코드가 다른 것만` 을 고르면 앞의 것이 목록에서 빠진다.
`FolderTest` 4건 추가(37 → 41건).
---
### D-2 · D-5 — **완료** (2026-09-09) ### D-2 · D-5 — **완료** (2026-09-09)
#### D-2. C++ 문법 강조 #### D-2. C++ 문법 강조

View File

@ -1,7 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using MergeHub.Views.Controls;
using System.Threading; using System.Threading;
namespace MergeHub.Services namespace MergeHub.Services
@ -18,7 +19,15 @@ namespace MergeHub.Services
/// <summary>오른쪽에만 있다.</summary> /// <summary>오른쪽에만 있다.</summary>
RightOnly, RightOnly,
/// <summary>내용이 다르지만 이진 파일이라 줄 단위로 볼 수 없다.</summary> /// <summary>내용이 다르지만 이진 파일이라 줄 단위로 볼 수 없다.</summary>
Binary Binary,
/// <summary>
/// <b>코드는 같고 주석·공백만</b> 다르다.
///
/// 따로 두는 이유 - 봐야 할 성격이 다르다. 주석만 손댄 파일은 눈으로 훑고 넘길 수 있는데
/// 다른 것과 같은 색으로 섞여 있으면 전부 열어 봐야 한다.
/// </summary>
CommentOnly
} }
/// <summary>폴더 비교 규칙.</summary> /// <summary>폴더 비교 규칙.</summary>
@ -250,7 +259,7 @@ namespace MergeHub.Services
if (!item.LeftExists) return FolderEntryStatus.RightOnly; if (!item.LeftExists) return FolderEntryStatus.RightOnly;
// 크기가 다르면 내용을 볼 필요가 없다. // 크기가 다르면 내용을 볼 필요가 없다.
if (item.LeftSize != item.RightSize) return DifferentKind(leftPath, rightPath); if (item.LeftSize != item.RightSize) return DifferentKind(leftPath, rightPath, Math.Max(item.LeftSize, item.RightSize), options);
var quick = options.QuickCompare || item.LeftSize > options.FullCompareLimitBytes; var quick = options.QuickCompare || item.LeftSize > options.FullCompareLimitBytes;
@ -262,19 +271,108 @@ namespace MergeHub.Services
var gap = left > right ? left - right : right - left; var gap = left > right ? left - right : right - left;
return gap <= options.TimeTolerance return gap <= options.TimeTolerance
? FolderEntryStatus.Same ? FolderEntryStatus.Same
: DifferentKind(leftPath, rightPath); : DifferentKind(leftPath, rightPath, Math.Max(item.LeftSize, item.RightSize), options);
} }
return SameContent(leftPath, rightPath) return SameContent(leftPath, rightPath)
? FolderEntryStatus.Same ? FolderEntryStatus.Same
: DifferentKind(leftPath, rightPath); : DifferentKind(leftPath, rightPath, Math.Max(item.LeftSize, item.RightSize), options);
} }
/// <summary>다른 파일이 이진이면 따로 표시한다 - 줄 단위 비교 화면을 열어도 볼 것이 없다.</summary> /// <summary>
private static FolderEntryStatus DifferentKind(string leftPath, string rightPath) => /// 다른 파일을 어떤 "다름"으로 볼지.
TextFile.IsBinary(leftPath) || TextFile.IsBinary(rightPath) ///
? FolderEntryStatus.Binary /// 이진이면 줄 단위 비교 화면을 열어도 볼 것이 없으니 따로 표시한다.
/// 글 파일이면 <b>코드는 같고 주석만 다른지</b>를 한 번 더 본다.
/// </summary>
private static FolderEntryStatus DifferentKind(string leftPath, string rightPath, long size,
FolderCompareOptions options)
{
if (TextFile.IsBinary(leftPath) || TextFile.IsBinary(rightPath)) return FolderEntryStatus.Binary;
return OnlyCommentsDiffer(leftPath, rightPath, size, options)
? FolderEntryStatus.CommentOnly
: FolderEntryStatus.Different; : FolderEntryStatus.Different;
}
/// <summary>
/// 코드는 같고 <b>주석·공백만</b> 다른가.
///
/// 판단은 주석 토큰을 걷어내고 공백을 지운 뒤 견주는 것이다. 주석 기호는 언어마다 다르므로
/// 확장자로 고른 언어표를 따른다. 언어를 모르는 파일(평문·표 등)은 이 판단을 하지 않는다 -
/// 주석이라는 것이 없다.
///
/// 줄 구분은 <b>코드가 있는 줄에만</b> 남긴다. 그래야 주석 한 줄을 통째로 지운 경우에도
/// 같다고 볼 수 있고, 반대로 코드를 다른 줄로 옮긴 것은 다르다고 본다.
///
/// 큰 파일은 하지 않는다. 전체 비교에서 이미 걸러 둔 한도를 그대로 쓴다 -
/// 폴더 하나에 수천 개가 있을 때 이 판단만으로 몇 분이 더 들면 안 된다.
/// </summary>
private static bool OnlyCommentsDiffer(string leftPath, string rightPath, long size,
FolderCompareOptions options)
{
if (size > options.FullCompareLimitBytes) return false;
var profile = LanguageCatalog.ForPath(leftPath);
if (!profile.IsCode) return false;
// 확장자가 서로 다르면 같은 언어로 볼 근거가 없다.
if (!ReferenceEquals(profile, LanguageCatalog.ForPath(rightPath))) return false;
var left = CodeOnly(leftPath, profile);
if (left is null) return false;
var right = CodeOnly(rightPath, profile);
if (right is null) return false;
return string.Equals(left, right, StringComparison.Ordinal);
}
/// <summary>주석과 공백을 걷어낸 글. 읽지 못하면 null.</summary>
private static string? CodeOnly(string path, LanguageProfile profile)
{
try
{
var text = TextFile.Read(path);
var builder = new System.Text.StringBuilder(text.Length);
var inBlockComment = false;
foreach (var line in text.Split('\n'))
{
var body = line.TrimEnd('\r');
var tokens = CodeTokenizer.Tokenize(body, profile, inBlockComment, out inBlockComment);
var isComment = new bool[body.Length];
foreach (var token in tokens)
{
if (token.Kind != CodeTokenKind.Comment && token.Kind != CodeTokenKind.XmlDoc) continue;
var end = Math.Min(body.Length, token.Start + token.Length);
for (var i = Math.Max(0, token.Start); i < end; i++) isComment[i] = true;
}
var before = builder.Length;
for (var i = 0; i < body.Length; i++)
{
if (isComment[i] || char.IsWhiteSpace(body[i])) continue;
builder.Append(body[i]);
}
// 코드가 한 글자라도 있던 줄에만 줄 구분을 남긴다.
if (builder.Length > before) builder.Append('\n');
}
return builder.ToString();
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
return null;
}
}
/// <summary>내용을 앞에서부터 덩어리째 견준다. 다른 곳을 만나면 바로 멈춘다.</summary> /// <summary>내용을 앞에서부터 덩어리째 견준다. 다른 곳을 만나면 바로 멈춘다.</summary>
private static bool SameContent(string leftPath, string rightPath) private static bool SameContent(string leftPath, string rightPath)

View File

@ -17,6 +17,10 @@ namespace MergeHub.ViewModels
public enum FolderFilter public enum FolderFilter
{ {
Different, Different,
/// <summary>주석만 다른 것은 빼고 <b>코드가 다른 것</b>만. 볼 것이 많을 때 쓴다.</summary>
CodeDifferent,
All, All,
LeftOnly, LeftOnly,
RightOnly, RightOnly,
@ -64,6 +68,7 @@ namespace MergeHub.ViewModels
FolderEntryStatus.LeftOnly => "왼쪽만", FolderEntryStatus.LeftOnly => "왼쪽만",
FolderEntryStatus.RightOnly => "오른쪽만", FolderEntryStatus.RightOnly => "오른쪽만",
FolderEntryStatus.Binary => "다름(이진)", FolderEntryStatus.Binary => "다름(이진)",
FolderEntryStatus.CommentOnly => "주석만 다름",
_ => string.Empty _ => string.Empty
}; };
@ -167,12 +172,13 @@ namespace MergeHub.ViewModels
public IReadOnlyList<FolderFilter> Filters { get; } = new[] public IReadOnlyList<FolderFilter> Filters { get; } = new[]
{ {
FolderFilter.Different, FolderFilter.All, FolderFilter.Different, FolderFilter.CodeDifferent, FolderFilter.All,
FolderFilter.LeftOnly, FolderFilter.RightOnly, FolderFilter.Same FolderFilter.LeftOnly, FolderFilter.RightOnly, FolderFilter.Same
}; };
public static string FilterName(FolderFilter filter) => filter switch public static string FilterName(FolderFilter filter) => filter switch
{ {
FolderFilter.CodeDifferent => "코드가 다른 것만",
FolderFilter.All => "전체", FolderFilter.All => "전체",
FolderFilter.LeftOnly => "왼쪽에만", FolderFilter.LeftOnly => "왼쪽에만",
FolderFilter.RightOnly => "오른쪽에만", FolderFilter.RightOnly => "오른쪽에만",
@ -318,6 +324,10 @@ namespace MergeHub.ViewModels
FolderFilter.LeftOnly => status == FolderEntryStatus.LeftOnly, FolderFilter.LeftOnly => status == FolderEntryStatus.LeftOnly,
FolderFilter.RightOnly => status == FolderEntryStatus.RightOnly, FolderFilter.RightOnly => status == FolderEntryStatus.RightOnly,
FolderFilter.Same => status == FolderEntryStatus.Same, FolderFilter.Same => status == FolderEntryStatus.Same,
// 주석만 다른 것은 뺀다. 볼 것이 많을 때 "진짜 다른 것"만 남기려는 필터다.
FolderFilter.CodeDifferent =>
status != FolderEntryStatus.Same && status != FolderEntryStatus.CommentOnly,
_ => status != FolderEntryStatus.Same, _ => status != FolderEntryStatus.Same,
}; };

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Globalization; using System.Globalization;
using System.Windows.Data; using System.Windows.Data;
using System.Windows.Media; using System.Windows.Media;
@ -100,6 +100,7 @@ namespace MergeHub.Views.Controls
{ {
Services.FolderEntryStatus.Different => LineHighlightBrushes.Changed, Services.FolderEntryStatus.Different => LineHighlightBrushes.Changed,
Services.FolderEntryStatus.Binary => LineHighlightBrushes.Moved, Services.FolderEntryStatus.Binary => LineHighlightBrushes.Moved,
Services.FolderEntryStatus.CommentOnly => LineHighlightBrushes.Ignored,
Services.FolderEntryStatus.LeftOnly => LineHighlightBrushes.Removed, Services.FolderEntryStatus.LeftOnly => LineHighlightBrushes.Removed,
Services.FolderEntryStatus.RightOnly => LineHighlightBrushes.Added, Services.FolderEntryStatus.RightOnly => LineHighlightBrushes.Added,
_ => LineHighlightBrushes.None _ => LineHighlightBrushes.None

View File

@ -1,7 +1,8 @@
<UserControl x:Class="MergeHub.Views.FolderView" <UserControl x:Class="MergeHub.Views.FolderView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:MergeHub.ViewModels" xmlns:vm="clr-namespace:MergeHub.ViewModels"
xmlns:svc="clr-namespace:MergeHub.Services"
xmlns:ctl="clr-namespace:MergeHub.Views.Controls"> xmlns:ctl="clr-namespace:MergeHub.Views.Controls">
<UserControl.Resources> <UserControl.Resources>
@ -80,6 +81,7 @@
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/> <RowDefinition Height="*"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
@ -158,8 +160,50 @@
</WrapPanel> </WrapPanel>
</Border> </Border>
<!--
④ 색 뜻풀이. 색만 칠해 두면 무슨 뜻인지 아무도 모른다 -
파일 비교 화면의 "색 구분" 줄과 같은 성격이다.
-->
<Border Grid.Row="2" Padding="8,4" BorderThickness="1,1,1,0" Margin="0,0,0,0"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<WrapPanel Orientation="Horizontal">
<TextBlock Text="색 구분" Style="{StaticResource Muted}" FontSize="11" Margin="0,0,10,0"
VerticalAlignment="Center"/>
<Border Width="12" Height="12" Margin="0,0,4,0" VerticalAlignment="Center"
Background="{Binding Source={x:Static svc:FolderEntryStatus.Different},
Converter={StaticResource StatusBrush}}"
BorderThickness="1" BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"/>
<TextBlock Text="다름" FontSize="11" Margin="0,0,14,0" VerticalAlignment="Center"/>
<Border Width="12" Height="12" Margin="0,0,4,0" VerticalAlignment="Center"
Background="{Binding Source={x:Static svc:FolderEntryStatus.CommentOnly},
Converter={StaticResource StatusBrush}}"
BorderThickness="1" BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"/>
<TextBlock Text="주석만 다름" FontSize="11" Margin="0,0,14,0" VerticalAlignment="Center"
ToolTip="코드는 같고 주석·공백만 다른 파일입니다"/>
<Border Width="12" Height="12" Margin="0,0,4,0" VerticalAlignment="Center"
Background="{Binding Source={x:Static svc:FolderEntryStatus.LeftOnly},
Converter={StaticResource StatusBrush}}"
BorderThickness="1" BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"/>
<TextBlock Text="왼쪽에만" FontSize="11" Margin="0,0,14,0" VerticalAlignment="Center"/>
<Border Width="12" Height="12" Margin="0,0,4,0" VerticalAlignment="Center"
Background="{Binding Source={x:Static svc:FolderEntryStatus.RightOnly},
Converter={StaticResource StatusBrush}}"
BorderThickness="1" BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"/>
<TextBlock Text="오른쪽에만" FontSize="11" Margin="0,0,14,0" VerticalAlignment="Center"/>
<Border Width="12" Height="12" Margin="0,0,4,0" VerticalAlignment="Center"
Background="{Binding Source={x:Static svc:FolderEntryStatus.Binary},
Converter={StaticResource StatusBrush}}"
BorderThickness="1" BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"/>
<TextBlock Text="다름(이진)" FontSize="11" VerticalAlignment="Center"/>
</WrapPanel>
</Border>
<!-- ③ 목록 머리글. 트리 항목의 칸 폭과 같은 값을 쓴다. --> <!-- ③ 목록 머리글. 트리 항목의 칸 폭과 같은 값을 쓴다. -->
<Border Grid.Row="2" Padding="8,4" BorderThickness="1,1,1,0" <Border Grid.Row="3" Padding="8,4" BorderThickness="1,1,1,0"
Background="{DynamicResource Ubisam.Brush.Sidebar.Background}" Background="{DynamicResource Ubisam.Brush.Sidebar.Background}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"> BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<Grid Margin="26,0,0,0"> <Grid Margin="26,0,0,0">
@ -182,7 +226,7 @@
채움 칸(*)이 0 이 되고 줄 배경이 글자 끝에서 끊긴다. 칸 폭이 고정이라 채움 칸(*)이 0 이 되고 줄 배경이 글자 끝에서 끊긴다. 칸 폭이 고정이라
가로로 넘칠 일이 없으므로 꺼 두는 편이 맞다. 가로로 넘칠 일이 없으므로 꺼 두는 편이 맞다.
--> -->
<TreeView Grid.Row="3" x:Name="Tree" BorderThickness="1" <TreeView Grid.Row="4" x:Name="Tree" BorderThickness="1"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}" BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
ItemsSource="{Binding Nodes}" ItemsSource="{Binding Nodes}"
ItemTemplate="{StaticResource NodeTemplate}" ItemTemplate="{StaticResource NodeTemplate}"
@ -196,7 +240,7 @@
VirtualizingPanel.VirtualizationMode="Recycling"/> VirtualizingPanel.VirtualizationMode="Recycling"/>
<!-- ⑤ 요약 · 동작 --> <!-- ⑤ 요약 · 동작 -->
<Border Grid.Row="4" Padding="8" BorderThickness="1,0,1,1" Margin="0,0,0,0" <Border Grid.Row="5" Padding="8" BorderThickness="1,0,1,1" Margin="0,0,0,0"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"> BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}">
<Grid> <Grid>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>