파일 비교: 바꾸기(Ctrl+H) · 덩어리 단위 반영 · 저장 형식 고르기

- 바꾸기: 찾기 칸 아래에 바꿀 말 줄이 붙는다. Ctrl+F 로 열면 안 뜬다 -
  찾기만 할 때 자리를 뺏지 않기 위해서다. "바꾸기" 는 고른 자리가 찾는 말일 때만
  바꾸고 다음으로 넘어간다 - 엉뚱한 자리를 덮어쓰는 것보다 한 번 더 누르게 하는 편이 낫다.
  "모두" 는 한 번에 바꾸고 몇 개인지 알려 준다(되돌리기는 편집기의 Ctrl+Z 가 맡는다).
- 덩어리 단위 반영: 줄 하나씩만 되면 연속 다섯 줄을 다섯 번 눌러야 한다.
  WinMerge·Beyond Compare 가 덩어리를 기본 단위로 쓰는 이유다. 덩어리는
  "차이로 잡힌 줄이 이어지는 구간"이고, 캐럿이 그 안에 있을 때만 켜진다. Alt+Shift+→.
- 저장 형식: 사내 소스에 CP949 로 저장된 파일이 섞여 있어 새로 만드는 파일과 형식이
  갈린다. 읽어서 보여 주기만 하면 그 갈림을 못 고치므로 저장할 때 고를 수 있게 했다.
  기본값 "원본 유지" 라 지금까지와 동작이 같다. Save & Commit 경로도 같은 값을 쓴다
  (전에는 File.WriteAllText 로 UTF-8 고정이었다).

실행 확인: PulseUs → PulseMicros 2개 바꿈 · 마지막 차이에서 2줄 한 번에 반영.
단축키 61 → 63건, 전체 305건 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
김승준 2026-09-08 14:44:08 +09:00
parent 524a6a715d
commit de1a43cc70
6 changed files with 241 additions and 20 deletions

View File

@ -55,14 +55,14 @@ CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는데 이 둘
되돌릴 수 없는 일이라 <b>화면 없이 검사할 수 있어야</b> 하기 때문이다 - 겹치는 파일은 덮고,
대상에만 있던 파일은 남기고, 두 번 복사해도 같은지를 테스트로 못 박았다.
### ② 파일 비교
### ② 파일 비교 (2026-09-08)
| 상태 | 항목 | 내용 |
|---|---|---|
| 대기 | 바꾸기 (Ctrl+H) | 찾기만 있다 |
| 대기 | 덩어리 단위 반영 | 줄 단위 아니면 전체뿐. 연속 5줄이면 5번 누른다 |
| 대기 | 인코딩·줄바꿈 변환 저장 | CP949 ↔ UTF-8. 지금은 읽어서 보여주기만 |
| 대기 | 바이너리·이미지 비교 | 감지해서 안내만 하고 끝난다 |
| 상태 | 항목 | 내용 | 확인 |
|---|---|---|---|
| **완료** | 바꾸기 (Ctrl+H) | 찾기 칸 아래에 바꿀 말 줄이 붙는다(Ctrl+F 로 열면 안 뜬다 - 찾기만 할 때 자리를 뺏지 않는다). `바꾸기` 는 고른 자리가 <b>찾는 말일 때만</b> 바꾸고 다음으로 넘어간다 - 엉뚱한 자리를 덮어쓰는 것보다 한 번 더 누르게 하는 편이 낫다 | `PulseUs → PulseMicros` 2개 바꿈 |
| **완료** | 덩어리 단위 반영 | `이 덩어리 반영`(`Alt+Shift+→`). 차이로 잡힌 줄이 이어지는 구간을 한 번에 | 마지막 차이에서 2줄 한 번에 반영 |
| **완료** | 인코딩·줄바꿈 변환 저장 | ACTIONS 의 `저장 형식` 두 칸. 기본 "원본 유지"라 지금까지와 똑같이 나간다. UTF-8 / UTF-8(BOM) / CP949 · CRLF / LF | 화면 확인 |
| 대기 | 바이너리·이미지 비교 | 감지해서 안내만 하고 끝난다 | |
---

View File

@ -21,11 +21,13 @@ namespace MergeHub.Services
GoToLine,
NextBookmark,
ToggleBookmark,
AcceptBlock,
NextConflict,
PrevConflict,
AutoMerge,
OpenFolderCompare,
Find,
Replace,
FindNext,
FindPrev,
CloseFind,
@ -118,6 +120,9 @@ namespace MergeHub.Services
"이전 충돌 (3-Way)"),
new ShortcutEntry(ShortcutAction.AutoMerge, Key.M, ModifierKeys.Alt, "병합·편집",
"자동 병합 - 저쪽만 고친 줄을 한 번에 가져온다 (3-Way)"),
new ShortcutEntry(ShortcutAction.AcceptBlock, Key.Right, ModifierKeys.Alt | ModifierKeys.Shift, "병합·편집",
"이 덩어리 반영 - 차이가 이어지는 구간을 한 번에",
"줄 하나씩만 되면 연속 다섯 줄을 다섯 번 눌러야 한다."),
new ShortcutEntry(ShortcutAction.Undo, Key.Z, ModifierKeys.Control, "병합·편집", "실행 취소",
"머지 반영을 되돌린다. 되돌릴 머지 작업이 없으면 편집기의 글자 되돌리기가 그대로 동작한다."),
@ -135,6 +140,7 @@ namespace MergeHub.Services
new ShortcutEntry(ShortcutAction.Save, Key.S, ModifierKeys.Control, "파일·보기", "저장"),
new ShortcutEntry(ShortcutAction.Find, Key.F, ModifierKeys.Control, "찾기", "텍스트 찾기"),
new ShortcutEntry(ShortcutAction.Replace, Key.H, ModifierKeys.Control, "찾기", "바꾸기 (찾기 칸이 열리며 바꿀 말 칸이 함께 뜬다)"),
new ShortcutEntry(ShortcutAction.FindNext, Key.F3, ModifierKeys.None, "찾기", "다음 찾기"),
new ShortcutEntry(ShortcutAction.FindPrev, Key.F3, ModifierKeys.Shift, "찾기", "이전 찾기"),
new ShortcutEntry(ShortcutAction.CloseFind, Key.Escape, ModifierKeys.None, "찾기", "찾기 칸 닫기"),

View File

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
@ -52,24 +53,70 @@ namespace MergeHub.Services
return encoding.GetString(bytes, offset, bytes.Length - offset);
}
/// <summary>저장할 때 고를 수 있는 인코딩. 빈 값(원본 유지)이 기본이다.</summary>
public const string KeepOriginal = "원본 유지";
public static IReadOnlyList<string> SaveEncodings { get; } =
new[] { KeepOriginal, "UTF-8", "UTF-8 (BOM)", "CP949" };
public static IReadOnlyList<string> SaveNewLines { get; } =
new[] { KeepOriginal, "CRLF", "LF" };
/// <summary>
/// 원래 파일의 인코딩과 줄바꿈을 그대로 유지해 덮어쓴다.
/// 새 파일이면 UTF-8(BOM 없음) · CRLF 로 쓴다.
/// </summary>
public static void Write(string path, string content)
public static void Write(string path, string content) => Write(path, content, null, null);
/// <summary>
/// 인코딩·줄바꿈을 <b>바꿔서</b> 저장한다. 둘 다 <c>null</c> 이나 "원본 유지"면 지금까지와 같다.
///
/// 사내 소스에는 CP949 로 저장된 파일이 섞여 있어, 새로 만드는 파일과 형식이 갈린다.
/// 읽어서 보여 주기만 하면 그 갈림을 못 고치므로 저장할 때 고를 수 있게 한다.
/// </summary>
public static void Write(string path, string content, string? encodingName, string? newLineName)
{
var encoding = File.Exists(path)
? Detect(File.ReadAllBytes(path))
: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
var encoding = ResolveEncoding(path, encodingName);
var newLine = ResolveNewLine(path, newLineName);
var newLine = File.Exists(path) ? Describe(path).NewLine : "CRLF";
var text = content.Replace("\r\n", "\n");
if (newLine != "LF") text = text.Replace("\n", "\r\n");
File.WriteAllText(path, text, encoding);
}
private static Encoding ResolveEncoding(string path, string? name)
{
switch (name)
{
case "UTF-8": return new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
case "UTF-8 (BOM)": return new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);
case "CP949": return GetCp949();
}
return File.Exists(path)
? Detect(File.ReadAllBytes(path))
: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
}
private static Encoding GetCp949()
{
try
{
return Encoding.GetEncoding(949);
}
catch (ArgumentException)
{
return Ansi; // 코드페이지가 없는 장비면 시스템 기본으로 물러난다
}
}
private static string ResolveNewLine(string path, string? name)
{
if (name == "CRLF" || name == "LF") return name;
return File.Exists(path) ? Describe(path).NewLine : "CRLF";
}
/// <summary>
/// 글이 아니라 이진 파일인지. 앞부분에 NUL 바이트가 있으면 이진으로 본다 -
/// Git 을 비롯한 대부분의 도구가 쓰는 판별이고, 실제 텍스트에는 NUL 이 들어가지 않는다.

View File

@ -406,6 +406,8 @@ namespace MergeHub.ViewModels
var content = BuildLocalText();
var message = CommitMessage;
var encoding = SaveEncoding;
var newLine = SaveNewLine;
string? sha = null;
var ok = await _workspace.RunAsync("Save & Commit", () =>
@ -420,7 +422,9 @@ namespace MergeHub.ViewModels
{
var full = Path.Combine(_workspace.RepositoryPath,
path!.Replace('/', Path.DirectorySeparatorChar));
File.WriteAllText(full, content);
// 저장 형식을 고른 값으로 쓴다("원본 유지"면 지금까지와 같다).
TextFile.Write(full, content, encoding, newLine);
_workspace.Git.Stage(path!);
}
@ -469,6 +473,7 @@ namespace MergeHub.ViewModels
SavePlainCommand.NotifyCanExecuteChanged();
AutoMergeCommand.NotifyCanExecuteChanged();
AcceptBlockCommand.NotifyCanExecuteChanged();
NextConflictCommand.NotifyCanExecuteChanged();
PrevConflictCommand.NotifyCanExecuteChanged();
}
@ -1520,7 +1525,11 @@ namespace MergeHub.ViewModels
var content = BuildLocalText();
var target = RightFile;
var ok = await _workspace.RunAsync("파일 저장", () => TextFile.Write(target, content));
var encoding = SaveEncoding;
var newLine = SaveNewLine;
var ok = await _workspace.RunAsync("파일 저장",
() => TextFile.Write(target, content, encoding, newLine));
if (!ok) return;
_workspace.OnUi(() =>
@ -1694,6 +1703,9 @@ namespace MergeHub.ViewModels
/// <summary>지금 캐럿이 놓인 행. 편집기 줄 번호와 1:1 이다.</summary>
[ObservableProperty] private int _caretRow;
/// <summary>줄을 옮기면 "이 덩어리 반영"이 켜지고 꺼진다.</summary>
partial void OnCaretRowChanged(int value) => AcceptBlockCommand.NotifyCanExecuteChanged();
/// <summary>상태바에 띄우는 "차이 3 / 15".</summary>
[ObservableProperty] private string _diffPosition = "차이 -";
@ -2023,6 +2035,57 @@ namespace MergeHub.ViewModels
RefreshProblems();
}
// ================= 저장 형식 · 덩어리 반영 =================
/// <summary>
/// 저장할 때 쓸 인코딩·줄바꿈. 기본은 "원본 유지"라 지금까지와 똑같이 동작한다.
///
/// 사내 소스에는 CP949 로 저장된 파일이 섞여 있어 새로 만드는 파일과 형식이 갈린다.
/// 읽어서 보여 주기만 하면 그 갈림을 못 고치므로 저장할 때 고를 수 있게 한다.
/// </summary>
public IReadOnlyList<string> SaveEncodings => TextFile.SaveEncodings;
public IReadOnlyList<string> SaveNewLines => TextFile.SaveNewLines;
[ObservableProperty] private string _saveEncoding = TextFile.KeepOriginal;
[ObservableProperty] private string _saveNewLine = TextFile.KeepOriginal;
/// <summary>
/// 지금 캐럿이 있는 <b>차이 덩어리</b> 전체를 BASE 내용으로 바꾼다.
///
/// 줄 하나씩만 되면 연속 다섯 줄을 다섯 번 눌러야 한다 - WinMerge·Beyond Compare 가
/// 덩어리를 기본 단위로 쓰는 이유다. 덩어리는 "차이로 잡힌 줄이 이어지는 구간"이다.
/// </summary>
private bool CanAcceptBlock() => BlockAround(CaretRow).Count > 0;
[RelayCommand(CanExecute = nameof(CanAcceptBlock))]
private void AcceptBlock()
{
var block = BlockAround(CaretRow);
if (block.Count == 0) return;
foreach (var line in block) Apply(line, requery: false);
AfterEdit();
MessageUtil.ShowSuccessToast($"{block.Count}줄 반영");
}
/// <summary>그 줄이 속한 차이 덩어리. 차이가 아니면 빈 목록이다.</summary>
private List<MergeLine> BlockAround(int row)
{
var found = new List<MergeLine>();
if (row < 0 || row >= Lines.Count) return found;
if (!Lines[row].CanTakeBase) return found;
var first = row;
while (first > 0 && Lines[first - 1].CanTakeBase) first--;
var last = row;
while (last + 1 < Lines.Count && Lines[last + 1].CanTakeBase) last++;
for (var i = first; i <= last; i++) found.Add(Lines[i]);
return found;
}
// ================= 자동 병합 · 충돌만 보기 =================
/// <summary>

View File

@ -955,6 +955,7 @@
Background="{DynamicResource Ubisam.Brush.Input.Background}"
BorderBrush="{DynamicResource Ubisam.Brush.Accent}" BorderThickness="1"
Effect="{DynamicResource Ubisam.Effect.Popup}">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBox x:Name="FindBox" Width="180" Height="24" MinHeight="24"
FontSize="11" VerticalContentAlignment="Center"
@ -977,6 +978,22 @@
FontSize="10" Background="Transparent" BorderThickness="0"
ToolTip="닫기 (Esc)"/>
</StackPanel>
<!-- 바꾸기 줄은 Ctrl+H 로 열 때만 뜬다. 찾기만 할 때 자리를 뺏지 않는다. -->
<StackPanel x:Name="ReplaceRow" Orientation="Horizontal" Margin="0,4,0,0"
Visibility="Collapsed">
<TextBox x:Name="ReplaceBox" Width="180" Height="24" MinHeight="24"
FontSize="11" VerticalContentAlignment="Center"
ToolTip="바꿀 말"/>
<Button Content="바꾸기" Click="Replace_Click" Padding="8,0" MinHeight="24"
FontSize="11" Margin="6,0,2,0"
ToolTip="고른 자리를 바꾸고 다음으로 넘어갑니다"/>
<Button Content="모두" Click="ReplaceAll_Click" Padding="8,0" MinHeight="24"
FontSize="11"
ToolTip="글 전체에서 한 번에 바꿉니다 (Ctrl+Z 로 되돌립니다)"/>
</StackPanel>
</StackPanel>
</Border>
</Grid>
@ -1224,6 +1241,24 @@
Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}"
ToolTip="선택한 줄을 REMOTE(Theirs) 내용으로"/>
<Button Content="이 덩어리 반영" Command="{Binding AcceptBlockCommand}"
MinHeight="30" Margin="2,4,2,0" FontSize="11"
ToolTip="차이가 이어지는 구간을 한 번에 반영합니다 (Alt+Shift+Right)"/>
<!--
저장 형식. 기본은 "원본 유지"라 지금까지와 똑같이 나간다.
사내 소스에 CP949 가 섞여 있어, 새로 만드는 파일과 형식이 갈릴 때 여기서 맞춘다.
-->
<TextBlock Text="저장 형식" Style="{StaticResource Muted}" FontSize="11" Margin="0,12,0,4"/>
<UniformGrid Columns="2">
<ComboBox ItemsSource="{Binding SaveEncodings}" SelectedItem="{Binding SaveEncoding}"
MinHeight="26" FontSize="11" Margin="2,0"
ToolTip="저장할 때 쓸 인코딩"/>
<ComboBox ItemsSource="{Binding SaveNewLines}" SelectedItem="{Binding SaveNewLine}"
MinHeight="26" FontSize="11" Margin="2,0"
ToolTip="저장할 때 쓸 줄바꿈"/>
</UniformGrid>
<!--
3-Way 흐름. "안 겹치는 변경은 자동으로, 사람은 충돌만" 이 순서다.
남은 충돌이 0 이 되면 그 자리에서 다 됐다고 알려 준다.

View File

@ -7,6 +7,7 @@ using System.Windows.Media;
using System.Windows.Threading;
using MergeHub.Models;
using MergeHub.Services;
using UbisamBase.Core.Messaging;
using MergeHub.ViewModels;
namespace MergeHub.Views
@ -720,7 +721,10 @@ namespace MergeHub.Views
vm.ToggleBookmarkCommand.Execute(vm.SelectedLine);
return true;
case ShortcutAction.Find: OpenFind(); return true;
case ShortcutAction.AcceptBlock: return Run(vm.AcceptBlockCommand);
case ShortcutAction.Find: OpenFind(showReplace: false); return true;
case ShortcutAction.Replace: OpenFind(showReplace: true); return true;
case ShortcutAction.FindNext: FindStep(forward: true); return true;
case ShortcutAction.FindPrev: FindStep(forward: false); return true;
@ -755,15 +759,16 @@ namespace MergeHub.Views
/// 찾기 칸을 연다. 글자를 골라 둔 채로 눌렀으면 그 말을 그대로 채워 준다 -
/// 화면에서 눈에 띈 이름을 다시 타이핑하지 않게 하려는 것이다.
/// </summary>
private void OpenFind()
private void OpenFind(bool showReplace)
{
FindBar.Visibility = Visibility.Visible;
ReplaceRow.Visibility = showReplace ? Visibility.Visible : Visibility.Collapsed;
var picked = LocalEditor.SelectedText;
if (!string.IsNullOrEmpty(picked) && !picked.Contains("\n")) FindBox.Text = picked;
FindBox.Focus();
FindBox.SelectAll();
if (showReplace) { ReplaceBox.Focus(); ReplaceBox.SelectAll(); }
else { FindBox.Focus(); FindBox.SelectAll(); }
}
private void CloseFind_Click(object sender, RoutedEventArgs e) => CloseFind();
@ -778,6 +783,71 @@ namespace MergeHub.Views
private void FindNext_Click(object sender, RoutedEventArgs e) => FindStep(forward: true);
private void FindPrev_Click(object sender, RoutedEventArgs e) => FindStep(forward: false);
/// <summary>
/// 지금 고른 자리를 바꾸고 다음으로 넘어간다.
///
/// 고른 자리가 찾는 말이 아니면 바꾸지 않고 <b>먼저 찾기만 한다</b> -
/// 엉뚱한 자리를 덮어쓰는 것보다 한 번 더 누르게 하는 편이 낫다.
/// </summary>
private void Replace_Click(object sender, RoutedEventArgs e)
{
var needle = FindBox.Text;
if (string.IsNullOrEmpty(needle)) return;
var how = FindMatchCase.IsChecked == true
? StringComparison.Ordinal
: StringComparison.OrdinalIgnoreCase;
if (LocalEditor.SelectionLength == needle.Length &&
string.Equals(LocalEditor.SelectedText, needle, how))
{
LocalEditor.SelectedText = ReplaceBox.Text;
}
FindStep(forward: true);
}
/// <summary>글 전체에서 한 번에 바꾼다. 되돌리기는 편집기의 Ctrl+Z 가 맡는다.</summary>
private void ReplaceAll_Click(object sender, RoutedEventArgs e)
{
var needle = FindBox.Text;
if (string.IsNullOrEmpty(needle)) return;
var how = FindMatchCase.IsChecked == true
? StringComparison.Ordinal
: StringComparison.OrdinalIgnoreCase;
var text = LocalEditor.Text;
var count = CountMatches(text, needle, how);
if (count == 0) { FindStatus.Text = "없음"; return; }
var caret = LocalEditor.CaretIndex;
LocalEditor.Text = ReplaceAll(text, needle, ReplaceBox.Text, how);
LocalEditor.CaretIndex = Math.Min(caret, LocalEditor.Text.Length);
FindStatus.Text = $"{count}개 바꿈";
MessageUtil.ShowSuccessToast($"{count}개를 바꿨습니다");
}
private static string ReplaceAll(string text, string needle, string with, StringComparison how)
{
var sb = new System.Text.StringBuilder(text.Length);
var at = 0;
while (true)
{
var next = text.IndexOf(needle, at, how);
if (next < 0) break;
sb.Append(text, at, next - at).Append(with);
at = next + needle.Length;
}
sb.Append(text, at, text.Length - at);
return sb.ToString();
}
/// <summary>찾기 칸에서 Enter 는 다음, Shift+Enter 는 이전, Esc 는 닫기.</summary>
private void FindBox_KeyDown(object sender, KeyEventArgs e)
{
@ -804,7 +874,7 @@ namespace MergeHub.Views
var needle = FindBox.Text;
if (string.IsNullOrEmpty(needle))
{
OpenFind();
OpenFind(showReplace: ReplaceRow.Visibility == Visibility.Visible);
return;
}